iwa 0.0.0__py3-none-any.whl → 0.0.1a2__py3-none-any.whl
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.
- conftest.py +22 -0
- iwa/__init__.py +1 -0
- iwa/__main__.py +6 -0
- iwa/core/__init__.py +1 -0
- iwa/core/chain/__init__.py +68 -0
- iwa/core/chain/errors.py +47 -0
- iwa/core/chain/interface.py +514 -0
- iwa/core/chain/manager.py +38 -0
- iwa/core/chain/models.py +128 -0
- iwa/core/chain/rate_limiter.py +193 -0
- iwa/core/cli.py +210 -0
- iwa/core/constants.py +28 -0
- iwa/core/contracts/__init__.py +1 -0
- iwa/core/contracts/contract.py +297 -0
- iwa/core/contracts/erc20.py +79 -0
- iwa/core/contracts/multisend.py +71 -0
- iwa/core/db.py +317 -0
- iwa/core/keys.py +361 -0
- iwa/core/mnemonic.py +385 -0
- iwa/core/models.py +344 -0
- iwa/core/monitor.py +209 -0
- iwa/core/plugins.py +45 -0
- iwa/core/pricing.py +91 -0
- iwa/core/services/__init__.py +17 -0
- iwa/core/services/account.py +57 -0
- iwa/core/services/balance.py +113 -0
- iwa/core/services/plugin.py +88 -0
- iwa/core/services/safe.py +392 -0
- iwa/core/services/transaction.py +172 -0
- iwa/core/services/transfer/__init__.py +166 -0
- iwa/core/services/transfer/base.py +260 -0
- iwa/core/services/transfer/erc20.py +247 -0
- iwa/core/services/transfer/multisend.py +386 -0
- iwa/core/services/transfer/native.py +262 -0
- iwa/core/services/transfer/swap.py +326 -0
- iwa/core/settings.py +95 -0
- iwa/core/tables.py +60 -0
- iwa/core/test.py +27 -0
- iwa/core/tests/test_wallet.py +255 -0
- iwa/core/types.py +59 -0
- iwa/core/ui.py +99 -0
- iwa/core/utils.py +59 -0
- iwa/core/wallet.py +380 -0
- iwa/plugins/__init__.py +1 -0
- iwa/plugins/gnosis/__init__.py +5 -0
- iwa/plugins/gnosis/cow/__init__.py +6 -0
- iwa/plugins/gnosis/cow/quotes.py +148 -0
- iwa/plugins/gnosis/cow/swap.py +403 -0
- iwa/plugins/gnosis/cow/types.py +20 -0
- iwa/plugins/gnosis/cow_utils.py +44 -0
- iwa/plugins/gnosis/plugin.py +68 -0
- iwa/plugins/gnosis/safe.py +157 -0
- iwa/plugins/gnosis/tests/test_cow.py +227 -0
- iwa/plugins/gnosis/tests/test_safe.py +100 -0
- iwa/plugins/olas/__init__.py +5 -0
- iwa/plugins/olas/constants.py +106 -0
- iwa/plugins/olas/contracts/activity_checker.py +93 -0
- iwa/plugins/olas/contracts/base.py +10 -0
- iwa/plugins/olas/contracts/mech.py +49 -0
- iwa/plugins/olas/contracts/mech_marketplace.py +43 -0
- iwa/plugins/olas/contracts/service.py +215 -0
- iwa/plugins/olas/contracts/staking.py +403 -0
- iwa/plugins/olas/importer.py +736 -0
- iwa/plugins/olas/mech_reference.py +135 -0
- iwa/plugins/olas/models.py +110 -0
- iwa/plugins/olas/plugin.py +243 -0
- iwa/plugins/olas/scripts/test_full_mech_flow.py +259 -0
- iwa/plugins/olas/scripts/test_simple_lifecycle.py +74 -0
- iwa/plugins/olas/service_manager/__init__.py +60 -0
- iwa/plugins/olas/service_manager/base.py +113 -0
- iwa/plugins/olas/service_manager/drain.py +336 -0
- iwa/plugins/olas/service_manager/lifecycle.py +839 -0
- iwa/plugins/olas/service_manager/mech.py +322 -0
- iwa/plugins/olas/service_manager/staking.py +530 -0
- iwa/plugins/olas/tests/conftest.py +30 -0
- iwa/plugins/olas/tests/test_importer.py +128 -0
- iwa/plugins/olas/tests/test_importer_error_handling.py +349 -0
- iwa/plugins/olas/tests/test_mech_contracts.py +85 -0
- iwa/plugins/olas/tests/test_olas_contracts.py +249 -0
- iwa/plugins/olas/tests/test_olas_integration.py +561 -0
- iwa/plugins/olas/tests/test_olas_models.py +144 -0
- iwa/plugins/olas/tests/test_olas_view.py +258 -0
- iwa/plugins/olas/tests/test_olas_view_actions.py +137 -0
- iwa/plugins/olas/tests/test_olas_view_modals.py +120 -0
- iwa/plugins/olas/tests/test_plugin.py +70 -0
- iwa/plugins/olas/tests/test_plugin_full.py +212 -0
- iwa/plugins/olas/tests/test_service_lifecycle.py +150 -0
- iwa/plugins/olas/tests/test_service_manager.py +1065 -0
- iwa/plugins/olas/tests/test_service_manager_errors.py +208 -0
- iwa/plugins/olas/tests/test_service_manager_flows.py +497 -0
- iwa/plugins/olas/tests/test_service_manager_mech.py +135 -0
- iwa/plugins/olas/tests/test_service_manager_rewards.py +360 -0
- iwa/plugins/olas/tests/test_service_manager_validation.py +145 -0
- iwa/plugins/olas/tests/test_service_staking.py +342 -0
- iwa/plugins/olas/tests/test_staking_integration.py +269 -0
- iwa/plugins/olas/tests/test_staking_validation.py +109 -0
- iwa/plugins/olas/tui/__init__.py +1 -0
- iwa/plugins/olas/tui/olas_view.py +952 -0
- iwa/tools/check_profile.py +67 -0
- iwa/tools/release.py +111 -0
- iwa/tools/reset_env.py +111 -0
- iwa/tools/reset_tenderly.py +362 -0
- iwa/tools/restore_backup.py +82 -0
- iwa/tui/__init__.py +1 -0
- iwa/tui/app.py +174 -0
- iwa/tui/modals/__init__.py +5 -0
- iwa/tui/modals/base.py +406 -0
- iwa/tui/rpc.py +63 -0
- iwa/tui/screens/__init__.py +1 -0
- iwa/tui/screens/wallets.py +749 -0
- iwa/tui/tests/test_app.py +125 -0
- iwa/tui/tests/test_rpc.py +139 -0
- iwa/tui/tests/test_wallets_refactor.py +30 -0
- iwa/tui/tests/test_widgets.py +123 -0
- iwa/tui/widgets/__init__.py +5 -0
- iwa/tui/widgets/base.py +100 -0
- iwa/tui/workers.py +42 -0
- iwa/web/dependencies.py +76 -0
- iwa/web/models.py +76 -0
- iwa/web/routers/accounts.py +115 -0
- iwa/web/routers/olas/__init__.py +24 -0
- iwa/web/routers/olas/admin.py +169 -0
- iwa/web/routers/olas/funding.py +135 -0
- iwa/web/routers/olas/general.py +29 -0
- iwa/web/routers/olas/services.py +378 -0
- iwa/web/routers/olas/staking.py +341 -0
- iwa/web/routers/state.py +65 -0
- iwa/web/routers/swap.py +617 -0
- iwa/web/routers/transactions.py +153 -0
- iwa/web/server.py +155 -0
- iwa/web/tests/test_web_endpoints.py +713 -0
- iwa/web/tests/test_web_olas.py +430 -0
- iwa/web/tests/test_web_swap.py +103 -0
- iwa-0.0.1a2.dist-info/METADATA +234 -0
- iwa-0.0.1a2.dist-info/RECORD +186 -0
- iwa-0.0.1a2.dist-info/entry_points.txt +2 -0
- iwa-0.0.1a2.dist-info/licenses/LICENSE +21 -0
- iwa-0.0.1a2.dist-info/top_level.txt +4 -0
- tests/legacy_cow.py +248 -0
- tests/legacy_safe.py +93 -0
- tests/legacy_transaction_retry_logic.py +51 -0
- tests/legacy_tui.py +440 -0
- tests/legacy_wallets_screen.py +554 -0
- tests/legacy_web.py +243 -0
- tests/test_account_service.py +120 -0
- tests/test_balance_service.py +186 -0
- tests/test_chain.py +490 -0
- tests/test_chain_interface.py +210 -0
- tests/test_cli.py +139 -0
- tests/test_contract.py +195 -0
- tests/test_db.py +180 -0
- tests/test_drain_coverage.py +174 -0
- tests/test_erc20.py +95 -0
- tests/test_gnosis_plugin.py +111 -0
- tests/test_keys.py +449 -0
- tests/test_legacy_wallet.py +1285 -0
- tests/test_main.py +13 -0
- tests/test_mnemonic.py +217 -0
- tests/test_modals.py +109 -0
- tests/test_models.py +213 -0
- tests/test_monitor.py +202 -0
- tests/test_multisend.py +84 -0
- tests/test_plugin_service.py +119 -0
- tests/test_pricing.py +143 -0
- tests/test_rate_limiter.py +199 -0
- tests/test_reset_tenderly.py +202 -0
- tests/test_rpc_view.py +73 -0
- tests/test_safe_coverage.py +139 -0
- tests/test_safe_service.py +168 -0
- tests/test_service_manager_integration.py +61 -0
- tests/test_service_manager_structure.py +31 -0
- tests/test_service_transaction.py +176 -0
- tests/test_staking_router.py +71 -0
- tests/test_staking_simple.py +31 -0
- tests/test_tables.py +76 -0
- tests/test_transaction_service.py +161 -0
- tests/test_transfer_multisend.py +179 -0
- tests/test_transfer_native.py +220 -0
- tests/test_transfer_security.py +93 -0
- tests/test_transfer_structure.py +37 -0
- tests/test_transfer_swap_unit.py +155 -0
- tests/test_ui_coverage.py +66 -0
- tests/test_utils.py +53 -0
- tests/test_workers.py +91 -0
- tools/verify_drain.py +183 -0
- __init__.py +0 -2
- hello.py +0 -6
- iwa-0.0.0.dist-info/METADATA +0 -10
- iwa-0.0.0.dist-info/RECORD +0 -6
- iwa-0.0.0.dist-info/top_level.txt +0 -2
- {iwa-0.0.0.dist-info → iwa-0.0.1a2.dist-info}/WHEEL +0 -0
conftest.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Pytest configuration."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture(autouse=True)
|
|
10
|
+
def caplog(caplog):
|
|
11
|
+
"""Make loguru logs visible to pytest caplog."""
|
|
12
|
+
|
|
13
|
+
class PropagateHandler(logging.Handler):
|
|
14
|
+
def emit(self, record):
|
|
15
|
+
logging.getLogger(record.name).handle(record)
|
|
16
|
+
|
|
17
|
+
handler_id = logger.add(PropagateHandler(), format="{message}")
|
|
18
|
+
yield caplog
|
|
19
|
+
try:
|
|
20
|
+
logger.remove(handler_id)
|
|
21
|
+
except ValueError:
|
|
22
|
+
pass
|
iwa/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""iwa package."""
|
iwa/__main__.py
ADDED
iwa/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""iwa.core package."""
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Chain interaction helpers.
|
|
2
|
+
|
|
3
|
+
This package provides chain-related utilities for blockchain interactions:
|
|
4
|
+
- ChainInterface: Main interface for interacting with a blockchain
|
|
5
|
+
- ChainInterfaces: Singleton manager for all supported chains
|
|
6
|
+
- SupportedChain: Base model for chain definitions
|
|
7
|
+
- Rate limiting and error handling utilities
|
|
8
|
+
|
|
9
|
+
All symbols are re-exported here for backward compatibility.
|
|
10
|
+
Import from `iwa.core.chain` to use these utilities.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from typing import TypeVar
|
|
14
|
+
|
|
15
|
+
# Re-export all public symbols for backward compatibility
|
|
16
|
+
from iwa.core.chain.errors import (
|
|
17
|
+
TenderlyQuotaExceededError,
|
|
18
|
+
sanitize_rpc_url,
|
|
19
|
+
)
|
|
20
|
+
from iwa.core.chain.interface import (
|
|
21
|
+
DEFAULT_RPC_TIMEOUT,
|
|
22
|
+
ChainInterface,
|
|
23
|
+
)
|
|
24
|
+
from iwa.core.chain.manager import ChainInterfaces
|
|
25
|
+
from iwa.core.chain.models import (
|
|
26
|
+
Base,
|
|
27
|
+
Ethereum,
|
|
28
|
+
Gnosis,
|
|
29
|
+
SupportedChain,
|
|
30
|
+
SupportedChains,
|
|
31
|
+
)
|
|
32
|
+
from iwa.core.chain.rate_limiter import (
|
|
33
|
+
RateLimitedEth,
|
|
34
|
+
RateLimitedWeb3,
|
|
35
|
+
RPCRateLimiter,
|
|
36
|
+
get_rate_limiter,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Backward compatibility alias
|
|
40
|
+
_sanitize_rpc_url = sanitize_rpc_url
|
|
41
|
+
|
|
42
|
+
# Expose type variable for retry decorator (used in type hints)
|
|
43
|
+
T = TypeVar("T")
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
# Errors
|
|
47
|
+
"TenderlyQuotaExceededError",
|
|
48
|
+
"sanitize_rpc_url",
|
|
49
|
+
"_sanitize_rpc_url",
|
|
50
|
+
# Rate limiting
|
|
51
|
+
"RPCRateLimiter",
|
|
52
|
+
"RateLimitedEth",
|
|
53
|
+
"RateLimitedWeb3",
|
|
54
|
+
"get_rate_limiter",
|
|
55
|
+
# Models
|
|
56
|
+
"SupportedChain",
|
|
57
|
+
"Gnosis",
|
|
58
|
+
"Ethereum",
|
|
59
|
+
"Base",
|
|
60
|
+
"SupportedChains",
|
|
61
|
+
# Interface
|
|
62
|
+
"ChainInterface",
|
|
63
|
+
"DEFAULT_RPC_TIMEOUT",
|
|
64
|
+
# Manager
|
|
65
|
+
"ChainInterfaces",
|
|
66
|
+
# Types
|
|
67
|
+
"T",
|
|
68
|
+
]
|
iwa/core/chain/errors.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Chain-related error classes and utilities."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from iwa.core.utils import configure_logger
|
|
6
|
+
|
|
7
|
+
logger = configure_logger()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TenderlyQuotaExceededError(Exception):
|
|
11
|
+
"""Raised when Tenderly virtual network quota is exceeded (403 Forbidden).
|
|
12
|
+
|
|
13
|
+
This is a fatal error that should halt execution and prompt the user to
|
|
14
|
+
reset the Tenderly network.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sanitize_rpc_url(url: str) -> str:
|
|
21
|
+
"""Remove API keys and sensitive data from RPC URLs for safe logging.
|
|
22
|
+
|
|
23
|
+
Sanitizes:
|
|
24
|
+
- Query parameters (may contain API keys)
|
|
25
|
+
- Path segments that look like API keys (32+ hex chars)
|
|
26
|
+
- Known API key patterns in subdomains
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
url: The RPC URL to sanitize.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Sanitized URL safe for logging.
|
|
33
|
+
|
|
34
|
+
"""
|
|
35
|
+
if not url:
|
|
36
|
+
return url
|
|
37
|
+
# Remove query params that might contain keys
|
|
38
|
+
sanitized = re.sub(r"\?.*$", "?***", url)
|
|
39
|
+
# Remove path segments that look like API keys (32+ hex chars)
|
|
40
|
+
sanitized = re.sub(r"/[a-fA-F0-9]{32,}", "/***", sanitized)
|
|
41
|
+
# Remove common API key patterns in path (e.g., /v3/YOUR_KEY)
|
|
42
|
+
sanitized = re.sub(r"/v[0-9]+/[a-zA-Z0-9_-]{20,}", "/v*/***", sanitized)
|
|
43
|
+
return sanitized
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Backward compatibility alias
|
|
47
|
+
_sanitize_rpc_url = sanitize_rpc_url
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
"""ChainInterface class for blockchain interactions."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Callable, Dict, Optional, Tuple, TypeVar, Union
|
|
5
|
+
|
|
6
|
+
from eth_account.datastructures import SignedTransaction
|
|
7
|
+
from web3 import Web3
|
|
8
|
+
|
|
9
|
+
from iwa.core.chain.errors import TenderlyQuotaExceededError, sanitize_rpc_url
|
|
10
|
+
from iwa.core.chain.models import Gnosis, SupportedChain, SupportedChains
|
|
11
|
+
from iwa.core.chain.rate_limiter import RateLimitedWeb3, get_rate_limiter
|
|
12
|
+
from iwa.core.models import Config, EthereumAddress
|
|
13
|
+
from iwa.core.utils import configure_logger
|
|
14
|
+
|
|
15
|
+
logger = configure_logger()
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
DEFAULT_RPC_TIMEOUT = 10
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ChainInterface:
|
|
22
|
+
"""ChainInterface with rate limiting, retry logic, and RPC rotation support."""
|
|
23
|
+
|
|
24
|
+
DEFAULT_MAX_RETRIES = 3
|
|
25
|
+
DEFAULT_RETRY_DELAY = 0.5
|
|
26
|
+
|
|
27
|
+
chain: SupportedChain
|
|
28
|
+
|
|
29
|
+
def __init__(self, chain: Union[SupportedChain, str] = None):
|
|
30
|
+
"""Initialize ChainInterface."""
|
|
31
|
+
if chain is None:
|
|
32
|
+
chain = Gnosis()
|
|
33
|
+
if isinstance(chain, str):
|
|
34
|
+
chain: SupportedChain = getattr(SupportedChains(), chain.lower())
|
|
35
|
+
|
|
36
|
+
self.chain = chain
|
|
37
|
+
self._rate_limiter = get_rate_limiter(chain.name)
|
|
38
|
+
self._current_rpc_index = 0
|
|
39
|
+
self._rpc_failure_counts: Dict[int, int] = {}
|
|
40
|
+
|
|
41
|
+
if self.chain.rpc and self.chain.rpc.startswith("http://"):
|
|
42
|
+
logger.warning(
|
|
43
|
+
f"Using insecure RPC URL for {self.chain.name}: "
|
|
44
|
+
f"{sanitize_rpc_url(self.chain.rpc)}. Please use HTTPS."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
self._initial_block = 0
|
|
48
|
+
self._init_web3()
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def is_tenderly(self) -> bool:
|
|
52
|
+
"""Check if connected to Tenderly vNet."""
|
|
53
|
+
rpc = self.chain.rpc or ""
|
|
54
|
+
return "tenderly" in rpc.lower() or "virtual" in rpc.lower()
|
|
55
|
+
|
|
56
|
+
def init_block_tracking(self):
|
|
57
|
+
"""Initialize block tracking for limit detection."""
|
|
58
|
+
try:
|
|
59
|
+
self._initial_block = self.web3.eth.block_number
|
|
60
|
+
|
|
61
|
+
if self.is_tenderly:
|
|
62
|
+
try:
|
|
63
|
+
from iwa.core.constants import get_tenderly_config_path
|
|
64
|
+
from iwa.core.models import TenderlyConfig
|
|
65
|
+
from iwa.core.settings import settings
|
|
66
|
+
|
|
67
|
+
profile = settings.tenderly_profile
|
|
68
|
+
config_path = get_tenderly_config_path(profile)
|
|
69
|
+
|
|
70
|
+
if config_path.exists():
|
|
71
|
+
t_config = TenderlyConfig.load(config_path)
|
|
72
|
+
vnet = t_config.vnets.get(self.chain.name)
|
|
73
|
+
if not vnet:
|
|
74
|
+
vnet = t_config.vnets.get(self.chain.name.lower())
|
|
75
|
+
|
|
76
|
+
if vnet and vnet.initial_block > 0:
|
|
77
|
+
self._initial_block = vnet.initial_block
|
|
78
|
+
logger.info(
|
|
79
|
+
f"Tenderly detected! Limit tracking relative to genesis block: {self._initial_block}"
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
logger.warning(
|
|
83
|
+
f"Tenderly detected but no initial_block in config. using session start: {self._initial_block}"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
logger.warning(
|
|
87
|
+
"Monitoring Tenderly vNet block usage (Limit ~50 blocks from vNet start)"
|
|
88
|
+
)
|
|
89
|
+
except Exception as ex:
|
|
90
|
+
logger.warning(f"Failed to load Tenderly config for block tracking: {ex}")
|
|
91
|
+
except Exception as e:
|
|
92
|
+
logger.warning(f"Failed to init block tracking: {e}")
|
|
93
|
+
|
|
94
|
+
def check_block_limit(self, show_progress_bar: bool = False):
|
|
95
|
+
"""Check if approaching block limit (heuristic).
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
show_progress_bar: If True, display a large ASCII progress bar (for startup).
|
|
99
|
+
|
|
100
|
+
"""
|
|
101
|
+
if not self.is_tenderly or self._initial_block == 0:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
current = self.web3.eth.block_number
|
|
106
|
+
delta = current - self._initial_block
|
|
107
|
+
limit = 20 # Tenderly free tier limit (updated Jan 2026)
|
|
108
|
+
percentage = min(100, int((delta / limit) * 100))
|
|
109
|
+
|
|
110
|
+
# Show progress bar at startup or when explicitly requested
|
|
111
|
+
if show_progress_bar or delta == 0:
|
|
112
|
+
self._display_tenderly_progress(delta, limit, percentage)
|
|
113
|
+
|
|
114
|
+
if delta >= 20:
|
|
115
|
+
logger.error(
|
|
116
|
+
f"🛑 CRITICAL TENDERLY LIMIT REACHED: {delta} blocks processed. "
|
|
117
|
+
f"The vNet has likely expired (limit 20). Transactions WILL fail. "
|
|
118
|
+
f"Please run `just reset-tenderly` immediately."
|
|
119
|
+
)
|
|
120
|
+
elif delta > 16:
|
|
121
|
+
logger.warning(
|
|
122
|
+
f"⚠️ TENDERLY LIMIT WARNING: {delta}/20 blocks ({percentage}%). "
|
|
123
|
+
f"You may experience errors soon."
|
|
124
|
+
)
|
|
125
|
+
elif delta > 0 and delta % 5 == 0:
|
|
126
|
+
logger.info(f"📊 Tenderly Usage: {delta}/20 blocks ({percentage}%)")
|
|
127
|
+
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
def _display_tenderly_progress(self, used: int, limit: int, percentage: int):
|
|
132
|
+
"""Display a visual ASCII progress bar for Tenderly block usage."""
|
|
133
|
+
bar_width = 40
|
|
134
|
+
filled = int(bar_width * percentage / 100)
|
|
135
|
+
empty = bar_width - filled
|
|
136
|
+
|
|
137
|
+
# Color coding based on usage
|
|
138
|
+
if percentage >= 80:
|
|
139
|
+
bar_char = "█"
|
|
140
|
+
status = "🔴 CRITICAL"
|
|
141
|
+
elif percentage >= 60:
|
|
142
|
+
bar_char = "█"
|
|
143
|
+
status = "🟡 WARNING"
|
|
144
|
+
else:
|
|
145
|
+
bar_char = "█"
|
|
146
|
+
status = "🟢 OK"
|
|
147
|
+
|
|
148
|
+
bar = bar_char * filled + "░" * empty
|
|
149
|
+
# Use print to ensure visibility in console (loguru writes to file)
|
|
150
|
+
print("")
|
|
151
|
+
print("╔══════════════════════════════════════════════════╗")
|
|
152
|
+
print("║ TENDERLY VIRTUAL NETWORK USAGE ║")
|
|
153
|
+
print("╠══════════════════════════════════════════════════╣")
|
|
154
|
+
print(f"║ [{bar}] ║")
|
|
155
|
+
print(f"║ {used:2d}/{limit} blocks ({percentage:3d}%) {status:12s} ║")
|
|
156
|
+
print("╚══════════════════════════════════════════════════╝")
|
|
157
|
+
print("")
|
|
158
|
+
|
|
159
|
+
def _init_web3(self):
|
|
160
|
+
"""Initialize Web3 with current RPC."""
|
|
161
|
+
rpc_url = self.chain.rpcs[self._current_rpc_index] if self.chain.rpcs else ""
|
|
162
|
+
raw_web3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": DEFAULT_RPC_TIMEOUT}))
|
|
163
|
+
self.web3 = RateLimitedWeb3(raw_web3, self._rate_limiter, self)
|
|
164
|
+
|
|
165
|
+
def _is_rate_limit_error(self, error: Exception) -> bool:
|
|
166
|
+
"""Check if error is a rate limit (429) error."""
|
|
167
|
+
err_text = str(error).lower()
|
|
168
|
+
rate_limit_signals = ["429", "rate limit", "too many requests", "ratelimit"]
|
|
169
|
+
return any(signal in err_text for signal in rate_limit_signals)
|
|
170
|
+
|
|
171
|
+
def _is_connection_error(self, error: Exception) -> bool:
|
|
172
|
+
"""Check if error is a connection/network error."""
|
|
173
|
+
err_text = str(error).lower()
|
|
174
|
+
connection_signals = [
|
|
175
|
+
"timeout",
|
|
176
|
+
"timed out",
|
|
177
|
+
"connection refused",
|
|
178
|
+
"connection reset",
|
|
179
|
+
"connection error",
|
|
180
|
+
"connection aborted",
|
|
181
|
+
"name resolution",
|
|
182
|
+
"dns",
|
|
183
|
+
"no route to host",
|
|
184
|
+
"network unreachable",
|
|
185
|
+
"max retries exceeded",
|
|
186
|
+
"read timeout",
|
|
187
|
+
"connect timeout",
|
|
188
|
+
"remote end closed",
|
|
189
|
+
"broken pipe",
|
|
190
|
+
]
|
|
191
|
+
return any(signal in err_text for signal in connection_signals)
|
|
192
|
+
|
|
193
|
+
def _is_tenderly_quota_exceeded(self, error: Exception) -> bool:
|
|
194
|
+
"""Check if error indicates Tenderly quota exceeded (403 Forbidden)."""
|
|
195
|
+
err_text = str(error).lower()
|
|
196
|
+
if "403" in err_text and "forbidden" in err_text:
|
|
197
|
+
if "tenderly" in err_text or "virtual" in err_text:
|
|
198
|
+
return True
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
def _is_server_error(self, error: Exception) -> bool:
|
|
202
|
+
"""Check if error is a server-side error (5xx)."""
|
|
203
|
+
err_text = str(error).lower()
|
|
204
|
+
server_error_signals = [
|
|
205
|
+
"500",
|
|
206
|
+
"502",
|
|
207
|
+
"503",
|
|
208
|
+
"504",
|
|
209
|
+
"internal server error",
|
|
210
|
+
"bad gateway",
|
|
211
|
+
"service unavailable",
|
|
212
|
+
"gateway timeout",
|
|
213
|
+
]
|
|
214
|
+
return any(signal in err_text for signal in server_error_signals)
|
|
215
|
+
|
|
216
|
+
def _handle_rpc_error(self, error: Exception) -> Dict[str, Union[bool, int]]:
|
|
217
|
+
"""Handle RPC errors with smart rotation and retry logic."""
|
|
218
|
+
result: Dict[str, Union[bool, int]] = {
|
|
219
|
+
"is_rate_limit": self._is_rate_limit_error(error),
|
|
220
|
+
"is_connection_error": self._is_connection_error(error),
|
|
221
|
+
"is_server_error": self._is_server_error(error),
|
|
222
|
+
"is_tenderly_quota": self._is_tenderly_quota_exceeded(error),
|
|
223
|
+
"rotated": False,
|
|
224
|
+
"should_retry": False,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if result["is_tenderly_quota"]:
|
|
228
|
+
logger.error(
|
|
229
|
+
"TENDERLY QUOTA EXCEEDED! The virtual network has reached its limit. "
|
|
230
|
+
"Please run 'uv run -m iwa.tools.reset_tenderly' to reset the network."
|
|
231
|
+
)
|
|
232
|
+
raise TenderlyQuotaExceededError(
|
|
233
|
+
"Tenderly virtual network quota exceeded (403 Forbidden). "
|
|
234
|
+
"Run 'uv run -m iwa.tools.reset_tenderly' to reset."
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
self._rpc_failure_counts[self._current_rpc_index] = (
|
|
238
|
+
self._rpc_failure_counts.get(self._current_rpc_index, 0) + 1
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
should_rotate = result["is_rate_limit"] or result["is_connection_error"]
|
|
242
|
+
|
|
243
|
+
if should_rotate:
|
|
244
|
+
error_type = "rate limit" if result["is_rate_limit"] else "connection"
|
|
245
|
+
logger.warning(
|
|
246
|
+
f"RPC {error_type} error on {self.chain.name} "
|
|
247
|
+
f"(RPC #{self._current_rpc_index}): {error}"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if self.rotate_rpc():
|
|
251
|
+
result["rotated"] = True
|
|
252
|
+
result["should_retry"] = True
|
|
253
|
+
logger.info(f"Rotated to RPC #{self._current_rpc_index} for {self.chain.name}")
|
|
254
|
+
else:
|
|
255
|
+
if result["is_rate_limit"]:
|
|
256
|
+
self._rate_limiter.trigger_backoff(seconds=5.0)
|
|
257
|
+
result["should_retry"] = True
|
|
258
|
+
logger.warning("No other RPCs available, triggered backoff")
|
|
259
|
+
|
|
260
|
+
elif result["is_server_error"]:
|
|
261
|
+
logger.warning(f"Server error on {self.chain.name}: {error}")
|
|
262
|
+
result["should_retry"] = True
|
|
263
|
+
|
|
264
|
+
return result
|
|
265
|
+
|
|
266
|
+
def rotate_rpc(self) -> bool:
|
|
267
|
+
"""Rotate to the next available RPC."""
|
|
268
|
+
if not self.chain.rpcs or len(self.chain.rpcs) <= 1:
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
original_index = self._current_rpc_index
|
|
272
|
+
attempts = 0
|
|
273
|
+
|
|
274
|
+
while attempts < len(self.chain.rpcs) - 1:
|
|
275
|
+
self._current_rpc_index = (self._current_rpc_index + 1) % len(self.chain.rpcs)
|
|
276
|
+
attempts += 1
|
|
277
|
+
|
|
278
|
+
if self._rpc_failure_counts.get(self._current_rpc_index, 0) >= 5:
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
logger.info(f"Rotating RPC for {self.chain.name} to index {self._current_rpc_index}")
|
|
282
|
+
self._init_web3()
|
|
283
|
+
|
|
284
|
+
if self.check_rpc_health():
|
|
285
|
+
return True
|
|
286
|
+
else:
|
|
287
|
+
logger.warning(f"RPC at index {self._current_rpc_index} failed health check")
|
|
288
|
+
self._rpc_failure_counts[self._current_rpc_index] = (
|
|
289
|
+
self._rpc_failure_counts.get(self._current_rpc_index, 0) + 1
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
self._current_rpc_index = original_index
|
|
293
|
+
self._init_web3()
|
|
294
|
+
return False
|
|
295
|
+
|
|
296
|
+
def check_rpc_health(self) -> bool:
|
|
297
|
+
"""Check if the current RPC is healthy."""
|
|
298
|
+
try:
|
|
299
|
+
block = self.web3._web3.eth.block_number
|
|
300
|
+
return block is not None and block > 0
|
|
301
|
+
except Exception as e:
|
|
302
|
+
logger.debug(f"RPC health check failed: {e}")
|
|
303
|
+
return False
|
|
304
|
+
|
|
305
|
+
def with_retry(
|
|
306
|
+
self,
|
|
307
|
+
operation: Callable[[], T],
|
|
308
|
+
max_retries: Optional[int] = None,
|
|
309
|
+
operation_name: str = "operation",
|
|
310
|
+
) -> T:
|
|
311
|
+
"""Execute an operation with retry logic."""
|
|
312
|
+
if max_retries is None:
|
|
313
|
+
max_retries = self.DEFAULT_MAX_RETRIES
|
|
314
|
+
|
|
315
|
+
last_error = None
|
|
316
|
+
|
|
317
|
+
for attempt in range(max_retries + 1):
|
|
318
|
+
try:
|
|
319
|
+
return operation()
|
|
320
|
+
except Exception as e:
|
|
321
|
+
last_error = e
|
|
322
|
+
result = self._handle_rpc_error(e)
|
|
323
|
+
|
|
324
|
+
if not result["should_retry"] or attempt >= max_retries:
|
|
325
|
+
logger.error(f"{operation_name} failed after {attempt + 1} attempts: {e}")
|
|
326
|
+
raise
|
|
327
|
+
|
|
328
|
+
delay = self.DEFAULT_RETRY_DELAY * (2**attempt)
|
|
329
|
+
logger.info(
|
|
330
|
+
f"{operation_name} attempt {attempt + 1} failed, retrying in {delay:.1f}s..."
|
|
331
|
+
)
|
|
332
|
+
time.sleep(delay)
|
|
333
|
+
|
|
334
|
+
if last_error:
|
|
335
|
+
raise last_error
|
|
336
|
+
raise RuntimeError(f"{operation_name} failed unexpectedly")
|
|
337
|
+
|
|
338
|
+
def is_contract(self, address: EthereumAddress) -> bool:
|
|
339
|
+
"""Check if address is a contract"""
|
|
340
|
+
code = self.web3.eth.get_code(address)
|
|
341
|
+
return code != b""
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def tokens(self) -> Dict[str, EthereumAddress]:
|
|
345
|
+
"""Get all tokens for this chain (default + custom)."""
|
|
346
|
+
defaults = self.chain.tokens.copy()
|
|
347
|
+
|
|
348
|
+
config = Config()
|
|
349
|
+
if config.core and config.core.custom_tokens:
|
|
350
|
+
custom = config.core.custom_tokens.get(self.chain.name.lower(), {})
|
|
351
|
+
if not custom:
|
|
352
|
+
custom = config.core.custom_tokens.get(self.chain.name, {})
|
|
353
|
+
defaults.update(custom)
|
|
354
|
+
|
|
355
|
+
return defaults
|
|
356
|
+
|
|
357
|
+
def get_token_symbol(self, address: EthereumAddress) -> str:
|
|
358
|
+
"""Get token symbol for an address."""
|
|
359
|
+
for symbol, addr in self.chain.tokens.items():
|
|
360
|
+
if addr.lower() == address.lower():
|
|
361
|
+
return symbol
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
from iwa.core.contracts.erc20 import ERC20Contract
|
|
365
|
+
|
|
366
|
+
erc20 = ERC20Contract(address, self.chain.name.lower())
|
|
367
|
+
return erc20.symbol or address[:6] + "..." + address[-4:]
|
|
368
|
+
except Exception:
|
|
369
|
+
return address[:6] + "..." + address[-4:]
|
|
370
|
+
|
|
371
|
+
def get_token_decimals(self, address: EthereumAddress) -> int:
|
|
372
|
+
"""Get token decimals for an address."""
|
|
373
|
+
try:
|
|
374
|
+
from iwa.core.contracts.erc20 import ERC20Contract
|
|
375
|
+
|
|
376
|
+
erc20 = ERC20Contract(address, self.chain.name.lower())
|
|
377
|
+
return erc20.decimals if erc20.decimals is not None else 18
|
|
378
|
+
except Exception:
|
|
379
|
+
return 18
|
|
380
|
+
|
|
381
|
+
def get_native_balance_wei(self, address: EthereumAddress):
|
|
382
|
+
"""Get the native balance in wei"""
|
|
383
|
+
return self.web3.eth.get_balance(address)
|
|
384
|
+
|
|
385
|
+
def get_native_balance_eth(self, address: EthereumAddress):
|
|
386
|
+
"""Get the native balance in ether"""
|
|
387
|
+
balance_wei = self.get_native_balance_wei(address)
|
|
388
|
+
balance_ether = self.web3.from_wei(balance_wei, "ether")
|
|
389
|
+
return balance_ether
|
|
390
|
+
|
|
391
|
+
def estimate_gas(self, built_method: Callable, tx_params: Dict[str, Union[str, int]]) -> int:
|
|
392
|
+
"""Estimate gas for a contract function call."""
|
|
393
|
+
from_address = tx_params["from"]
|
|
394
|
+
value = int(tx_params.get("value", 0))
|
|
395
|
+
|
|
396
|
+
if self.is_contract(str(from_address)):
|
|
397
|
+
logger.debug(f"Skipping gas estimation for contract caller {str(from_address)[:10]}...")
|
|
398
|
+
return 0
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
estimated_gas = built_method.estimate_gas({"from": from_address, "value": value})
|
|
402
|
+
return int(estimated_gas * 1.1)
|
|
403
|
+
except Exception as e:
|
|
404
|
+
logger.warning(f"Gas estimation failed: {e}")
|
|
405
|
+
return 500_000
|
|
406
|
+
|
|
407
|
+
def calculate_transaction_params(
|
|
408
|
+
self, built_method: Callable, tx_params: Dict[str, Union[str, int]]
|
|
409
|
+
) -> Dict[str, Union[str, int]]:
|
|
410
|
+
"""Calculate transaction parameters for a contract function call."""
|
|
411
|
+
params = {
|
|
412
|
+
"from": tx_params["from"],
|
|
413
|
+
"value": tx_params.get("value", 0),
|
|
414
|
+
"nonce": self.web3.eth.get_transaction_count(tx_params["from"]),
|
|
415
|
+
"gas": self.estimate_gas(built_method, tx_params),
|
|
416
|
+
"gasPrice": self.web3.eth.gas_price,
|
|
417
|
+
}
|
|
418
|
+
return params
|
|
419
|
+
|
|
420
|
+
def wait_for_no_pending_tx(
|
|
421
|
+
self, from_address: EthereumAddress, max_wait_seconds: int = 60, poll_interval: float = 2.0
|
|
422
|
+
):
|
|
423
|
+
"""Wait for no pending transactions for a specified time."""
|
|
424
|
+
start_time = time.time()
|
|
425
|
+
while time.time() - start_time < max_wait_seconds:
|
|
426
|
+
latest_nonce = self.web3.eth.get_transaction_count(
|
|
427
|
+
from_address, block_identifier="latest"
|
|
428
|
+
)
|
|
429
|
+
pending_nonce = self.web3.eth.get_transaction_count(
|
|
430
|
+
from_address, block_identifier="pending"
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
if pending_nonce == latest_nonce:
|
|
434
|
+
return True
|
|
435
|
+
|
|
436
|
+
time.sleep(poll_interval)
|
|
437
|
+
|
|
438
|
+
return False
|
|
439
|
+
|
|
440
|
+
def send_native_transfer(
|
|
441
|
+
self,
|
|
442
|
+
from_address: EthereumAddress,
|
|
443
|
+
to_address: EthereumAddress,
|
|
444
|
+
value_wei: int,
|
|
445
|
+
sign_callback: Callable[[dict], SignedTransaction],
|
|
446
|
+
) -> Tuple[bool, Optional[str]]:
|
|
447
|
+
"""Send native currency transaction with retry logic."""
|
|
448
|
+
|
|
449
|
+
def _do_transfer() -> Tuple[bool, Optional[str]]:
|
|
450
|
+
tx = {
|
|
451
|
+
"from": from_address,
|
|
452
|
+
"to": to_address,
|
|
453
|
+
"value": value_wei,
|
|
454
|
+
"nonce": self.web3.eth.get_transaction_count(from_address),
|
|
455
|
+
"chainId": self.chain.chain_id,
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
balance_wei = self.get_native_balance_wei(from_address)
|
|
459
|
+
gas_price = self.web3.eth.gas_price
|
|
460
|
+
gas_estimate = self.web3.eth.estimate_gas(tx)
|
|
461
|
+
required_wei = value_wei + (gas_estimate * gas_price)
|
|
462
|
+
|
|
463
|
+
if balance_wei < required_wei:
|
|
464
|
+
logger.error(
|
|
465
|
+
f"Insufficient balance. "
|
|
466
|
+
f"Balance: {self.web3.from_wei(balance_wei, 'ether'):.4f} "
|
|
467
|
+
f"{self.chain.native_currency}, "
|
|
468
|
+
f"Required: {self.web3.from_wei(required_wei, 'ether'):.4f} "
|
|
469
|
+
f"{self.chain.native_currency}"
|
|
470
|
+
)
|
|
471
|
+
return False, None
|
|
472
|
+
|
|
473
|
+
tx["gas"] = gas_estimate
|
|
474
|
+
tx["gasPrice"] = gas_price
|
|
475
|
+
|
|
476
|
+
signed_tx = sign_callback(tx)
|
|
477
|
+
txn_hash = self.web3.eth.send_raw_transaction(signed_tx.raw_transaction)
|
|
478
|
+
receipt = self.web3.eth.wait_for_transaction_receipt(txn_hash)
|
|
479
|
+
|
|
480
|
+
status = getattr(receipt, "status", None)
|
|
481
|
+
if status is None and isinstance(receipt, dict):
|
|
482
|
+
status = receipt.get("status")
|
|
483
|
+
|
|
484
|
+
if receipt and status == 1:
|
|
485
|
+
self.wait_for_no_pending_tx(from_address)
|
|
486
|
+
logger.info(f"Transaction sent successfully. Tx Hash: {txn_hash.hex()}")
|
|
487
|
+
# Check Tenderly block limit after each successful transaction
|
|
488
|
+
self.check_block_limit()
|
|
489
|
+
return True, receipt["transactionHash"].hex()
|
|
490
|
+
|
|
491
|
+
logger.error("Transaction failed (status != 1)")
|
|
492
|
+
return False, None
|
|
493
|
+
|
|
494
|
+
try:
|
|
495
|
+
return self.with_retry(
|
|
496
|
+
_do_transfer,
|
|
497
|
+
operation_name=f"native_transfer to {str(to_address)[:10]}...",
|
|
498
|
+
)
|
|
499
|
+
except Exception as e:
|
|
500
|
+
logger.exception(f"Native transfer failed: {e}")
|
|
501
|
+
return False, None
|
|
502
|
+
|
|
503
|
+
def get_token_address(self, token_name: str) -> Optional[EthereumAddress]:
|
|
504
|
+
"""Get token address by name"""
|
|
505
|
+
return self.chain.get_token_address(token_name)
|
|
506
|
+
|
|
507
|
+
def get_contract_address(self, contract_name: str) -> Optional[EthereumAddress]:
|
|
508
|
+
"""Get contract address by name from the chain's contracts mapping."""
|
|
509
|
+
return self.chain.contracts.get(contract_name)
|
|
510
|
+
|
|
511
|
+
def reset_rpc_failure_counts(self):
|
|
512
|
+
"""Reset RPC failure tracking. Call periodically to allow retrying failed RPCs."""
|
|
513
|
+
self._rpc_failure_counts.clear()
|
|
514
|
+
logger.debug("Reset RPC failure counts")
|