iwa 0.0.60__py3-none-any.whl → 0.0.62__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.
- iwa/core/chain/interface.py +87 -12
- iwa/core/chain/manager.py +8 -0
- iwa/core/chainlist.py +183 -5
- iwa/core/services/safe_executor.py +110 -26
- {iwa-0.0.60.dist-info → iwa-0.0.62.dist-info}/METADATA +1 -1
- {iwa-0.0.60.dist-info → iwa-0.0.62.dist-info}/RECORD +13 -12
- tests/test_chainlist_enrichment.py +354 -0
- tests/test_safe_executor.py +278 -0
- tests/test_transaction_service.py +178 -2
- {iwa-0.0.60.dist-info → iwa-0.0.62.dist-info}/WHEEL +0 -0
- {iwa-0.0.60.dist-info → iwa-0.0.62.dist-info}/entry_points.txt +0 -0
- {iwa-0.0.60.dist-info → iwa-0.0.62.dist-info}/licenses/LICENSE +0 -0
- {iwa-0.0.60.dist-info → iwa-0.0.62.dist-info}/top_level.txt +0 -0
iwa/core/chain/interface.py
CHANGED
|
@@ -54,9 +54,41 @@ class ChainInterface:
|
|
|
54
54
|
|
|
55
55
|
self._initial_block = 0
|
|
56
56
|
self._rotation_lock = threading.Lock()
|
|
57
|
-
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
|
+
|
|
58
63
|
self._init_web3()
|
|
59
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
|
+
|
|
60
92
|
@property
|
|
61
93
|
def current_rpc(self) -> str:
|
|
62
94
|
"""Get the current active RPC URL."""
|
|
@@ -251,6 +283,39 @@ class ChainInterface:
|
|
|
251
283
|
]
|
|
252
284
|
return any(signal in err_text for signal in quota_signals)
|
|
253
285
|
|
|
286
|
+
# -- ChainList enrichment ----------------------------------------------
|
|
287
|
+
|
|
288
|
+
MAX_RPCS = 20 # 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
|
+
logger.debug(
|
|
294
|
+
f"{self.chain.name}: skipping ChainList enrichment "
|
|
295
|
+
f"(already have {len(self.chain.rpcs)} RPCs)"
|
|
296
|
+
)
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
from iwa.core.chainlist import ChainlistRPC
|
|
301
|
+
|
|
302
|
+
chainlist = ChainlistRPC()
|
|
303
|
+
extra = chainlist.get_validated_rpcs(
|
|
304
|
+
self.chain.chain_id,
|
|
305
|
+
existing_rpcs=self.chain.rpcs,
|
|
306
|
+
max_results=self.MAX_RPCS - len(self.chain.rpcs),
|
|
307
|
+
)
|
|
308
|
+
if extra:
|
|
309
|
+
self.chain.rpcs.extend(extra)
|
|
310
|
+
logger.info(
|
|
311
|
+
f"Enriched {self.chain.name} with {len(extra)} "
|
|
312
|
+
f"ChainList RPCs (total: {len(self.chain.rpcs)})"
|
|
313
|
+
)
|
|
314
|
+
except Exception as e:
|
|
315
|
+
logger.debug(
|
|
316
|
+
f"ChainList enrichment failed for {self.chain.name}: {e}"
|
|
317
|
+
)
|
|
318
|
+
|
|
254
319
|
# -- Per-RPC health tracking ------------------------------------------
|
|
255
320
|
|
|
256
321
|
def _mark_rpc_backoff(self, index: int, seconds: float) -> None:
|
|
@@ -293,35 +358,45 @@ class ChainInterface:
|
|
|
293
358
|
|
|
294
359
|
if should_rotate:
|
|
295
360
|
failed_index = self._current_rpc_index
|
|
361
|
+
failed_rpc = sanitize_rpc_url(self.chain.rpcs[failed_index]) if self.chain.rpcs else "?"
|
|
296
362
|
|
|
297
363
|
# Apply per-RPC backoff so smart rotation skips this RPC.
|
|
298
364
|
if result["is_quota_exceeded"]:
|
|
299
|
-
error_type = "
|
|
300
|
-
|
|
365
|
+
error_type = "QUOTA"
|
|
366
|
+
backoff = self.QUOTA_EXCEEDED_BACKOFF
|
|
367
|
+
self._mark_rpc_backoff(failed_index, backoff)
|
|
301
368
|
elif result["is_rate_limit"]:
|
|
302
|
-
error_type = "
|
|
303
|
-
|
|
369
|
+
error_type = "RATE_LIMIT"
|
|
370
|
+
backoff = self.RATE_LIMIT_BACKOFF
|
|
371
|
+
self._mark_rpc_backoff(failed_index, backoff)
|
|
304
372
|
# Brief global backoff so other threads don't immediately flood
|
|
305
373
|
# the same (now backed-off) RPC before rotation takes effect.
|
|
306
374
|
self._rate_limiter.trigger_backoff(seconds=2.0)
|
|
307
375
|
else:
|
|
308
|
-
error_type = "
|
|
309
|
-
|
|
376
|
+
error_type = "CONNECTION"
|
|
377
|
+
backoff = self.CONNECTION_ERROR_BACKOFF
|
|
378
|
+
self._mark_rpc_backoff(failed_index, backoff)
|
|
379
|
+
|
|
380
|
+
# Count healthy RPCs for visibility
|
|
381
|
+
healthy_count = sum(1 for i in range(len(self.chain.rpcs)) if self._is_rpc_healthy(i))
|
|
382
|
+
total_rpcs = len(self.chain.rpcs) if self.chain.rpcs else 0
|
|
310
383
|
|
|
311
384
|
logger.warning(
|
|
312
|
-
f"
|
|
313
|
-
f"(
|
|
385
|
+
f"[{self.chain.name}] RPC #{failed_index} {error_type} → "
|
|
386
|
+
f"backoff {int(backoff)}s ({healthy_count}/{total_rpcs} healthy) | "
|
|
387
|
+
f"{failed_rpc}: {str(error)[:100]}"
|
|
314
388
|
)
|
|
315
389
|
|
|
316
390
|
if self.rotate_rpc():
|
|
317
391
|
result["rotated"] = True
|
|
318
392
|
result["should_retry"] = True
|
|
319
|
-
|
|
393
|
+
new_rpc = sanitize_rpc_url(self.chain.rpcs[self._current_rpc_index])
|
|
394
|
+
logger.info(f"[{self.chain.name}] Rotated to RPC #{self._current_rpc_index}: {new_rpc}")
|
|
320
395
|
else:
|
|
321
396
|
# Rotation skipped (cooldown or single RPC) - still allow retry
|
|
322
397
|
result["should_retry"] = True
|
|
323
|
-
logger.
|
|
324
|
-
f"
|
|
398
|
+
logger.debug(
|
|
399
|
+
f"[{self.chain.name}] Rotation skipped (cooldown), retrying RPC #{self._current_rpc_index}"
|
|
325
400
|
)
|
|
326
401
|
|
|
327
402
|
elif result["is_server_error"]:
|
iwa/core/chain/manager.py
CHANGED
|
@@ -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()
|
iwa/core/chainlist.py
CHANGED
|
@@ -2,12 +2,158 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import time
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
5
6
|
from dataclasses import dataclass
|
|
6
|
-
from typing import Any, Dict, List, Optional
|
|
7
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
7
8
|
|
|
8
9
|
import requests
|
|
9
10
|
|
|
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
|
|
11
157
|
|
|
12
158
|
|
|
13
159
|
@dataclass
|
|
@@ -50,11 +196,12 @@ class ChainlistRPC:
|
|
|
50
196
|
except Exception as e:
|
|
51
197
|
print(f"Error reading Chainlist cache: {e}")
|
|
52
198
|
|
|
53
|
-
# 2. Fetch from remote
|
|
199
|
+
# 2. Fetch from remote (use session context for proper cleanup)
|
|
54
200
|
try:
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
201
|
+
with requests.Session() as session:
|
|
202
|
+
response = session.get(self.URL, timeout=10)
|
|
203
|
+
response.raise_for_status()
|
|
204
|
+
self._data = response.json()
|
|
58
205
|
|
|
59
206
|
# 3. Update local cache
|
|
60
207
|
if self._data:
|
|
@@ -119,3 +266,34 @@ class ChainlistRPC:
|
|
|
119
266
|
for node in rpcs
|
|
120
267
|
if node.url.startswith("wss://") or node.url.startswith("ws://")
|
|
121
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
|
|
@@ -39,6 +39,10 @@ class SafeTransactionExecutor:
|
|
|
39
39
|
MAX_GAS_MULTIPLIER = 10 # Hard cap: never exceed 10x original estimate
|
|
40
40
|
DEFAULT_FALLBACK_GAS = 500_000 # Fallback when estimation fails
|
|
41
41
|
|
|
42
|
+
# Fee bumping for "max fee per gas less than block base fee" errors
|
|
43
|
+
FEE_BUMP_PERCENTAGE = 1.30 # 30% bump per retry on fee errors
|
|
44
|
+
MAX_FEE_BUMP_FACTOR = 3.0 # Cap: never bump more than 3x original
|
|
45
|
+
|
|
42
46
|
def __init__(
|
|
43
47
|
self,
|
|
44
48
|
chain_interface: "ChainInterface",
|
|
@@ -76,6 +80,7 @@ class SafeTransactionExecutor:
|
|
|
76
80
|
last_error = None
|
|
77
81
|
current_gas = safe_tx.safe_tx_gas
|
|
78
82
|
base_estimate = current_gas if current_gas > 0 else 0
|
|
83
|
+
fee_bump_factor = 1.0 # Multiplier for EIP-1559 fees, increases on fee errors
|
|
79
84
|
|
|
80
85
|
for attempt in range(self.max_retries + 1):
|
|
81
86
|
SAFE_TX_STATS["total_attempts"] += 1
|
|
@@ -89,6 +94,7 @@ class SafeTransactionExecutor:
|
|
|
89
94
|
attempt,
|
|
90
95
|
current_gas,
|
|
91
96
|
base_estimate,
|
|
97
|
+
fee_bump_factor,
|
|
92
98
|
)
|
|
93
99
|
|
|
94
100
|
# Check receipt
|
|
@@ -106,7 +112,7 @@ class SafeTransactionExecutor:
|
|
|
106
112
|
raise ValueError("Transaction reverted on-chain")
|
|
107
113
|
|
|
108
114
|
except Exception as e:
|
|
109
|
-
updated_tx, should_retry = self._handle_execution_failure(
|
|
115
|
+
updated_tx, should_retry, is_fee_error = self._handle_execution_failure(
|
|
110
116
|
e, safe_address, safe_tx, attempt, operation_name
|
|
111
117
|
)
|
|
112
118
|
last_error = e
|
|
@@ -115,7 +121,12 @@ class SafeTransactionExecutor:
|
|
|
115
121
|
|
|
116
122
|
# Update gas/nonce for next loop if needed
|
|
117
123
|
safe_tx = updated_tx
|
|
118
|
-
|
|
124
|
+
|
|
125
|
+
# Bump fee multiplier on fee-related errors (base fee > max fee)
|
|
126
|
+
if is_fee_error and fee_bump_factor < self.MAX_FEE_BUMP_FACTOR:
|
|
127
|
+
fee_bump_factor *= self.FEE_BUMP_PERCENTAGE
|
|
128
|
+
fee_bump_factor = min(fee_bump_factor, self.MAX_FEE_BUMP_FACTOR)
|
|
129
|
+
logger.info(f"[{operation_name}] Fee bump factor increased to {fee_bump_factor:.2f}x")
|
|
119
130
|
|
|
120
131
|
delay = self.DEFAULT_RETRY_DELAY * (2**attempt)
|
|
121
132
|
time.sleep(delay)
|
|
@@ -131,6 +142,7 @@ class SafeTransactionExecutor:
|
|
|
131
142
|
attempt,
|
|
132
143
|
current_gas,
|
|
133
144
|
base_estimate,
|
|
145
|
+
fee_bump_factor: float = 1.0,
|
|
134
146
|
) -> str:
|
|
135
147
|
"""Prepare client, estimate gas, simulate, and execute."""
|
|
136
148
|
# 1. (Re)Create Safe client
|
|
@@ -171,24 +183,11 @@ class SafeTransactionExecutor:
|
|
|
171
183
|
signatures_backup = safe_tx.signatures
|
|
172
184
|
|
|
173
185
|
try:
|
|
174
|
-
#
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
# Handle both tuple return (tx_hash, tx) and bytes return
|
|
180
|
-
if isinstance(result, tuple):
|
|
181
|
-
tx_hash_bytes = result[0]
|
|
182
|
-
else:
|
|
183
|
-
tx_hash_bytes = result
|
|
184
|
-
|
|
185
|
-
# Handle both bytes and hex string returns
|
|
186
|
-
if isinstance(tx_hash_bytes, bytes):
|
|
187
|
-
return f"0x{tx_hash_bytes.hex()}"
|
|
188
|
-
elif isinstance(tx_hash_bytes, str):
|
|
189
|
-
return tx_hash_bytes if tx_hash_bytes.startswith("0x") else f"0x{tx_hash_bytes}"
|
|
190
|
-
else:
|
|
191
|
-
return str(tx_hash_bytes)
|
|
186
|
+
# Execute with appropriate gas pricing
|
|
187
|
+
result = self._execute_with_gas_pricing(
|
|
188
|
+
safe_tx, signer_keys[0], fee_bump_factor, operation_name
|
|
189
|
+
)
|
|
190
|
+
return self._extract_tx_hash(result)
|
|
192
191
|
|
|
193
192
|
finally:
|
|
194
193
|
# Restore signatures for next attempt if needed
|
|
@@ -196,6 +195,39 @@ class SafeTransactionExecutor:
|
|
|
196
195
|
if safe_tx.signatures != signatures_backup:
|
|
197
196
|
safe_tx.signatures = signatures_backup
|
|
198
197
|
|
|
198
|
+
def _execute_with_gas_pricing(
|
|
199
|
+
self, safe_tx: SafeTx, signer_key: str, fee_bump_factor: float, operation_name: str
|
|
200
|
+
):
|
|
201
|
+
"""Execute transaction with appropriate gas pricing strategy.
|
|
202
|
+
|
|
203
|
+
If fee_bump_factor > 1.0, calculates a bumped gas price to overcome
|
|
204
|
+
base fee volatility. Otherwise uses EIP-1559 FAST speed.
|
|
205
|
+
"""
|
|
206
|
+
if fee_bump_factor > 1.0:
|
|
207
|
+
bumped_gas_price = self._calculate_bumped_gas_price(fee_bump_factor)
|
|
208
|
+
if bumped_gas_price:
|
|
209
|
+
logger.debug(
|
|
210
|
+
f"[{operation_name}] Using bumped gas price: {bumped_gas_price} wei "
|
|
211
|
+
f"(factor: {fee_bump_factor:.2f}x)"
|
|
212
|
+
)
|
|
213
|
+
return safe_tx.execute(signer_key, tx_gas_price=bumped_gas_price)
|
|
214
|
+
# Fallback to FAST if calculation fails
|
|
215
|
+
return safe_tx.execute(signer_key, eip1559_speed=TxSpeed.FAST)
|
|
216
|
+
# Default: use EIP-1559 'FAST' speed
|
|
217
|
+
return safe_tx.execute(signer_key, eip1559_speed=TxSpeed.FAST)
|
|
218
|
+
|
|
219
|
+
def _extract_tx_hash(self, result) -> str:
|
|
220
|
+
"""Extract transaction hash from execute() result."""
|
|
221
|
+
# Handle both tuple return (tx_hash, tx) and bytes return
|
|
222
|
+
tx_hash_bytes = result[0] if isinstance(result, tuple) else result
|
|
223
|
+
|
|
224
|
+
# Handle both bytes and hex string returns
|
|
225
|
+
if isinstance(tx_hash_bytes, bytes):
|
|
226
|
+
return f"0x{tx_hash_bytes.hex()}"
|
|
227
|
+
if isinstance(tx_hash_bytes, str):
|
|
228
|
+
return tx_hash_bytes if tx_hash_bytes.startswith("0x") else f"0x{tx_hash_bytes}"
|
|
229
|
+
return str(tx_hash_bytes)
|
|
230
|
+
|
|
199
231
|
def _check_receipt_status(self, receipt) -> bool:
|
|
200
232
|
"""Check if receipt has successful status."""
|
|
201
233
|
status = getattr(receipt, "status", None)
|
|
@@ -210,14 +242,20 @@ class SafeTransactionExecutor:
|
|
|
210
242
|
safe_tx: SafeTx,
|
|
211
243
|
attempt: int,
|
|
212
244
|
operation_name: str,
|
|
213
|
-
) -> Tuple[SafeTx, bool]:
|
|
214
|
-
"""Handle execution failure and determine next steps.
|
|
245
|
+
) -> Tuple[SafeTx, bool, bool]:
|
|
246
|
+
"""Handle execution failure and determine next steps.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Tuple of (updated_safe_tx, should_retry, is_fee_error)
|
|
250
|
+
|
|
251
|
+
"""
|
|
215
252
|
classification = self._classify_error(error)
|
|
253
|
+
is_fee_error = classification["is_fee_error"]
|
|
216
254
|
|
|
217
255
|
if attempt >= self.max_retries:
|
|
218
256
|
SAFE_TX_STATS["final_failures"] += 1
|
|
219
257
|
logger.error(f"[{operation_name}] Failed after {attempt + 1} attempts: {error}")
|
|
220
|
-
return safe_tx, False
|
|
258
|
+
return safe_tx, False, is_fee_error
|
|
221
259
|
|
|
222
260
|
strategy = "retry"
|
|
223
261
|
safe = self._recreate_safe_client(safe_address)
|
|
@@ -231,13 +269,16 @@ class SafeTransactionExecutor:
|
|
|
231
269
|
SAFE_TX_STATS["rpc_rotations"] += 1
|
|
232
270
|
result = self.chain_interface._handle_rpc_error(error)
|
|
233
271
|
if not result["should_retry"]:
|
|
234
|
-
return safe_tx, False
|
|
272
|
+
return safe_tx, False, is_fee_error
|
|
273
|
+
elif is_fee_error:
|
|
274
|
+
strategy = "fee bump"
|
|
275
|
+
SAFE_TX_STATS["gas_retries"] += 1
|
|
235
276
|
elif classification["is_gas_error"]:
|
|
236
277
|
strategy = "gas increase"
|
|
237
|
-
|
|
278
|
+
SAFE_TX_STATS["gas_retries"] += 1
|
|
238
279
|
|
|
239
280
|
self._log_retry(attempt + 1, error, strategy)
|
|
240
|
-
return safe_tx, True
|
|
281
|
+
return safe_tx, True, is_fee_error
|
|
241
282
|
|
|
242
283
|
def _estimate_safe_tx_gas(self, safe: Safe, safe_tx: SafeTx, base_estimate: int = 0) -> int:
|
|
243
284
|
"""Estimate gas for a Safe transaction with buffer and hard cap."""
|
|
@@ -320,14 +361,57 @@ class SafeTransactionExecutor:
|
|
|
320
361
|
error
|
|
321
362
|
) or self.chain_interface._is_connection_error(error)
|
|
322
363
|
|
|
364
|
+
# Fee-specific errors: base fee jumped above our max fee
|
|
365
|
+
fee_error_signals = [
|
|
366
|
+
"max fee per gas less than block base fee",
|
|
367
|
+
"maxfeepergas",
|
|
368
|
+
"fee too low",
|
|
369
|
+
"underpriced",
|
|
370
|
+
]
|
|
371
|
+
is_fee_error = any(signal in err_text for signal in fee_error_signals)
|
|
372
|
+
|
|
323
373
|
return {
|
|
324
374
|
"is_gas_error": any(x in err_text for x in ["gas", "out of gas", "intrinsic"]),
|
|
375
|
+
"is_fee_error": is_fee_error,
|
|
325
376
|
"is_nonce_error": self._is_nonce_error(error),
|
|
326
377
|
"is_rpc_error": is_rpc,
|
|
327
378
|
"is_revert": "revert" in err_text or "execution reverted" in err_text,
|
|
328
379
|
"is_signature_error": self._is_signature_error(error),
|
|
329
380
|
}
|
|
330
381
|
|
|
382
|
+
def _calculate_bumped_gas_price(self, bump_factor: float) -> Optional[int]:
|
|
383
|
+
"""Calculate a bumped gas price based on current base fee.
|
|
384
|
+
|
|
385
|
+
Uses legacy gas price (not EIP-1559) for compatibility with safe-eth-py's
|
|
386
|
+
tx_gas_price parameter. The bumped price ensures we're above the current
|
|
387
|
+
base fee even if it's volatile.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
bump_factor: Multiplier to apply to the base fee (e.g., 1.3 = 30% bump)
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
Gas price in wei, or None if calculation fails
|
|
394
|
+
|
|
395
|
+
"""
|
|
396
|
+
try:
|
|
397
|
+
web3 = self.chain_interface.web3
|
|
398
|
+
latest_block = web3.eth.get_block("latest")
|
|
399
|
+
base_fee = latest_block.get("baseFeePerGas")
|
|
400
|
+
|
|
401
|
+
if base_fee is not None:
|
|
402
|
+
# EIP-1559 chain: calculate bumped max fee
|
|
403
|
+
# base_fee * bump_factor * 1.5 (extra buffer) + priority fee
|
|
404
|
+
priority_fee = max(int(web3.eth.max_priority_fee), 1)
|
|
405
|
+
bumped_fee = int(base_fee * bump_factor * 1.5) + priority_fee
|
|
406
|
+
return bumped_fee
|
|
407
|
+
else:
|
|
408
|
+
# Legacy chain: bump the gas price directly
|
|
409
|
+
gas_price = web3.eth.gas_price
|
|
410
|
+
return int(gas_price * bump_factor)
|
|
411
|
+
except Exception as e:
|
|
412
|
+
logger.debug(f"Failed to calculate bumped gas price: {e}")
|
|
413
|
+
return None
|
|
414
|
+
|
|
331
415
|
def _decode_revert_reason(self, error: Exception) -> Optional[str]:
|
|
332
416
|
"""Attempt to decode the revert reason."""
|
|
333
417
|
import re
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
iwa/__init__.py,sha256=vu12UytYNREtMRvIWp6AfV1GgUe53XWwCMhYyqKAPgo,19
|
|
2
2
|
iwa/__main__.py,sha256=eJU5Uxeu9Y7shWg5dt5Mcq0pMC4wFVNWjeYGKSf4Apw,88
|
|
3
3
|
iwa/core/__init__.py,sha256=GJv4LJOXeZ3hgGvbt5I6omkoFkP2A9qhHjpDlOep9ik,24
|
|
4
|
-
iwa/core/chainlist.py,sha256=
|
|
4
|
+
iwa/core/chainlist.py,sha256=OraylOuaGXR65p7HNkEssjQzQe5as1WxZoRlxQFYVho,10187
|
|
5
5
|
iwa/core/cli.py,sha256=Qo0SXvgKFOd3Ru-LnX5zEXIaR7r3uwYoqwPhVShEqiQ,8315
|
|
6
6
|
iwa/core/constants.py,sha256=_CYUVQpR--dRPuxotsmbzQE-22y61tlnjUD7IhlvVVA,997
|
|
7
7
|
iwa/core/db.py,sha256=WI-mP0tQAmwFPeEi9w7RCa_Mcf_zBfd_7JcbHJwU1aU,10377
|
|
@@ -23,8 +23,8 @@ iwa/core/utils.py,sha256=FTYpIdQ1wnugD4lYU4TQ7d7_TlDs4CTUIhEpHGEJph4,4281
|
|
|
23
23
|
iwa/core/wallet.py,sha256=xSGFOK5Wzh-ctLGhBMK1BySlXN0Ircpztyk1an21QiQ,13129
|
|
24
24
|
iwa/core/chain/__init__.py,sha256=XJMmn0ed-_aVkY2iEMKpuTxPgIKBd41dexSVmEZTa-o,1604
|
|
25
25
|
iwa/core/chain/errors.py,sha256=9SEbhxZ-qASPkzt-DoI51qq0GRJVqRgqgL720gO7a64,1275
|
|
26
|
-
iwa/core/chain/interface.py,sha256=
|
|
27
|
-
iwa/core/chain/manager.py,sha256=
|
|
26
|
+
iwa/core/chain/interface.py,sha256=lW8jmxQNVrUeoBUuYUx9X5kwUhr-Xw3MHS1RsGQeaRQ,29064
|
|
27
|
+
iwa/core/chain/manager.py,sha256=XHwn7ciapFCZVk0rPSJopUqM5Wu3Kpp6XrenkgTE1HA,1397
|
|
28
28
|
iwa/core/chain/models.py,sha256=WUhAighMKcFdbAUkPU_3dkGbWyAUpRJqXMHLcWFC1xg,5261
|
|
29
29
|
iwa/core/chain/rate_limiter.py,sha256=Ps1MrR4HHtylxgUAawe6DoC9tuqKagjQdKulqcJD2gs,9093
|
|
30
30
|
iwa/core/contracts/__init__.py,sha256=P5GFY_pnuI02teqVY2U0t98bn1_SSPAbcAzRMpCdTi4,34
|
|
@@ -41,7 +41,7 @@ iwa/core/services/account.py,sha256=0l14qD8_-ZbN_hQUNa7bRZt0tkceHPPc4GHmB8UKqy4,
|
|
|
41
41
|
iwa/core/services/balance.py,sha256=MSCEzPRDPlHIjaWD1A2X2oIuiMz5MFJjD7sSHUxQ8OM,3324
|
|
42
42
|
iwa/core/services/plugin.py,sha256=GNNlbtELyHl7MNVChrypF76GYphxXduxDog4kx1MLi8,3277
|
|
43
43
|
iwa/core/services/safe.py,sha256=vqvpk7aIqHljaG1zYYpmKdW4mi5OVuoyXcpReISPYM0,15744
|
|
44
|
-
iwa/core/services/safe_executor.py,sha256=
|
|
44
|
+
iwa/core/services/safe_executor.py,sha256=TqpDgtvh8d5cedYAKBj7s1SW7EnomTT9MW_GnYWMxDE,17157
|
|
45
45
|
iwa/core/services/transaction.py,sha256=FrGRWn1xo5rbGIr2ToZ2kPzapr3zmWW38oycyB87TK8,19971
|
|
46
46
|
iwa/core/services/transfer/__init__.py,sha256=ZJfshFxJRsp8rkOqfVvd1cqEzIJ9tqBJh8pc0l90GLk,5576
|
|
47
47
|
iwa/core/services/transfer/base.py,sha256=sohz-Ss2i-pGYGl4x9bD93cnYKcSvsXaXyvyRawvgQs,9043
|
|
@@ -166,7 +166,7 @@ iwa/web/tests/test_web_endpoints.py,sha256=vA25YghHNB23sbmhD4ciesn_f_okSq0tjlkrS
|
|
|
166
166
|
iwa/web/tests/test_web_olas.py,sha256=0CVSsrncOeJ3x0ECV7mVLQV_CXZRrOqGiVjgLIi6hZ8,16308
|
|
167
167
|
iwa/web/tests/test_web_swap.py,sha256=7A4gBJFL01kIXPtW1E1J17SCsVc_0DmUn-R8kKrnnVA,2974
|
|
168
168
|
iwa/web/tests/test_web_swap_coverage.py,sha256=zGNrzlhZ_vWDCvWmLcoUwFgqxnrp_ACbo49AtWBS_Kw,5584
|
|
169
|
-
iwa-0.0.
|
|
169
|
+
iwa-0.0.62.dist-info/licenses/LICENSE,sha256=eIubm_IlBHPYRQlLNZKbBNKhJUUP3JH0A2miZUhAVfI,1078
|
|
170
170
|
tests/legacy_cow.py,sha256=oOkZvIxL70ReEoD9oHQbOD5GpjIr6AGNHcOCgfPlerU,8389
|
|
171
171
|
tests/legacy_safe.py,sha256=AssM2g13E74dNGODu_H0Q0y412lgqsrYnEzI97nm_Ts,2972
|
|
172
172
|
tests/legacy_transaction_retry_logic.py,sha256=D9RqZ7DBu61Xr2djBAodU2p9UE939LL-DnQXswX5iQk,1497
|
|
@@ -178,6 +178,7 @@ tests/test_balance_service.py,sha256=wcuCOVszxPy8nPkldAVcEiygcOK3BuQt797fqAJvbp4
|
|
|
178
178
|
tests/test_chain.py,sha256=VZoidSojWyt1y4mQdZdoZsjuuDZjLC6neTC-2SF_Q7I,13957
|
|
179
179
|
tests/test_chain_interface.py,sha256=bgqGM8wJGZjc-BOX6i0K4sh06KCJl-6UAvrwl8x24lA,8324
|
|
180
180
|
tests/test_chain_interface_coverage.py,sha256=fvrVvw8-DMwdsSFKQHUhpbfutrVRxnnTc-tjB7Bb-jo,3327
|
|
181
|
+
tests/test_chainlist_enrichment.py,sha256=2iD5121SxSDpvQTkaoBXOW-cV-FD1NwkRvzlqRn2Ynk,12928
|
|
181
182
|
tests/test_cli.py,sha256=Pl4RC2xp1omiJUnL3Dza6pCmIoO29LJ0vGw33_ZpT5c,3980
|
|
182
183
|
tests/test_contract.py,sha256=tApHAxsfKGawYJWA9PhTNrOZUE0VVAq79ruIe3KxeWY,14412
|
|
183
184
|
tests/test_db.py,sha256=dmbrupj0qlUeiiycZ2mzMFjf7HrDa6tcqMPY8zpiKIk,5710
|
|
@@ -202,7 +203,7 @@ tests/test_rpc_rate_limit.py,sha256=3P_Nd9voFmz-4r_Et-vw8W-Esbq5elSYmRBSOtJGx1Y,
|
|
|
202
203
|
tests/test_rpc_rotation.py,sha256=a1cFKsf0fo-73_MSDnTuU6Zpv7bJHjrCVu3ANe8PXDU,12541
|
|
203
204
|
tests/test_rpc_view.py,sha256=sgZ53KEHl8VGb7WKYa0VI7Cdxbf8JH1SdroHYbWHjfQ,2031
|
|
204
205
|
tests/test_safe_coverage.py,sha256=KBxKz64XkK8CgN0N0LTNVKakf8Wg8EpghcBlLmDFmLs,6119
|
|
205
|
-
tests/test_safe_executor.py,sha256=
|
|
206
|
+
tests/test_safe_executor.py,sha256=xyiNmPo0Ux3jIcdz5AT98YSmha7Pn3HUDoBtBF6AYRE,25032
|
|
206
207
|
tests/test_safe_integration.py,sha256=WWAKDio3N-CFyr5RRvphbOPdu3TI9WSM8IesfbFbvWQ,5363
|
|
207
208
|
tests/test_safe_service.py,sha256=5ULlj0fPZRwg-4fCBJplhm4Msr_Beof7W-Zf_JljZc8,5782
|
|
208
209
|
tests/test_service_manager_integration.py,sha256=I_BLUzEKrVTyg_8jqsUK0oFD3aQVPCRJ7z0gY8P-j04,2354
|
|
@@ -211,7 +212,7 @@ tests/test_service_transaction.py,sha256=IeqYhmRD-pIXffBJrBQwfPx-qnfNEJs0iPM3eCb
|
|
|
211
212
|
tests/test_staking_router.py,sha256=cnOtwWeQPu09kecVhlCf1WA4ONqs13OcQJhJCx2EOPY,3067
|
|
212
213
|
tests/test_staking_simple.py,sha256=NHyZ1pcVQEJGFiGseC5m6Y9Y6FJGnRIFJUwhd1hAV9g,1138
|
|
213
214
|
tests/test_tables.py,sha256=1KQHgxuizoOrRxpubDdnzk9iaU5Lwyp3bcWP_hZD5uU,2686
|
|
214
|
-
tests/test_transaction_service.py,sha256=
|
|
215
|
+
tests/test_transaction_service.py,sha256=q2IQ6cJ6sZtzc_pVCM_dv0vW7LW2sONNrK5Pvrm63rU,12816
|
|
215
216
|
tests/test_transfer_multisend.py,sha256=PErjNqNwN66TMh4oVa307re64Ucccg1LkXqB0KlkmsI,6677
|
|
216
217
|
tests/test_transfer_native.py,sha256=cDbb4poV_veIw6eHpokrHe9yUndOjA6rQhrHd_IY3HQ,7445
|
|
217
218
|
tests/test_transfer_security.py,sha256=gdpC6ybdXQbQgILbAQ0GqjWdwn9AJRNR3B_7TYg0NxI,3617
|
|
@@ -222,8 +223,8 @@ tests/test_utils.py,sha256=vkP49rYNI8BRzLpWR3WnKdDr8upeZjZcs7Rx0pjbQMo,1292
|
|
|
222
223
|
tests/test_workers.py,sha256=MInwdkFY5LdmFB3o1odIaSD7AQZb3263hNafO1De5PE,2793
|
|
223
224
|
tools/create_and_stake_service.py,sha256=1xwy_bJQI1j9yIQ968Oc9Db_F6mk1659LuuZntTASDE,3742
|
|
224
225
|
tools/verify_drain.py,sha256=PkMjblyOOAuQge88FwfEzRtCYeEtJxXhPBmtQYCoQ-8,6743
|
|
225
|
-
iwa-0.0.
|
|
226
|
-
iwa-0.0.
|
|
227
|
-
iwa-0.0.
|
|
228
|
-
iwa-0.0.
|
|
229
|
-
iwa-0.0.
|
|
226
|
+
iwa-0.0.62.dist-info/METADATA,sha256=jnpILBgHX2FmSfLn2CXjYKhmkMp_Yr4rlOV5Ep_uknQ,7337
|
|
227
|
+
iwa-0.0.62.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
228
|
+
iwa-0.0.62.dist-info/entry_points.txt,sha256=nwB6kscrfA7M00pYmL2j-sBH6eF6h2ga9IK1BZxdiyQ,241
|
|
229
|
+
iwa-0.0.62.dist-info/top_level.txt,sha256=kedS9cRUbm4JE2wYeabIXilhHjN8KCw0IGbqqqsw0Bs,16
|
|
230
|
+
iwa-0.0.62.dist-info/RECORD,,
|