iwa 0.0.33__py3-none-any.whl → 0.0.58__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 +116 -8
- iwa/core/chain/models.py +15 -3
- iwa/core/chain/rate_limiter.py +54 -12
- iwa/core/cli.py +1 -1
- iwa/core/ipfs.py +24 -2
- iwa/core/keys.py +59 -15
- iwa/core/models.py +60 -13
- iwa/core/pricing.py +24 -2
- iwa/core/secrets.py +27 -0
- iwa/core/services/account.py +1 -1
- iwa/core/services/balance.py +0 -22
- iwa/core/services/safe.py +64 -43
- iwa/core/services/safe_executor.py +316 -0
- iwa/core/services/transaction.py +11 -1
- iwa/core/services/transfer/erc20.py +14 -2
- iwa/core/services/transfer/native.py +14 -31
- iwa/core/services/transfer/swap.py +1 -0
- iwa/core/tests/test_gnosis_fee.py +87 -0
- iwa/core/tests/test_ipfs.py +85 -0
- iwa/core/tests/test_pricing.py +65 -0
- iwa/core/tests/test_regression_fixes.py +100 -0
- iwa/core/wallet.py +3 -3
- iwa/plugins/gnosis/cow/quotes.py +2 -2
- iwa/plugins/gnosis/cow/swap.py +18 -32
- iwa/plugins/gnosis/tests/test_cow.py +19 -10
- iwa/plugins/olas/importer.py +5 -7
- iwa/plugins/olas/models.py +0 -3
- iwa/plugins/olas/service_manager/drain.py +16 -7
- iwa/plugins/olas/service_manager/lifecycle.py +15 -4
- iwa/plugins/olas/service_manager/staking.py +4 -4
- iwa/plugins/olas/tests/test_importer_error_handling.py +13 -0
- iwa/plugins/olas/tests/test_olas_archiving.py +73 -0
- iwa/plugins/olas/tests/test_olas_view.py +5 -1
- iwa/plugins/olas/tests/test_service_manager.py +7 -7
- iwa/plugins/olas/tests/test_service_manager_errors.py +1 -1
- iwa/plugins/olas/tests/test_service_manager_flows.py +1 -1
- iwa/plugins/olas/tests/test_service_manager_rewards.py +5 -1
- iwa/tools/drain_accounts.py +60 -0
- iwa/tools/list_contracts.py +2 -0
- iwa/tui/screens/wallets.py +2 -2
- iwa/web/routers/accounts.py +1 -1
- iwa/web/static/app.js +21 -9
- iwa/web/static/style.css +4 -0
- iwa/web/tests/test_web_endpoints.py +2 -2
- {iwa-0.0.33.dist-info → iwa-0.0.58.dist-info}/METADATA +6 -3
- {iwa-0.0.33.dist-info → iwa-0.0.58.dist-info}/RECORD +64 -54
- {iwa-0.0.33.dist-info → iwa-0.0.58.dist-info}/WHEEL +1 -1
- tests/test_balance_service.py +0 -41
- tests/test_chain.py +13 -4
- tests/test_cli.py +2 -2
- tests/test_drain_coverage.py +12 -6
- tests/test_keys.py +23 -23
- tests/test_rate_limiter.py +2 -2
- tests/test_rate_limiter_retry.py +108 -0
- tests/test_rpc_rate_limit.py +33 -0
- tests/test_rpc_rotation.py +55 -7
- tests/test_safe_coverage.py +37 -23
- tests/test_safe_executor.py +335 -0
- tests/test_safe_integration.py +148 -0
- tests/test_safe_service.py +1 -1
- tests/test_transfer_swap_unit.py +5 -1
- tests/test_pricing.py +0 -160
- {iwa-0.0.33.dist-info → iwa-0.0.58.dist-info}/entry_points.txt +0 -0
- {iwa-0.0.33.dist-info → iwa-0.0.58.dist-info}/licenses/LICENSE +0 -0
- {iwa-0.0.33.dist-info → iwa-0.0.58.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Tests for SafeTransactionExecutor."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from safe_eth.safe.safe_tx import SafeTx
|
|
7
|
+
|
|
8
|
+
from iwa.core.services.safe_executor import (
|
|
9
|
+
MIN_SIGNATURE_LENGTH,
|
|
10
|
+
SAFE_TX_STATS,
|
|
11
|
+
SafeTransactionExecutor,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture(autouse=True)
|
|
16
|
+
def reset_stats():
|
|
17
|
+
"""Reset SAFE_TX_STATS before each test to prevent state leakage."""
|
|
18
|
+
for key in SAFE_TX_STATS:
|
|
19
|
+
SAFE_TX_STATS[key] = 0
|
|
20
|
+
yield
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def mock_chain_interface():
|
|
25
|
+
ci = MagicMock()
|
|
26
|
+
ci.current_rpc = "http://mock-rpc"
|
|
27
|
+
ci.DEFAULT_MAX_RETRIES = 6
|
|
28
|
+
ci._is_rate_limit_error.return_value = False
|
|
29
|
+
ci._is_connection_error.return_value = False
|
|
30
|
+
ci._handle_rpc_error.return_value = {"should_retry": True}
|
|
31
|
+
return ci
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture
|
|
35
|
+
def executor(mock_chain_interface):
|
|
36
|
+
return SafeTransactionExecutor(mock_chain_interface)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.fixture
|
|
40
|
+
def mock_safe_tx():
|
|
41
|
+
"""Mock SafeTx with valid 65-byte signature."""
|
|
42
|
+
tx = MagicMock(spec=SafeTx)
|
|
43
|
+
tx.safe_tx_gas = 100000
|
|
44
|
+
tx.base_gas = 0
|
|
45
|
+
tx.gas_price = 1000000000
|
|
46
|
+
tx.to = "0xTo"
|
|
47
|
+
tx.value = 0
|
|
48
|
+
tx.data = b""
|
|
49
|
+
tx.operation = 0
|
|
50
|
+
# Valid signatures must be >= 65 bytes (one ECDSA signature)
|
|
51
|
+
tx.signatures = b"x" * 65
|
|
52
|
+
return tx
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.fixture
|
|
56
|
+
def mock_safe():
|
|
57
|
+
s = MagicMock()
|
|
58
|
+
s.estimate_tx_gas.return_value = 100000
|
|
59
|
+
s.retrieve_nonce.return_value = 5
|
|
60
|
+
return s
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# =============================================================================
|
|
64
|
+
# Test: Basic execution success
|
|
65
|
+
# =============================================================================
|
|
66
|
+
|
|
67
|
+
def test_execute_success_first_try(executor, mock_chain_interface, mock_safe_tx, mock_safe):
|
|
68
|
+
"""Test successful execution on first attempt."""
|
|
69
|
+
with patch.object(executor, '_recreate_safe_client', return_value=mock_safe):
|
|
70
|
+
mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(status=1)
|
|
71
|
+
mock_safe_tx.execute.return_value = b"tx_hash"
|
|
72
|
+
|
|
73
|
+
success, tx_hash, receipt = executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
|
|
74
|
+
|
|
75
|
+
assert success is True
|
|
76
|
+
assert tx_hash == "0x" + b"tx_hash".hex()
|
|
77
|
+
assert mock_safe_tx.execute.call_count == 1
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# =============================================================================
|
|
81
|
+
# Test: Tuple vs bytes return handling
|
|
82
|
+
# =============================================================================
|
|
83
|
+
|
|
84
|
+
def test_execute_handles_tuple_return(executor, mock_chain_interface, mock_safe_tx, mock_safe):
|
|
85
|
+
"""Test that executor handles safe_tx.execute() returning tuple (tx_hash, tx)."""
|
|
86
|
+
with patch.object(executor, '_recreate_safe_client', return_value=mock_safe):
|
|
87
|
+
mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(status=1)
|
|
88
|
+
# Simulate tuple return: (tx_hash_bytes, tx_data)
|
|
89
|
+
mock_safe_tx.execute.return_value = (b"tx_hash", {"gas": 21000})
|
|
90
|
+
|
|
91
|
+
success, tx_hash, receipt = executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
|
|
92
|
+
|
|
93
|
+
assert success is True
|
|
94
|
+
assert tx_hash == "0x" + b"tx_hash".hex()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_execute_handles_bytes_return(executor, mock_chain_interface, mock_safe_tx, mock_safe):
|
|
98
|
+
"""Test that executor handles safe_tx.execute() returning raw bytes."""
|
|
99
|
+
with patch.object(executor, '_recreate_safe_client', return_value=mock_safe):
|
|
100
|
+
mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(status=1)
|
|
101
|
+
mock_safe_tx.execute.return_value = b"raw_hash"
|
|
102
|
+
|
|
103
|
+
success, tx_hash, receipt = executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
|
|
104
|
+
|
|
105
|
+
assert success is True
|
|
106
|
+
assert tx_hash.startswith("0x")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_execute_handles_string_return(executor, mock_chain_interface, mock_safe_tx, mock_safe):
|
|
110
|
+
"""Test that executor handles safe_tx.execute() returning hex string."""
|
|
111
|
+
with patch.object(executor, '_recreate_safe_client', return_value=mock_safe):
|
|
112
|
+
mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(status=1)
|
|
113
|
+
mock_safe_tx.execute.return_value = "0xabcdef1234567890"
|
|
114
|
+
|
|
115
|
+
success, tx_hash, receipt = executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
|
|
116
|
+
|
|
117
|
+
assert success is True
|
|
118
|
+
assert tx_hash == "0xabcdef1234567890"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# =============================================================================
|
|
122
|
+
# Test: Signature validation
|
|
123
|
+
# =============================================================================
|
|
124
|
+
|
|
125
|
+
def test_execute_fails_on_empty_signatures(executor, mock_chain_interface, mock_safe_tx, mock_safe):
|
|
126
|
+
"""Verify we fail immediately if no signatures exist."""
|
|
127
|
+
mock_safe_tx.signatures = b"" # Empty signatures
|
|
128
|
+
|
|
129
|
+
with patch.object(executor, '_recreate_safe_client', return_value=mock_safe):
|
|
130
|
+
with patch("time.sleep"):
|
|
131
|
+
success, error, _ = executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
|
|
132
|
+
|
|
133
|
+
assert success is False
|
|
134
|
+
assert "No valid signatures" in error
|
|
135
|
+
assert mock_safe_tx.execute.call_count == 0 # Never tried to execute
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_execute_fails_on_truncated_signatures(executor, mock_chain_interface, mock_safe_tx, mock_safe):
|
|
139
|
+
"""Verify we detect signatures shorter than 65 bytes."""
|
|
140
|
+
mock_safe_tx.signatures = b"x" * 30 # Too short (need 65)
|
|
141
|
+
|
|
142
|
+
with patch.object(executor, '_recreate_safe_client', return_value=mock_safe):
|
|
143
|
+
with patch("time.sleep"):
|
|
144
|
+
success, error, _ = executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
|
|
145
|
+
|
|
146
|
+
assert success is False
|
|
147
|
+
assert "No valid signatures" in error or str(MIN_SIGNATURE_LENGTH) in error
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_execute_fails_on_none_signatures(executor, mock_chain_interface, mock_safe_tx, mock_safe):
|
|
151
|
+
"""Verify we handle None signatures gracefully."""
|
|
152
|
+
mock_safe_tx.signatures = None
|
|
153
|
+
|
|
154
|
+
with patch.object(executor, '_recreate_safe_client', return_value=mock_safe):
|
|
155
|
+
with patch("time.sleep"):
|
|
156
|
+
success, error, _ = executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
|
|
157
|
+
|
|
158
|
+
assert success is False
|
|
159
|
+
assert "No valid signatures" in error
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# =============================================================================
|
|
163
|
+
# Test: Error classification (GS0xx codes)
|
|
164
|
+
# =============================================================================
|
|
165
|
+
|
|
166
|
+
def test_gs020_fails_fast(executor, mock_chain_interface, mock_safe_tx, mock_safe):
|
|
167
|
+
"""GS020 (signatures too short) should not trigger retries."""
|
|
168
|
+
with patch.object(executor, '_recreate_safe_client', return_value=mock_safe):
|
|
169
|
+
mock_safe_tx.call.side_effect = ValueError("execution reverted: GS020")
|
|
170
|
+
|
|
171
|
+
with patch("time.sleep"):
|
|
172
|
+
success, error, _ = executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
|
|
173
|
+
|
|
174
|
+
assert success is False
|
|
175
|
+
assert "GS020" in error
|
|
176
|
+
assert mock_safe_tx.execute.call_count == 0 # Never got to execute
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_gs026_fails_fast(executor, mock_chain_interface, mock_safe_tx, mock_safe):
|
|
180
|
+
"""GS026 (invalid owner) should not trigger retries."""
|
|
181
|
+
with patch.object(executor, '_recreate_safe_client', return_value=mock_safe):
|
|
182
|
+
mock_safe_tx.call.side_effect = ValueError("execution reverted: GS026")
|
|
183
|
+
|
|
184
|
+
with patch("time.sleep"):
|
|
185
|
+
success, error, _ = executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
|
|
186
|
+
|
|
187
|
+
assert success is False
|
|
188
|
+
assert "GS026" in error
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@pytest.mark.parametrize("error_code,is_signature_error", [
|
|
192
|
+
("GS020", True), # Signatures data too short
|
|
193
|
+
("GS021", True), # Invalid signature data pointer
|
|
194
|
+
("GS024", True), # Invalid contract signature
|
|
195
|
+
("GS026", True), # Invalid owner
|
|
196
|
+
("GS025", False), # Invalid nonce (not a signature error)
|
|
197
|
+
("GS010", False), # Not enough gas
|
|
198
|
+
("GS013", False), # Safe transaction failed
|
|
199
|
+
])
|
|
200
|
+
def test_error_classification(executor, error_code, is_signature_error):
|
|
201
|
+
"""Verify correct classification of Safe error codes."""
|
|
202
|
+
error = ValueError(f"execution reverted: {error_code}")
|
|
203
|
+
result = executor._is_signature_error(error)
|
|
204
|
+
assert result == is_signature_error
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# =============================================================================
|
|
208
|
+
# Test: Retry behavior
|
|
209
|
+
# =============================================================================
|
|
210
|
+
|
|
211
|
+
def test_retry_on_transient_error(executor, mock_chain_interface, mock_safe_tx, mock_safe):
|
|
212
|
+
"""Test that transient errors trigger retries without modifying tx."""
|
|
213
|
+
with patch.object(executor, '_recreate_safe_client', return_value=mock_safe):
|
|
214
|
+
mock_safe_tx.execute.side_effect = [
|
|
215
|
+
ConnectionError("Network timeout"),
|
|
216
|
+
b"success_hash"
|
|
217
|
+
]
|
|
218
|
+
mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(status=1)
|
|
219
|
+
mock_chain_interface._is_connection_error.return_value = True
|
|
220
|
+
|
|
221
|
+
with patch("time.sleep"):
|
|
222
|
+
success, tx_hash, receipt = executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
|
|
223
|
+
|
|
224
|
+
assert success is True
|
|
225
|
+
assert mock_safe_tx.execute.call_count == 2
|
|
226
|
+
# Gas should NOT have changed (we don't modify after signing)
|
|
227
|
+
assert mock_safe_tx.safe_tx_gas == 100000
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_retry_on_nonce_error(executor, mock_chain_interface, mock_safe_tx, mock_safe):
|
|
231
|
+
"""Test nonce refresh on GS025 error."""
|
|
232
|
+
with patch.object(executor, '_recreate_safe_client', return_value=mock_safe):
|
|
233
|
+
mock_safe_tx.execute.side_effect = [
|
|
234
|
+
ValueError("GS025: invalid nonce"),
|
|
235
|
+
b"success_hash"
|
|
236
|
+
]
|
|
237
|
+
mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(status=1)
|
|
238
|
+
|
|
239
|
+
new_tx = MagicMock(spec=SafeTx)
|
|
240
|
+
new_tx.signatures = b"x" * 65
|
|
241
|
+
executor._refresh_nonce = MagicMock(return_value=new_tx)
|
|
242
|
+
|
|
243
|
+
with patch("time.sleep"):
|
|
244
|
+
executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
|
|
245
|
+
|
|
246
|
+
assert executor._refresh_nonce.called
|
|
247
|
+
assert new_tx.execute.called
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_retry_on_rpc_error(executor, mock_chain_interface, mock_safe_tx, mock_safe):
|
|
251
|
+
"""Test RPC rotation on rate limit error."""
|
|
252
|
+
with patch.object(executor, '_recreate_safe_client', return_value=mock_safe):
|
|
253
|
+
mock_safe_tx.execute.side_effect = [
|
|
254
|
+
ValueError("Rate limit exceeded"),
|
|
255
|
+
b"success_hash"
|
|
256
|
+
]
|
|
257
|
+
mock_chain_interface._is_rate_limit_error.return_value = True
|
|
258
|
+
mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(status=1)
|
|
259
|
+
|
|
260
|
+
with patch("time.sleep"):
|
|
261
|
+
executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
|
|
262
|
+
|
|
263
|
+
assert mock_chain_interface._handle_rpc_error.called
|
|
264
|
+
assert mock_chain_interface._handle_rpc_error.call_count == 1
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_fail_after_max_retries(executor, mock_chain_interface, mock_safe_tx, mock_safe):
|
|
268
|
+
"""Test failure after exhausting all retries."""
|
|
269
|
+
executor.max_retries = 2
|
|
270
|
+
with patch.object(executor, '_recreate_safe_client', return_value=mock_safe):
|
|
271
|
+
mock_safe_tx.execute.side_effect = ValueError("Persistent error")
|
|
272
|
+
|
|
273
|
+
with patch("time.sleep"):
|
|
274
|
+
success, tx_hash_or_err, receipt = executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
|
|
275
|
+
|
|
276
|
+
assert success is False
|
|
277
|
+
assert mock_safe_tx.execute.call_count == 3 # 1 initial + 2 retries
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# =============================================================================
|
|
281
|
+
# Test: State preservation during retries
|
|
282
|
+
# =============================================================================
|
|
283
|
+
|
|
284
|
+
def test_retry_preserves_signatures_despite_clearing(executor, mock_chain_interface, mock_safe_tx, mock_safe):
|
|
285
|
+
"""Verify that retries don't corrupt/lose signatures even if library clears them."""
|
|
286
|
+
original_signatures = mock_safe_tx.signatures
|
|
287
|
+
|
|
288
|
+
# Define a side effect that clears signatures on success (mimicking safe-eth-py)
|
|
289
|
+
def execute_side_effect(key, **kwargs):
|
|
290
|
+
# Simulate library behavior: clears signatures after "executing"
|
|
291
|
+
mock_safe_tx.signatures = b""
|
|
292
|
+
return b"hash"
|
|
293
|
+
|
|
294
|
+
with patch.object(executor, '_recreate_safe_client', return_value=mock_safe):
|
|
295
|
+
# Scenario:
|
|
296
|
+
# 1. Execute success (sigs cleared) but Receipt not found (triggering retry)
|
|
297
|
+
# 2. Retry: Execute called again (must have restored sigs) -> Success -> Receipt found
|
|
298
|
+
|
|
299
|
+
mock_chain_interface.web3.eth.wait_for_transaction_receipt.side_effect = [
|
|
300
|
+
ValueError("Transaction not found"),
|
|
301
|
+
MagicMock(status=1)
|
|
302
|
+
]
|
|
303
|
+
|
|
304
|
+
mock_safe_tx.execute.side_effect = execute_side_effect
|
|
305
|
+
mock_chain_interface._is_connection_error.return_value = False
|
|
306
|
+
|
|
307
|
+
with patch("time.sleep"):
|
|
308
|
+
success, tx_hash, receipt = executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
|
|
309
|
+
|
|
310
|
+
assert success is True
|
|
311
|
+
# Signatures should be restored after the loop (or at least valid during 2nd call)
|
|
312
|
+
# We assert they match original at the end because the finally block restores if changed?
|
|
313
|
+
# Wait, finally restores IF signatures != backup.
|
|
314
|
+
# If call 2 succeeds, execute() sets signatures to b"" AGAIN at the end of call 2.
|
|
315
|
+
# So at the end of execution, signatures ARE empty locally if we updated them?
|
|
316
|
+
# NO, the finally block runs AFTER safe_tx.execute returns.
|
|
317
|
+
# So after call 2 returns (sigs=b""), finally restores them (sigs=original).
|
|
318
|
+
# So they should be original.
|
|
319
|
+
assert mock_safe_tx.signatures == original_signatures
|
|
320
|
+
assert mock_safe_tx.execute.call_count == 2
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_retry_preserves_gas(executor, mock_chain_interface, mock_safe_tx, mock_safe):
|
|
324
|
+
"""Verify that retries don't modify safe_tx_gas (which would invalidate signatures)."""
|
|
325
|
+
original_gas = mock_safe_tx.safe_tx_gas
|
|
326
|
+
|
|
327
|
+
with patch.object(executor, '_recreate_safe_client', return_value=mock_safe):
|
|
328
|
+
mock_safe_tx.execute.side_effect = [ConnectionError("timeout"), b"hash"]
|
|
329
|
+
mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(status=1)
|
|
330
|
+
mock_chain_interface._is_connection_error.return_value = True
|
|
331
|
+
|
|
332
|
+
with patch("time.sleep"):
|
|
333
|
+
executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
|
|
334
|
+
|
|
335
|
+
assert mock_safe_tx.safe_tx_gas == original_gas
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Integration tests for SafeTransactionExecutor using REAL SafeTx class.
|
|
2
|
+
|
|
3
|
+
These tests do NOT mock SafeTx methods. They use the real SafeTx class from safe-eth-py
|
|
4
|
+
to ensure that we correctly handle its internal state changes (like signature clearing).
|
|
5
|
+
We only mock the EthereumClient/w3 layer to avoid actual network calls.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from unittest.mock import MagicMock, patch
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from hexbytes import HexBytes
|
|
12
|
+
from safe_eth.eth import EthereumClient
|
|
13
|
+
from safe_eth.safe.safe_tx import SafeTx
|
|
14
|
+
|
|
15
|
+
from iwa.core.services.safe_executor import SafeTransactionExecutor
|
|
16
|
+
|
|
17
|
+
# Valid 65-byte mock signature
|
|
18
|
+
MOCK_SIGNATURE = b"x" * 65
|
|
19
|
+
MOCK_TX_HASH = HexBytes("0x" + "a" * 64)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def mock_chain_interface():
|
|
24
|
+
ci = MagicMock()
|
|
25
|
+
ci.current_rpc = "http://mock-rpc"
|
|
26
|
+
ci.DEFAULT_MAX_RETRIES = 6
|
|
27
|
+
ci._is_rate_limit_error.return_value = False
|
|
28
|
+
ci._is_connection_error.return_value = False
|
|
29
|
+
ci._handle_rpc_error.return_value = {"should_retry": True}
|
|
30
|
+
|
|
31
|
+
# Needs a web3 instance
|
|
32
|
+
ci.web3 = MagicMock()
|
|
33
|
+
return ci
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.fixture
|
|
37
|
+
def real_safe_tx_mock_eth(mock_chain_interface):
|
|
38
|
+
"""Create a REAL SafeTx object but with mocked EthereumClient."""
|
|
39
|
+
|
|
40
|
+
# 1. Setup Mock EthereumClient
|
|
41
|
+
mock_eth_client = MagicMock(spec=EthereumClient)
|
|
42
|
+
mock_eth_client.w3 = mock_chain_interface.web3
|
|
43
|
+
|
|
44
|
+
# 2. Setup Mock Contract for SafeTx internals
|
|
45
|
+
# SafeTx calls get_safe_contract(self.w3, address)
|
|
46
|
+
# We need to mock the contract function calls to avoid errors
|
|
47
|
+
mock_contract = MagicMock()
|
|
48
|
+
# Mock execTransaction function build_transaction
|
|
49
|
+
mock_contract.functions.execTransaction.return_value.build_transaction.return_value = {
|
|
50
|
+
"to": "0xSafe", "data": b"", "value": 0, "gas": 500000, "nonce": 5, "from": "0xExecutor"
|
|
51
|
+
}
|
|
52
|
+
# Mock nonce call
|
|
53
|
+
mock_contract.functions.nonce().call.return_value = 5
|
|
54
|
+
# Mock VERSION call
|
|
55
|
+
mock_contract.functions.VERSION().call.return_value = "1.3.0"
|
|
56
|
+
|
|
57
|
+
# We must patch get_safe_contract to return our mock
|
|
58
|
+
# because SafeTx uses it internally via @cached_property
|
|
59
|
+
with patch("safe_eth.safe.safe_tx.get_safe_contract", return_value=mock_contract):
|
|
60
|
+
safe_tx = SafeTx(
|
|
61
|
+
mock_eth_client,
|
|
62
|
+
"0xSafeAddress",
|
|
63
|
+
"0xTo",
|
|
64
|
+
0,
|
|
65
|
+
b"",
|
|
66
|
+
0,
|
|
67
|
+
200000, # safe_tx_gas
|
|
68
|
+
0,
|
|
69
|
+
0,
|
|
70
|
+
None,
|
|
71
|
+
None,
|
|
72
|
+
signatures=MOCK_SIGNATURE,
|
|
73
|
+
safe_nonce=5,
|
|
74
|
+
chain_id=1
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# HACK: Force initialize properties that rely on cached_property + network
|
|
78
|
+
# safe_tx.contract calls get_safe_contract (mocked above)
|
|
79
|
+
_ = safe_tx.contract
|
|
80
|
+
|
|
81
|
+
yield safe_tx, mock_eth_client
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_integration_full_execution_flow(mock_chain_interface, real_safe_tx_mock_eth):
|
|
85
|
+
"""
|
|
86
|
+
Test execution flow using REAL SafeTx.
|
|
87
|
+
This verifies that our executor handles the SafeTx.execute() side-effects (clearing signatures).
|
|
88
|
+
"""
|
|
89
|
+
safe_tx, mock_eth_client = real_safe_tx_mock_eth
|
|
90
|
+
executor = SafeTransactionExecutor(mock_chain_interface)
|
|
91
|
+
|
|
92
|
+
# Setup successful broadcast mock
|
|
93
|
+
mock_eth_client.send_unsigned_transaction.return_value = MOCK_TX_HASH
|
|
94
|
+
mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(status=1)
|
|
95
|
+
|
|
96
|
+
# Use a dummy key (needs to be valid hex for account generation if SafeTx uses it)
|
|
97
|
+
dummy_key = "0x" + "1" * 64
|
|
98
|
+
|
|
99
|
+
with patch.object(executor, '_recreate_safe_client', return_value=MagicMock()):
|
|
100
|
+
# Pre-execution check
|
|
101
|
+
assert len(safe_tx.signatures) == 65
|
|
102
|
+
|
|
103
|
+
success, tx_hash, receipt = executor.execute_with_retry("0xSafe", safe_tx, [dummy_key])
|
|
104
|
+
|
|
105
|
+
assert success is True
|
|
106
|
+
assert tx_hash == "0x" + MOCK_TX_HASH.hex()
|
|
107
|
+
|
|
108
|
+
# VITAL CHECK: Signatures must be present after execution!
|
|
109
|
+
# If backup/restore logic isn't working, this will fail because SafeTx clears them.
|
|
110
|
+
assert len(safe_tx.signatures) == 65
|
|
111
|
+
assert safe_tx.signatures == MOCK_SIGNATURE
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_integration_retry_preserves_signatures(mock_chain_interface, real_safe_tx_mock_eth):
|
|
115
|
+
"""
|
|
116
|
+
Test retry flow with REAL SafeTx.
|
|
117
|
+
Simulate:
|
|
118
|
+
1. Exec -> SafeTx clears sigs -> Network sends
|
|
119
|
+
2. Wait -> Timeout (failure)
|
|
120
|
+
3. Retry -> Exec again (Needs sigs!) -> Success
|
|
121
|
+
"""
|
|
122
|
+
safe_tx, mock_eth_client = real_safe_tx_mock_eth
|
|
123
|
+
executor = SafeTransactionExecutor(mock_chain_interface)
|
|
124
|
+
|
|
125
|
+
# Setup mocks
|
|
126
|
+
mock_eth_client.send_unsigned_transaction.return_value = MOCK_TX_HASH
|
|
127
|
+
|
|
128
|
+
# First attempt: Transaction not found (Timeout)
|
|
129
|
+
# Second attempt: Success (status 1)
|
|
130
|
+
mock_chain_interface.web3.eth.wait_for_transaction_receipt.side_effect = [
|
|
131
|
+
ValueError("Transaction not found"),
|
|
132
|
+
MagicMock(status=1)
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
dummy_key = "0x" + "1" * 64
|
|
136
|
+
|
|
137
|
+
with patch.object(executor, '_recreate_safe_client', return_value=MagicMock()):
|
|
138
|
+
with patch("time.sleep"): # Skip sleep
|
|
139
|
+
success, tx_hash, receipt = executor.execute_with_retry("0xSafe", safe_tx, [dummy_key])
|
|
140
|
+
|
|
141
|
+
assert success is True
|
|
142
|
+
|
|
143
|
+
# Verify we actually called send twice (retry happened)
|
|
144
|
+
assert mock_eth_client.send_unsigned_transaction.call_count == 2
|
|
145
|
+
|
|
146
|
+
# Verify signatures preserved at the end
|
|
147
|
+
assert len(safe_tx.signatures) == 65
|
|
148
|
+
assert safe_tx.signatures == MOCK_SIGNATURE
|
tests/test_safe_service.py
CHANGED
|
@@ -126,7 +126,7 @@ def test_create_safe_standard(mock_key_storage, mock_account_service, mock_depen
|
|
|
126
126
|
assert tx_hash == "0xTxHash"
|
|
127
127
|
|
|
128
128
|
mock_dependencies["safe"].create.assert_called_once()
|
|
129
|
-
mock_key_storage.
|
|
129
|
+
mock_key_storage.register_account.assert_called_once()
|
|
130
130
|
|
|
131
131
|
|
|
132
132
|
def test_create_safe_with_salt(mock_key_storage, mock_account_service, mock_dependencies):
|
tests/test_transfer_swap_unit.py
CHANGED
|
@@ -151,5 +151,9 @@ async def test_swap_full_balance(transfer_service, mock_chain_interfaces, mock_c
|
|
|
151
151
|
|
|
152
152
|
# Verify correct amount passed to swap
|
|
153
153
|
cow_instance.swap.assert_called_with(
|
|
154
|
-
amount_wei=500,
|
|
154
|
+
amount_wei=500,
|
|
155
|
+
sell_token_name="WETH",
|
|
156
|
+
buy_token_name="USDC",
|
|
157
|
+
order_type=OrderType.SELL,
|
|
158
|
+
wait_for_execution=True,
|
|
155
159
|
)
|
tests/test_pricing.py
DELETED
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
from datetime import timedelta
|
|
2
|
-
from unittest.mock import patch
|
|
3
|
-
|
|
4
|
-
import pytest
|
|
5
|
-
|
|
6
|
-
from iwa.core.pricing import PriceService
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
@pytest.fixture
|
|
10
|
-
def mock_secrets():
|
|
11
|
-
with patch("iwa.core.pricing.secrets") as mock:
|
|
12
|
-
mock.coingecko_api_key.get_secret_value.return_value = "test_api_key"
|
|
13
|
-
yield mock
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@pytest.fixture
|
|
17
|
-
def price_service(mock_secrets):
|
|
18
|
-
return PriceService()
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@pytest.fixture(autouse=True)
|
|
22
|
-
def clear_cache():
|
|
23
|
-
"""Clear global cache before each test to ensure isolation."""
|
|
24
|
-
with patch("iwa.core.pricing._PRICE_CACHE", {}):
|
|
25
|
-
yield
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def test_get_token_price_success(price_service):
|
|
29
|
-
with patch("iwa.core.pricing.requests.get") as mock_get:
|
|
30
|
-
mock_get.return_value.status_code = 200
|
|
31
|
-
mock_get.return_value.json.return_value = {"ethereum": {"eur": 2000.50}}
|
|
32
|
-
|
|
33
|
-
price = price_service.get_token_price("ethereum", "eur")
|
|
34
|
-
|
|
35
|
-
assert price == 2000.50
|
|
36
|
-
mock_get.assert_called_once()
|
|
37
|
-
# Verify API key in headers
|
|
38
|
-
args, kwargs = mock_get.call_args
|
|
39
|
-
assert kwargs["headers"]["x-cg-demo-api-key"] == "test_api_key"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def test_get_token_price_cached(price_service):
|
|
43
|
-
# Pre-populate cache
|
|
44
|
-
from datetime import datetime
|
|
45
|
-
|
|
46
|
-
cache_data = {
|
|
47
|
-
"ethereum_eur": {"price": 100.0, "timestamp": datetime.now()}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
with patch.dict("iwa.core.pricing._PRICE_CACHE", cache_data):
|
|
51
|
-
with patch("iwa.core.pricing.requests.get") as mock_get:
|
|
52
|
-
price = price_service.get_token_price("ethereum", "eur")
|
|
53
|
-
assert price == 100.0
|
|
54
|
-
mock_get.assert_not_called()
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def test_get_token_price_cache_expired(price_service):
|
|
58
|
-
# Pre-populate expired cache
|
|
59
|
-
from datetime import datetime
|
|
60
|
-
|
|
61
|
-
cache_data = {
|
|
62
|
-
"ethereum_eur": {
|
|
63
|
-
"price": 100.0,
|
|
64
|
-
"timestamp": datetime.now() - timedelta(minutes=60), # > 30 min TTL
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
with patch.dict("iwa.core.pricing._PRICE_CACHE", cache_data):
|
|
69
|
-
with patch("iwa.core.pricing.requests.get") as mock_get:
|
|
70
|
-
mock_get.return_value.status_code = 200
|
|
71
|
-
mock_get.return_value.json.return_value = {"ethereum": {"eur": 200.0}}
|
|
72
|
-
|
|
73
|
-
price = price_service.get_token_price("ethereum", "eur")
|
|
74
|
-
assert price == 200.0
|
|
75
|
-
mock_get.assert_called_once()
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def test_get_token_price_api_error(price_service):
|
|
79
|
-
with patch("iwa.core.pricing.requests.get") as mock_get:
|
|
80
|
-
mock_get.side_effect = Exception("API Error")
|
|
81
|
-
|
|
82
|
-
price = price_service.get_token_price("ethereum", "eur")
|
|
83
|
-
assert price is None
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def test_get_token_price_key_not_found(price_service):
|
|
87
|
-
with patch("iwa.core.pricing.requests.get") as mock_get:
|
|
88
|
-
mock_get.return_value.status_code = 200
|
|
89
|
-
mock_get.return_value.json.return_value = {} # Empty response
|
|
90
|
-
|
|
91
|
-
price = price_service.get_token_price("ethereum", "eur")
|
|
92
|
-
assert price is None
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def test_get_token_price_rate_limit():
|
|
96
|
-
"""Test rate limit (429) handling with retries."""
|
|
97
|
-
with patch("iwa.core.pricing.secrets") as mock_secrets:
|
|
98
|
-
mock_secrets.coingecko_api_key = None
|
|
99
|
-
|
|
100
|
-
# Need to re-instantiate or patch secrets on instance since it's read in __init__
|
|
101
|
-
service = PriceService()
|
|
102
|
-
service.api_key = None
|
|
103
|
-
|
|
104
|
-
with patch("iwa.core.pricing.requests.get") as mock_get, patch("time.sleep"):
|
|
105
|
-
# Return 429 for all attempts
|
|
106
|
-
mock_response = type("Response", (), {"status_code": 429})()
|
|
107
|
-
mock_get.return_value = mock_response
|
|
108
|
-
|
|
109
|
-
price = service.get_token_price("ethereum", "eur")
|
|
110
|
-
|
|
111
|
-
assert price is None
|
|
112
|
-
# Should have tried max_retries + 1 times (3 total)
|
|
113
|
-
assert mock_get.call_count == 3
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def test_get_token_price_rate_limit_then_success():
|
|
117
|
-
"""Test rate limit recovery on retry."""
|
|
118
|
-
from unittest.mock import MagicMock
|
|
119
|
-
|
|
120
|
-
with patch("iwa.core.pricing.secrets") as mock_secrets:
|
|
121
|
-
mock_secrets.coingecko_api_key = None
|
|
122
|
-
|
|
123
|
-
service = PriceService()
|
|
124
|
-
service.api_key = None
|
|
125
|
-
|
|
126
|
-
with patch("iwa.core.pricing.requests.get") as mock_get, patch("time.sleep"):
|
|
127
|
-
# First call returns 429, second succeeds
|
|
128
|
-
mock_429 = MagicMock()
|
|
129
|
-
mock_429.status_code = 429
|
|
130
|
-
|
|
131
|
-
mock_ok = MagicMock()
|
|
132
|
-
mock_ok.status_code = 200
|
|
133
|
-
mock_ok.json.return_value = {"ethereum": {"eur": 1500.0}}
|
|
134
|
-
|
|
135
|
-
mock_get.side_effect = [mock_429, mock_ok]
|
|
136
|
-
|
|
137
|
-
price = service.get_token_price("ethereum", "eur")
|
|
138
|
-
|
|
139
|
-
assert price == 1500.0
|
|
140
|
-
assert mock_get.call_count == 2
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def test_get_token_price_no_api_key():
|
|
144
|
-
"""Test getting price without API key."""
|
|
145
|
-
with patch("iwa.core.pricing.secrets") as mock_secrets:
|
|
146
|
-
mock_secrets.coingecko_api_key = None
|
|
147
|
-
|
|
148
|
-
service = PriceService()
|
|
149
|
-
service.api_key = None
|
|
150
|
-
|
|
151
|
-
with patch("iwa.core.pricing.requests.get") as mock_get:
|
|
152
|
-
mock_get.return_value.status_code = 200
|
|
153
|
-
mock_get.return_value.json.return_value = {"gnosis": {"eur": 100.0}}
|
|
154
|
-
|
|
155
|
-
price = service.get_token_price("gnosis", "eur")
|
|
156
|
-
|
|
157
|
-
assert price == 100.0
|
|
158
|
-
# Verify no API key header
|
|
159
|
-
args, kwargs = mock_get.call_args
|
|
160
|
-
assert "x-cg-demo-api-key" not in kwargs.get("headers", {})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|