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
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""Tests for ChainList RPC enrichment and quality probing."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from iwa.core.chainlist import (
|
|
9
|
+
ChainlistRPC,
|
|
10
|
+
RPCNode,
|
|
11
|
+
_is_template_url,
|
|
12
|
+
_normalize_url,
|
|
13
|
+
probe_rpc,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture(autouse=True)
|
|
18
|
+
def mock_chainlist_enrichment():
|
|
19
|
+
"""Override conftest — allow real enrichment calls in this test file."""
|
|
20
|
+
yield
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestNormalizeUrl:
|
|
24
|
+
"""Test URL normalization for deduplication."""
|
|
25
|
+
|
|
26
|
+
def test_strips_trailing_slash(self):
|
|
27
|
+
assert _normalize_url("https://rpc.example.com/") == "https://rpc.example.com"
|
|
28
|
+
|
|
29
|
+
def test_lowercases(self):
|
|
30
|
+
assert _normalize_url("https://RPC.Example.COM") == "https://rpc.example.com"
|
|
31
|
+
|
|
32
|
+
def test_no_change_needed(self):
|
|
33
|
+
assert _normalize_url("https://rpc.example.com") == "https://rpc.example.com"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TestIsTemplateUrl:
|
|
37
|
+
"""Test template URL detection."""
|
|
38
|
+
|
|
39
|
+
def test_dollar_brace(self):
|
|
40
|
+
assert _is_template_url("https://rpc.example.com/${API_KEY}") is True
|
|
41
|
+
|
|
42
|
+
def test_plain_brace(self):
|
|
43
|
+
assert _is_template_url("https://rpc.example.com/{api_key}") is True
|
|
44
|
+
|
|
45
|
+
def test_no_template(self):
|
|
46
|
+
assert _is_template_url("https://rpc.example.com") is False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestProbeRpc:
|
|
50
|
+
"""Test single RPC probing."""
|
|
51
|
+
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
def mock_session(self):
|
|
54
|
+
"""Create a mock session for probe_rpc tests."""
|
|
55
|
+
with patch("iwa.core.chainlist.requests.Session") as mock_session_cls:
|
|
56
|
+
mock_session = MagicMock()
|
|
57
|
+
mock_session_cls.return_value = mock_session
|
|
58
|
+
# Make it work as context manager too
|
|
59
|
+
mock_session.__enter__ = MagicMock(return_value=mock_session)
|
|
60
|
+
mock_session.__exit__ = MagicMock(return_value=False)
|
|
61
|
+
yield mock_session
|
|
62
|
+
|
|
63
|
+
def test_success(self, mock_session):
|
|
64
|
+
mock_resp = MagicMock()
|
|
65
|
+
mock_resp.json.return_value = {"jsonrpc": "2.0", "result": "0x1A4B5C", "id": 1}
|
|
66
|
+
mock_session.post.return_value = mock_resp
|
|
67
|
+
|
|
68
|
+
result = probe_rpc("https://rpc.example.com")
|
|
69
|
+
|
|
70
|
+
assert result is not None
|
|
71
|
+
url, latency, block = result
|
|
72
|
+
assert url == "https://rpc.example.com"
|
|
73
|
+
assert latency > 0
|
|
74
|
+
assert block == 0x1A4B5C
|
|
75
|
+
# Verify session was closed
|
|
76
|
+
mock_session.close.assert_called_once()
|
|
77
|
+
|
|
78
|
+
def test_timeout_returns_none(self, mock_session):
|
|
79
|
+
mock_session.post.side_effect = requests.exceptions.Timeout("timed out")
|
|
80
|
+
|
|
81
|
+
result = probe_rpc("https://slow.example.com")
|
|
82
|
+
assert result is None
|
|
83
|
+
# Session still closed on error
|
|
84
|
+
mock_session.close.assert_called_once()
|
|
85
|
+
|
|
86
|
+
def test_connection_error_returns_none(self, mock_session):
|
|
87
|
+
mock_session.post.side_effect = requests.exceptions.ConnectionError("refused")
|
|
88
|
+
|
|
89
|
+
result = probe_rpc("https://dead.example.com")
|
|
90
|
+
assert result is None
|
|
91
|
+
mock_session.close.assert_called_once()
|
|
92
|
+
|
|
93
|
+
def test_zero_block_returns_none(self, mock_session):
|
|
94
|
+
mock_resp = MagicMock()
|
|
95
|
+
mock_resp.json.return_value = {"jsonrpc": "2.0", "result": "0x0", "id": 1}
|
|
96
|
+
mock_session.post.return_value = mock_resp
|
|
97
|
+
|
|
98
|
+
result = probe_rpc("https://rpc.example.com")
|
|
99
|
+
assert result is None
|
|
100
|
+
|
|
101
|
+
def test_null_result_returns_none(self, mock_session):
|
|
102
|
+
mock_resp = MagicMock()
|
|
103
|
+
mock_resp.json.return_value = {"jsonrpc": "2.0", "result": None, "id": 1}
|
|
104
|
+
mock_session.post.return_value = mock_resp
|
|
105
|
+
|
|
106
|
+
result = probe_rpc("https://rpc.example.com")
|
|
107
|
+
assert result is None
|
|
108
|
+
|
|
109
|
+
def test_error_response_returns_none(self, mock_session):
|
|
110
|
+
mock_resp = MagicMock()
|
|
111
|
+
mock_resp.json.return_value = {
|
|
112
|
+
"jsonrpc": "2.0",
|
|
113
|
+
"error": {"code": -32600, "message": "Invalid Request"},
|
|
114
|
+
"id": 1,
|
|
115
|
+
}
|
|
116
|
+
mock_session.post.return_value = mock_resp
|
|
117
|
+
|
|
118
|
+
result = probe_rpc("https://rpc.example.com")
|
|
119
|
+
assert result is None
|
|
120
|
+
|
|
121
|
+
def test_uses_provided_session(self):
|
|
122
|
+
"""When a session is provided, use it instead of creating one."""
|
|
123
|
+
provided_session = MagicMock()
|
|
124
|
+
mock_resp = MagicMock()
|
|
125
|
+
mock_resp.json.return_value = {"jsonrpc": "2.0", "result": "0x100", "id": 1}
|
|
126
|
+
provided_session.post.return_value = mock_resp
|
|
127
|
+
|
|
128
|
+
result = probe_rpc("https://rpc.example.com", session=provided_session)
|
|
129
|
+
|
|
130
|
+
assert result is not None
|
|
131
|
+
provided_session.post.assert_called_once()
|
|
132
|
+
# Should NOT close provided session (caller's responsibility)
|
|
133
|
+
provided_session.close.assert_not_called()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TestGetValidatedRpcs:
|
|
137
|
+
"""Test ChainlistRPC.get_validated_rpcs()."""
|
|
138
|
+
|
|
139
|
+
def _make_node(self, url, tracking="none"):
|
|
140
|
+
return RPCNode(url=url, is_working=True, tracking=tracking)
|
|
141
|
+
|
|
142
|
+
@patch.object(ChainlistRPC, "get_rpcs")
|
|
143
|
+
@patch("iwa.core.chainlist.probe_rpc")
|
|
144
|
+
def test_filters_template_urls(self, mock_probe, mock_get_rpcs):
|
|
145
|
+
mock_get_rpcs.return_value = [
|
|
146
|
+
self._make_node("https://rpc.example.com/${API_KEY}"),
|
|
147
|
+
self._make_node("https://good.example.com"),
|
|
148
|
+
]
|
|
149
|
+
mock_probe.return_value = ("https://good.example.com", 50.0, 1000)
|
|
150
|
+
|
|
151
|
+
cl = ChainlistRPC()
|
|
152
|
+
result = cl.get_validated_rpcs(100, existing_rpcs=[])
|
|
153
|
+
|
|
154
|
+
assert result == ["https://good.example.com"]
|
|
155
|
+
|
|
156
|
+
@patch.object(ChainlistRPC, "get_rpcs")
|
|
157
|
+
@patch("iwa.core.chainlist.probe_rpc")
|
|
158
|
+
def test_filters_non_https(self, mock_probe, mock_get_rpcs):
|
|
159
|
+
mock_get_rpcs.return_value = [
|
|
160
|
+
self._make_node("http://insecure.example.com"),
|
|
161
|
+
self._make_node("wss://ws.example.com"),
|
|
162
|
+
self._make_node("https://good.example.com"),
|
|
163
|
+
]
|
|
164
|
+
mock_probe.return_value = ("https://good.example.com", 50.0, 1000)
|
|
165
|
+
|
|
166
|
+
cl = ChainlistRPC()
|
|
167
|
+
result = cl.get_validated_rpcs(100, existing_rpcs=[])
|
|
168
|
+
|
|
169
|
+
assert result == ["https://good.example.com"]
|
|
170
|
+
|
|
171
|
+
@patch.object(ChainlistRPC, "get_rpcs")
|
|
172
|
+
@patch("iwa.core.chainlist.probe_rpc")
|
|
173
|
+
def test_deduplicates_existing(self, mock_probe, mock_get_rpcs):
|
|
174
|
+
mock_get_rpcs.return_value = [
|
|
175
|
+
self._make_node("https://already.configured.com"),
|
|
176
|
+
self._make_node("https://new.example.com"),
|
|
177
|
+
]
|
|
178
|
+
mock_probe.return_value = ("https://new.example.com", 50.0, 1000)
|
|
179
|
+
|
|
180
|
+
cl = ChainlistRPC()
|
|
181
|
+
result = cl.get_validated_rpcs(
|
|
182
|
+
100, existing_rpcs=["https://already.configured.com/"]
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
assert result == ["https://new.example.com"]
|
|
186
|
+
|
|
187
|
+
@patch.object(ChainlistRPC, "get_rpcs")
|
|
188
|
+
@patch("iwa.core.chainlist.probe_rpc")
|
|
189
|
+
def test_filters_stale_rpcs(self, mock_probe, mock_get_rpcs):
|
|
190
|
+
mock_get_rpcs.return_value = [
|
|
191
|
+
self._make_node("https://fresh.example.com"),
|
|
192
|
+
self._make_node("https://stale.example.com"),
|
|
193
|
+
self._make_node("https://also-fresh.example.com"),
|
|
194
|
+
]
|
|
195
|
+
# fresh=1000, stale=900 (100 blocks behind), also-fresh=999
|
|
196
|
+
mock_probe.side_effect = [
|
|
197
|
+
("https://fresh.example.com", 50.0, 1000),
|
|
198
|
+
("https://stale.example.com", 30.0, 900),
|
|
199
|
+
("https://also-fresh.example.com", 60.0, 999),
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
cl = ChainlistRPC()
|
|
203
|
+
result = cl.get_validated_rpcs(100, existing_rpcs=[])
|
|
204
|
+
|
|
205
|
+
# Stale RPC (900) is 100 blocks behind median (999) > MAX_BLOCK_LAG
|
|
206
|
+
assert "https://stale.example.com" not in result
|
|
207
|
+
assert "https://fresh.example.com" in result
|
|
208
|
+
assert "https://also-fresh.example.com" in result
|
|
209
|
+
|
|
210
|
+
@patch.object(ChainlistRPC, "get_rpcs")
|
|
211
|
+
@patch("iwa.core.chainlist.probe_rpc")
|
|
212
|
+
def test_sorts_by_latency(self, mock_probe, mock_get_rpcs):
|
|
213
|
+
mock_get_rpcs.return_value = [
|
|
214
|
+
self._make_node("https://slow.example.com"),
|
|
215
|
+
self._make_node("https://fast.example.com"),
|
|
216
|
+
self._make_node("https://medium.example.com"),
|
|
217
|
+
]
|
|
218
|
+
mock_probe.side_effect = [
|
|
219
|
+
("https://slow.example.com", 200.0, 1000),
|
|
220
|
+
("https://fast.example.com", 10.0, 1000),
|
|
221
|
+
("https://medium.example.com", 80.0, 1000),
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
cl = ChainlistRPC()
|
|
225
|
+
result = cl.get_validated_rpcs(100, existing_rpcs=[])
|
|
226
|
+
|
|
227
|
+
assert result == [
|
|
228
|
+
"https://fast.example.com",
|
|
229
|
+
"https://medium.example.com",
|
|
230
|
+
"https://slow.example.com",
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
@patch.object(ChainlistRPC, "get_rpcs")
|
|
234
|
+
@patch("iwa.core.chainlist.probe_rpc")
|
|
235
|
+
def test_respects_max_results(self, mock_probe, mock_get_rpcs):
|
|
236
|
+
nodes = [self._make_node(f"https://rpc{i}.example.com") for i in range(10)]
|
|
237
|
+
mock_get_rpcs.return_value = nodes
|
|
238
|
+
mock_probe.side_effect = [
|
|
239
|
+
(f"https://rpc{i}.example.com", float(i * 10), 1000) for i in range(10)
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
cl = ChainlistRPC()
|
|
243
|
+
result = cl.get_validated_rpcs(100, existing_rpcs=[], max_results=3)
|
|
244
|
+
|
|
245
|
+
assert len(result) == 3
|
|
246
|
+
|
|
247
|
+
@patch.object(ChainlistRPC, "get_rpcs")
|
|
248
|
+
def test_returns_empty_on_no_rpcs(self, mock_get_rpcs):
|
|
249
|
+
mock_get_rpcs.return_value = []
|
|
250
|
+
|
|
251
|
+
cl = ChainlistRPC()
|
|
252
|
+
result = cl.get_validated_rpcs(100, existing_rpcs=[])
|
|
253
|
+
|
|
254
|
+
assert result == []
|
|
255
|
+
|
|
256
|
+
@patch.object(ChainlistRPC, "get_rpcs")
|
|
257
|
+
@patch("iwa.core.chainlist.probe_rpc")
|
|
258
|
+
def test_returns_empty_when_all_probes_fail(self, mock_probe, mock_get_rpcs):
|
|
259
|
+
mock_get_rpcs.return_value = [
|
|
260
|
+
self._make_node("https://dead1.example.com"),
|
|
261
|
+
self._make_node("https://dead2.example.com"),
|
|
262
|
+
]
|
|
263
|
+
mock_probe.return_value = None
|
|
264
|
+
|
|
265
|
+
cl = ChainlistRPC()
|
|
266
|
+
result = cl.get_validated_rpcs(100, existing_rpcs=[])
|
|
267
|
+
|
|
268
|
+
assert result == []
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class TestEnrichFromChainlist:
|
|
272
|
+
"""Test ChainInterface._enrich_rpcs_from_chainlist().
|
|
273
|
+
|
|
274
|
+
The conftest fixture is overridden in this file so the real
|
|
275
|
+
enrichment method runs during __init__.
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
@patch("iwa.core.chain.interface.Web3")
|
|
279
|
+
def test_skipped_for_tenderly(self, mock_web3):
|
|
280
|
+
from iwa.core.chain.interface import ChainInterface
|
|
281
|
+
from iwa.core.chain.models import SupportedChain
|
|
282
|
+
|
|
283
|
+
chain = MagicMock(spec=SupportedChain)
|
|
284
|
+
chain.name = "TestChain"
|
|
285
|
+
chain.rpcs = ["https://virtual.tenderly.co/test"]
|
|
286
|
+
chain.rpc = "https://virtual.tenderly.co/test"
|
|
287
|
+
chain.chain_id = 100
|
|
288
|
+
|
|
289
|
+
with patch("iwa.core.chainlist.ChainlistRPC") as mock_cl_cls:
|
|
290
|
+
ci = ChainInterface(chain)
|
|
291
|
+
|
|
292
|
+
# is_tenderly=True → enrichment skipped → ChainlistRPC never called
|
|
293
|
+
assert "tenderly" in ci.current_rpc.lower()
|
|
294
|
+
mock_cl_cls.assert_not_called()
|
|
295
|
+
|
|
296
|
+
@patch("iwa.core.chain.interface.Web3")
|
|
297
|
+
def test_enriches_non_tenderly(self, mock_web3):
|
|
298
|
+
from iwa.core.chain.interface import ChainInterface
|
|
299
|
+
from iwa.core.chain.models import SupportedChain
|
|
300
|
+
|
|
301
|
+
chain = MagicMock(spec=SupportedChain)
|
|
302
|
+
chain.name = "TestChain"
|
|
303
|
+
chain.rpcs = ["https://rpc1.example.com"]
|
|
304
|
+
chain.rpc = "https://rpc1.example.com"
|
|
305
|
+
chain.chain_id = 100
|
|
306
|
+
|
|
307
|
+
with patch("iwa.core.chainlist.ChainlistRPC") as mock_cl_cls:
|
|
308
|
+
mock_cl = mock_cl_cls.return_value
|
|
309
|
+
mock_cl.get_validated_rpcs.return_value = [
|
|
310
|
+
"https://extra1.example.com",
|
|
311
|
+
"https://extra2.example.com",
|
|
312
|
+
]
|
|
313
|
+
ChainInterface(chain)
|
|
314
|
+
|
|
315
|
+
assert len(chain.rpcs) == 3
|
|
316
|
+
assert "https://extra1.example.com" in chain.rpcs
|
|
317
|
+
assert "https://extra2.example.com" in chain.rpcs
|
|
318
|
+
# Original RPC stays first
|
|
319
|
+
assert chain.rpcs[0] == "https://rpc1.example.com"
|
|
320
|
+
|
|
321
|
+
@patch("iwa.core.chain.interface.Web3")
|
|
322
|
+
def test_survives_fetch_failure(self, mock_web3):
|
|
323
|
+
from iwa.core.chain.interface import ChainInterface
|
|
324
|
+
from iwa.core.chain.models import SupportedChain
|
|
325
|
+
|
|
326
|
+
chain = MagicMock(spec=SupportedChain)
|
|
327
|
+
chain.name = "TestChain"
|
|
328
|
+
chain.rpcs = ["https://rpc1.example.com"]
|
|
329
|
+
chain.rpc = "https://rpc1.example.com"
|
|
330
|
+
chain.chain_id = 100
|
|
331
|
+
|
|
332
|
+
with patch("iwa.core.chainlist.ChainlistRPC") as mock_cl_cls:
|
|
333
|
+
mock_cl_cls.side_effect = Exception("network error")
|
|
334
|
+
ChainInterface(chain)
|
|
335
|
+
|
|
336
|
+
# Should still work with original RPCs
|
|
337
|
+
assert chain.rpcs == ["https://rpc1.example.com"]
|
|
338
|
+
|
|
339
|
+
@patch("iwa.core.chain.interface.Web3")
|
|
340
|
+
def test_respects_max_rpcs(self, mock_web3):
|
|
341
|
+
from iwa.core.chain.interface import ChainInterface
|
|
342
|
+
from iwa.core.chain.models import SupportedChain
|
|
343
|
+
|
|
344
|
+
chain = MagicMock(spec=SupportedChain)
|
|
345
|
+
chain.name = "TestChain"
|
|
346
|
+
chain.rpcs = [f"https://rpc{i}.example.com" for i in range(20)]
|
|
347
|
+
chain.rpc = "https://rpc0.example.com"
|
|
348
|
+
chain.chain_id = 100
|
|
349
|
+
|
|
350
|
+
with patch("iwa.core.chainlist.ChainlistRPC") as mock_cl_cls:
|
|
351
|
+
ChainInterface(chain)
|
|
352
|
+
|
|
353
|
+
# Already at MAX_RPCS=20, ChainlistRPC should not be called
|
|
354
|
+
mock_cl_cls.assert_not_called()
|
tests/test_safe_executor.py
CHANGED
|
@@ -359,3 +359,281 @@ def test_retry_preserves_gas(executor, mock_chain_interface, mock_safe_tx, mock_
|
|
|
359
359
|
executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
|
|
360
360
|
|
|
361
361
|
assert mock_safe_tx.safe_tx_gas == original_gas
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# =============================================================================
|
|
365
|
+
# Test: Gas estimation (_estimate_safe_tx_gas)
|
|
366
|
+
# =============================================================================
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_estimate_safe_tx_gas_with_buffer(executor, mock_safe):
|
|
370
|
+
"""Test gas estimation applies buffer correctly."""
|
|
371
|
+
mock_safe.estimate_tx_gas.return_value = 100_000
|
|
372
|
+
mock_safe_tx = MagicMock()
|
|
373
|
+
mock_safe_tx.to = "0xDest"
|
|
374
|
+
mock_safe_tx.value = 0
|
|
375
|
+
mock_safe_tx.data = b""
|
|
376
|
+
mock_safe_tx.operation = 0
|
|
377
|
+
|
|
378
|
+
result = executor._estimate_safe_tx_gas(mock_safe, mock_safe_tx)
|
|
379
|
+
|
|
380
|
+
# Default buffer is 1.5, so 100000 * 1.5 = 150000
|
|
381
|
+
assert result == 150_000
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def test_estimate_safe_tx_gas_caps_at_10x(executor, mock_safe):
|
|
385
|
+
"""Test gas estimation respects x10 cap when base_estimate is provided."""
|
|
386
|
+
mock_safe.estimate_tx_gas.return_value = 500_000 # High estimate
|
|
387
|
+
mock_safe_tx = MagicMock()
|
|
388
|
+
mock_safe_tx.to = "0xDest"
|
|
389
|
+
mock_safe_tx.value = 0
|
|
390
|
+
mock_safe_tx.data = b""
|
|
391
|
+
mock_safe_tx.operation = 0
|
|
392
|
+
|
|
393
|
+
# 500000 * 1.5 = 750000, but base_estimate * 10 = 50000
|
|
394
|
+
result = executor._estimate_safe_tx_gas(mock_safe, mock_safe_tx, base_estimate=5_000)
|
|
395
|
+
|
|
396
|
+
# Should be capped at 5000 * 10 = 50000
|
|
397
|
+
assert result == 50_000
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def test_estimate_safe_tx_gas_fallback_on_failure(executor, mock_safe):
|
|
401
|
+
"""Test gas estimation uses fallback when estimation fails."""
|
|
402
|
+
mock_safe.estimate_tx_gas.side_effect = Exception("Estimation failed")
|
|
403
|
+
mock_safe_tx = MagicMock()
|
|
404
|
+
mock_safe_tx.to = "0xDest"
|
|
405
|
+
mock_safe_tx.value = 0
|
|
406
|
+
mock_safe_tx.data = b""
|
|
407
|
+
mock_safe_tx.operation = 0
|
|
408
|
+
|
|
409
|
+
result = executor._estimate_safe_tx_gas(mock_safe, mock_safe_tx)
|
|
410
|
+
|
|
411
|
+
assert result == executor.DEFAULT_FALLBACK_GAS
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# =============================================================================
|
|
415
|
+
# Test: Error decoding (_decode_revert_reason)
|
|
416
|
+
# =============================================================================
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def test_decode_revert_reason_with_hex_data(executor):
|
|
420
|
+
"""Test decoding when error contains hex data."""
|
|
421
|
+
# Create an error with hex data that might be decodable
|
|
422
|
+
error = ValueError("execution reverted: 0x08c379a0...")
|
|
423
|
+
|
|
424
|
+
with patch("iwa.core.services.safe_executor.ErrorDecoder") as mock_decoder:
|
|
425
|
+
mock_decoder.return_value.decode.return_value = [("Error", "Insufficient balance", "ERC20")]
|
|
426
|
+
result = executor._decode_revert_reason(error)
|
|
427
|
+
|
|
428
|
+
# Note: Due to hex matching, this should find the data and attempt decode
|
|
429
|
+
assert result == "Insufficient balance (from ERC20)"
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def test_decode_revert_reason_no_hex_data(executor):
|
|
433
|
+
"""Test decoding when error has no hex data."""
|
|
434
|
+
error = ValueError("Some generic error without hex")
|
|
435
|
+
|
|
436
|
+
result = executor._decode_revert_reason(error)
|
|
437
|
+
|
|
438
|
+
assert result is None
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def test_decode_revert_reason_decode_fails(executor):
|
|
442
|
+
"""Test decoding when decoder returns None."""
|
|
443
|
+
error = ValueError("error: 0xdeadbeef")
|
|
444
|
+
|
|
445
|
+
with patch("iwa.core.services.safe_executor.ErrorDecoder") as mock_decoder:
|
|
446
|
+
mock_decoder.return_value.decode.return_value = None
|
|
447
|
+
result = executor._decode_revert_reason(error)
|
|
448
|
+
|
|
449
|
+
assert result is None
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# =============================================================================
|
|
453
|
+
# Test: Error classification
|
|
454
|
+
# =============================================================================
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def test_classify_error_gas_error(executor):
|
|
458
|
+
"""Test classification of gas-related errors."""
|
|
459
|
+
error = ValueError("intrinsic gas too low")
|
|
460
|
+
result = executor._classify_error(error)
|
|
461
|
+
|
|
462
|
+
assert result["is_gas_error"] is True
|
|
463
|
+
assert result["is_nonce_error"] is False
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def test_classify_error_revert(executor):
|
|
467
|
+
"""Test classification of revert errors."""
|
|
468
|
+
error = ValueError("execution reverted: some reason")
|
|
469
|
+
result = executor._classify_error(error)
|
|
470
|
+
|
|
471
|
+
assert result["is_revert"] is True
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def test_classify_error_out_of_gas(executor):
|
|
475
|
+
"""Test classification of out of gas errors."""
|
|
476
|
+
error = ValueError("out of gas")
|
|
477
|
+
result = executor._classify_error(error)
|
|
478
|
+
|
|
479
|
+
assert result["is_gas_error"] is True
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# =============================================================================
|
|
483
|
+
# Test: Transaction failures
|
|
484
|
+
# =============================================================================
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def test_transaction_reverts_onchain(executor, mock_chain_interface, mock_safe_tx, mock_safe):
|
|
488
|
+
"""Test handling when transaction is mined but reverts (status 0)."""
|
|
489
|
+
with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
|
|
490
|
+
mock_safe_tx.execute.return_value = b"tx_hash"
|
|
491
|
+
# Receipt with status 0 (reverted)
|
|
492
|
+
mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(
|
|
493
|
+
status=0
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
with patch("time.sleep"):
|
|
497
|
+
success, error, receipt = executor.execute_with_retry(
|
|
498
|
+
"0xSafe", mock_safe_tx, ["key1"]
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
assert success is False
|
|
502
|
+
assert "reverted" in error.lower()
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def test_check_receipt_status_dict_format(executor):
|
|
506
|
+
"""Test receipt status check with dict-style receipt."""
|
|
507
|
+
# Dict-style receipt (not MagicMock)
|
|
508
|
+
receipt_dict = {"status": 1, "gasUsed": 21000}
|
|
509
|
+
assert executor._check_receipt_status(receipt_dict) is True
|
|
510
|
+
|
|
511
|
+
receipt_dict_failed = {"status": 0}
|
|
512
|
+
assert executor._check_receipt_status(receipt_dict_failed) is False
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def test_simulation_revert_not_nonce(executor, mock_chain_interface, mock_safe_tx, mock_safe):
|
|
516
|
+
"""Test handling when simulation reverts with non-nonce error."""
|
|
517
|
+
with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
|
|
518
|
+
# Simulation fails with generic revert
|
|
519
|
+
mock_safe_tx.call.side_effect = ValueError("execution reverted: insufficient funds")
|
|
520
|
+
|
|
521
|
+
with patch("time.sleep"):
|
|
522
|
+
success, error, receipt = executor.execute_with_retry(
|
|
523
|
+
"0xSafe", mock_safe_tx, ["key1"]
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
assert success is False
|
|
527
|
+
assert "insufficient funds" in error.lower() or "reverted" in error.lower()
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def test_gas_error_strategy_triggers_retry(
|
|
531
|
+
executor, mock_chain_interface, mock_safe_tx, mock_safe
|
|
532
|
+
):
|
|
533
|
+
"""Test that gas errors trigger retry with gas increase strategy."""
|
|
534
|
+
with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
|
|
535
|
+
mock_safe_tx.execute.side_effect = [
|
|
536
|
+
ValueError("intrinsic gas too low"),
|
|
537
|
+
b"tx_hash",
|
|
538
|
+
]
|
|
539
|
+
mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(
|
|
540
|
+
status=1
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
with patch("time.sleep"):
|
|
544
|
+
success, tx_hash, receipt = executor.execute_with_retry(
|
|
545
|
+
"0xSafe", mock_safe_tx, ["key1"]
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# Should have retried and succeeded
|
|
549
|
+
assert success is True
|
|
550
|
+
assert mock_safe_tx.execute.call_count == 2
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def test_rpc_rotation_stops_when_should_not_retry(
|
|
554
|
+
executor, mock_chain_interface, mock_safe_tx, mock_safe
|
|
555
|
+
):
|
|
556
|
+
"""Test that execution stops when RPC handler says not to retry."""
|
|
557
|
+
with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
|
|
558
|
+
mock_safe_tx.execute.side_effect = ValueError("Rate limit exceeded")
|
|
559
|
+
mock_chain_interface._is_rate_limit_error.return_value = True
|
|
560
|
+
mock_chain_interface._handle_rpc_error.return_value = {"should_retry": False}
|
|
561
|
+
|
|
562
|
+
with patch("time.sleep"):
|
|
563
|
+
success, error, receipt = executor.execute_with_retry(
|
|
564
|
+
"0xSafe", mock_safe_tx, ["key1"]
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
assert success is False
|
|
568
|
+
# Only 1 attempt because should_retry=False
|
|
569
|
+
assert mock_safe_tx.execute.call_count == 1
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
# =============================================================================
|
|
573
|
+
# Test: Fee bumping on base fee errors
|
|
574
|
+
# =============================================================================
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def test_fee_error_triggers_bump(executor, mock_chain_interface, mock_safe_tx, mock_safe):
|
|
578
|
+
"""Test that fee errors trigger gas price bump on retry."""
|
|
579
|
+
with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
|
|
580
|
+
# First attempt fails with fee error, second succeeds
|
|
581
|
+
mock_safe_tx.execute.side_effect = [
|
|
582
|
+
ValueError("max fee per gas less than block base fee: maxFeePerGas: 596, baseFee: 681"),
|
|
583
|
+
b"tx_hash",
|
|
584
|
+
]
|
|
585
|
+
mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(
|
|
586
|
+
status=1
|
|
587
|
+
)
|
|
588
|
+
# Mock fee calculation
|
|
589
|
+
mock_chain_interface.web3.eth.get_block.return_value = {"baseFeePerGas": 700}
|
|
590
|
+
mock_chain_interface.web3.eth.max_priority_fee = 1
|
|
591
|
+
|
|
592
|
+
with patch("time.sleep"):
|
|
593
|
+
success, tx_hash, receipt = executor.execute_with_retry(
|
|
594
|
+
"0xSafe", mock_safe_tx, ["key1"]
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
assert success is True
|
|
598
|
+
assert mock_safe_tx.execute.call_count == 2
|
|
599
|
+
# Second call should have tx_gas_price (bumped), not eip1559_speed
|
|
600
|
+
second_call_kwargs = mock_safe_tx.execute.call_args_list[1][1]
|
|
601
|
+
assert "tx_gas_price" in second_call_kwargs
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def test_fee_error_classification(executor):
|
|
605
|
+
"""Test classification of fee-related errors."""
|
|
606
|
+
fee_errors = [
|
|
607
|
+
"max fee per gas less than block base fee",
|
|
608
|
+
"transaction underpriced",
|
|
609
|
+
"maxFeePerGas too low",
|
|
610
|
+
"fee too low for mempool",
|
|
611
|
+
]
|
|
612
|
+
for error_msg in fee_errors:
|
|
613
|
+
error = ValueError(error_msg)
|
|
614
|
+
result = executor._classify_error(error)
|
|
615
|
+
assert result["is_fee_error"] is True, f"Should detect fee error: {error_msg}"
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def test_calculate_bumped_gas_price_eip1559(executor, mock_chain_interface):
|
|
619
|
+
"""Test bumped gas price calculation for EIP-1559 chains."""
|
|
620
|
+
mock_chain_interface.web3.eth.get_block.return_value = {"baseFeePerGas": 1000}
|
|
621
|
+
mock_chain_interface.web3.eth.max_priority_fee = 10
|
|
622
|
+
|
|
623
|
+
# With 1.3x bump factor: base_fee * 1.3 * 1.5 + priority = 1000 * 1.3 * 1.5 + 10 = 1960
|
|
624
|
+
result = executor._calculate_bumped_gas_price(1.3)
|
|
625
|
+
|
|
626
|
+
assert result is not None
|
|
627
|
+
assert result == int(1000 * 1.3 * 1.5) + 10
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def test_calculate_bumped_gas_price_legacy(executor, mock_chain_interface):
|
|
631
|
+
"""Test bumped gas price calculation for legacy chains."""
|
|
632
|
+
mock_chain_interface.web3.eth.get_block.return_value = {} # No baseFeePerGas
|
|
633
|
+
mock_chain_interface.web3.eth.gas_price = 2000
|
|
634
|
+
|
|
635
|
+
# Legacy: gas_price * bump_factor = 2000 * 1.3 = 2600
|
|
636
|
+
result = executor._calculate_bumped_gas_price(1.3)
|
|
637
|
+
|
|
638
|
+
assert result is not None
|
|
639
|
+
assert result == int(2000 * 1.3)
|