iwa 0.0.58__tar.gz → 0.0.60__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 (234) hide show
  1. {iwa-0.0.58/src/iwa.egg-info → iwa-0.0.60}/PKG-INFO +1 -1
  2. {iwa-0.0.58 → iwa-0.0.60}/pyproject.toml +2 -2
  3. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/chain/interface.py +118 -53
  4. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/chain/rate_limiter.py +35 -12
  5. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/chainlist.py +15 -10
  6. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/cli.py +3 -0
  7. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/contracts/cache.py +1 -1
  8. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/contracts/contract.py +1 -0
  9. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/contracts/decoder.py +10 -4
  10. iwa-0.0.60/src/iwa/core/http.py +31 -0
  11. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/ipfs.py +11 -19
  12. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/keys.py +10 -4
  13. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/models.py +1 -3
  14. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/pricing.py +3 -21
  15. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/rpc_monitor.py +1 -0
  16. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/services/balance.py +0 -1
  17. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/services/safe.py +8 -2
  18. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/services/safe_executor.py +52 -18
  19. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/services/transaction.py +32 -12
  20. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/services/transfer/erc20.py +0 -1
  21. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/services/transfer/native.py +1 -1
  22. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/tests/test_gnosis_fee.py +6 -2
  23. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/tests/test_ipfs.py +1 -1
  24. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/tests/test_regression_fixes.py +3 -6
  25. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/utils.py +2 -0
  26. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/wallet.py +3 -1
  27. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/constants.py +15 -5
  28. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/contracts/activity_checker.py +3 -3
  29. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/contracts/staking.py +0 -1
  30. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/events.py +15 -13
  31. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/importer.py +26 -20
  32. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/plugin.py +16 -14
  33. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/service_manager/drain.py +1 -3
  34. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/service_manager/lifecycle.py +9 -9
  35. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/service_manager/staking.py +11 -6
  36. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tests/test_olas_archiving.py +25 -15
  37. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tests/test_olas_integration.py +49 -29
  38. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tests/test_service_manager.py +8 -10
  39. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tests/test_service_manager_errors.py +5 -4
  40. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tests/test_service_manager_flows.py +6 -5
  41. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tests/test_service_staking.py +64 -38
  42. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tools/drain_accounts.py +2 -1
  43. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tools/reset_env.py +2 -1
  44. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tools/test_chainlist.py +5 -1
  45. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tui/screens/wallets.py +1 -3
  46. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/web/routers/olas/services.py +10 -5
  47. {iwa-0.0.58 → iwa-0.0.60/src/iwa.egg-info}/PKG-INFO +1 -1
  48. {iwa-0.0.58 → iwa-0.0.60}/src/iwa.egg-info/SOURCES.txt +1 -0
  49. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_balance_service.py +0 -2
  50. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_chain.py +1 -2
  51. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_chain_interface.py +3 -3
  52. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_rate_limiter.py +7 -5
  53. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_rate_limiter_retry.py +34 -33
  54. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_rpc_efficiency.py +4 -1
  55. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_rpc_rate_limit.py +4 -3
  56. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_rpc_rotation.py +4 -4
  57. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_safe_executor.py +76 -50
  58. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_safe_integration.py +11 -6
  59. {iwa-0.0.58 → iwa-0.0.60}/LICENSE +0 -0
  60. {iwa-0.0.58 → iwa-0.0.60}/README.md +0 -0
  61. {iwa-0.0.58 → iwa-0.0.60}/setup.cfg +0 -0
  62. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/__init__.py +0 -0
  63. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/__main__.py +0 -0
  64. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/__init__.py +0 -0
  65. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/chain/__init__.py +0 -0
  66. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/chain/errors.py +0 -0
  67. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/chain/manager.py +0 -0
  68. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/chain/models.py +0 -0
  69. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/constants.py +0 -0
  70. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/contracts/__init__.py +0 -0
  71. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/contracts/abis/erc20.json +0 -0
  72. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/contracts/abis/multisend.json +0 -0
  73. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/contracts/abis/multisend_call_only.json +0 -0
  74. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/contracts/erc20.py +0 -0
  75. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/contracts/multisend.py +0 -0
  76. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/db.py +0 -0
  77. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/mnemonic.py +0 -0
  78. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/monitor.py +0 -0
  79. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/plugins.py +0 -0
  80. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/secrets.py +0 -0
  81. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/services/__init__.py +0 -0
  82. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/services/account.py +0 -0
  83. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/services/plugin.py +0 -0
  84. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/services/transfer/__init__.py +0 -0
  85. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/services/transfer/base.py +0 -0
  86. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/services/transfer/multisend.py +0 -0
  87. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/services/transfer/swap.py +0 -0
  88. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/tables.py +0 -0
  89. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/test.py +0 -0
  90. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/tests/test_pricing.py +0 -0
  91. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/tests/test_wallet.py +0 -0
  92. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/types.py +0 -0
  93. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/core/ui.py +0 -0
  94. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/__init__.py +0 -0
  95. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/gnosis/__init__.py +0 -0
  96. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/gnosis/cow/__init__.py +0 -0
  97. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/gnosis/cow/quotes.py +0 -0
  98. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/gnosis/cow/swap.py +0 -0
  99. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/gnosis/cow/types.py +0 -0
  100. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/gnosis/cow_utils.py +0 -0
  101. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/gnosis/plugin.py +0 -0
  102. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/gnosis/safe.py +0 -0
  103. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/gnosis/tests/test_cow.py +0 -0
  104. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/gnosis/tests/test_safe.py +0 -0
  105. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/__init__.py +0 -0
  106. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/contracts/abis/activity_checker.json +0 -0
  107. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/contracts/abis/mech.json +0 -0
  108. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/contracts/abis/mech_marketplace.json +0 -0
  109. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/contracts/abis/mech_marketplace_v1.json +0 -0
  110. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/contracts/abis/mech_new.json +0 -0
  111. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/contracts/abis/service_manager.json +0 -0
  112. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/contracts/abis/service_registry.json +0 -0
  113. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/contracts/abis/service_registry_token_utility.json +0 -0
  114. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/contracts/abis/staking.json +0 -0
  115. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/contracts/abis/staking_token.json +0 -0
  116. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/contracts/base.py +0 -0
  117. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/contracts/mech.py +0 -0
  118. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/contracts/mech_marketplace.py +0 -0
  119. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/contracts/mech_marketplace_v1.py +0 -0
  120. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/contracts/service.py +0 -0
  121. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/mech_reference.py +0 -0
  122. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/models.py +0 -0
  123. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/scripts/test_full_mech_flow.py +0 -0
  124. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/scripts/test_simple_lifecycle.py +0 -0
  125. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/service_manager/__init__.py +0 -0
  126. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/service_manager/base.py +0 -0
  127. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/service_manager/mech.py +0 -0
  128. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tests/conftest.py +0 -0
  129. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tests/test_importer.py +0 -0
  130. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tests/test_importer_error_handling.py +0 -0
  131. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tests/test_mech_contracts.py +0 -0
  132. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tests/test_olas_contracts.py +0 -0
  133. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tests/test_olas_models.py +0 -0
  134. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tests/test_olas_view.py +0 -0
  135. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tests/test_olas_view_actions.py +0 -0
  136. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tests/test_olas_view_modals.py +0 -0
  137. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tests/test_plugin.py +0 -0
  138. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tests/test_plugin_full.py +0 -0
  139. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tests/test_service_lifecycle.py +0 -0
  140. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tests/test_service_manager_mech.py +0 -0
  141. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tests/test_service_manager_rewards.py +0 -0
  142. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tests/test_service_manager_validation.py +0 -0
  143. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tests/test_staking_integration.py +0 -0
  144. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tests/test_staking_validation.py +0 -0
  145. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tui/__init__.py +0 -0
  146. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/plugins/olas/tui/olas_view.py +0 -0
  147. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tools/__init__.py +0 -0
  148. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tools/check_profile.py +0 -0
  149. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tools/list_contracts.py +0 -0
  150. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tools/release.py +0 -0
  151. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tools/reset_tenderly.py +0 -0
  152. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tools/restore_backup.py +0 -0
  153. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tools/wallet_check.py +0 -0
  154. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tui/__init__.py +0 -0
  155. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tui/app.py +0 -0
  156. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tui/modals/__init__.py +0 -0
  157. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tui/modals/base.py +0 -0
  158. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tui/rpc.py +0 -0
  159. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tui/screens/__init__.py +0 -0
  160. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tui/tests/test_app.py +0 -0
  161. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tui/tests/test_rpc.py +0 -0
  162. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tui/tests/test_wallets_refactor.py +0 -0
  163. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tui/tests/test_widgets.py +0 -0
  164. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tui/widgets/__init__.py +0 -0
  165. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tui/widgets/base.py +0 -0
  166. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/tui/workers.py +0 -0
  167. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/web/dependencies.py +0 -0
  168. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/web/models.py +0 -0
  169. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/web/routers/accounts.py +0 -0
  170. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/web/routers/olas/__init__.py +0 -0
  171. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/web/routers/olas/admin.py +0 -0
  172. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/web/routers/olas/funding.py +0 -0
  173. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/web/routers/olas/general.py +0 -0
  174. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/web/routers/olas/staking.py +0 -0
  175. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/web/routers/state.py +0 -0
  176. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/web/routers/swap.py +0 -0
  177. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/web/routers/transactions.py +0 -0
  178. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/web/server.py +0 -0
  179. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/web/static/app.js +0 -0
  180. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/web/static/index.html +0 -0
  181. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/web/static/style.css +0 -0
  182. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/web/tests/test_web_endpoints.py +0 -0
  183. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/web/tests/test_web_olas.py +0 -0
  184. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/web/tests/test_web_swap.py +0 -0
  185. {iwa-0.0.58 → iwa-0.0.60}/src/iwa/web/tests/test_web_swap_coverage.py +0 -0
  186. {iwa-0.0.58 → iwa-0.0.60}/src/iwa.egg-info/dependency_links.txt +0 -0
  187. {iwa-0.0.58 → iwa-0.0.60}/src/iwa.egg-info/entry_points.txt +0 -0
  188. {iwa-0.0.58 → iwa-0.0.60}/src/iwa.egg-info/requires.txt +0 -0
  189. {iwa-0.0.58 → iwa-0.0.60}/src/iwa.egg-info/top_level.txt +0 -0
  190. {iwa-0.0.58 → iwa-0.0.60}/src/tests/legacy_cow.py +0 -0
  191. {iwa-0.0.58 → iwa-0.0.60}/src/tests/legacy_safe.py +0 -0
  192. {iwa-0.0.58 → iwa-0.0.60}/src/tests/legacy_transaction_retry_logic.py +0 -0
  193. {iwa-0.0.58 → iwa-0.0.60}/src/tests/legacy_tui.py +0 -0
  194. {iwa-0.0.58 → iwa-0.0.60}/src/tests/legacy_wallets_screen.py +0 -0
  195. {iwa-0.0.58 → iwa-0.0.60}/src/tests/legacy_web.py +0 -0
  196. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_account_service.py +0 -0
  197. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_chain_interface_coverage.py +0 -0
  198. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_cli.py +0 -0
  199. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_contract.py +0 -0
  200. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_db.py +0 -0
  201. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_drain_coverage.py +0 -0
  202. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_erc20.py +0 -0
  203. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_gnosis_plugin.py +0 -0
  204. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_keys.py +0 -0
  205. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_legacy_wallet.py +0 -0
  206. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_main.py +0 -0
  207. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_migration.py +0 -0
  208. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_mnemonic.py +0 -0
  209. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_modals.py +0 -0
  210. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_models.py +0 -0
  211. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_monitor.py +0 -0
  212. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_multisend.py +0 -0
  213. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_plugin_service.py +0 -0
  214. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_reset_tenderly.py +0 -0
  215. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_rpc_view.py +0 -0
  216. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_safe_coverage.py +0 -0
  217. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_safe_service.py +0 -0
  218. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_service_manager_integration.py +0 -0
  219. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_service_manager_structure.py +0 -0
  220. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_service_transaction.py +0 -0
  221. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_staking_router.py +0 -0
  222. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_staking_simple.py +0 -0
  223. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_tables.py +0 -0
  224. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_transaction_service.py +0 -0
  225. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_transfer_multisend.py +0 -0
  226. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_transfer_native.py +0 -0
  227. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_transfer_security.py +0 -0
  228. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_transfer_structure.py +0 -0
  229. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_transfer_swap_unit.py +0 -0
  230. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_ui_coverage.py +0 -0
  231. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_utils.py +0 -0
  232. {iwa-0.0.58 → iwa-0.0.60}/src/tests/test_workers.py +0 -0
  233. {iwa-0.0.58 → iwa-0.0.60}/src/tools/create_and_stake_service.py +0 -0
  234. {iwa-0.0.58 → iwa-0.0.60}/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.58
3
+ Version: 0.0.60
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.58"
3
+ version = "0.0.60"
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.58"
75
+ target-version = "0.0.60"
76
76
  fix = true
77
77
 
78
78
  [tool.ruff.lint]
@@ -4,6 +4,7 @@ import threading
4
4
  import time
5
5
  from typing import Callable, Dict, Optional, TypeVar, Union
6
6
 
7
+ import requests
7
8
  from web3 import Web3
8
9
 
9
10
  from iwa.core.chain.errors import TenderlyQuotaExceededError, sanitize_rpc_url
@@ -23,6 +24,12 @@ class ChainInterface:
23
24
 
24
25
  DEFAULT_MAX_RETRIES = 6 # Allow trying most/all available RPCs on rate limit
25
26
  DEFAULT_RETRY_DELAY = 1.0 # Base delay between retries (exponential backoff)
27
+ ROTATION_COOLDOWN_SECONDS = 2.0 # Minimum time between RPC rotations
28
+
29
+ # Per-error-type backoff durations (seconds) applied to the offending RPC.
30
+ RATE_LIMIT_BACKOFF = 10.0 # 429 Too Many Requests
31
+ QUOTA_EXCEEDED_BACKOFF = 300.0 # RPC quota exhausted (resets hourly/daily)
32
+ CONNECTION_ERROR_BACKOFF = 30.0 # Timeout / connection refused / DNS
26
33
 
27
34
  chain: SupportedChain
28
35
 
@@ -34,10 +41,9 @@ class ChainInterface:
34
41
  chain: SupportedChain = getattr(SupportedChains(), chain.lower())
35
42
 
36
43
  self.chain = chain
37
- # Enforce strict 1.0 RPS limit to prevent synchronization issues
38
- self._rate_limiter = get_rate_limiter(chain.name, rate=1.0, burst=1)
44
+ self._rate_limiter = get_rate_limiter(chain.name, rate=5.0, burst=10)
39
45
  self._current_rpc_index = 0
40
- self._rpc_failure_counts: Dict[int, int] = {}
46
+ self._rpc_backoff_until: Dict[int, float] = {} # index -> monotonic expiry
41
47
  self._last_rotation_time = 0.0 # Monotonic timestamp of last rotation
42
48
 
43
49
  if self.chain.rpc and self.chain.rpc.startswith("http://"):
@@ -48,6 +54,7 @@ class ChainInterface:
48
54
 
49
55
  self._initial_block = 0
50
56
  self._rotation_lock = threading.Lock()
57
+ self._session = requests.Session()
51
58
  self._init_web3()
52
59
 
53
60
  @property
@@ -226,6 +233,34 @@ class ChainInterface:
226
233
  ]
227
234
  return any(signal in err_text for signal in gas_signals)
228
235
 
236
+ def _is_quota_exceeded_error(self, error: Exception) -> bool:
237
+ """Check if the RPC's usage quota has been exhausted.
238
+
239
+ JSON-RPC code -32001 with messages like "Exceeded the quota usage"
240
+ indicates the provider's daily/hourly quota is spent. This is NOT
241
+ a transient 429 rate-limit; the RPC will reject ALL requests until
242
+ the quota resets, so it must be backed off for a long period.
243
+ """
244
+ err_text = str(error).lower()
245
+ quota_signals = [
246
+ "exceeded the quota",
247
+ "exceeded quota",
248
+ "quota usage",
249
+ "quota exceeded",
250
+ "allowance exceeded",
251
+ ]
252
+ return any(signal in err_text for signal in quota_signals)
253
+
254
+ # -- Per-RPC health tracking ------------------------------------------
255
+
256
+ def _mark_rpc_backoff(self, index: int, seconds: float) -> None:
257
+ """Mark an RPC as temporarily unavailable for *seconds*."""
258
+ self._rpc_backoff_until[index] = time.monotonic() + seconds
259
+
260
+ def _is_rpc_healthy(self, index: int) -> bool:
261
+ """Return True if the RPC at *index* is not in backoff."""
262
+ return time.monotonic() >= self._rpc_backoff_until.get(index, 0.0)
263
+
229
264
  def _handle_rpc_error(self, error: Exception) -> Dict[str, Union[bool, int]]:
230
265
  """Handle RPC errors with smart rotation and retry logic."""
231
266
  result: Dict[str, Union[bool, int]] = {
@@ -234,6 +269,7 @@ class ChainInterface:
234
269
  "is_server_error": self._is_server_error(error),
235
270
  "is_gas_error": self._is_gas_error(error),
236
271
  "is_tenderly_quota": self._is_tenderly_quota_exceeded(error),
272
+ "is_quota_exceeded": self._is_quota_exceeded_error(error),
237
273
  "rotated": False,
238
274
  "should_retry": False,
239
275
  }
@@ -248,19 +284,33 @@ class ChainInterface:
248
284
  "Run 'uv run -m iwa.tools.reset_tenderly' to reset."
249
285
  )
250
286
 
251
- self._rpc_failure_counts[self._current_rpc_index] = (
252
- self._rpc_failure_counts.get(self._current_rpc_index, 0) + 1
287
+ # Determine if we need to rotate and what backoff to apply.
288
+ should_rotate = (
289
+ result["is_rate_limit"]
290
+ or result["is_connection_error"]
291
+ or result["is_quota_exceeded"]
253
292
  )
254
293
 
255
- should_rotate = result["is_rate_limit"] or result["is_connection_error"]
256
-
257
294
  if should_rotate:
258
- error_type = "rate limit" if result["is_rate_limit"] else "connection"
259
- # Extract the original URL from the error message for clarity
260
- error_msg = str(error)
295
+ failed_index = self._current_rpc_index
296
+
297
+ # Apply per-RPC backoff so smart rotation skips this RPC.
298
+ if result["is_quota_exceeded"]:
299
+ error_type = "quota exceeded"
300
+ self._mark_rpc_backoff(failed_index, self.QUOTA_EXCEEDED_BACKOFF)
301
+ elif result["is_rate_limit"]:
302
+ error_type = "rate limit"
303
+ self._mark_rpc_backoff(failed_index, self.RATE_LIMIT_BACKOFF)
304
+ # Brief global backoff so other threads don't immediately flood
305
+ # the same (now backed-off) RPC before rotation takes effect.
306
+ self._rate_limiter.trigger_backoff(seconds=2.0)
307
+ else:
308
+ error_type = "connection"
309
+ self._mark_rpc_backoff(failed_index, self.CONNECTION_ERROR_BACKOFF)
310
+
261
311
  logger.warning(
262
312
  f"RPC {error_type} error on {self.chain.name} "
263
- f"(current RPC #{self._current_rpc_index}): {error_msg}"
313
+ f"(RPC #{failed_index}): {error}"
264
314
  )
265
315
 
266
316
  if self.rotate_rpc():
@@ -268,14 +318,11 @@ class ChainInterface:
268
318
  result["should_retry"] = True
269
319
  logger.info(f"Rotated to RPC #{self._current_rpc_index} for {self.chain.name}")
270
320
  else:
271
- if result["is_rate_limit"]:
272
- # Rotation was skipped (cooldown or single RPC) - still allow retry with current RPC
273
- # We don't trigger backoff here because that would block ALL threads.
274
- # Instead, we let the individual thread retry (which has its own exponential backoff).
275
- result["should_retry"] = True
276
- logger.info(
277
- f"RPC rotation skipped, retrying with current RPC #{self._current_rpc_index}"
278
- )
321
+ # Rotation skipped (cooldown or single RPC) - still allow retry
322
+ result["should_retry"] = True
323
+ logger.info(
324
+ f"RPC rotation skipped, retrying with current RPC #{self._current_rpc_index}"
325
+ )
279
326
 
280
327
  elif result["is_server_error"]:
281
328
  logger.warning(f"Server error on {self.chain.name}: {error}")
@@ -288,33 +335,40 @@ class ChainInterface:
288
335
  return result
289
336
 
290
337
  def rotate_rpc(self) -> bool:
291
- """Rotate to the next available RPC."""
292
- # Minimum time between rotations to prevent cascade rotations from parallel requests
293
- # failing simultaneously
294
- cooldown_seconds = 2.0
295
-
338
+ """Rotate to the next healthy RPC, skipping those in backoff."""
296
339
  with self._rotation_lock:
297
- if not self.chain.rpcs or len(self.chain.rpcs) <= 1:
340
+ n = len(self.chain.rpcs) if self.chain.rpcs else 0
341
+ if n <= 1:
298
342
  return False
299
343
 
300
344
  # Cooldown: prevent cascade rotations from in-flight requests
301
345
  now = time.monotonic()
302
- if now - self._last_rotation_time < cooldown_seconds:
303
- logger.debug(
304
- f"RPC rotation skipped for {self.chain.name} (cooldown active, "
305
- f"{cooldown_seconds - (now - self._last_rotation_time):.1f}s remaining)"
306
- )
346
+ if now - self._last_rotation_time < self.ROTATION_COOLDOWN_SECONDS:
307
347
  return False
308
348
 
309
- # Simple Round Robin rotation
310
- self._current_rpc_index = (self._current_rpc_index + 1) % len(self.chain.rpcs)
311
- # Internal call to _init_web3 already expects to be under lock if called from here,
312
- # but _init_web3 itself doesn't have a lock. Let's make it consistent.
349
+ # Try each other RPC in round-robin order, preferring healthy ones.
350
+ best: Optional[int] = None
351
+ for offset in range(1, n):
352
+ candidate = (self._current_rpc_index + offset) % n
353
+ if self._is_rpc_healthy(candidate):
354
+ best = candidate
355
+ break
356
+
357
+ if best is None:
358
+ # All RPCs are in backoff — pick the one whose backoff expires soonest.
359
+ best = min(
360
+ (i for i in range(n) if i != self._current_rpc_index),
361
+ key=lambda i: self._rpc_backoff_until.get(i, 0.0),
362
+ )
363
+
364
+ self._current_rpc_index = best
313
365
  self._init_web3_under_lock()
314
366
  self._last_rotation_time = now
315
367
 
368
+ healthy_tag = "" if self._is_rpc_healthy(best) else " (still in backoff)"
316
369
  logger.info(
317
- f"Rotated RPC for {self.chain.name} to index {self._current_rpc_index}: {self.chain.rpcs[self._current_rpc_index]}"
370
+ f"Rotated RPC for {self.chain.name} to index {best}: "
371
+ f"{self.chain.rpcs[best]}{healthy_tag}"
318
372
  )
319
373
  return True
320
374
 
@@ -326,7 +380,11 @@ class ChainInterface:
326
380
  def _init_web3_under_lock(self):
327
381
  """Internal non-thread-safe web3 initialization."""
328
382
  rpc_url = self.chain.rpcs[self._current_rpc_index] if self.chain.rpcs else ""
329
- raw_web3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": DEFAULT_RPC_TIMEOUT}))
383
+ raw_web3 = Web3(
384
+ Web3.HTTPProvider(
385
+ rpc_url, request_kwargs={"timeout": DEFAULT_RPC_TIMEOUT}, session=self._session
386
+ )
387
+ )
330
388
 
331
389
  # Use duck typing to check if current web3 is a RateLimitedWeb3 wrapper
332
390
  if hasattr(self, "web3") and hasattr(self.web3, "set_backend"):
@@ -409,7 +467,9 @@ class ChainInterface:
409
467
  except Exception:
410
468
  return address[:6] + "..." + address[-4:]
411
469
 
412
- def get_token_decimals(self, address: EthereumAddress, fallback_to_18: bool = True) -> Optional[int]:
470
+ def get_token_decimals(
471
+ self, address: EthereumAddress, fallback_to_18: bool = True
472
+ ) -> Optional[int]:
413
473
  """Get token decimals for an address.
414
474
 
415
475
  Args:
@@ -426,7 +486,15 @@ class ChainInterface:
426
486
  # Use _web3 directly to ensure current provider after RPC rotation
427
487
  contract = self.web3._web3.eth.contract(
428
488
  address=self.web3.to_checksum_address(address),
429
- abi=[{"constant": True, "inputs": [], "name": "decimals", "outputs": [{"type": "uint8"}], "type": "function"}]
489
+ abi=[
490
+ {
491
+ "constant": True,
492
+ "inputs": [],
493
+ "name": "decimals",
494
+ "outputs": [{"type": "uint8"}],
495
+ "type": "function",
496
+ }
497
+ ],
430
498
  )
431
499
  return contract.functions.decimals().call()
432
500
  except Exception:
@@ -475,8 +543,10 @@ class ChainInterface:
475
543
  # Contract calls already have the target address in the contract object
476
544
  if not built_method and "to" in tx_params:
477
545
  params["to"] = tx_params["to"]
478
- elif not built_method and "to" in params: # Fallback if added to params earlier (though not here yet)
479
- pass
546
+ elif (
547
+ not built_method and "to" in params
548
+ ): # Fallback if added to params earlier (though not here yet)
549
+ pass
480
550
 
481
551
  # Determine gas
482
552
  if built_method:
@@ -489,11 +559,7 @@ class ChainInterface:
489
559
  # Native transfer - dynamic estimation
490
560
  try:
491
561
  # web3.eth.estimate_gas returns gas for the dict it receives
492
- est_params = {
493
- "from": params["from"],
494
- "to": params["to"],
495
- "value": params["value"]
496
- }
562
+ est_params = {"from": params["from"], "to": params["to"], "value": params["value"]}
497
563
  # Remove None 'to' for contract creation simulation if needed, but usually send() has to
498
564
  if not est_params["to"]:
499
565
  est_params.pop("to")
@@ -501,7 +567,9 @@ class ChainInterface:
501
567
  estimated = self.web3.eth.estimate_gas(est_params)
502
568
  # Apply 10% buffer for safety
503
569
  params["gas"] = int(estimated * 1.1)
504
- logger.debug(f"[GAS] Estimated native transfer gas: {params['gas']} (raw: {estimated})")
570
+ logger.debug(
571
+ f"[GAS] Estimated native transfer gas: {params['gas']} (raw: {estimated})"
572
+ )
505
573
  except Exception as e:
506
574
  logger.debug(f"[GAS] Native estimation failed, fallback to 21000: {e}")
507
575
  params["gas"] = 21_000
@@ -533,10 +601,7 @@ class ChainInterface:
533
601
  # Buffer max_fee to handle base fee expansion
534
602
  max_fee = int(base_fee * 1.5) + max_priority_fee
535
603
 
536
- return {
537
- "maxFeePerGas": max_fee,
538
- "maxPriorityFeePerGas": max_priority_fee
539
- }
604
+ return {"maxFeePerGas": max_fee, "maxPriorityFeePerGas": max_priority_fee}
540
605
  except Exception as e:
541
606
  logger.debug(f"Failed to calculate EIP-1559 fees: {e}, falling back to legacy")
542
607
 
@@ -572,6 +637,6 @@ class ChainInterface:
572
637
  return self.chain.contracts.get(contract_name)
573
638
 
574
639
  def reset_rpc_failure_counts(self):
575
- """Reset RPC failure tracking. Call periodically to allow retrying failed RPCs."""
576
- self._rpc_failure_counts.clear()
577
- logger.debug("Reset RPC failure counts")
640
+ """Reset RPC backoff tracking. Call periodically to allow retrying backed-off RPCs."""
641
+ self._rpc_backoff_until.clear()
642
+ logger.debug("Reset RPC backoff tracking")
@@ -128,9 +128,22 @@ class RateLimitedEth:
128
128
  # Helper sets for efficient lookup
129
129
  RPC_METHODS = READ_METHODS | WRITE_METHODS
130
130
 
131
- DEFAULT_READ_RETRIES = 3
131
+ DEFAULT_READ_RETRIES = 1 # Keep low; ChainInterface.with_retry handles cross-RPC retries
132
132
  DEFAULT_READ_RETRY_DELAY = 0.5
133
133
 
134
+ # Only retry errors that are clearly transient network issues.
135
+ # Rate-limit / quota / server errors propagate up to with_retry for rotation.
136
+ TRANSIENT_SIGNALS = (
137
+ "timeout",
138
+ "timed out",
139
+ "connection reset",
140
+ "connection refused",
141
+ "connection aborted",
142
+ "broken pipe",
143
+ "eof",
144
+ "remote end closed",
145
+ )
146
+
134
147
  def __init__(self, web3_eth, rate_limiter: RPCRateLimiter, chain_interface: "ChainInterface"):
135
148
  """Initialize RateLimitedEth wrapper."""
136
149
  object.__setattr__(self, "_eth", web3_eth)
@@ -187,29 +200,39 @@ class RateLimitedEth:
187
200
  return wrapper
188
201
 
189
202
  def _execute_with_retry(self, method, method_name, *args, **kwargs):
190
- """Execute read operation with retry logic."""
191
- last_error = None
203
+ """Execute a read operation with limited retry for transient errors.
204
+
205
+ Only connection-level failures (timeout, reset, broken pipe) are
206
+ retried here. Rate-limit, quota, and server errors propagate up
207
+ to ``ChainInterface.with_retry`` which handles RPC rotation.
208
+ This avoids the double-retry amplification that previously caused
209
+ up to 4x7 = 28 RPC requests per logical call.
210
+ """
192
211
  for attempt in range(self.DEFAULT_READ_RETRIES + 1):
193
212
  try:
194
213
  return method(*args, **kwargs)
195
214
  except Exception as e:
196
- last_error = e
197
- # Use chain interface to handle error (logging, rotation, etc.)
198
- result = self._chain_interface._handle_rpc_error(e)
215
+ if attempt >= self.DEFAULT_READ_RETRIES:
216
+ raise
199
217
 
200
- if not result["should_retry"] or attempt >= self.DEFAULT_READ_RETRIES:
218
+ # Only retry clearly transient network errors.
219
+ err_text = str(e).lower()
220
+ if not any(signal in err_text for signal in self.TRANSIENT_SIGNALS):
201
221
  raise
202
222
 
223
+ # Re-acquire a rate-limiter token before retrying.
224
+ if not self._rate_limiter.acquire(timeout=30.0):
225
+ raise TimeoutError(
226
+ f"Rate limit timeout for retry of {method_name}"
227
+ ) from e
228
+
203
229
  delay = self.DEFAULT_READ_RETRY_DELAY * (2**attempt)
204
230
  logger.debug(
205
- f"{method_name} attempt {attempt + 1} failed, retrying in {delay:.1f}s..."
231
+ f"{method_name} attempt {attempt + 1} failed (transient), "
232
+ f"retrying in {delay:.1f}s..."
206
233
  )
207
234
  time.sleep(delay)
208
235
 
209
- if last_error:
210
- raise last_error
211
- raise RuntimeError(f"{method_name} failed unexpectedly")
212
-
213
236
 
214
237
  class RateLimitedWeb3:
215
238
  """Wrapper around Web3 instance that applies rate limiting transparently."""
@@ -1,4 +1,5 @@
1
1
  """Module for fetching and parsing RPCs from Chainlist.org."""
2
+
2
3
  import json
3
4
  import time
4
5
  from dataclasses import dataclass
@@ -78,7 +79,7 @@ class ChainlistRPC:
78
79
  self.fetch_data()
79
80
 
80
81
  for entry in self._data:
81
- if entry.get('chainId') == chain_id:
82
+ if entry.get("chainId") == chain_id:
82
83
  return entry
83
84
  return None
84
85
 
@@ -88,22 +89,25 @@ class ChainlistRPC:
88
89
  if not chain_data:
89
90
  return []
90
91
 
91
- raw_rpcs = chain_data.get('rpc', [])
92
+ raw_rpcs = chain_data.get("rpc", [])
92
93
  nodes = []
93
94
  for rpc in raw_rpcs:
94
- nodes.append(RPCNode(
95
- url=rpc.get('url', ''),
96
- is_working=True,
97
- privacy=rpc.get('privacy'),
98
- tracking=rpc.get('tracking')
99
- ))
95
+ nodes.append(
96
+ RPCNode(
97
+ url=rpc.get("url", ""),
98
+ is_working=True,
99
+ privacy=rpc.get("privacy"),
100
+ tracking=rpc.get("tracking"),
101
+ )
102
+ )
100
103
  return nodes
101
104
 
102
105
  def get_https_rpcs(self, chain_id: int) -> List[str]:
103
106
  """Returns a list of HTTPS RPC URLs for the given chain."""
104
107
  rpcs = self.get_rpcs(chain_id)
105
108
  return [
106
- node.url for node in rpcs
109
+ node.url
110
+ for node in rpcs
107
111
  if node.url.startswith("https://") or node.url.startswith("http://")
108
112
  ]
109
113
 
@@ -111,6 +115,7 @@ class ChainlistRPC:
111
115
  """Returns a list of WSS RPC URLs for the given chain."""
112
116
  rpcs = self.get_rpcs(chain_id)
113
117
  return [
114
- node.url for node in rpcs
118
+ node.url
119
+ for node in rpcs
115
120
  if node.url.startswith("wss://") or node.url.startswith("ws://")
116
121
  ]
@@ -16,13 +16,16 @@ from iwa.tui.app import IwaApp
16
16
 
17
17
  iwa_cli = typer.Typer(help="iwa command line interface")
18
18
 
19
+
19
20
  @iwa_cli.callback()
20
21
  def main_callback(ctx: typer.Context):
21
22
  """Initialize IWA CLI."""
22
23
  # Print banner on startup
23
24
  from iwa.core.utils import get_version, print_banner
25
+
24
26
  print_banner("iwa", get_version("iwa"))
25
27
 
28
+
26
29
  wallet_cli = typer.Typer(help="Manage wallet")
27
30
 
28
31
  iwa_cli.add_typer(wallet_cli, name="wallet")
@@ -62,7 +62,7 @@ class ContractCache:
62
62
 
63
63
  key = self._make_key(contract_cls, address, chain_name)
64
64
  now = time.time()
65
- expiry = (ttl if ttl is not None else self.ttl)
65
+ expiry = ttl if ttl is not None else self.ttl
66
66
 
67
67
  with self._lock:
68
68
  # Check if cached and valid
@@ -31,6 +31,7 @@ def clear_abi_cache() -> None:
31
31
  global _ABI_CACHE
32
32
  _ABI_CACHE = {}
33
33
 
34
+
34
35
  # Panic codes (from Solidity)
35
36
  # ... (rest of PANIC_CODES) ...
36
37
  PANIC_CODES = {
@@ -58,7 +58,7 @@ class ErrorDecoder:
58
58
  # Also check core ABIs if they are in a different place
59
59
  core_abi_path = src_root / "iwa" / "core" / "contracts" / "abis"
60
60
  if core_abi_path.exists() and core_abi_path not in [f.parent for f in abi_files]:
61
- abi_files.extend(list(core_abi_path.glob("*.json")))
61
+ abi_files.extend(list(core_abi_path.glob("*.json")))
62
62
 
63
63
  logger.debug(f"Found {len(abi_files)} ABI files for error decoding.")
64
64
 
@@ -66,7 +66,11 @@ class ErrorDecoder:
66
66
  try:
67
67
  with open(abi_path, "r", encoding="utf-8") as f:
68
68
  content = json.load(f)
69
- abi = content.get("abi") if isinstance(content, dict) and "abi" in content else content
69
+ abi = (
70
+ content.get("abi")
71
+ if isinstance(content, dict) and "abi" in content
72
+ else content
73
+ )
70
74
  if isinstance(abi, list):
71
75
  self._process_abi(abi, abi_path.name)
72
76
  except Exception as e:
@@ -91,7 +95,7 @@ class ErrorDecoder:
91
95
  "types": types,
92
96
  "arg_names": names,
93
97
  "source": source_name,
94
- "signature": signature
98
+ "signature": signature,
95
99
  }
96
100
 
97
101
  if selector not in self._selectors:
@@ -145,7 +149,9 @@ class ErrorDecoder:
145
149
  for d in self._selectors[selector]:
146
150
  try:
147
151
  decoded = decode(d["types"], bytes.fromhex(encoded_args))
148
- args_str = ", ".join(f"{n}={v}" for n, v in zip(d["arg_names"], decoded, strict=False))
152
+ args_str = ", ".join(
153
+ f"{n}={v}" for n, v in zip(d["arg_names"], decoded, strict=False)
154
+ )
149
155
  results.append((d["name"], f"{d['name']}({args_str})", d["source"]))
150
156
  except Exception:
151
157
  # Try next possible decoding for this selector
@@ -0,0 +1,31 @@
1
+ """Shared HTTP session utilities."""
2
+
3
+ import requests
4
+ from requests.adapters import HTTPAdapter
5
+ from urllib3.util.retry import Retry
6
+
7
+ DEFAULT_RETRY_TOTAL = 3
8
+ DEFAULT_BACKOFF_FACTOR = 1
9
+ DEFAULT_STATUS_FORCELIST = [429, 500, 502, 503, 504]
10
+
11
+
12
+ def create_retry_session(
13
+ retries: int = DEFAULT_RETRY_TOTAL,
14
+ backoff_factor: int = DEFAULT_BACKOFF_FACTOR,
15
+ status_forcelist: list[int] | None = None,
16
+ ) -> requests.Session:
17
+ """Create a requests.Session with retry strategy.
18
+
19
+ Used by PriceService, IPFS, and other modules that need
20
+ persistent HTTP connections with automatic retry.
21
+ """
22
+ session = requests.Session()
23
+ retry_strategy = Retry(
24
+ total=retries,
25
+ backoff_factor=backoff_factor,
26
+ status_forcelist=status_forcelist or DEFAULT_STATUS_FORCELIST,
27
+ )
28
+ adapter = HTTPAdapter(max_retries=retry_strategy)
29
+ session.mount("https://", adapter)
30
+ session.mount("http://", adapter)
31
+ return session
@@ -12,14 +12,15 @@ from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple
12
12
  import aiohttp
13
13
  from multiformats import CID
14
14
 
15
+ from iwa.core.http import create_retry_session
15
16
  from iwa.core.models import Config
16
17
 
17
18
  if TYPE_CHECKING:
18
19
  import requests
19
20
 
20
- # Global session for sync requests
21
+ # Global persistent sessions (reused across calls to prevent FD leaks)
21
22
  _SYNC_SESSION: Optional["requests.Session"] = None
22
-
23
+ _ASYNC_SESSION: Optional[aiohttp.ClientSession] = None
23
24
 
24
25
 
25
26
  def _compute_cid_v1_hex(data: bytes) -> str:
@@ -63,10 +64,13 @@ async def push_to_ipfs_async(
63
64
  form = aiohttp.FormData()
64
65
  form.add_field("file", data, filename="data", content_type="application/octet-stream")
65
66
 
66
- async with aiohttp.ClientSession() as session:
67
- async with session.post(endpoint, data=form, params=params) as response:
68
- response.raise_for_status()
69
- result = await response.json()
67
+ global _ASYNC_SESSION
68
+ if _ASYNC_SESSION is None or _ASYNC_SESSION.closed:
69
+ _ASYNC_SESSION = aiohttp.ClientSession()
70
+
71
+ async with _ASYNC_SESSION.post(endpoint, data=form, params=params) as response:
72
+ response.raise_for_status()
73
+ result = await response.json()
70
74
 
71
75
  cid_str = result["Hash"]
72
76
  cid = CID.decode(cid_str)
@@ -90,22 +94,10 @@ def push_to_ipfs_sync(
90
94
  :param pin: Whether to pin the content (default True).
91
95
  :return: Tuple of (CIDv1 string, CIDv1 hex representation).
92
96
  """
93
- import requests
94
- from requests.adapters import HTTPAdapter
95
- from urllib3.util.retry import Retry
96
-
97
97
  global _SYNC_SESSION
98
98
 
99
99
  if _SYNC_SESSION is None:
100
- _SYNC_SESSION = requests.Session()
101
- retry_strategy = Retry(
102
- total=3,
103
- backoff_factor=1,
104
- status_forcelist=[429, 500, 502, 503, 504],
105
- )
106
- adapter = HTTPAdapter(max_retries=retry_strategy)
107
- _SYNC_SESSION.mount("http://", adapter)
108
- _SYNC_SESSION.mount("https://", adapter)
100
+ _SYNC_SESSION = create_retry_session()
109
101
 
110
102
  url = api_url or Config().core.ipfs_api_url
111
103
  endpoint = f"{url}/api/v0/add"
@@ -249,7 +249,7 @@ class KeyStorage(BaseModel):
249
249
 
250
250
  with open(self._path, "w", encoding="utf-8") as f:
251
251
  # Use mode='json' to ensure all types (EthereumAddress) are correctly serialized
252
- json.dump(self.model_dump(mode='json'), f, indent=4)
252
+ json.dump(self.model_dump(mode="json"), f, indent=4)
253
253
  f.flush()
254
254
  os.fsync(f.fileno()) # Force write to disk (critical for Docker volumes)
255
255
 
@@ -376,10 +376,14 @@ class KeyStorage(BaseModel):
376
376
  # Check for duplicate tags
377
377
  for existing in self.accounts.values():
378
378
  if existing.tag == account.tag and existing.address != account.address:
379
- raise ValueError(f"Tag '{account.tag}' is already used by address {existing.address}")
379
+ raise ValueError(
380
+ f"Tag '{account.tag}' is already used by address {existing.address}"
381
+ )
380
382
 
381
383
  self.accounts[account.address] = account
382
- logger.info(f"[KeyStorage] Registering account: tag='{account.tag}', address={account.address}")
384
+ logger.info(
385
+ f"[KeyStorage] Registering account: tag='{account.tag}', address={account.address}"
386
+ )
383
387
  self.save()
384
388
 
385
389
  def get_pending_mnemonic(self) -> Optional[str]:
@@ -460,7 +464,9 @@ class KeyStorage(BaseModel):
460
464
 
461
465
  old_tag = account.tag
462
466
  account.tag = new_tag
463
- logger.info(f"[KeyStorage] Renaming account: '{old_tag}' -> '{new_tag}' (address={account.address})")
467
+ logger.info(
468
+ f"[KeyStorage] Renaming account: '{old_tag}' -> '{new_tag}' (address={account.address})"
469
+ )
464
470
  self.save()
465
471
 
466
472
  def _get_private_key(self, address: str) -> Optional[str]: