iwa 0.0.65__tar.gz → 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. {iwa-0.0.65/src/iwa.egg-info → iwa-0.1.0}/PKG-INFO +1 -1
  2. {iwa-0.0.65 → iwa-0.1.0}/pyproject.toml +2 -2
  3. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/chain/interface.py +35 -0
  4. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/services/safe.py +9 -12
  5. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/services/safe_executor.py +7 -7
  6. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/gnosis/cow/swap.py +18 -3
  7. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/gnosis/safe.py +61 -6
  8. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/gnosis/tests/test_cow.py +36 -33
  9. iwa-0.1.0/src/iwa/plugins/gnosis/tests/test_safe.py +187 -0
  10. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/importer.py +9 -0
  11. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/plugin.py +3 -2
  12. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tui/olas_view.py +4 -4
  13. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/web/static/index.html +2 -2
  14. {iwa-0.0.65 → iwa-0.1.0/src/iwa.egg-info}/PKG-INFO +1 -1
  15. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_chain_interface.py +55 -0
  16. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_safe_coverage.py +5 -2
  17. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_safe_service.py +3 -0
  18. iwa-0.0.65/src/iwa/plugins/gnosis/tests/test_safe.py +0 -102
  19. {iwa-0.0.65 → iwa-0.1.0}/LICENSE +0 -0
  20. {iwa-0.0.65 → iwa-0.1.0}/README.md +0 -0
  21. {iwa-0.0.65 → iwa-0.1.0}/setup.cfg +0 -0
  22. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/__init__.py +0 -0
  23. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/__main__.py +0 -0
  24. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/__init__.py +0 -0
  25. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/chain/__init__.py +0 -0
  26. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/chain/errors.py +0 -0
  27. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/chain/manager.py +0 -0
  28. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/chain/models.py +0 -0
  29. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/chain/rate_limiter.py +0 -0
  30. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/chainlist.py +0 -0
  31. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/cli.py +0 -0
  32. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/constants.py +0 -0
  33. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/contracts/__init__.py +0 -0
  34. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/contracts/abis/erc20.json +0 -0
  35. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/contracts/abis/multisend.json +0 -0
  36. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/contracts/abis/multisend_call_only.json +0 -0
  37. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/contracts/cache.py +0 -0
  38. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/contracts/contract.py +0 -0
  39. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/contracts/decoder.py +0 -0
  40. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/contracts/erc20.py +0 -0
  41. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/contracts/multisend.py +0 -0
  42. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/db.py +0 -0
  43. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/http.py +0 -0
  44. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/ipfs.py +0 -0
  45. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/keys.py +0 -0
  46. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/mnemonic.py +0 -0
  47. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/models.py +0 -0
  48. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/monitor.py +0 -0
  49. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/plugins.py +0 -0
  50. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/pricing.py +0 -0
  51. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/rpc_monitor.py +0 -0
  52. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/secrets.py +0 -0
  53. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/services/__init__.py +0 -0
  54. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/services/account.py +0 -0
  55. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/services/balance.py +0 -0
  56. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/services/plugin.py +0 -0
  57. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/services/transaction.py +0 -0
  58. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/services/transfer/__init__.py +0 -0
  59. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/services/transfer/base.py +0 -0
  60. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/services/transfer/erc20.py +0 -0
  61. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/services/transfer/multisend.py +0 -0
  62. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/services/transfer/native.py +0 -0
  63. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/services/transfer/swap.py +0 -0
  64. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/tables.py +0 -0
  65. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/test.py +0 -0
  66. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/tests/test_gnosis_fee.py +0 -0
  67. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/tests/test_ipfs.py +0 -0
  68. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/tests/test_pricing.py +0 -0
  69. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/tests/test_regression_fixes.py +0 -0
  70. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/tests/test_wallet.py +0 -0
  71. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/types.py +0 -0
  72. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/ui.py +0 -0
  73. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/utils.py +0 -0
  74. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/core/wallet.py +0 -0
  75. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/__init__.py +0 -0
  76. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/gnosis/__init__.py +0 -0
  77. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/gnosis/cow/__init__.py +0 -0
  78. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/gnosis/cow/quotes.py +0 -0
  79. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/gnosis/cow/types.py +0 -0
  80. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/gnosis/cow_utils.py +0 -0
  81. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/gnosis/plugin.py +0 -0
  82. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/__init__.py +0 -0
  83. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/constants.py +0 -0
  84. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/contracts/abis/activity_checker.json +0 -0
  85. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/contracts/abis/mech.json +0 -0
  86. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/contracts/abis/mech_marketplace.json +0 -0
  87. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/contracts/abis/mech_marketplace_v1.json +0 -0
  88. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/contracts/abis/mech_new.json +0 -0
  89. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/contracts/abis/service_manager.json +0 -0
  90. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/contracts/abis/service_registry.json +0 -0
  91. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/contracts/abis/service_registry_token_utility.json +0 -0
  92. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/contracts/abis/staking.json +0 -0
  93. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/contracts/abis/staking_token.json +0 -0
  94. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/contracts/activity_checker.py +0 -0
  95. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/contracts/base.py +0 -0
  96. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/contracts/mech.py +0 -0
  97. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/contracts/mech_marketplace.py +0 -0
  98. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/contracts/mech_marketplace_v1.py +0 -0
  99. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/contracts/service.py +0 -0
  100. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/contracts/staking.py +0 -0
  101. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/events.py +0 -0
  102. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/mech_reference.py +0 -0
  103. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/models.py +0 -0
  104. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/scripts/test_full_mech_flow.py +0 -0
  105. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/scripts/test_simple_lifecycle.py +0 -0
  106. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/service_manager/__init__.py +0 -0
  107. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/service_manager/base.py +0 -0
  108. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/service_manager/drain.py +0 -0
  109. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/service_manager/lifecycle.py +0 -0
  110. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/service_manager/mech.py +0 -0
  111. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/service_manager/staking.py +0 -0
  112. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tests/conftest.py +0 -0
  113. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tests/test_importer.py +0 -0
  114. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tests/test_importer_error_handling.py +0 -0
  115. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tests/test_mech_contracts.py +0 -0
  116. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tests/test_olas_archiving.py +0 -0
  117. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tests/test_olas_contracts.py +0 -0
  118. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tests/test_olas_integration.py +0 -0
  119. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tests/test_olas_models.py +0 -0
  120. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tests/test_olas_view.py +0 -0
  121. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tests/test_olas_view_actions.py +0 -0
  122. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tests/test_olas_view_modals.py +0 -0
  123. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tests/test_plugin.py +0 -0
  124. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tests/test_plugin_full.py +0 -0
  125. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tests/test_service_lifecycle.py +0 -0
  126. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tests/test_service_manager.py +0 -0
  127. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tests/test_service_manager_errors.py +0 -0
  128. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tests/test_service_manager_flows.py +0 -0
  129. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tests/test_service_manager_mech.py +0 -0
  130. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tests/test_service_manager_rewards.py +0 -0
  131. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tests/test_service_manager_validation.py +0 -0
  132. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tests/test_service_staking.py +0 -0
  133. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tests/test_staking_integration.py +0 -0
  134. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tests/test_staking_validation.py +0 -0
  135. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/plugins/olas/tui/__init__.py +0 -0
  136. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tools/__init__.py +0 -0
  137. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tools/check_profile.py +0 -0
  138. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tools/drain_accounts.py +0 -0
  139. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tools/list_contracts.py +0 -0
  140. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tools/release.py +0 -0
  141. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tools/reset_env.py +0 -0
  142. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tools/reset_tenderly.py +0 -0
  143. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tools/restore_backup.py +0 -0
  144. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tools/test_chainlist.py +0 -0
  145. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tools/wallet_check.py +0 -0
  146. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tui/__init__.py +0 -0
  147. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tui/app.py +0 -0
  148. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tui/modals/__init__.py +0 -0
  149. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tui/modals/base.py +0 -0
  150. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tui/rpc.py +0 -0
  151. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tui/screens/__init__.py +0 -0
  152. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tui/screens/wallets.py +0 -0
  153. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tui/tests/test_app.py +0 -0
  154. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tui/tests/test_rpc.py +0 -0
  155. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tui/tests/test_wallets_refactor.py +0 -0
  156. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tui/tests/test_widgets.py +0 -0
  157. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tui/widgets/__init__.py +0 -0
  158. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tui/widgets/base.py +0 -0
  159. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/tui/workers.py +0 -0
  160. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/web/dependencies.py +0 -0
  161. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/web/models.py +0 -0
  162. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/web/routers/accounts.py +0 -0
  163. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/web/routers/olas/__init__.py +0 -0
  164. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/web/routers/olas/admin.py +0 -0
  165. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/web/routers/olas/funding.py +0 -0
  166. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/web/routers/olas/general.py +0 -0
  167. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/web/routers/olas/services.py +0 -0
  168. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/web/routers/olas/staking.py +0 -0
  169. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/web/routers/state.py +0 -0
  170. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/web/routers/swap.py +0 -0
  171. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/web/routers/transactions.py +0 -0
  172. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/web/server.py +0 -0
  173. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/web/static/app.js +0 -0
  174. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/web/static/style.css +0 -0
  175. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/web/tests/test_web_endpoints.py +0 -0
  176. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/web/tests/test_web_olas.py +0 -0
  177. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/web/tests/test_web_swap.py +0 -0
  178. {iwa-0.0.65 → iwa-0.1.0}/src/iwa/web/tests/test_web_swap_coverage.py +0 -0
  179. {iwa-0.0.65 → iwa-0.1.0}/src/iwa.egg-info/SOURCES.txt +0 -0
  180. {iwa-0.0.65 → iwa-0.1.0}/src/iwa.egg-info/dependency_links.txt +0 -0
  181. {iwa-0.0.65 → iwa-0.1.0}/src/iwa.egg-info/entry_points.txt +0 -0
  182. {iwa-0.0.65 → iwa-0.1.0}/src/iwa.egg-info/requires.txt +0 -0
  183. {iwa-0.0.65 → iwa-0.1.0}/src/iwa.egg-info/top_level.txt +0 -0
  184. {iwa-0.0.65 → iwa-0.1.0}/src/tests/legacy_cow.py +0 -0
  185. {iwa-0.0.65 → iwa-0.1.0}/src/tests/legacy_safe.py +0 -0
  186. {iwa-0.0.65 → iwa-0.1.0}/src/tests/legacy_transaction_retry_logic.py +0 -0
  187. {iwa-0.0.65 → iwa-0.1.0}/src/tests/legacy_tui.py +0 -0
  188. {iwa-0.0.65 → iwa-0.1.0}/src/tests/legacy_wallets_screen.py +0 -0
  189. {iwa-0.0.65 → iwa-0.1.0}/src/tests/legacy_web.py +0 -0
  190. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_account_service.py +0 -0
  191. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_balance_service.py +0 -0
  192. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_chain.py +0 -0
  193. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_chain_interface_coverage.py +0 -0
  194. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_chainlist_enrichment.py +0 -0
  195. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_cli.py +0 -0
  196. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_contract.py +0 -0
  197. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_contract_cache.py +0 -0
  198. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_db.py +0 -0
  199. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_drain_coverage.py +0 -0
  200. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_erc20.py +0 -0
  201. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_gnosis_plugin.py +0 -0
  202. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_keys.py +0 -0
  203. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_legacy_wallet.py +0 -0
  204. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_main.py +0 -0
  205. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_migration.py +0 -0
  206. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_mnemonic.py +0 -0
  207. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_modals.py +0 -0
  208. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_models.py +0 -0
  209. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_monitor.py +0 -0
  210. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_multisend.py +0 -0
  211. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_plugin_service.py +0 -0
  212. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_rate_limiter.py +0 -0
  213. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_rate_limiter_retry.py +0 -0
  214. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_reset_tenderly.py +0 -0
  215. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_rpc_efficiency.py +0 -0
  216. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_rpc_rate_limit.py +0 -0
  217. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_rpc_rotation.py +0 -0
  218. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_rpc_view.py +0 -0
  219. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_safe_executor.py +0 -0
  220. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_safe_integration.py +0 -0
  221. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_service_manager_integration.py +0 -0
  222. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_service_manager_structure.py +0 -0
  223. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_service_transaction.py +0 -0
  224. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_staking_router.py +0 -0
  225. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_staking_simple.py +0 -0
  226. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_tables.py +0 -0
  227. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_transaction_service.py +0 -0
  228. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_transfer_multisend.py +0 -0
  229. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_transfer_native.py +0 -0
  230. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_transfer_security.py +0 -0
  231. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_transfer_structure.py +0 -0
  232. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_transfer_swap_unit.py +0 -0
  233. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_ui_coverage.py +0 -0
  234. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_utils.py +0 -0
  235. {iwa-0.0.65 → iwa-0.1.0}/src/tests/test_workers.py +0 -0
  236. {iwa-0.0.65 → iwa-0.1.0}/src/tools/create_and_stake_service.py +0 -0
  237. {iwa-0.0.65 → iwa-0.1.0}/src/tools/verify_drain.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iwa
3
- Version: 0.0.65
3
+ Version: 0.1.0
4
4
  Summary: A secure, modular, and plugin-based framework for crypto agents and ops
5
5
  Requires-Python: <4.0,>=3.12
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "iwa"
3
- version = "0.0.65"
3
+ version = "0.1.0"
4
4
  description = "A secure, modular, and plugin-based framework for crypto agents and ops"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<4.0"
@@ -72,7 +72,7 @@ where = ["src"]
72
72
 
73
73
  [tool.ruff]
74
74
  line-length = 100
75
- target-version = "0.0.65"
75
+ target-version = "0.1.0"
76
76
  fix = true
77
77
 
78
78
  [tool.ruff.lint]
@@ -228,6 +228,7 @@ class ChainInterface:
228
228
  "connect timeout",
229
229
  "remote end closed",
230
230
  "broken pipe",
231
+ # Note: "too many open files" handled separately by _is_fd_exhaustion_error
231
232
  ]
232
233
  return any(signal in err_text for signal in connection_signals)
233
234
 
@@ -254,6 +255,21 @@ class ChainInterface:
254
255
  ]
255
256
  return any(signal in err_text for signal in server_error_signals)
256
257
 
258
+ def _is_fd_exhaustion_error(self, error: Exception) -> bool:
259
+ """Check if error is due to file descriptor exhaustion (OSError 24).
260
+
261
+ When FDs are exhausted, rotating RPCs makes things WORSE because it
262
+ creates more connections. Instead, we need to pause and let existing
263
+ connections drain before retrying.
264
+ """
265
+ err_text = str(error).lower()
266
+ fd_signals = [
267
+ "too many open files",
268
+ "oserror(24",
269
+ "errno 24",
270
+ ]
271
+ return any(signal in err_text for signal in fd_signals)
272
+
257
273
  def _is_gas_error(self, error: Exception) -> bool:
258
274
  """Check if error is related to gas limits or fees."""
259
275
  err_text = str(error).lower()
@@ -326,6 +342,9 @@ class ChainInterface:
326
342
  """Return True if the RPC at *index* is not in backoff."""
327
343
  return time.monotonic() >= self._rpc_backoff_until.get(index, 0.0)
328
344
 
345
+ # FD exhaustion backoff: wait for connections to drain
346
+ FD_EXHAUSTION_BACKOFF = 60.0 # Long pause to let FDs drain
347
+
329
348
  def _handle_rpc_error(self, error: Exception) -> Dict[str, Union[bool, int]]:
330
349
  """Handle RPC errors with smart rotation and retry logic."""
331
350
  result: Dict[str, Union[bool, int]] = {
@@ -335,10 +354,26 @@ class ChainInterface:
335
354
  "is_gas_error": self._is_gas_error(error),
336
355
  "is_tenderly_quota": self._is_tenderly_quota_exceeded(error),
337
356
  "is_quota_exceeded": self._is_quota_exceeded_error(error),
357
+ "is_fd_exhaustion": self._is_fd_exhaustion_error(error),
338
358
  "rotated": False,
339
359
  "should_retry": False,
340
360
  }
341
361
 
362
+ # FD exhaustion: DO NOT rotate (creates more connections), just pause
363
+ if result["is_fd_exhaustion"]:
364
+ logger.error(
365
+ f"[{self.chain.name}] FD EXHAUSTION detected (too many open files). "
366
+ f"Pausing all RPCs for {int(self.FD_EXHAUSTION_BACKOFF)}s to drain connections. "
367
+ f"NO rotation to avoid creating more connections."
368
+ )
369
+ # Mark ALL RPCs as in backoff to prevent any activity
370
+ for i in range(len(self.chain.rpcs) if self.chain.rpcs else 0):
371
+ self._mark_rpc_backoff(i, self.FD_EXHAUSTION_BACKOFF)
372
+ # Trigger global rate limit backoff
373
+ self._rate_limiter.trigger_backoff(seconds=self.FD_EXHAUSTION_BACKOFF)
374
+ result["should_retry"] = True # Retry after backoff
375
+ return result
376
+
342
377
  if result["is_tenderly_quota"]:
343
378
  logger.error(
344
379
  "TENDERLY QUOTA EXCEEDED! The virtual network has reached its limit. "
@@ -1,6 +1,6 @@
1
1
  """Safe service module."""
2
2
 
3
- from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
3
+ from typing import TYPE_CHECKING, List, Optional, Tuple
4
4
 
5
5
  from loguru import logger
6
6
  from safe_eth.eth import EthereumClient
@@ -35,7 +35,6 @@ class SafeService:
35
35
  """Initialize SafeService."""
36
36
  self.key_storage = key_storage
37
37
  self.account_service = account_service
38
- self._client_cache: Dict[str, EthereumClient] = {}
39
38
 
40
39
  def create_safe(
41
40
  self,
@@ -100,15 +99,14 @@ class SafeService:
100
99
 
101
100
  def _get_ethereum_client(self, chain_name: str) -> EthereumClient:
102
101
  from iwa.core.chain import ChainInterfaces
102
+ from iwa.plugins.gnosis.safe import get_ethereum_client
103
103
 
104
104
  # Use ChainInterface which has proper RPC rotation and parsing
105
105
  chain_interface = ChainInterfaces().get(chain_name)
106
106
  rpc_url = chain_interface.current_rpc
107
107
 
108
- if rpc_url not in self._client_cache:
109
- self._client_cache[rpc_url] = EthereumClient(rpc_url)
110
-
111
- return self._client_cache[rpc_url]
108
+ # Use shared cache to prevent FD exhaustion
109
+ return get_ethereum_client(rpc_url)
112
110
 
113
111
  def _deploy_safe_contract(
114
112
  self,
@@ -256,11 +254,8 @@ class SafeService:
256
254
  continue
257
255
 
258
256
  for chain in account.chains:
259
- from iwa.core.chain import ChainInterfaces
260
-
261
- # Use ChainInterface which has proper RPC rotation and parsing
262
- chain_interface = ChainInterfaces().get(chain)
263
- ethereum_client = EthereumClient(chain_interface.current_rpc)
257
+ # Reuse cached EthereumClient to prevent FD exhaustion
258
+ ethereum_client = self._get_ethereum_client(chain)
264
259
 
265
260
  code = ethereum_client.w3.eth.get_code(account.address)
266
261
 
@@ -375,7 +370,9 @@ class SafeService:
375
370
  if not safe_account or not isinstance(safe_account, StoredSafeAccount):
376
371
  raise ValueError(f"Safe account '{safe_address_or_tag}' not found.")
377
372
 
378
- safe = SafeMultisig(safe_account, chain_name)
373
+ # Reuse cached EthereumClient to prevent FD exhaustion
374
+ ethereum_client = self._get_ethereum_client(chain_name)
375
+ safe = SafeMultisig(safe_account, chain_name, ethereum_client=ethereum_client)
379
376
  safe_tx = safe.build_tx(
380
377
  to=to,
381
378
  value=value,
@@ -1,10 +1,10 @@
1
1
  """Safe transaction executor with retry logic and gas handling."""
2
2
 
3
3
  import time
4
- from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
4
+ from typing import TYPE_CHECKING, List, Optional, Tuple
5
5
 
6
6
  from loguru import logger
7
- from safe_eth.eth import EthereumClient, TxSpeed
7
+ from safe_eth.eth import TxSpeed
8
8
  from safe_eth.safe import Safe
9
9
  from safe_eth.safe.safe_tx import SafeTx
10
10
 
@@ -56,7 +56,6 @@ class SafeTransactionExecutor:
56
56
  config = Config().core
57
57
  self.max_retries = max_retries or config.safe_tx_max_retries
58
58
  self.gas_buffer = gas_buffer or config.safe_tx_gas_buffer
59
- self._client_cache: Dict[str, EthereumClient] = {}
60
59
 
61
60
  def execute_with_retry(
62
61
  self,
@@ -64,7 +63,7 @@ class SafeTransactionExecutor:
64
63
  safe_tx: SafeTx,
65
64
  signer_keys: List[str],
66
65
  operation_name: str = "safe_tx",
67
- ) -> Tuple[bool, str, Optional[Dict]]:
66
+ ) -> Tuple[bool, str, Optional[dict]]:
68
67
  """Execute SafeTx with full retry mechanism.
69
68
 
70
69
  Args:
@@ -303,10 +302,11 @@ class SafeTransactionExecutor:
303
302
 
304
303
  def _recreate_safe_client(self, safe_address: str) -> Safe:
305
304
  """Recreate Safe with current (possibly rotated) RPC."""
305
+ from iwa.plugins.gnosis.safe import get_ethereum_client
306
+
306
307
  rpc_url = self.chain_interface.current_rpc
307
- if rpc_url not in self._client_cache:
308
- self._client_cache[rpc_url] = EthereumClient(rpc_url)
309
- ethereum_client = self._client_cache[rpc_url]
308
+ # Use shared cache to prevent FD exhaustion
309
+ ethereum_client = get_ethereum_client(rpc_url)
310
310
  return Safe(safe_address, ethereum_client)
311
311
 
312
312
  def _is_nonce_error(self, error: Exception) -> bool:
@@ -29,6 +29,20 @@ warnings.filterwarnings(
29
29
 
30
30
  logger = configure_logger()
31
31
 
32
+ # Module-level session for connection pooling (avoids FD leak)
33
+ _session: requests.Session | None = None
34
+
35
+
36
+ def _get_session() -> requests.Session:
37
+ """Get or create the module-level HTTP session."""
38
+ global _session
39
+ if _session is None:
40
+ from iwa.core.http import create_retry_session
41
+
42
+ _session = create_retry_session()
43
+ return _session
44
+
45
+
32
46
  if TYPE_CHECKING:
33
47
  from cowdao_cowpy.common.chains import Chain
34
48
  from cowdao_cowpy.cow.swap import CompletedOrder
@@ -97,13 +111,14 @@ class CowSwap:
97
111
  logger.info(f"Checking order status for UID: {order.uid}")
98
112
 
99
113
  sleep_between_retries = 15
114
+ session = _get_session()
115
+ loop = asyncio.get_event_loop()
100
116
 
101
117
  while True:
102
118
  try:
103
- # Use a thread executor for blocking requests.get
104
- loop = asyncio.get_event_loop()
119
+ # Use a thread executor for blocking HTTP request
105
120
  response = await loop.run_in_executor(
106
- None, lambda: requests.get(order.url, timeout=60)
121
+ None, lambda s=session: s.get(order.url, timeout=60)
107
122
  )
108
123
  except Exception as e:
109
124
  logger.warning(f"Error checking order status: {e}")
@@ -1,6 +1,6 @@
1
1
  """Gnosis Safe interaction."""
2
2
 
3
- from typing import Callable, Optional
3
+ from typing import Callable, Dict, Optional
4
4
 
5
5
  from safe_eth.eth import EthereumClient
6
6
  from safe_eth.eth.constants import NULL_ADDRESS
@@ -12,6 +12,46 @@ from iwa.core.utils import configure_logger
12
12
 
13
13
  logger = configure_logger()
14
14
 
15
+ # Shared EthereumClient cache to prevent FD leaks
16
+ # Limited to MAX_CACHED_CLIENTS to avoid unbounded growth during RPC rotations
17
+ _ethereum_client_cache: Dict[str, EthereumClient] = {}
18
+ MAX_CACHED_CLIENTS = 3 # Keep only recent clients to limit FD usage
19
+
20
+
21
+ def _cleanup_old_clients(keep_url: str) -> None:
22
+ """Remove oldest cached clients to stay under limit."""
23
+ global _ethereum_client_cache
24
+ while len(_ethereum_client_cache) >= MAX_CACHED_CLIENTS:
25
+ # Remove oldest entry (first key in dict, preserves insertion order in Python 3.7+)
26
+ for old_url in list(_ethereum_client_cache.keys()):
27
+ if old_url != keep_url:
28
+ old_client = _ethereum_client_cache.pop(old_url)
29
+ # Try to close any underlying HTTP session
30
+ if hasattr(old_client, "w3") and hasattr(old_client.w3, "provider"):
31
+ provider = old_client.w3.provider
32
+ if hasattr(provider, "_request_kwargs"):
33
+ session = provider._request_kwargs.get("session")
34
+ if session and hasattr(session, "close"):
35
+ try:
36
+ session.close()
37
+ except Exception:
38
+ pass
39
+ logger.debug(f"Evicted EthereumClient for {old_url} from cache")
40
+ break
41
+
42
+
43
+ def get_ethereum_client(rpc_url: str) -> EthereumClient:
44
+ """Get a cached EthereumClient for the given RPC URL.
45
+
46
+ Reuses existing clients to prevent file descriptor exhaustion.
47
+ Limited to MAX_CACHED_CLIENTS to prevent unbounded cache growth
48
+ during RPC rotations.
49
+ """
50
+ if rpc_url not in _ethereum_client_cache:
51
+ _cleanup_old_clients(rpc_url)
52
+ _ethereum_client_cache[rpc_url] = EthereumClient(rpc_url)
53
+ return _ethereum_client_cache[rpc_url]
54
+
15
55
 
16
56
  class SafeMultisig:
17
57
  """Class to interact with Gnosis Safe multisig wallets.
@@ -20,17 +60,32 @@ class SafeMultisig:
20
60
  checking owners, thresholds, and building/sending multi-signature transactions.
21
61
  """
22
62
 
23
- def __init__(self, safe_account: StoredSafeAccount, chain_name: str):
24
- """Initialize the SafeMultisig instance."""
63
+ def __init__(
64
+ self,
65
+ safe_account: StoredSafeAccount,
66
+ chain_name: str,
67
+ ethereum_client: Optional[EthereumClient] = None,
68
+ ):
69
+ """Initialize the SafeMultisig instance.
70
+
71
+ Args:
72
+ safe_account: The Safe account to interact with.
73
+ chain_name: The chain name (e.g., 'gnosis').
74
+ ethereum_client: Optional pre-existing EthereumClient.
75
+ If not provided, uses a shared cached client.
76
+
77
+ """
25
78
  # Normalize chain comparison to be case-insensitive
26
79
  normalized_chains = [c.lower() for c in safe_account.chains]
27
80
  if chain_name.lower() not in normalized_chains:
28
81
  raise ValueError(f"Safe account is not deployed on chain: {chain_name}")
29
82
 
30
- from iwa.core.chain import ChainInterfaces
83
+ if ethereum_client is None:
84
+ from iwa.core.chain import ChainInterfaces
85
+
86
+ chain_interface = ChainInterfaces().get(chain_name.lower())
87
+ ethereum_client = get_ethereum_client(chain_interface.current_rpc)
31
88
 
32
- chain_interface = ChainInterfaces().get(chain_name.lower())
33
- ethereum_client = EthereumClient(chain_interface.current_rpc)
34
89
  self.multisig = Safe(safe_account.address, ethereum_client)
35
90
  self.ethereum_client = ethereum_client
36
91
 
@@ -175,19 +175,18 @@ async def test_check_cowswap_order_success(cowswap):
175
175
  mock_order = MagicMock()
176
176
  mock_order.url = "http://api/order"
177
177
 
178
- with patch("requests.get") as mock_get:
179
- mock_get.return_value.status_code = 200
180
- mock_get.return_value.json.return_value = {
181
- "status": "fulfilled",
182
- "executedSellAmount": "100",
183
- "executedBuyAmount": "90",
184
- }
185
-
186
- # Need to mock loop.run_in_executor since check_cowswap_order uses it
187
- # Or just let it run if requests.get is mocked?
188
- # check_cowswap_order calls loop.run_in_executor(None, lambda: requests.get(...))
189
- # This will run the lambda in a thread. The mock should work.
190
-
178
+ mock_response = MagicMock()
179
+ mock_response.status_code = 200
180
+ mock_response.json.return_value = {
181
+ "status": "fulfilled",
182
+ "executedSellAmount": "100",
183
+ "executedBuyAmount": "90",
184
+ }
185
+
186
+ mock_session = MagicMock()
187
+ mock_session.get.return_value = mock_response
188
+
189
+ with patch("iwa.plugins.gnosis.cow.swap._get_session", return_value=mock_session):
191
190
  result = await cowswap.check_cowswap_order(mock_order)
192
191
 
193
192
  assert result == {
@@ -203,10 +202,14 @@ async def test_check_cowswap_order_expired(cowswap):
203
202
  mock_order = MagicMock()
204
203
  mock_order.url = "http://api/order"
205
204
 
206
- with patch("requests.get") as mock_get:
207
- mock_get.return_value.status_code = 200
208
- mock_get.return_value.json.return_value = {"status": "expired"}
205
+ mock_response = MagicMock()
206
+ mock_response.status_code = 200
207
+ mock_response.json.return_value = {"status": "expired"}
208
+
209
+ mock_session = MagicMock()
210
+ mock_session.get.return_value = mock_response
209
211
 
212
+ with patch("iwa.plugins.gnosis.cow.swap._get_session", return_value=mock_session):
210
213
  result = await cowswap.check_cowswap_order(mock_order)
211
214
  assert result is None
212
215
 
@@ -217,20 +220,20 @@ async def test_check_cowswap_order_timeout(cowswap):
217
220
  mock_order = MagicMock()
218
221
  mock_order.url = "http://api/order"
219
222
 
220
- with patch("requests.get") as mock_get:
221
- mock_get.return_value.status_code = 200
222
- # Order is always open
223
- mock_get.return_value.json.return_value = {
224
- "status": "open",
225
- "executedSellAmount": "0",
226
- "validTo": 1000,
227
- }
228
-
229
- # Mock time to start at 900 and then jump to 1100 to trigger timeout
230
- with patch("time.time") as mock_time:
231
- mock_time.side_effect = [900, 1100]
232
- # Speed up retry sleep (asyncio.sleep)
233
- with patch("asyncio.sleep", new_callable=AsyncMock):
234
- result = await cowswap.check_cowswap_order(mock_order)
235
- assert result is None
236
- assert mock_time.call_count >= 2
223
+ mock_response = MagicMock()
224
+ mock_response.status_code = 200
225
+ # Order is always open, validTo = 1000
226
+ mock_response.json.return_value = {
227
+ "status": "open",
228
+ "executedSellAmount": "0",
229
+ "validTo": 1000,
230
+ }
231
+
232
+ mock_session = MagicMock()
233
+ mock_session.get.return_value = mock_response
234
+
235
+ with patch("iwa.plugins.gnosis.cow.swap._get_session", return_value=mock_session):
236
+ # Mock time to return >1060 (validTo + 60) to trigger timeout on first check
237
+ with patch("iwa.plugins.gnosis.cow.swap.time.time", return_value=1100):
238
+ result = await cowswap.check_cowswap_order(mock_order)
239
+ assert result is None
@@ -0,0 +1,187 @@
1
+ """Tests for Safe module."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import pytest
6
+
7
+ from iwa.core.models import StoredSafeAccount
8
+ from iwa.plugins.gnosis.safe import (
9
+ MAX_CACHED_CLIENTS,
10
+ SafeMultisig,
11
+ _ethereum_client_cache,
12
+ get_ethereum_client,
13
+ )
14
+
15
+
16
+ @pytest.fixture
17
+ def mock_settings():
18
+ """Mock settings."""
19
+ # secrets is no longer used in this module, so we don't need to patch it here
20
+ yield None
21
+
22
+
23
+ @pytest.fixture
24
+ def mock_safe_eth():
25
+ """Mock safe_eth module."""
26
+ with (
27
+ patch("iwa.plugins.gnosis.safe.EthereumClient") as mock_client,
28
+ patch("iwa.plugins.gnosis.safe.Safe") as mock_safe,
29
+ ):
30
+ yield mock_client, mock_safe
31
+
32
+
33
+ @pytest.fixture
34
+ def safe_account():
35
+ """Mock safe account."""
36
+ return StoredSafeAccount(
37
+ address="0x1234567890123456789012345678901234567890",
38
+ owners=["0x1234567890123456789012345678901234567890"],
39
+ threshold=1,
40
+ chains=["gnosis"],
41
+ tag="mysafe",
42
+ signers=[],
43
+ )
44
+
45
+
46
+ def test_init(safe_account, mock_settings, mock_safe_eth):
47
+ """Test initialization."""
48
+ with patch("iwa.core.chain.ChainInterfaces") as mock_ci_cls:
49
+ mock_ci = mock_ci_cls.return_value
50
+ mock_ci.get.return_value.current_rpc = "http://rpc"
51
+ ms = SafeMultisig(safe_account, "gnosis")
52
+ assert ms.multisig is not None
53
+ mock_safe_eth[0].assert_called_with("http://rpc") # EthereumClient init
54
+ mock_safe_eth[1].assert_called() # Safe init
55
+
56
+
57
+ def test_init_invalid_chain(safe_account, mock_settings, mock_safe_eth):
58
+ """Test initialization with invalid chain."""
59
+ with pytest.raises(ValueError, match="not deployed on chain"):
60
+ SafeMultisig(safe_account, "ethereum")
61
+
62
+
63
+ def test_getters(safe_account, mock_settings, mock_safe_eth):
64
+ """Test safe property getters."""
65
+ ms = SafeMultisig(safe_account, "gnosis")
66
+ mock_safe_instance = mock_safe_eth[1].return_value
67
+
68
+ mock_safe_instance.retrieve_owners.return_value = ["0x1"]
69
+ assert ms.get_owners() == ["0x1"]
70
+
71
+ mock_safe_instance.retrieve_threshold.return_value = 2
72
+ assert ms.get_threshold() == 2
73
+
74
+ mock_safe_instance.retrieve_nonce.return_value = 5
75
+ assert ms.get_nonce() == 5
76
+
77
+ mock_safe_instance.retrieve_all_info.return_value = {"info": "test"}
78
+ assert ms.retrieve_all_info() == {"info": "test"}
79
+
80
+
81
+ def test_build_tx(safe_account, mock_settings, mock_safe_eth):
82
+ """Test build_multisig_tx."""
83
+ ms = SafeMultisig(safe_account, "gnosis")
84
+ mock_safe_instance = mock_safe_eth[1].return_value
85
+ mock_safe_instance.build_multisig_tx.return_value = "0xTx"
86
+
87
+ tx = ms.build_tx("0xTo", 100)
88
+ assert tx == "0xTx"
89
+
90
+ mock_safe_instance.build_multisig_tx.assert_called()
91
+
92
+
93
+ def test_send_tx(safe_account, mock_settings, mock_safe_eth):
94
+ """Test send_multisig_tx."""
95
+ ms = SafeMultisig(safe_account, "gnosis")
96
+
97
+ # Mock build_tx just in case (though it delegates)
98
+ # Actually we can let it delegate to mock_safe_instance which returns "0xSafeTx"
99
+ mock_safe_instance = mock_safe_eth[1].return_value
100
+ mock_safe_instance.build_multisig_tx.return_value = "0xSafeTx"
101
+
102
+ callback = MagicMock(return_value="0xHash")
103
+
104
+ tx_hash = ms.send_tx("0xTo", 100, callback)
105
+ assert tx_hash == "0xHash"
106
+
107
+ callback.assert_called_with("0xSafeTx")
108
+
109
+
110
+ class TestEthereumClientCache:
111
+ """Tests for EthereumClient caching to prevent FD exhaustion."""
112
+
113
+ def setup_method(self):
114
+ """Clear cache before each test."""
115
+ _ethereum_client_cache.clear()
116
+
117
+ def test_cache_reuses_client(self):
118
+ """Test that the same RPC URL returns the same cached client."""
119
+ with patch("iwa.plugins.gnosis.safe.EthereumClient") as mock_client_cls:
120
+ mock_client_cls.return_value = MagicMock()
121
+
122
+ client1 = get_ethereum_client("https://rpc1.example.com")
123
+ client2 = get_ethereum_client("https://rpc1.example.com")
124
+
125
+ assert client1 is client2
126
+ # Should only create one instance
127
+ assert mock_client_cls.call_count == 1
128
+
129
+ def test_cache_different_urls(self):
130
+ """Test that different URLs create different clients."""
131
+ with patch("iwa.plugins.gnosis.safe.EthereumClient") as mock_client_cls:
132
+ mock_client_cls.side_effect = lambda url: MagicMock(url=url)
133
+
134
+ client1 = get_ethereum_client("https://rpc1.example.com")
135
+ client2 = get_ethereum_client("https://rpc2.example.com")
136
+
137
+ assert client1 is not client2
138
+ assert mock_client_cls.call_count == 2
139
+
140
+ def test_cache_limit_enforced(self):
141
+ """Test that cache is limited to MAX_CACHED_CLIENTS."""
142
+ with patch("iwa.plugins.gnosis.safe.EthereumClient") as mock_client_cls:
143
+ # Create mock clients with closeable sessions
144
+ def create_mock_client(url):
145
+ client = MagicMock(url=url)
146
+ client.w3 = MagicMock()
147
+ client.w3.provider = MagicMock()
148
+ client.w3.provider._request_kwargs = {"session": MagicMock()}
149
+ return client
150
+
151
+ mock_client_cls.side_effect = create_mock_client
152
+
153
+ # Create more clients than the limit
154
+ urls = [f"https://rpc{i}.example.com" for i in range(MAX_CACHED_CLIENTS + 2)]
155
+ for url in urls:
156
+ get_ethereum_client(url)
157
+
158
+ # Cache should not exceed limit
159
+ assert len(_ethereum_client_cache) <= MAX_CACHED_CLIENTS
160
+
161
+ def test_cache_evicts_oldest(self):
162
+ """Test that oldest entries are evicted when limit is reached."""
163
+ with patch("iwa.plugins.gnosis.safe.EthereumClient") as mock_client_cls:
164
+
165
+ def create_mock_client(url):
166
+ client = MagicMock(url=url)
167
+ client.w3 = MagicMock()
168
+ client.w3.provider = MagicMock()
169
+ client.w3.provider._request_kwargs = {"session": MagicMock()}
170
+ return client
171
+
172
+ mock_client_cls.side_effect = create_mock_client
173
+
174
+ # Fill cache to limit
175
+ first_url = "https://first.example.com"
176
+ get_ethereum_client(first_url)
177
+ for i in range(1, MAX_CACHED_CLIENTS):
178
+ get_ethereum_client(f"https://rpc{i}.example.com")
179
+
180
+ assert first_url in _ethereum_client_cache
181
+
182
+ # Add one more - should evict the first
183
+ get_ethereum_client("https://new.example.com")
184
+
185
+ # First URL should be evicted
186
+ assert first_url not in _ethereum_client_cache
187
+ assert "https://new.example.com" in _ethereum_client_cache
@@ -598,6 +598,15 @@ class OlasServiceImporter:
598
598
  content = file_path.read_text().strip()
599
599
  keystore = json.loads(content)
600
600
 
601
+ # Handle operate format: keystore is stringified inside "private_key"
602
+ if "private_key" in keystore and isinstance(keystore["private_key"], str):
603
+ try:
604
+ inner_keystore = json.loads(keystore["private_key"])
605
+ if "crypto" in inner_keystore and "address" in inner_keystore:
606
+ keystore = inner_keystore
607
+ except json.JSONDecodeError:
608
+ pass # Not a nested keystore, continue with original
609
+
601
610
  # Validate it's a keystore
602
611
  if "crypto" not in keystore or "address" not in keystore:
603
612
  return None
@@ -67,10 +67,10 @@ class OlasPlugin(Plugin):
67
67
 
68
68
  """
69
69
  try:
70
- from safe_eth.eth import EthereumClient
71
70
  from safe_eth.safe import Safe
72
71
 
73
72
  from iwa.core.chain import ChainInterfaces
73
+ from iwa.plugins.gnosis.safe import get_ethereum_client
74
74
 
75
75
  try:
76
76
  chain_interface = ChainInterfaces().get(chain_name)
@@ -79,7 +79,8 @@ class OlasPlugin(Plugin):
79
79
  except ValueError:
80
80
  return None, None # Chain not supported/configured
81
81
 
82
- ethereum_client = EthereumClient(chain_interface.current_rpc)
82
+ # Reuse cached EthereumClient to prevent FD exhaustion
83
+ ethereum_client = get_ethereum_client(chain_interface.current_rpc)
83
84
  safe = Safe(safe_address, ethereum_client)
84
85
  owners = safe.retrieve_owners()
85
86
  return owners, True
@@ -545,10 +545,10 @@ class OlasView(Static):
545
545
  """Filter staking contracts based on bond requirements and slots."""
546
546
  import json
547
547
 
548
- from iwa.core.chain import ChainInterface
548
+ from iwa.core.chain import ChainInterfaces
549
549
  from iwa.plugins.olas.contracts.base import OLAS_ABI_PATH
550
550
 
551
- w3 = ChainInterface(self._chain).web3
551
+ w3 = ChainInterfaces().get(self._chain).web3
552
552
  with open(OLAS_ABI_PATH / "staking.json", "r") as f:
553
553
  abi = json.load(f)
554
554
 
@@ -677,14 +677,14 @@ class OlasView(Static):
677
677
  try:
678
678
  import json
679
679
 
680
- from iwa.core.chain import ChainInterface
680
+ from iwa.core.chain import ChainInterfaces
681
681
  from iwa.plugins.olas.constants import OLAS_TRADER_STAKING_CONTRACTS
682
682
  from iwa.plugins.olas.contracts.base import OLAS_ABI_PATH
683
683
 
684
684
  contracts_dict = OLAS_TRADER_STAKING_CONTRACTS.get(self._chain, {})
685
685
 
686
686
  # Load ABI and check slots for each contract
687
- w3 = ChainInterface(self._chain).web3
687
+ w3 = ChainInterfaces().get(self._chain).web3
688
688
  with open(OLAS_ABI_PATH / "staking.json", "r") as f:
689
689
  abi = json.load(f)
690
690