iwa 0.0.33__py3-none-any.whl → 0.0.59__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. iwa/core/chain/interface.py +130 -11
  2. iwa/core/chain/models.py +15 -3
  3. iwa/core/chain/rate_limiter.py +48 -12
  4. iwa/core/chainlist.py +15 -10
  5. iwa/core/cli.py +4 -1
  6. iwa/core/contracts/cache.py +1 -1
  7. iwa/core/contracts/contract.py +1 -0
  8. iwa/core/contracts/decoder.py +10 -4
  9. iwa/core/http.py +31 -0
  10. iwa/core/ipfs.py +21 -7
  11. iwa/core/keys.py +65 -15
  12. iwa/core/models.py +58 -13
  13. iwa/core/pricing.py +10 -6
  14. iwa/core/rpc_monitor.py +1 -0
  15. iwa/core/secrets.py +27 -0
  16. iwa/core/services/account.py +1 -1
  17. iwa/core/services/balance.py +0 -23
  18. iwa/core/services/safe.py +72 -45
  19. iwa/core/services/safe_executor.py +350 -0
  20. iwa/core/services/transaction.py +43 -13
  21. iwa/core/services/transfer/erc20.py +14 -3
  22. iwa/core/services/transfer/native.py +14 -31
  23. iwa/core/services/transfer/swap.py +1 -0
  24. iwa/core/tests/test_gnosis_fee.py +91 -0
  25. iwa/core/tests/test_ipfs.py +85 -0
  26. iwa/core/tests/test_pricing.py +65 -0
  27. iwa/core/tests/test_regression_fixes.py +97 -0
  28. iwa/core/utils.py +2 -0
  29. iwa/core/wallet.py +6 -4
  30. iwa/plugins/gnosis/cow/quotes.py +2 -2
  31. iwa/plugins/gnosis/cow/swap.py +18 -32
  32. iwa/plugins/gnosis/tests/test_cow.py +19 -10
  33. iwa/plugins/olas/constants.py +15 -5
  34. iwa/plugins/olas/contracts/activity_checker.py +3 -3
  35. iwa/plugins/olas/contracts/staking.py +0 -1
  36. iwa/plugins/olas/events.py +15 -13
  37. iwa/plugins/olas/importer.py +29 -25
  38. iwa/plugins/olas/models.py +0 -3
  39. iwa/plugins/olas/plugin.py +16 -14
  40. iwa/plugins/olas/service_manager/drain.py +16 -9
  41. iwa/plugins/olas/service_manager/lifecycle.py +23 -12
  42. iwa/plugins/olas/service_manager/staking.py +15 -10
  43. iwa/plugins/olas/tests/test_importer_error_handling.py +13 -0
  44. iwa/plugins/olas/tests/test_olas_archiving.py +83 -0
  45. iwa/plugins/olas/tests/test_olas_integration.py +49 -29
  46. iwa/plugins/olas/tests/test_olas_view.py +5 -1
  47. iwa/plugins/olas/tests/test_service_manager.py +15 -17
  48. iwa/plugins/olas/tests/test_service_manager_errors.py +6 -5
  49. iwa/plugins/olas/tests/test_service_manager_flows.py +7 -6
  50. iwa/plugins/olas/tests/test_service_manager_rewards.py +5 -1
  51. iwa/plugins/olas/tests/test_service_staking.py +64 -38
  52. iwa/tools/drain_accounts.py +61 -0
  53. iwa/tools/list_contracts.py +2 -0
  54. iwa/tools/reset_env.py +2 -1
  55. iwa/tools/test_chainlist.py +5 -1
  56. iwa/tui/screens/wallets.py +2 -4
  57. iwa/web/routers/accounts.py +1 -1
  58. iwa/web/routers/olas/services.py +10 -5
  59. iwa/web/static/app.js +21 -9
  60. iwa/web/static/style.css +4 -0
  61. iwa/web/tests/test_web_endpoints.py +2 -2
  62. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/METADATA +6 -3
  63. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/RECORD +82 -71
  64. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/WHEEL +1 -1
  65. tests/test_balance_service.py +0 -43
  66. tests/test_chain.py +13 -5
  67. tests/test_cli.py +2 -2
  68. tests/test_drain_coverage.py +12 -6
  69. tests/test_keys.py +23 -23
  70. tests/test_rate_limiter.py +2 -2
  71. tests/test_rate_limiter_retry.py +103 -0
  72. tests/test_rpc_efficiency.py +4 -1
  73. tests/test_rpc_rate_limit.py +34 -0
  74. tests/test_rpc_rotation.py +59 -11
  75. tests/test_safe_coverage.py +37 -23
  76. tests/test_safe_executor.py +361 -0
  77. tests/test_safe_integration.py +153 -0
  78. tests/test_safe_service.py +1 -1
  79. tests/test_transfer_swap_unit.py +5 -1
  80. tests/test_pricing.py +0 -160
  81. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/entry_points.txt +0 -0
  82. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/licenses/LICENSE +0 -0
  83. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,361 @@
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
+
68
+ def test_execute_success_first_try(executor, mock_chain_interface, mock_safe_tx, mock_safe):
69
+ """Test successful execution on first attempt."""
70
+ with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
71
+ mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(
72
+ status=1
73
+ )
74
+ mock_safe_tx.execute.return_value = b"tx_hash"
75
+
76
+ success, tx_hash, receipt = executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
77
+
78
+ assert success is True
79
+ assert tx_hash == "0x" + b"tx_hash".hex()
80
+ assert mock_safe_tx.execute.call_count == 1
81
+
82
+
83
+ # =============================================================================
84
+ # Test: Tuple vs bytes return handling
85
+ # =============================================================================
86
+
87
+
88
+ def test_execute_handles_tuple_return(executor, mock_chain_interface, mock_safe_tx, mock_safe):
89
+ """Test that executor handles safe_tx.execute() returning tuple (tx_hash, tx)."""
90
+ with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
91
+ mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(
92
+ status=1
93
+ )
94
+ # Simulate tuple return: (tx_hash_bytes, tx_data)
95
+ mock_safe_tx.execute.return_value = (b"tx_hash", {"gas": 21000})
96
+
97
+ success, tx_hash, receipt = executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
98
+
99
+ assert success is True
100
+ assert tx_hash == "0x" + b"tx_hash".hex()
101
+
102
+
103
+ def test_execute_handles_bytes_return(executor, mock_chain_interface, mock_safe_tx, mock_safe):
104
+ """Test that executor handles safe_tx.execute() returning raw bytes."""
105
+ with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
106
+ mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(
107
+ status=1
108
+ )
109
+ mock_safe_tx.execute.return_value = b"raw_hash"
110
+
111
+ success, tx_hash, receipt = executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
112
+
113
+ assert success is True
114
+ assert tx_hash.startswith("0x")
115
+
116
+
117
+ def test_execute_handles_string_return(executor, mock_chain_interface, mock_safe_tx, mock_safe):
118
+ """Test that executor handles safe_tx.execute() returning hex string."""
119
+ with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
120
+ mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(
121
+ status=1
122
+ )
123
+ mock_safe_tx.execute.return_value = "0xabcdef1234567890"
124
+
125
+ success, tx_hash, receipt = executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
126
+
127
+ assert success is True
128
+ assert tx_hash == "0xabcdef1234567890"
129
+
130
+
131
+ # =============================================================================
132
+ # Test: Signature validation
133
+ # =============================================================================
134
+
135
+
136
+ def test_execute_fails_on_empty_signatures(executor, mock_chain_interface, mock_safe_tx, mock_safe):
137
+ """Verify we fail immediately if no signatures exist."""
138
+ mock_safe_tx.signatures = b"" # Empty signatures
139
+
140
+ with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
141
+ with patch("time.sleep"):
142
+ success, error, _ = executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
143
+
144
+ assert success is False
145
+ assert "No valid signatures" in error
146
+ assert mock_safe_tx.execute.call_count == 0 # Never tried to execute
147
+
148
+
149
+ def test_execute_fails_on_truncated_signatures(
150
+ executor, mock_chain_interface, mock_safe_tx, mock_safe
151
+ ):
152
+ """Verify we detect signatures shorter than 65 bytes."""
153
+ mock_safe_tx.signatures = b"x" * 30 # Too short (need 65)
154
+
155
+ with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
156
+ with patch("time.sleep"):
157
+ success, error, _ = executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
158
+
159
+ assert success is False
160
+ assert "No valid signatures" in error or str(MIN_SIGNATURE_LENGTH) in error
161
+
162
+
163
+ def test_execute_fails_on_none_signatures(executor, mock_chain_interface, mock_safe_tx, mock_safe):
164
+ """Verify we handle None signatures gracefully."""
165
+ mock_safe_tx.signatures = None
166
+
167
+ with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
168
+ with patch("time.sleep"):
169
+ success, error, _ = executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
170
+
171
+ assert success is False
172
+ assert "No valid signatures" in error
173
+
174
+
175
+ # =============================================================================
176
+ # Test: Error classification (GS0xx codes)
177
+ # =============================================================================
178
+
179
+
180
+ def test_gs020_fails_fast(executor, mock_chain_interface, mock_safe_tx, mock_safe):
181
+ """GS020 (signatures too short) should not trigger retries."""
182
+ with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
183
+ mock_safe_tx.call.side_effect = ValueError("execution reverted: GS020")
184
+
185
+ with patch("time.sleep"):
186
+ success, error, _ = executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
187
+
188
+ assert success is False
189
+ assert "GS020" in error
190
+ assert mock_safe_tx.execute.call_count == 0 # Never got to execute
191
+
192
+
193
+ def test_gs026_fails_fast(executor, mock_chain_interface, mock_safe_tx, mock_safe):
194
+ """GS026 (invalid owner) should not trigger retries."""
195
+ with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
196
+ mock_safe_tx.call.side_effect = ValueError("execution reverted: GS026")
197
+
198
+ with patch("time.sleep"):
199
+ success, error, _ = executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
200
+
201
+ assert success is False
202
+ assert "GS026" in error
203
+
204
+
205
+ @pytest.mark.parametrize(
206
+ "error_code,is_signature_error",
207
+ [
208
+ ("GS020", True), # Signatures data too short
209
+ ("GS021", True), # Invalid signature data pointer
210
+ ("GS024", True), # Invalid contract signature
211
+ ("GS026", True), # Invalid owner
212
+ ("GS025", False), # Invalid nonce (not a signature error)
213
+ ("GS010", False), # Not enough gas
214
+ ("GS013", False), # Safe transaction failed
215
+ ],
216
+ )
217
+ def test_error_classification(executor, error_code, is_signature_error):
218
+ """Verify correct classification of Safe error codes."""
219
+ error = ValueError(f"execution reverted: {error_code}")
220
+ result = executor._is_signature_error(error)
221
+ assert result == is_signature_error
222
+
223
+
224
+ # =============================================================================
225
+ # Test: Retry behavior
226
+ # =============================================================================
227
+
228
+
229
+ def test_retry_on_transient_error(executor, mock_chain_interface, mock_safe_tx, mock_safe):
230
+ """Test that transient errors trigger retries without modifying tx."""
231
+ with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
232
+ mock_safe_tx.execute.side_effect = [ConnectionError("Network timeout"), b"success_hash"]
233
+ mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(
234
+ status=1
235
+ )
236
+ mock_chain_interface._is_connection_error.return_value = True
237
+
238
+ with patch("time.sleep"):
239
+ success, tx_hash, receipt = executor.execute_with_retry(
240
+ "0xSafe", mock_safe_tx, ["key1"]
241
+ )
242
+
243
+ assert success is True
244
+ assert mock_safe_tx.execute.call_count == 2
245
+ # Gas should NOT have changed (we don't modify after signing)
246
+ assert mock_safe_tx.safe_tx_gas == 100000
247
+
248
+
249
+ def test_retry_on_nonce_error(executor, mock_chain_interface, mock_safe_tx, mock_safe):
250
+ """Test nonce refresh on GS025 error."""
251
+ with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
252
+ mock_safe_tx.execute.side_effect = [ValueError("GS025: invalid nonce"), b"success_hash"]
253
+ mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(
254
+ status=1
255
+ )
256
+
257
+ new_tx = MagicMock(spec=SafeTx)
258
+ new_tx.signatures = b"x" * 65
259
+ executor._refresh_nonce = MagicMock(return_value=new_tx)
260
+
261
+ with patch("time.sleep"):
262
+ executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
263
+
264
+ assert executor._refresh_nonce.called
265
+ assert new_tx.execute.called
266
+
267
+
268
+ def test_retry_on_rpc_error(executor, mock_chain_interface, mock_safe_tx, mock_safe):
269
+ """Test RPC rotation on rate limit error."""
270
+ with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
271
+ mock_safe_tx.execute.side_effect = [ValueError("Rate limit exceeded"), b"success_hash"]
272
+ mock_chain_interface._is_rate_limit_error.return_value = True
273
+ mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(
274
+ status=1
275
+ )
276
+
277
+ with patch("time.sleep"):
278
+ executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
279
+
280
+ assert mock_chain_interface._handle_rpc_error.called
281
+ assert mock_chain_interface._handle_rpc_error.call_count == 1
282
+
283
+
284
+ def test_fail_after_max_retries(executor, mock_chain_interface, mock_safe_tx, mock_safe):
285
+ """Test failure after exhausting all retries."""
286
+ executor.max_retries = 2
287
+ with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
288
+ mock_safe_tx.execute.side_effect = ValueError("Persistent error")
289
+
290
+ with patch("time.sleep"):
291
+ success, tx_hash_or_err, receipt = executor.execute_with_retry(
292
+ "0xSafe", mock_safe_tx, ["key1"]
293
+ )
294
+
295
+ assert success is False
296
+ assert mock_safe_tx.execute.call_count == 3 # 1 initial + 2 retries
297
+
298
+
299
+ # =============================================================================
300
+ # Test: State preservation during retries
301
+ # =============================================================================
302
+
303
+
304
+ def test_retry_preserves_signatures_despite_clearing(
305
+ executor, mock_chain_interface, mock_safe_tx, mock_safe
306
+ ):
307
+ """Verify that retries don't corrupt/lose signatures even if library clears them."""
308
+ original_signatures = mock_safe_tx.signatures
309
+
310
+ # Define a side effect that clears signatures on success (mimicking safe-eth-py)
311
+ def execute_side_effect(key, **kwargs):
312
+ # Simulate library behavior: clears signatures after "executing"
313
+ mock_safe_tx.signatures = b""
314
+ return b"hash"
315
+
316
+ with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
317
+ # Scenario:
318
+ # 1. Execute success (sigs cleared) but Receipt not found (triggering retry)
319
+ # 2. Retry: Execute called again (must have restored sigs) -> Success -> Receipt found
320
+
321
+ mock_chain_interface.web3.eth.wait_for_transaction_receipt.side_effect = [
322
+ ValueError("Transaction not found"),
323
+ MagicMock(status=1),
324
+ ]
325
+
326
+ mock_safe_tx.execute.side_effect = execute_side_effect
327
+ mock_chain_interface._is_connection_error.return_value = False
328
+
329
+ with patch("time.sleep"):
330
+ success, tx_hash, receipt = executor.execute_with_retry(
331
+ "0xSafe", mock_safe_tx, ["key1"]
332
+ )
333
+
334
+ assert success is True
335
+ # Signatures should be restored after the loop (or at least valid during 2nd call)
336
+ # We assert they match original at the end because the finally block restores if changed?
337
+ # Wait, finally restores IF signatures != backup.
338
+ # If call 2 succeeds, execute() sets signatures to b"" AGAIN at the end of call 2.
339
+ # So at the end of execution, signatures ARE empty locally if we updated them?
340
+ # NO, the finally block runs AFTER safe_tx.execute returns.
341
+ # So after call 2 returns (sigs=b""), finally restores them (sigs=original).
342
+ # So they should be original.
343
+ assert mock_safe_tx.signatures == original_signatures
344
+ assert mock_safe_tx.execute.call_count == 2
345
+
346
+
347
+ def test_retry_preserves_gas(executor, mock_chain_interface, mock_safe_tx, mock_safe):
348
+ """Verify that retries don't modify safe_tx_gas (which would invalidate signatures)."""
349
+ original_gas = mock_safe_tx.safe_tx_gas
350
+
351
+ with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
352
+ mock_safe_tx.execute.side_effect = [ConnectionError("timeout"), b"hash"]
353
+ mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(
354
+ status=1
355
+ )
356
+ mock_chain_interface._is_connection_error.return_value = True
357
+
358
+ with patch("time.sleep"):
359
+ executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
360
+
361
+ assert mock_safe_tx.safe_tx_gas == original_gas
@@ -0,0 +1,153 @@
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",
51
+ "data": b"",
52
+ "value": 0,
53
+ "gas": 500000,
54
+ "nonce": 5,
55
+ "from": "0xExecutor",
56
+ }
57
+ # Mock nonce call
58
+ mock_contract.functions.nonce().call.return_value = 5
59
+ # Mock VERSION call
60
+ mock_contract.functions.VERSION().call.return_value = "1.3.0"
61
+
62
+ # We must patch get_safe_contract to return our mock
63
+ # because SafeTx uses it internally via @cached_property
64
+ with patch("safe_eth.safe.safe_tx.get_safe_contract", return_value=mock_contract):
65
+ safe_tx = SafeTx(
66
+ mock_eth_client,
67
+ "0xSafeAddress",
68
+ "0xTo",
69
+ 0,
70
+ b"",
71
+ 0,
72
+ 200000, # safe_tx_gas
73
+ 0,
74
+ 0,
75
+ None,
76
+ None,
77
+ signatures=MOCK_SIGNATURE,
78
+ safe_nonce=5,
79
+ chain_id=1,
80
+ )
81
+
82
+ # HACK: Force initialize properties that rely on cached_property + network
83
+ # safe_tx.contract calls get_safe_contract (mocked above)
84
+ _ = safe_tx.contract
85
+
86
+ yield safe_tx, mock_eth_client
87
+
88
+
89
+ def test_integration_full_execution_flow(mock_chain_interface, real_safe_tx_mock_eth):
90
+ """
91
+ Test execution flow using REAL SafeTx.
92
+ This verifies that our executor handles the SafeTx.execute() side-effects (clearing signatures).
93
+ """
94
+ safe_tx, mock_eth_client = real_safe_tx_mock_eth
95
+ executor = SafeTransactionExecutor(mock_chain_interface)
96
+
97
+ # Setup successful broadcast mock
98
+ mock_eth_client.send_unsigned_transaction.return_value = MOCK_TX_HASH
99
+ mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(status=1)
100
+
101
+ # Use a dummy key (needs to be valid hex for account generation if SafeTx uses it)
102
+ dummy_key = "0x" + "1" * 64
103
+
104
+ with patch.object(executor, "_recreate_safe_client", return_value=MagicMock()):
105
+ # Pre-execution check
106
+ assert len(safe_tx.signatures) == 65
107
+
108
+ success, tx_hash, receipt = executor.execute_with_retry("0xSafe", safe_tx, [dummy_key])
109
+
110
+ assert success is True
111
+ assert tx_hash == "0x" + MOCK_TX_HASH.hex()
112
+
113
+ # VITAL CHECK: Signatures must be present after execution!
114
+ # If backup/restore logic isn't working, this will fail because SafeTx clears them.
115
+ assert len(safe_tx.signatures) == 65
116
+ assert safe_tx.signatures == MOCK_SIGNATURE
117
+
118
+
119
+ def test_integration_retry_preserves_signatures(mock_chain_interface, real_safe_tx_mock_eth):
120
+ """
121
+ Test retry flow with REAL SafeTx.
122
+ Simulate:
123
+ 1. Exec -> SafeTx clears sigs -> Network sends
124
+ 2. Wait -> Timeout (failure)
125
+ 3. Retry -> Exec again (Needs sigs!) -> Success
126
+ """
127
+ safe_tx, mock_eth_client = real_safe_tx_mock_eth
128
+ executor = SafeTransactionExecutor(mock_chain_interface)
129
+
130
+ # Setup mocks
131
+ mock_eth_client.send_unsigned_transaction.return_value = MOCK_TX_HASH
132
+
133
+ # First attempt: Transaction not found (Timeout)
134
+ # Second attempt: Success (status 1)
135
+ mock_chain_interface.web3.eth.wait_for_transaction_receipt.side_effect = [
136
+ ValueError("Transaction not found"),
137
+ MagicMock(status=1),
138
+ ]
139
+
140
+ dummy_key = "0x" + "1" * 64
141
+
142
+ with patch.object(executor, "_recreate_safe_client", return_value=MagicMock()):
143
+ with patch("time.sleep"): # Skip sleep
144
+ success, tx_hash, receipt = executor.execute_with_retry("0xSafe", safe_tx, [dummy_key])
145
+
146
+ assert success is True
147
+
148
+ # Verify we actually called send twice (retry happened)
149
+ assert mock_eth_client.send_unsigned_transaction.call_count == 2
150
+
151
+ # Verify signatures preserved at the end
152
+ assert len(safe_tx.signatures) == 65
153
+ assert safe_tx.signatures == MOCK_SIGNATURE
@@ -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.save.assert_called_once()
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):
@@ -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, sell_token_name="WETH", buy_token_name="USDC", order_type=OrderType.SELL
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", {})