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.
Files changed (191) hide show
  1. conftest.py +22 -0
  2. iwa/__init__.py +1 -0
  3. iwa/__main__.py +6 -0
  4. iwa/core/__init__.py +1 -0
  5. iwa/core/chain/__init__.py +68 -0
  6. iwa/core/chain/errors.py +47 -0
  7. iwa/core/chain/interface.py +514 -0
  8. iwa/core/chain/manager.py +38 -0
  9. iwa/core/chain/models.py +128 -0
  10. iwa/core/chain/rate_limiter.py +193 -0
  11. iwa/core/cli.py +210 -0
  12. iwa/core/constants.py +28 -0
  13. iwa/core/contracts/__init__.py +1 -0
  14. iwa/core/contracts/contract.py +297 -0
  15. iwa/core/contracts/erc20.py +79 -0
  16. iwa/core/contracts/multisend.py +71 -0
  17. iwa/core/db.py +317 -0
  18. iwa/core/keys.py +361 -0
  19. iwa/core/mnemonic.py +385 -0
  20. iwa/core/models.py +344 -0
  21. iwa/core/monitor.py +209 -0
  22. iwa/core/plugins.py +45 -0
  23. iwa/core/pricing.py +91 -0
  24. iwa/core/services/__init__.py +17 -0
  25. iwa/core/services/account.py +57 -0
  26. iwa/core/services/balance.py +113 -0
  27. iwa/core/services/plugin.py +88 -0
  28. iwa/core/services/safe.py +392 -0
  29. iwa/core/services/transaction.py +172 -0
  30. iwa/core/services/transfer/__init__.py +166 -0
  31. iwa/core/services/transfer/base.py +260 -0
  32. iwa/core/services/transfer/erc20.py +247 -0
  33. iwa/core/services/transfer/multisend.py +386 -0
  34. iwa/core/services/transfer/native.py +262 -0
  35. iwa/core/services/transfer/swap.py +326 -0
  36. iwa/core/settings.py +95 -0
  37. iwa/core/tables.py +60 -0
  38. iwa/core/test.py +27 -0
  39. iwa/core/tests/test_wallet.py +255 -0
  40. iwa/core/types.py +59 -0
  41. iwa/core/ui.py +99 -0
  42. iwa/core/utils.py +59 -0
  43. iwa/core/wallet.py +380 -0
  44. iwa/plugins/__init__.py +1 -0
  45. iwa/plugins/gnosis/__init__.py +5 -0
  46. iwa/plugins/gnosis/cow/__init__.py +6 -0
  47. iwa/plugins/gnosis/cow/quotes.py +148 -0
  48. iwa/plugins/gnosis/cow/swap.py +403 -0
  49. iwa/plugins/gnosis/cow/types.py +20 -0
  50. iwa/plugins/gnosis/cow_utils.py +44 -0
  51. iwa/plugins/gnosis/plugin.py +68 -0
  52. iwa/plugins/gnosis/safe.py +157 -0
  53. iwa/plugins/gnosis/tests/test_cow.py +227 -0
  54. iwa/plugins/gnosis/tests/test_safe.py +100 -0
  55. iwa/plugins/olas/__init__.py +5 -0
  56. iwa/plugins/olas/constants.py +106 -0
  57. iwa/plugins/olas/contracts/activity_checker.py +93 -0
  58. iwa/plugins/olas/contracts/base.py +10 -0
  59. iwa/plugins/olas/contracts/mech.py +49 -0
  60. iwa/plugins/olas/contracts/mech_marketplace.py +43 -0
  61. iwa/plugins/olas/contracts/service.py +215 -0
  62. iwa/plugins/olas/contracts/staking.py +403 -0
  63. iwa/plugins/olas/importer.py +736 -0
  64. iwa/plugins/olas/mech_reference.py +135 -0
  65. iwa/plugins/olas/models.py +110 -0
  66. iwa/plugins/olas/plugin.py +243 -0
  67. iwa/plugins/olas/scripts/test_full_mech_flow.py +259 -0
  68. iwa/plugins/olas/scripts/test_simple_lifecycle.py +74 -0
  69. iwa/plugins/olas/service_manager/__init__.py +60 -0
  70. iwa/plugins/olas/service_manager/base.py +113 -0
  71. iwa/plugins/olas/service_manager/drain.py +336 -0
  72. iwa/plugins/olas/service_manager/lifecycle.py +839 -0
  73. iwa/plugins/olas/service_manager/mech.py +322 -0
  74. iwa/plugins/olas/service_manager/staking.py +530 -0
  75. iwa/plugins/olas/tests/conftest.py +30 -0
  76. iwa/plugins/olas/tests/test_importer.py +128 -0
  77. iwa/plugins/olas/tests/test_importer_error_handling.py +349 -0
  78. iwa/plugins/olas/tests/test_mech_contracts.py +85 -0
  79. iwa/plugins/olas/tests/test_olas_contracts.py +249 -0
  80. iwa/plugins/olas/tests/test_olas_integration.py +561 -0
  81. iwa/plugins/olas/tests/test_olas_models.py +144 -0
  82. iwa/plugins/olas/tests/test_olas_view.py +258 -0
  83. iwa/plugins/olas/tests/test_olas_view_actions.py +137 -0
  84. iwa/plugins/olas/tests/test_olas_view_modals.py +120 -0
  85. iwa/plugins/olas/tests/test_plugin.py +70 -0
  86. iwa/plugins/olas/tests/test_plugin_full.py +212 -0
  87. iwa/plugins/olas/tests/test_service_lifecycle.py +150 -0
  88. iwa/plugins/olas/tests/test_service_manager.py +1065 -0
  89. iwa/plugins/olas/tests/test_service_manager_errors.py +208 -0
  90. iwa/plugins/olas/tests/test_service_manager_flows.py +497 -0
  91. iwa/plugins/olas/tests/test_service_manager_mech.py +135 -0
  92. iwa/plugins/olas/tests/test_service_manager_rewards.py +360 -0
  93. iwa/plugins/olas/tests/test_service_manager_validation.py +145 -0
  94. iwa/plugins/olas/tests/test_service_staking.py +342 -0
  95. iwa/plugins/olas/tests/test_staking_integration.py +269 -0
  96. iwa/plugins/olas/tests/test_staking_validation.py +109 -0
  97. iwa/plugins/olas/tui/__init__.py +1 -0
  98. iwa/plugins/olas/tui/olas_view.py +952 -0
  99. iwa/tools/check_profile.py +67 -0
  100. iwa/tools/release.py +111 -0
  101. iwa/tools/reset_env.py +111 -0
  102. iwa/tools/reset_tenderly.py +362 -0
  103. iwa/tools/restore_backup.py +82 -0
  104. iwa/tui/__init__.py +1 -0
  105. iwa/tui/app.py +174 -0
  106. iwa/tui/modals/__init__.py +5 -0
  107. iwa/tui/modals/base.py +406 -0
  108. iwa/tui/rpc.py +63 -0
  109. iwa/tui/screens/__init__.py +1 -0
  110. iwa/tui/screens/wallets.py +749 -0
  111. iwa/tui/tests/test_app.py +125 -0
  112. iwa/tui/tests/test_rpc.py +139 -0
  113. iwa/tui/tests/test_wallets_refactor.py +30 -0
  114. iwa/tui/tests/test_widgets.py +123 -0
  115. iwa/tui/widgets/__init__.py +5 -0
  116. iwa/tui/widgets/base.py +100 -0
  117. iwa/tui/workers.py +42 -0
  118. iwa/web/dependencies.py +76 -0
  119. iwa/web/models.py +76 -0
  120. iwa/web/routers/accounts.py +115 -0
  121. iwa/web/routers/olas/__init__.py +24 -0
  122. iwa/web/routers/olas/admin.py +169 -0
  123. iwa/web/routers/olas/funding.py +135 -0
  124. iwa/web/routers/olas/general.py +29 -0
  125. iwa/web/routers/olas/services.py +378 -0
  126. iwa/web/routers/olas/staking.py +341 -0
  127. iwa/web/routers/state.py +65 -0
  128. iwa/web/routers/swap.py +617 -0
  129. iwa/web/routers/transactions.py +153 -0
  130. iwa/web/server.py +155 -0
  131. iwa/web/tests/test_web_endpoints.py +713 -0
  132. iwa/web/tests/test_web_olas.py +430 -0
  133. iwa/web/tests/test_web_swap.py +103 -0
  134. iwa-0.0.1a2.dist-info/METADATA +234 -0
  135. iwa-0.0.1a2.dist-info/RECORD +186 -0
  136. iwa-0.0.1a2.dist-info/entry_points.txt +2 -0
  137. iwa-0.0.1a2.dist-info/licenses/LICENSE +21 -0
  138. iwa-0.0.1a2.dist-info/top_level.txt +4 -0
  139. tests/legacy_cow.py +248 -0
  140. tests/legacy_safe.py +93 -0
  141. tests/legacy_transaction_retry_logic.py +51 -0
  142. tests/legacy_tui.py +440 -0
  143. tests/legacy_wallets_screen.py +554 -0
  144. tests/legacy_web.py +243 -0
  145. tests/test_account_service.py +120 -0
  146. tests/test_balance_service.py +186 -0
  147. tests/test_chain.py +490 -0
  148. tests/test_chain_interface.py +210 -0
  149. tests/test_cli.py +139 -0
  150. tests/test_contract.py +195 -0
  151. tests/test_db.py +180 -0
  152. tests/test_drain_coverage.py +174 -0
  153. tests/test_erc20.py +95 -0
  154. tests/test_gnosis_plugin.py +111 -0
  155. tests/test_keys.py +449 -0
  156. tests/test_legacy_wallet.py +1285 -0
  157. tests/test_main.py +13 -0
  158. tests/test_mnemonic.py +217 -0
  159. tests/test_modals.py +109 -0
  160. tests/test_models.py +213 -0
  161. tests/test_monitor.py +202 -0
  162. tests/test_multisend.py +84 -0
  163. tests/test_plugin_service.py +119 -0
  164. tests/test_pricing.py +143 -0
  165. tests/test_rate_limiter.py +199 -0
  166. tests/test_reset_tenderly.py +202 -0
  167. tests/test_rpc_view.py +73 -0
  168. tests/test_safe_coverage.py +139 -0
  169. tests/test_safe_service.py +168 -0
  170. tests/test_service_manager_integration.py +61 -0
  171. tests/test_service_manager_structure.py +31 -0
  172. tests/test_service_transaction.py +176 -0
  173. tests/test_staking_router.py +71 -0
  174. tests/test_staking_simple.py +31 -0
  175. tests/test_tables.py +76 -0
  176. tests/test_transaction_service.py +161 -0
  177. tests/test_transfer_multisend.py +179 -0
  178. tests/test_transfer_native.py +220 -0
  179. tests/test_transfer_security.py +93 -0
  180. tests/test_transfer_structure.py +37 -0
  181. tests/test_transfer_swap_unit.py +155 -0
  182. tests/test_ui_coverage.py +66 -0
  183. tests/test_utils.py +53 -0
  184. tests/test_workers.py +91 -0
  185. tools/verify_drain.py +183 -0
  186. __init__.py +0 -2
  187. hello.py +0 -6
  188. iwa-0.0.0.dist-info/METADATA +0 -10
  189. iwa-0.0.0.dist-info/RECORD +0 -6
  190. iwa-0.0.0.dist-info/top_level.txt +0 -2
  191. {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
@@ -0,0 +1,6 @@
1
+ """iwa main."""
2
+
3
+ from iwa.core import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli.iwa_cli()
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
+ ]
@@ -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")