iwa 0.0.59__tar.gz → 0.0.61__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.59/src/iwa.egg-info → iwa-0.0.61}/PKG-INFO +1 -1
  2. {iwa-0.0.59 → iwa-0.0.61}/pyproject.toml +2 -2
  3. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/chain/interface.py +151 -36
  4. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/chain/manager.py +8 -0
  5. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/chain/rate_limiter.py +35 -6
  6. iwa-0.0.61/src/iwa/core/chainlist.py +299 -0
  7. {iwa-0.0.59 → iwa-0.0.61/src/iwa.egg-info}/PKG-INFO +1 -1
  8. {iwa-0.0.59 → iwa-0.0.61}/src/iwa.egg-info/SOURCES.txt +1 -0
  9. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_chain_interface.py +3 -3
  10. iwa-0.0.61/src/tests/test_chainlist_enrichment.py +354 -0
  11. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_rate_limiter.py +7 -5
  12. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_rate_limiter_retry.py +33 -27
  13. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_rpc_rate_limit.py +3 -3
  14. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_safe_executor.py +208 -0
  15. iwa-0.0.61/src/tests/test_transaction_service.py +355 -0
  16. iwa-0.0.59/src/iwa/core/chainlist.py +0 -121
  17. iwa-0.0.59/src/tests/test_transaction_service.py +0 -179
  18. {iwa-0.0.59 → iwa-0.0.61}/LICENSE +0 -0
  19. {iwa-0.0.59 → iwa-0.0.61}/README.md +0 -0
  20. {iwa-0.0.59 → iwa-0.0.61}/setup.cfg +0 -0
  21. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/__init__.py +0 -0
  22. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/__main__.py +0 -0
  23. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/__init__.py +0 -0
  24. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/chain/__init__.py +0 -0
  25. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/chain/errors.py +0 -0
  26. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/chain/models.py +0 -0
  27. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/cli.py +0 -0
  28. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/constants.py +0 -0
  29. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/contracts/__init__.py +0 -0
  30. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/contracts/abis/erc20.json +0 -0
  31. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/contracts/abis/multisend.json +0 -0
  32. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/contracts/abis/multisend_call_only.json +0 -0
  33. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/contracts/cache.py +0 -0
  34. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/contracts/contract.py +0 -0
  35. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/contracts/decoder.py +0 -0
  36. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/contracts/erc20.py +0 -0
  37. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/contracts/multisend.py +0 -0
  38. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/db.py +0 -0
  39. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/http.py +0 -0
  40. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/ipfs.py +0 -0
  41. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/keys.py +0 -0
  42. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/mnemonic.py +0 -0
  43. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/models.py +0 -0
  44. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/monitor.py +0 -0
  45. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/plugins.py +0 -0
  46. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/pricing.py +0 -0
  47. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/rpc_monitor.py +0 -0
  48. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/secrets.py +0 -0
  49. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/__init__.py +0 -0
  50. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/account.py +0 -0
  51. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/balance.py +0 -0
  52. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/plugin.py +0 -0
  53. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/safe.py +0 -0
  54. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/safe_executor.py +0 -0
  55. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/transaction.py +0 -0
  56. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/transfer/__init__.py +0 -0
  57. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/transfer/base.py +0 -0
  58. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/transfer/erc20.py +0 -0
  59. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/transfer/multisend.py +0 -0
  60. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/transfer/native.py +0 -0
  61. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/transfer/swap.py +0 -0
  62. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/tables.py +0 -0
  63. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/test.py +0 -0
  64. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/tests/test_gnosis_fee.py +0 -0
  65. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/tests/test_ipfs.py +0 -0
  66. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/tests/test_pricing.py +0 -0
  67. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/tests/test_regression_fixes.py +0 -0
  68. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/tests/test_wallet.py +0 -0
  69. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/types.py +0 -0
  70. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/ui.py +0 -0
  71. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/utils.py +0 -0
  72. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/wallet.py +0 -0
  73. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/__init__.py +0 -0
  74. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/gnosis/__init__.py +0 -0
  75. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/gnosis/cow/__init__.py +0 -0
  76. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/gnosis/cow/quotes.py +0 -0
  77. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/gnosis/cow/swap.py +0 -0
  78. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/gnosis/cow/types.py +0 -0
  79. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/gnosis/cow_utils.py +0 -0
  80. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/gnosis/plugin.py +0 -0
  81. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/gnosis/safe.py +0 -0
  82. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/gnosis/tests/test_cow.py +0 -0
  83. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/gnosis/tests/test_safe.py +0 -0
  84. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/__init__.py +0 -0
  85. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/constants.py +0 -0
  86. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/abis/activity_checker.json +0 -0
  87. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/abis/mech.json +0 -0
  88. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/abis/mech_marketplace.json +0 -0
  89. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/abis/mech_marketplace_v1.json +0 -0
  90. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/abis/mech_new.json +0 -0
  91. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/abis/service_manager.json +0 -0
  92. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/abis/service_registry.json +0 -0
  93. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/abis/service_registry_token_utility.json +0 -0
  94. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/abis/staking.json +0 -0
  95. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/abis/staking_token.json +0 -0
  96. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/activity_checker.py +0 -0
  97. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/base.py +0 -0
  98. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/mech.py +0 -0
  99. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/mech_marketplace.py +0 -0
  100. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/mech_marketplace_v1.py +0 -0
  101. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/service.py +0 -0
  102. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/staking.py +0 -0
  103. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/events.py +0 -0
  104. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/importer.py +0 -0
  105. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/mech_reference.py +0 -0
  106. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/models.py +0 -0
  107. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/plugin.py +0 -0
  108. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/scripts/test_full_mech_flow.py +0 -0
  109. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/scripts/test_simple_lifecycle.py +0 -0
  110. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/service_manager/__init__.py +0 -0
  111. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/service_manager/base.py +0 -0
  112. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/service_manager/drain.py +0 -0
  113. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/service_manager/lifecycle.py +0 -0
  114. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/service_manager/mech.py +0 -0
  115. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/service_manager/staking.py +0 -0
  116. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/conftest.py +0 -0
  117. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_importer.py +0 -0
  118. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_importer_error_handling.py +0 -0
  119. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_mech_contracts.py +0 -0
  120. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_olas_archiving.py +0 -0
  121. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_olas_contracts.py +0 -0
  122. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_olas_integration.py +0 -0
  123. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_olas_models.py +0 -0
  124. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_olas_view.py +0 -0
  125. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_olas_view_actions.py +0 -0
  126. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_olas_view_modals.py +0 -0
  127. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_plugin.py +0 -0
  128. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_plugin_full.py +0 -0
  129. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_service_lifecycle.py +0 -0
  130. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_service_manager.py +0 -0
  131. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_service_manager_errors.py +0 -0
  132. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_service_manager_flows.py +0 -0
  133. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_service_manager_mech.py +0 -0
  134. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_service_manager_rewards.py +0 -0
  135. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_service_manager_validation.py +0 -0
  136. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_service_staking.py +0 -0
  137. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_staking_integration.py +0 -0
  138. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_staking_validation.py +0 -0
  139. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tui/__init__.py +0 -0
  140. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tui/olas_view.py +0 -0
  141. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tools/__init__.py +0 -0
  142. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tools/check_profile.py +0 -0
  143. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tools/drain_accounts.py +0 -0
  144. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tools/list_contracts.py +0 -0
  145. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tools/release.py +0 -0
  146. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tools/reset_env.py +0 -0
  147. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tools/reset_tenderly.py +0 -0
  148. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tools/restore_backup.py +0 -0
  149. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tools/test_chainlist.py +0 -0
  150. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tools/wallet_check.py +0 -0
  151. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/__init__.py +0 -0
  152. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/app.py +0 -0
  153. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/modals/__init__.py +0 -0
  154. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/modals/base.py +0 -0
  155. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/rpc.py +0 -0
  156. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/screens/__init__.py +0 -0
  157. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/screens/wallets.py +0 -0
  158. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/tests/test_app.py +0 -0
  159. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/tests/test_rpc.py +0 -0
  160. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/tests/test_wallets_refactor.py +0 -0
  161. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/tests/test_widgets.py +0 -0
  162. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/widgets/__init__.py +0 -0
  163. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/widgets/base.py +0 -0
  164. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/workers.py +0 -0
  165. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/dependencies.py +0 -0
  166. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/models.py +0 -0
  167. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/routers/accounts.py +0 -0
  168. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/routers/olas/__init__.py +0 -0
  169. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/routers/olas/admin.py +0 -0
  170. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/routers/olas/funding.py +0 -0
  171. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/routers/olas/general.py +0 -0
  172. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/routers/olas/services.py +0 -0
  173. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/routers/olas/staking.py +0 -0
  174. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/routers/state.py +0 -0
  175. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/routers/swap.py +0 -0
  176. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/routers/transactions.py +0 -0
  177. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/server.py +0 -0
  178. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/static/app.js +0 -0
  179. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/static/index.html +0 -0
  180. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/static/style.css +0 -0
  181. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/tests/test_web_endpoints.py +0 -0
  182. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/tests/test_web_olas.py +0 -0
  183. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/tests/test_web_swap.py +0 -0
  184. {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/tests/test_web_swap_coverage.py +0 -0
  185. {iwa-0.0.59 → iwa-0.0.61}/src/iwa.egg-info/dependency_links.txt +0 -0
  186. {iwa-0.0.59 → iwa-0.0.61}/src/iwa.egg-info/entry_points.txt +0 -0
  187. {iwa-0.0.59 → iwa-0.0.61}/src/iwa.egg-info/requires.txt +0 -0
  188. {iwa-0.0.59 → iwa-0.0.61}/src/iwa.egg-info/top_level.txt +0 -0
  189. {iwa-0.0.59 → iwa-0.0.61}/src/tests/legacy_cow.py +0 -0
  190. {iwa-0.0.59 → iwa-0.0.61}/src/tests/legacy_safe.py +0 -0
  191. {iwa-0.0.59 → iwa-0.0.61}/src/tests/legacy_transaction_retry_logic.py +0 -0
  192. {iwa-0.0.59 → iwa-0.0.61}/src/tests/legacy_tui.py +0 -0
  193. {iwa-0.0.59 → iwa-0.0.61}/src/tests/legacy_wallets_screen.py +0 -0
  194. {iwa-0.0.59 → iwa-0.0.61}/src/tests/legacy_web.py +0 -0
  195. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_account_service.py +0 -0
  196. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_balance_service.py +0 -0
  197. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_chain.py +0 -0
  198. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_chain_interface_coverage.py +0 -0
  199. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_cli.py +0 -0
  200. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_contract.py +0 -0
  201. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_db.py +0 -0
  202. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_drain_coverage.py +0 -0
  203. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_erc20.py +0 -0
  204. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_gnosis_plugin.py +0 -0
  205. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_keys.py +0 -0
  206. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_legacy_wallet.py +0 -0
  207. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_main.py +0 -0
  208. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_migration.py +0 -0
  209. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_mnemonic.py +0 -0
  210. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_modals.py +0 -0
  211. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_models.py +0 -0
  212. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_monitor.py +0 -0
  213. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_multisend.py +0 -0
  214. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_plugin_service.py +0 -0
  215. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_reset_tenderly.py +0 -0
  216. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_rpc_efficiency.py +0 -0
  217. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_rpc_rotation.py +0 -0
  218. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_rpc_view.py +0 -0
  219. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_safe_coverage.py +0 -0
  220. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_safe_integration.py +0 -0
  221. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_safe_service.py +0 -0
  222. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_service_manager_integration.py +0 -0
  223. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_service_manager_structure.py +0 -0
  224. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_service_transaction.py +0 -0
  225. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_staking_router.py +0 -0
  226. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_staking_simple.py +0 -0
  227. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_tables.py +0 -0
  228. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_transfer_multisend.py +0 -0
  229. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_transfer_native.py +0 -0
  230. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_transfer_security.py +0 -0
  231. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_transfer_structure.py +0 -0
  232. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_transfer_swap_unit.py +0 -0
  233. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_ui_coverage.py +0 -0
  234. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_utils.py +0 -0
  235. {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_workers.py +0 -0
  236. {iwa-0.0.59 → iwa-0.0.61}/src/tools/create_and_stake_service.py +0 -0
  237. {iwa-0.0.59 → iwa-0.0.61}/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.59
3
+ Version: 0.0.61
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.59"
3
+ version = "0.0.61"
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.59"
75
+ target-version = "0.0.61"
76
76
  fix = true
77
77
 
78
78
  [tool.ruff.lint]
@@ -26,6 +26,11 @@ class ChainInterface:
26
26
  DEFAULT_RETRY_DELAY = 1.0 # Base delay between retries (exponential backoff)
27
27
  ROTATION_COOLDOWN_SECONDS = 2.0 # Minimum time between RPC rotations
28
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
33
+
29
34
  chain: SupportedChain
30
35
 
31
36
  def __init__(self, chain: Union[SupportedChain, str] = None):
@@ -36,10 +41,9 @@ class ChainInterface:
36
41
  chain: SupportedChain = getattr(SupportedChains(), chain.lower())
37
42
 
38
43
  self.chain = chain
39
- # Enforce strict 1.0 RPS limit to prevent synchronization issues
40
- 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)
41
45
  self._current_rpc_index = 0
42
- self._rpc_failure_counts: Dict[int, int] = {}
46
+ self._rpc_backoff_until: Dict[int, float] = {} # index -> monotonic expiry
43
47
  self._last_rotation_time = 0.0 # Monotonic timestamp of last rotation
44
48
 
45
49
  if self.chain.rpc and self.chain.rpc.startswith("http://"):
@@ -50,9 +54,41 @@ class ChainInterface:
50
54
 
51
55
  self._initial_block = 0
52
56
  self._rotation_lock = threading.Lock()
53
- self._session = requests.Session()
57
+ self._session = self._create_session()
58
+
59
+ # Enrich with public RPCs from ChainList (skip for Tenderly vNets)
60
+ if not self.is_tenderly:
61
+ self._enrich_rpcs_from_chainlist()
62
+
54
63
  self._init_web3()
55
64
 
65
+ def _create_session(self) -> requests.Session:
66
+ """Create a requests Session with bounded connection pooling.
67
+
68
+ Configures the session with limited pool sizes to prevent file
69
+ descriptor exhaustion during RPC rotations. Connections are reused
70
+ within the pool but won't accumulate unboundedly.
71
+ """
72
+ session = requests.Session()
73
+ # Limit pool size: we only talk to one RPC at a time, but may rotate
74
+ # through multiple during the session lifetime. Keep modest limits.
75
+ adapter = requests.adapters.HTTPAdapter(
76
+ pool_connections=5, # Max different hosts to keep connections to
77
+ pool_maxsize=10, # Max connections per host
78
+ )
79
+ session.mount("https://", adapter)
80
+ session.mount("http://", adapter)
81
+ return session
82
+
83
+ def close(self) -> None:
84
+ """Close the session and release all connections.
85
+
86
+ Call this when the ChainInterface is no longer needed to ensure
87
+ proper cleanup of network resources.
88
+ """
89
+ if hasattr(self, "_session") and self._session:
90
+ self._session.close()
91
+
56
92
  @property
57
93
  def current_rpc(self) -> str:
58
94
  """Get the current active RPC URL."""
@@ -229,6 +265,63 @@ class ChainInterface:
229
265
  ]
230
266
  return any(signal in err_text for signal in gas_signals)
231
267
 
268
+ def _is_quota_exceeded_error(self, error: Exception) -> bool:
269
+ """Check if the RPC's usage quota has been exhausted.
270
+
271
+ JSON-RPC code -32001 with messages like "Exceeded the quota usage"
272
+ indicates the provider's daily/hourly quota is spent. This is NOT
273
+ a transient 429 rate-limit; the RPC will reject ALL requests until
274
+ the quota resets, so it must be backed off for a long period.
275
+ """
276
+ err_text = str(error).lower()
277
+ quota_signals = [
278
+ "exceeded the quota",
279
+ "exceeded quota",
280
+ "quota usage",
281
+ "quota exceeded",
282
+ "allowance exceeded",
283
+ ]
284
+ return any(signal in err_text for signal in quota_signals)
285
+
286
+ # -- ChainList enrichment ----------------------------------------------
287
+
288
+ MAX_RPCS = 10 # Cap total RPCs per chain
289
+
290
+ def _enrich_rpcs_from_chainlist(self) -> None:
291
+ """Add validated public RPCs from ChainList to the rotation pool."""
292
+ if len(self.chain.rpcs) >= self.MAX_RPCS:
293
+ return
294
+
295
+ try:
296
+ from iwa.core.chainlist import ChainlistRPC
297
+
298
+ chainlist = ChainlistRPC()
299
+ extra = chainlist.get_validated_rpcs(
300
+ self.chain.chain_id,
301
+ existing_rpcs=self.chain.rpcs,
302
+ max_results=self.MAX_RPCS - len(self.chain.rpcs),
303
+ )
304
+ if extra:
305
+ self.chain.rpcs.extend(extra)
306
+ logger.info(
307
+ f"Enriched {self.chain.name} with {len(extra)} "
308
+ f"ChainList RPCs (total: {len(self.chain.rpcs)})"
309
+ )
310
+ except Exception as e:
311
+ logger.debug(
312
+ f"ChainList enrichment failed for {self.chain.name}: {e}"
313
+ )
314
+
315
+ # -- Per-RPC health tracking ------------------------------------------
316
+
317
+ def _mark_rpc_backoff(self, index: int, seconds: float) -> None:
318
+ """Mark an RPC as temporarily unavailable for *seconds*."""
319
+ self._rpc_backoff_until[index] = time.monotonic() + seconds
320
+
321
+ def _is_rpc_healthy(self, index: int) -> bool:
322
+ """Return True if the RPC at *index* is not in backoff."""
323
+ return time.monotonic() >= self._rpc_backoff_until.get(index, 0.0)
324
+
232
325
  def _handle_rpc_error(self, error: Exception) -> Dict[str, Union[bool, int]]:
233
326
  """Handle RPC errors with smart rotation and retry logic."""
234
327
  result: Dict[str, Union[bool, int]] = {
@@ -237,6 +330,7 @@ class ChainInterface:
237
330
  "is_server_error": self._is_server_error(error),
238
331
  "is_gas_error": self._is_gas_error(error),
239
332
  "is_tenderly_quota": self._is_tenderly_quota_exceeded(error),
333
+ "is_quota_exceeded": self._is_quota_exceeded_error(error),
240
334
  "rotated": False,
241
335
  "should_retry": False,
242
336
  }
@@ -251,19 +345,33 @@ class ChainInterface:
251
345
  "Run 'uv run -m iwa.tools.reset_tenderly' to reset."
252
346
  )
253
347
 
254
- self._rpc_failure_counts[self._current_rpc_index] = (
255
- self._rpc_failure_counts.get(self._current_rpc_index, 0) + 1
348
+ # Determine if we need to rotate and what backoff to apply.
349
+ should_rotate = (
350
+ result["is_rate_limit"]
351
+ or result["is_connection_error"]
352
+ or result["is_quota_exceeded"]
256
353
  )
257
354
 
258
- should_rotate = result["is_rate_limit"] or result["is_connection_error"]
259
-
260
355
  if should_rotate:
261
- error_type = "rate limit" if result["is_rate_limit"] else "connection"
262
- # Extract the original URL from the error message for clarity
263
- error_msg = str(error)
356
+ failed_index = self._current_rpc_index
357
+
358
+ # Apply per-RPC backoff so smart rotation skips this RPC.
359
+ if result["is_quota_exceeded"]:
360
+ error_type = "quota exceeded"
361
+ self._mark_rpc_backoff(failed_index, self.QUOTA_EXCEEDED_BACKOFF)
362
+ elif result["is_rate_limit"]:
363
+ error_type = "rate limit"
364
+ self._mark_rpc_backoff(failed_index, self.RATE_LIMIT_BACKOFF)
365
+ # Brief global backoff so other threads don't immediately flood
366
+ # the same (now backed-off) RPC before rotation takes effect.
367
+ self._rate_limiter.trigger_backoff(seconds=2.0)
368
+ else:
369
+ error_type = "connection"
370
+ self._mark_rpc_backoff(failed_index, self.CONNECTION_ERROR_BACKOFF)
371
+
264
372
  logger.warning(
265
373
  f"RPC {error_type} error on {self.chain.name} "
266
- f"(current RPC #{self._current_rpc_index}): {error_msg}"
374
+ f"(RPC #{failed_index}): {error}"
267
375
  )
268
376
 
269
377
  if self.rotate_rpc():
@@ -271,14 +379,11 @@ class ChainInterface:
271
379
  result["should_retry"] = True
272
380
  logger.info(f"Rotated to RPC #{self._current_rpc_index} for {self.chain.name}")
273
381
  else:
274
- if result["is_rate_limit"]:
275
- # Rotation was skipped (cooldown or single RPC) - still allow retry with current RPC
276
- # We don't trigger backoff here because that would block ALL threads.
277
- # Instead, we let the individual thread retry (which has its own exponential backoff).
278
- result["should_retry"] = True
279
- logger.info(
280
- f"RPC rotation skipped, retrying with current RPC #{self._current_rpc_index}"
281
- )
382
+ # Rotation skipped (cooldown or single RPC) - still allow retry
383
+ result["should_retry"] = True
384
+ logger.info(
385
+ f"RPC rotation skipped, retrying with current RPC #{self._current_rpc_index}"
386
+ )
282
387
 
283
388
  elif result["is_server_error"]:
284
389
  logger.warning(f"Server error on {self.chain.name}: {error}")
@@ -291,30 +396,40 @@ class ChainInterface:
291
396
  return result
292
397
 
293
398
  def rotate_rpc(self) -> bool:
294
- """Rotate to the next available RPC."""
399
+ """Rotate to the next healthy RPC, skipping those in backoff."""
295
400
  with self._rotation_lock:
296
- if not self.chain.rpcs or len(self.chain.rpcs) <= 1:
401
+ n = len(self.chain.rpcs) if self.chain.rpcs else 0
402
+ if n <= 1:
297
403
  return False
298
404
 
299
405
  # Cooldown: prevent cascade rotations from in-flight requests
300
406
  now = time.monotonic()
301
- elapsed = now - self._last_rotation_time
302
- if elapsed < self.ROTATION_COOLDOWN_SECONDS:
303
- logger.debug(
304
- f"RPC rotation skipped for {self.chain.name} (cooldown active, "
305
- f"{self.ROTATION_COOLDOWN_SECONDS - elapsed:.1f}s remaining)"
306
- )
407
+ if now - self._last_rotation_time < self.ROTATION_COOLDOWN_SECONDS:
307
408
  return False
308
409
 
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.
410
+ # Try each other RPC in round-robin order, preferring healthy ones.
411
+ best: Optional[int] = None
412
+ for offset in range(1, n):
413
+ candidate = (self._current_rpc_index + offset) % n
414
+ if self._is_rpc_healthy(candidate):
415
+ best = candidate
416
+ break
417
+
418
+ if best is None:
419
+ # All RPCs are in backoff — pick the one whose backoff expires soonest.
420
+ best = min(
421
+ (i for i in range(n) if i != self._current_rpc_index),
422
+ key=lambda i: self._rpc_backoff_until.get(i, 0.0),
423
+ )
424
+
425
+ self._current_rpc_index = best
313
426
  self._init_web3_under_lock()
314
427
  self._last_rotation_time = now
315
428
 
429
+ healthy_tag = "" if self._is_rpc_healthy(best) else " (still in backoff)"
316
430
  logger.info(
317
- f"Rotated RPC for {self.chain.name} to index {self._current_rpc_index}: {self.chain.rpcs[self._current_rpc_index]}"
431
+ f"Rotated RPC for {self.chain.name} to index {best}: "
432
+ f"{self.chain.rpcs[best]}{healthy_tag}"
318
433
  )
319
434
  return True
320
435
 
@@ -583,6 +698,6 @@ class ChainInterface:
583
698
  return self.chain.contracts.get(contract_name)
584
699
 
585
700
  def reset_rpc_failure_counts(self):
586
- """Reset RPC failure tracking. Call periodically to allow retrying failed RPCs."""
587
- self._rpc_failure_counts.clear()
588
- logger.debug("Reset RPC failure counts")
701
+ """Reset RPC backoff tracking. Call periodically to allow retrying backed-off RPCs."""
702
+ self._rpc_backoff_until.clear()
703
+ logger.debug("Reset RPC backoff tracking")
@@ -36,3 +36,11 @@ class ChainInterfaces:
36
36
  for name, interface in self.items():
37
37
  results[name] = interface.check_rpc_health()
38
38
  return results
39
+
40
+ def close_all(self) -> None:
41
+ """Close all chain interface sessions.
42
+
43
+ Call this at application shutdown to release network resources.
44
+ """
45
+ for _, interface in self.items():
46
+ interface.close()
@@ -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,20 +200,36 @@ 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."""
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
+ """
191
211
  for attempt in range(self.DEFAULT_READ_RETRIES + 1):
192
212
  try:
193
213
  return method(*args, **kwargs)
194
214
  except Exception as e:
195
- # Use chain interface to handle error (logging, rotation, etc.)
196
- result = self._chain_interface._handle_rpc_error(e)
215
+ if attempt >= self.DEFAULT_READ_RETRIES:
216
+ raise
197
217
 
198
- 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):
199
221
  raise
200
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
+
201
229
  delay = self.DEFAULT_READ_RETRY_DELAY * (2**attempt)
202
230
  logger.debug(
203
- 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..."
204
233
  )
205
234
  time.sleep(delay)
206
235
 
@@ -0,0 +1,299 @@
1
+ """Module for fetching and parsing RPCs from Chainlist.org."""
2
+
3
+ import json
4
+ import time
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
6
+ from dataclasses import dataclass
7
+ from typing import Any, Dict, List, Optional, Tuple
8
+
9
+ import requests
10
+
11
+ from iwa.core.constants import CACHE_DIR
12
+ from iwa.core.utils import configure_logger
13
+
14
+ logger = configure_logger()
15
+
16
+ # -- RPC probing constants --------------------------------------------------
17
+
18
+ MAX_CHAINLIST_CANDIDATES = 15 # Probe at most this many candidates
19
+ PROBE_TIMEOUT = 5.0 # Seconds per probe request
20
+ MAX_BLOCK_LAG = 10 # Blocks behind majority → considered stale
21
+
22
+
23
+ def _normalize_url(url: str) -> str:
24
+ """Normalize an RPC URL for deduplication (lowercase, strip trailing slash)."""
25
+ return url.rstrip("/").lower()
26
+
27
+
28
+ def _is_template_url(url: str) -> bool:
29
+ """Return True if the URL contains template variables requiring an API key."""
30
+ return "${" in url or "{" in url
31
+
32
+
33
+ def probe_rpc(
34
+ url: str,
35
+ timeout: float = PROBE_TIMEOUT,
36
+ session: Optional[requests.Session] = None,
37
+ ) -> Optional[Tuple[str, float, int]]:
38
+ """Probe an RPC endpoint with eth_blockNumber.
39
+
40
+ Returns ``(url, latency_ms, block_number)`` on success, or ``None``
41
+ if the endpoint is unreachable, slow, or returns invalid data.
42
+
43
+ Args:
44
+ url: The RPC endpoint URL to probe.
45
+ timeout: Request timeout in seconds.
46
+ session: Optional requests.Session for connection reuse. If None,
47
+ creates a temporary session that is properly closed.
48
+
49
+ """
50
+ # Use provided session or create temporary one with proper cleanup
51
+ own_session = session is None
52
+ if own_session:
53
+ session = requests.Session()
54
+
55
+ try:
56
+ start = time.monotonic()
57
+ resp = session.post(
58
+ url,
59
+ json={"jsonrpc": "2.0", "method": "eth_blockNumber", "params": [], "id": 1},
60
+ timeout=timeout,
61
+ )
62
+ latency_ms = (time.monotonic() - start) * 1000
63
+ data = resp.json()
64
+ block_hex = data.get("result")
65
+ if not block_hex or not isinstance(block_hex, str) or block_hex == "0x0":
66
+ return None
67
+ return (url, latency_ms, int(block_hex, 16))
68
+ except Exception:
69
+ return None
70
+ finally:
71
+ if own_session:
72
+ session.close()
73
+
74
+
75
+ def _filter_candidates(
76
+ nodes: "List[RPCNode]",
77
+ existing_normalized: set,
78
+ ) -> List[str]:
79
+ """Filter ChainList nodes to usable HTTPS candidates."""
80
+ candidates: List[str] = []
81
+ for node in nodes:
82
+ url = node.url
83
+ if not url.startswith("https://"):
84
+ continue
85
+ if _is_template_url(url):
86
+ continue
87
+ if _normalize_url(url) in existing_normalized:
88
+ continue
89
+ candidates.append(url)
90
+ if len(candidates) >= MAX_CHAINLIST_CANDIDATES:
91
+ break
92
+ return candidates
93
+
94
+
95
+ def _probe_candidates(
96
+ candidates: List[str],
97
+ ) -> List[Tuple[str, float, int]]:
98
+ """Probe a list of RPC URLs in parallel, returning successful results.
99
+
100
+ Uses a shared session for all probes to enable connection pooling and
101
+ ensure proper cleanup of all connections when probing completes.
102
+ """
103
+ results: List[Tuple[str, float, int]] = []
104
+ # Use a shared session with connection pooling for all probes
105
+ # This prevents FD leaks from individual probe connections
106
+ with requests.Session() as session:
107
+ # Configure connection pool size to match our max workers
108
+ adapter = requests.adapters.HTTPAdapter(
109
+ pool_connections=10,
110
+ pool_maxsize=10,
111
+ max_retries=0, # No retries - we handle failure gracefully
112
+ )
113
+ session.mount("https://", adapter)
114
+ session.mount("http://", adapter)
115
+
116
+ with ThreadPoolExecutor(max_workers=min(len(candidates), 10)) as pool:
117
+ futures = {
118
+ pool.submit(probe_rpc, url, PROBE_TIMEOUT, session): url
119
+ for url in candidates
120
+ }
121
+ for future in as_completed(futures, timeout=15):
122
+ try:
123
+ result = future.result()
124
+ if result is not None:
125
+ results.append(result)
126
+ except Exception:
127
+ pass
128
+ # Session is closed here via context manager, releasing all connections
129
+ return results
130
+
131
+
132
+ def _rank_and_select(
133
+ results: List[Tuple[str, float, int]],
134
+ candidates: List[str],
135
+ chain_id: int,
136
+ max_results: int,
137
+ ) -> List[str]:
138
+ """Rank probed RPCs by latency, filtering stale ones."""
139
+ blocks = sorted(r[2] for r in results)
140
+ median_block = blocks[len(blocks) // 2]
141
+
142
+ valid = [
143
+ (url, latency)
144
+ for url, latency, block in results
145
+ if median_block - block <= MAX_BLOCK_LAG
146
+ ]
147
+ valid.sort(key=lambda x: x[1])
148
+
149
+ selected = [url for url, _ in valid[:max_results]]
150
+ if selected:
151
+ logger.info(
152
+ f"ChainList: validated {len(selected)}/{len(candidates)} "
153
+ f"candidates for chain {chain_id} "
154
+ f"(median block: {median_block})"
155
+ )
156
+ return selected
157
+
158
+
159
+ @dataclass
160
+ class RPCNode:
161
+ """Represents a single RPC node with its properties."""
162
+
163
+ url: str
164
+ is_working: bool
165
+ privacy: Optional[str] = None
166
+ tracking: Optional[str] = None
167
+
168
+ @property
169
+ def is_tracking(self) -> bool:
170
+ """Returns True if the RPC is known to track user data."""
171
+ return self.privacy == "privacy" or self.tracking in ("limited", "yes")
172
+
173
+
174
+ class ChainlistRPC:
175
+ """Fetcher and parser for Chainlist RPC data."""
176
+
177
+ URL = "https://chainlist.org/rpcs.json"
178
+ CACHE_PATH = CACHE_DIR / "chainlist_rpcs.json"
179
+ CACHE_TTL = 86400 # 24 hours
180
+
181
+ def __init__(self) -> None:
182
+ """Initialize the ChainlistRPC instance."""
183
+ self._data: List[Dict[str, Any]] = []
184
+
185
+ def fetch_data(self, force_refresh: bool = False) -> None:
186
+ """Fetches the RPC data from Chainlist with local caching."""
187
+ # 1. Try local cache first unless force_refresh is requested
188
+ if not force_refresh and self.CACHE_PATH.exists():
189
+ try:
190
+ mtime = self.CACHE_PATH.stat().st_mtime
191
+ if time.time() - mtime < self.CACHE_TTL:
192
+ with self.CACHE_PATH.open("r") as f:
193
+ self._data = json.load(f)
194
+ if self._data:
195
+ return
196
+ except Exception as e:
197
+ print(f"Error reading Chainlist cache: {e}")
198
+
199
+ # 2. Fetch from remote (use session context for proper cleanup)
200
+ try:
201
+ with requests.Session() as session:
202
+ response = session.get(self.URL, timeout=10)
203
+ response.raise_for_status()
204
+ self._data = response.json()
205
+
206
+ # 3. Update local cache
207
+ if self._data:
208
+ self.CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
209
+ with self.CACHE_PATH.open("w") as f:
210
+ json.dump(self._data, f)
211
+ except requests.RequestException as e:
212
+ print(f"Error fetching Chainlist data from {self.URL}: {e}")
213
+ # Fallback to expired cache if available
214
+ if not self._data and self.CACHE_PATH.exists():
215
+ try:
216
+ with self.CACHE_PATH.open("r") as f:
217
+ self._data = json.load(f)
218
+ except Exception:
219
+ pass
220
+ if not self._data:
221
+ self._data = []
222
+
223
+ def get_chain_data(self, chain_id: int) -> Optional[Dict[str, Any]]:
224
+ """Returns the raw chain data for a specific chain ID."""
225
+ if not self._data:
226
+ self.fetch_data()
227
+
228
+ for entry in self._data:
229
+ if entry.get("chainId") == chain_id:
230
+ return entry
231
+ return None
232
+
233
+ def get_rpcs(self, chain_id: int) -> List[RPCNode]:
234
+ """Returns a list of RPCNode objects for a parsed and cleaner view."""
235
+ chain_data = self.get_chain_data(chain_id)
236
+ if not chain_data:
237
+ return []
238
+
239
+ raw_rpcs = chain_data.get("rpc", [])
240
+ nodes = []
241
+ for rpc in raw_rpcs:
242
+ nodes.append(
243
+ RPCNode(
244
+ url=rpc.get("url", ""),
245
+ is_working=True,
246
+ privacy=rpc.get("privacy"),
247
+ tracking=rpc.get("tracking"),
248
+ )
249
+ )
250
+ return nodes
251
+
252
+ def get_https_rpcs(self, chain_id: int) -> List[str]:
253
+ """Returns a list of HTTPS RPC URLs for the given chain."""
254
+ rpcs = self.get_rpcs(chain_id)
255
+ return [
256
+ node.url
257
+ for node in rpcs
258
+ if node.url.startswith("https://") or node.url.startswith("http://")
259
+ ]
260
+
261
+ def get_wss_rpcs(self, chain_id: int) -> List[str]:
262
+ """Returns a list of WSS RPC URLs for the given chain."""
263
+ rpcs = self.get_rpcs(chain_id)
264
+ return [
265
+ node.url
266
+ for node in rpcs
267
+ if node.url.startswith("wss://") or node.url.startswith("ws://")
268
+ ]
269
+
270
+ def get_validated_rpcs(
271
+ self,
272
+ chain_id: int,
273
+ existing_rpcs: List[str],
274
+ max_results: int = 5,
275
+ ) -> List[str]:
276
+ """Return ChainList RPCs filtered, probed, and sorted by quality.
277
+
278
+ 1. Fetch HTTPS RPCs from ChainList for *chain_id*.
279
+ 2. Filter out template URLs, duplicates of *existing_rpcs*, and
280
+ websocket endpoints.
281
+ 3. Probe the top candidates in parallel with ``eth_blockNumber``.
282
+ 4. Discard RPCs that are stale (block number lagging behind majority).
283
+ 5. Return up to *max_results* URLs sorted by latency (fastest first).
284
+ """
285
+ nodes = self.get_rpcs(chain_id)
286
+ if not nodes:
287
+ return []
288
+
289
+ existing_normalized = {_normalize_url(u) for u in existing_rpcs}
290
+ candidates = _filter_candidates(nodes, existing_normalized)
291
+ if not candidates:
292
+ return []
293
+
294
+ results = _probe_candidates(candidates)
295
+ if not results:
296
+ return []
297
+
298
+ selected = _rank_and_select(results, candidates, chain_id, max_results)
299
+ return selected
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iwa
3
- Version: 0.0.59
3
+ Version: 0.0.61
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