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.
- {iwa-0.0.59/src/iwa.egg-info → iwa-0.0.61}/PKG-INFO +1 -1
- {iwa-0.0.59 → iwa-0.0.61}/pyproject.toml +2 -2
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/chain/interface.py +151 -36
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/chain/manager.py +8 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/chain/rate_limiter.py +35 -6
- iwa-0.0.61/src/iwa/core/chainlist.py +299 -0
- {iwa-0.0.59 → iwa-0.0.61/src/iwa.egg-info}/PKG-INFO +1 -1
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa.egg-info/SOURCES.txt +1 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_chain_interface.py +3 -3
- iwa-0.0.61/src/tests/test_chainlist_enrichment.py +354 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_rate_limiter.py +7 -5
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_rate_limiter_retry.py +33 -27
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_rpc_rate_limit.py +3 -3
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_safe_executor.py +208 -0
- iwa-0.0.61/src/tests/test_transaction_service.py +355 -0
- iwa-0.0.59/src/iwa/core/chainlist.py +0 -121
- iwa-0.0.59/src/tests/test_transaction_service.py +0 -179
- {iwa-0.0.59 → iwa-0.0.61}/LICENSE +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/README.md +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/setup.cfg +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/__init__.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/__main__.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/__init__.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/chain/__init__.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/chain/errors.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/chain/models.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/cli.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/constants.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/contracts/__init__.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/contracts/abis/erc20.json +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/contracts/abis/multisend.json +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/contracts/abis/multisend_call_only.json +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/contracts/cache.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/contracts/contract.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/contracts/decoder.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/contracts/erc20.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/contracts/multisend.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/db.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/http.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/ipfs.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/keys.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/mnemonic.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/models.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/monitor.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/plugins.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/pricing.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/rpc_monitor.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/secrets.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/__init__.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/account.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/balance.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/plugin.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/safe.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/safe_executor.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/transaction.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/transfer/__init__.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/transfer/base.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/transfer/erc20.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/transfer/multisend.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/transfer/native.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/services/transfer/swap.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/tables.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/test.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/tests/test_gnosis_fee.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/tests/test_ipfs.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/tests/test_pricing.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/tests/test_regression_fixes.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/tests/test_wallet.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/types.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/ui.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/utils.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/core/wallet.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/__init__.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/gnosis/__init__.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/gnosis/cow/__init__.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/gnosis/cow/quotes.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/gnosis/cow/swap.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/gnosis/cow/types.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/gnosis/cow_utils.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/gnosis/plugin.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/gnosis/safe.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/gnosis/tests/test_cow.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/gnosis/tests/test_safe.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/__init__.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/constants.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/abis/activity_checker.json +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/abis/mech.json +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/abis/mech_marketplace.json +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/abis/mech_marketplace_v1.json +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/abis/mech_new.json +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/abis/service_manager.json +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/abis/service_registry.json +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/abis/service_registry_token_utility.json +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/abis/staking.json +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/abis/staking_token.json +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/activity_checker.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/base.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/mech.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/mech_marketplace.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/mech_marketplace_v1.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/service.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/contracts/staking.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/events.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/importer.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/mech_reference.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/models.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/plugin.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/scripts/test_full_mech_flow.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/scripts/test_simple_lifecycle.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/service_manager/__init__.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/service_manager/base.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/service_manager/drain.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/service_manager/lifecycle.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/service_manager/mech.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/service_manager/staking.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/conftest.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_importer.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_importer_error_handling.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_mech_contracts.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_olas_archiving.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_olas_contracts.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_olas_integration.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_olas_models.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_olas_view.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_olas_view_actions.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_olas_view_modals.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_plugin.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_plugin_full.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_service_lifecycle.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_service_manager.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_service_manager_errors.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_service_manager_flows.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_service_manager_mech.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_service_manager_rewards.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_service_manager_validation.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_service_staking.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_staking_integration.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tests/test_staking_validation.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tui/__init__.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/plugins/olas/tui/olas_view.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tools/__init__.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tools/check_profile.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tools/drain_accounts.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tools/list_contracts.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tools/release.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tools/reset_env.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tools/reset_tenderly.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tools/restore_backup.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tools/test_chainlist.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tools/wallet_check.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/__init__.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/app.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/modals/__init__.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/modals/base.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/rpc.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/screens/__init__.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/screens/wallets.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/tests/test_app.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/tests/test_rpc.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/tests/test_wallets_refactor.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/tests/test_widgets.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/widgets/__init__.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/widgets/base.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/tui/workers.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/dependencies.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/models.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/routers/accounts.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/routers/olas/__init__.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/routers/olas/admin.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/routers/olas/funding.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/routers/olas/general.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/routers/olas/services.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/routers/olas/staking.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/routers/state.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/routers/swap.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/routers/transactions.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/server.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/static/app.js +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/static/index.html +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/static/style.css +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/tests/test_web_endpoints.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/tests/test_web_olas.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/tests/test_web_swap.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa/web/tests/test_web_swap_coverage.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa.egg-info/dependency_links.txt +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa.egg-info/entry_points.txt +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa.egg-info/requires.txt +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/iwa.egg-info/top_level.txt +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/legacy_cow.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/legacy_safe.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/legacy_transaction_retry_logic.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/legacy_tui.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/legacy_wallets_screen.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/legacy_web.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_account_service.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_balance_service.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_chain.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_chain_interface_coverage.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_cli.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_contract.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_db.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_drain_coverage.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_erc20.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_gnosis_plugin.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_keys.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_legacy_wallet.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_main.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_migration.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_mnemonic.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_modals.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_models.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_monitor.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_multisend.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_plugin_service.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_reset_tenderly.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_rpc_efficiency.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_rpc_rotation.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_rpc_view.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_safe_coverage.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_safe_integration.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_safe_service.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_service_manager_integration.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_service_manager_structure.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_service_transaction.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_staking_router.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_staking_simple.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_tables.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_transfer_multisend.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_transfer_native.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_transfer_security.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_transfer_structure.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_transfer_swap_unit.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_ui_coverage.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_utils.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tests/test_workers.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tools/create_and_stake_service.py +0 -0
- {iwa-0.0.59 → iwa-0.0.61}/src/tools/verify_drain.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "iwa"
|
|
3
|
-
version = "0.0.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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"(
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
399
|
+
"""Rotate to the next healthy RPC, skipping those in backoff."""
|
|
295
400
|
with self._rotation_lock:
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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 {
|
|
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
|
|
587
|
-
self.
|
|
588
|
-
logger.debug("Reset RPC
|
|
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 =
|
|
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
|
|
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
|
-
|
|
196
|
-
|
|
215
|
+
if attempt >= self.DEFAULT_READ_RETRIES:
|
|
216
|
+
raise
|
|
197
217
|
|
|
198
|
-
|
|
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,
|
|
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
|