iwa 0.0.32__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.
Files changed (65) hide show
  1. iwa/core/chain/interface.py +116 -8
  2. iwa/core/chain/models.py +15 -3
  3. iwa/core/chain/rate_limiter.py +54 -12
  4. iwa/core/cli.py +1 -1
  5. iwa/core/ipfs.py +24 -2
  6. iwa/core/keys.py +59 -15
  7. iwa/core/models.py +60 -13
  8. iwa/core/pricing.py +24 -2
  9. iwa/core/secrets.py +27 -0
  10. iwa/core/services/account.py +1 -1
  11. iwa/core/services/balance.py +0 -22
  12. iwa/core/services/safe.py +64 -43
  13. iwa/core/services/safe_executor.py +316 -0
  14. iwa/core/services/transaction.py +11 -1
  15. iwa/core/services/transfer/erc20.py +14 -2
  16. iwa/core/services/transfer/native.py +14 -31
  17. iwa/core/services/transfer/swap.py +1 -0
  18. iwa/core/tests/test_gnosis_fee.py +87 -0
  19. iwa/core/tests/test_ipfs.py +85 -0
  20. iwa/core/tests/test_pricing.py +65 -0
  21. iwa/core/tests/test_regression_fixes.py +100 -0
  22. iwa/core/wallet.py +3 -3
  23. iwa/plugins/gnosis/cow/quotes.py +2 -2
  24. iwa/plugins/gnosis/cow/swap.py +18 -32
  25. iwa/plugins/gnosis/tests/test_cow.py +19 -10
  26. iwa/plugins/olas/importer.py +5 -7
  27. iwa/plugins/olas/models.py +0 -3
  28. iwa/plugins/olas/service_manager/drain.py +16 -7
  29. iwa/plugins/olas/service_manager/lifecycle.py +15 -4
  30. iwa/plugins/olas/service_manager/staking.py +4 -4
  31. iwa/plugins/olas/tests/test_importer_error_handling.py +13 -0
  32. iwa/plugins/olas/tests/test_olas_archiving.py +73 -0
  33. iwa/plugins/olas/tests/test_olas_view.py +5 -1
  34. iwa/plugins/olas/tests/test_service_manager.py +7 -7
  35. iwa/plugins/olas/tests/test_service_manager_errors.py +1 -1
  36. iwa/plugins/olas/tests/test_service_manager_flows.py +1 -1
  37. iwa/plugins/olas/tests/test_service_manager_rewards.py +5 -1
  38. iwa/tools/drain_accounts.py +60 -0
  39. iwa/tools/list_contracts.py +2 -0
  40. iwa/tui/screens/wallets.py +2 -2
  41. iwa/web/routers/accounts.py +1 -1
  42. iwa/web/static/app.js +21 -9
  43. iwa/web/static/style.css +4 -0
  44. iwa/web/tests/test_web_endpoints.py +2 -2
  45. {iwa-0.0.32.dist-info → iwa-0.0.58.dist-info}/METADATA +6 -3
  46. {iwa-0.0.32.dist-info → iwa-0.0.58.dist-info}/RECORD +64 -54
  47. {iwa-0.0.32.dist-info → iwa-0.0.58.dist-info}/WHEEL +1 -1
  48. tests/test_balance_service.py +0 -41
  49. tests/test_chain.py +13 -4
  50. tests/test_cli.py +2 -2
  51. tests/test_drain_coverage.py +12 -6
  52. tests/test_keys.py +23 -23
  53. tests/test_rate_limiter.py +2 -2
  54. tests/test_rate_limiter_retry.py +108 -0
  55. tests/test_rpc_rate_limit.py +33 -0
  56. tests/test_rpc_rotation.py +55 -7
  57. tests/test_safe_coverage.py +37 -23
  58. tests/test_safe_executor.py +335 -0
  59. tests/test_safe_integration.py +148 -0
  60. tests/test_safe_service.py +1 -1
  61. tests/test_transfer_swap_unit.py +5 -1
  62. tests/test_pricing.py +0 -160
  63. {iwa-0.0.32.dist-info → iwa-0.0.58.dist-info}/entry_points.txt +0 -0
  64. {iwa-0.0.32.dist-info → iwa-0.0.58.dist-info}/licenses/LICENSE +0 -0
  65. {iwa-0.0.32.dist-info → iwa-0.0.58.dist-info}/top_level.txt +0 -0
@@ -85,24 +85,14 @@ class NativeTransferMixin:
85
85
  ) -> Optional[str]:
86
86
  """Send native currency via EOA using unified TransactionService."""
87
87
  # Build transaction dict
88
- try:
89
- gas_price = chain_interface.web3.eth.gas_price
90
- gas_estimate = chain_interface.web3.eth.estimate_gas({
88
+ tx = chain_interface.calculate_transaction_params(
89
+ built_method=None,
90
+ tx_params={
91
91
  "from": from_account.address,
92
92
  "to": to_address,
93
93
  "value": amount_wei,
94
- })
95
- except Exception as e:
96
- logger.error(f"Failed to estimate gas for native transfer: {e}")
97
- return None
98
-
99
- tx = {
100
- "from": from_account.address,
101
- "to": to_address,
102
- "value": amount_wei,
103
- "gas": gas_estimate,
104
- "gasPrice": gas_price,
105
- }
94
+ }
95
+ )
106
96
 
107
97
  # Use unified TransactionService
108
98
  success, receipt = self.transaction_service.sign_and_send(
@@ -189,17 +179,13 @@ class NativeTransferMixin:
189
179
  logger.info(f"Wrapping {amount_eth:.4f} xDAI → WXDAI...")
190
180
 
191
181
  try:
192
- tx = contract.functions.deposit().build_transaction(
193
- {
194
- "from": account.address,
195
- "value": amount_wei,
196
- "gas": 100000,
197
- "gasPrice": chain_interface.web3._web3.eth.gas_price,
198
- "nonce": chain_interface.web3._web3.eth.get_transaction_count(account.address),
199
- }
182
+ tx_params = chain_interface.calculate_transaction_params(
183
+ built_method=contract.functions.deposit(),
184
+ tx_params={"from": account.address, "value": amount_wei},
200
185
  )
186
+ transaction = contract.functions.deposit().build_transaction(tx_params)
201
187
 
202
- signed = self.key_storage.sign_transaction(tx, account.address)
188
+ signed = self.key_storage.sign_transaction(transaction, account.address)
203
189
  tx_hash = chain_interface.web3._web3.eth.send_raw_transaction(signed.raw_transaction)
204
190
  receipt = chain_interface.web3._web3.eth.wait_for_transaction_receipt(
205
191
  tx_hash, timeout=60
@@ -270,14 +256,11 @@ class NativeTransferMixin:
270
256
  logger.info(f"Unwrapping {amount_eth:.4f} WXDAI → xDAI...")
271
257
 
272
258
  try:
273
- tx = contract.functions.withdraw(amount_wei).build_transaction(
274
- {
275
- "from": account.address,
276
- "gas": 100000,
277
- "gasPrice": chain_interface.web3._web3.eth.gas_price,
278
- "nonce": chain_interface.web3._web3.eth.get_transaction_count(account.address),
279
- }
259
+ tx_params = chain_interface.calculate_transaction_params(
260
+ built_method=contract.functions.withdraw(amount_wei),
261
+ tx_params={"from": account.address},
280
262
  )
263
+ tx = contract.functions.withdraw(amount_wei).build_transaction(tx_params)
281
264
 
282
265
  signed = self.key_storage.sign_transaction(tx, account.address)
283
266
  tx_hash = chain_interface.web3._web3.eth.send_raw_transaction(signed.raw_transaction)
@@ -104,6 +104,7 @@ class SwapMixin:
104
104
  sell_token_name=sell_token_name,
105
105
  buy_token_name=buy_token_name,
106
106
  order_type=order_type,
107
+ wait_for_execution=True,
107
108
  )
108
109
 
109
110
  if result:
@@ -0,0 +1,87 @@
1
+ """Test Gnosis fee calculation fix."""
2
+
3
+ import unittest
4
+ from unittest.mock import MagicMock
5
+
6
+ from iwa.core.chain.interface import ChainInterface
7
+
8
+
9
+ class TestGnosisFeeFix(unittest.TestCase):
10
+ """Test fee calculation for Gnosis chain."""
11
+
12
+ def setUp(self):
13
+ """Set up test fixtures."""
14
+ self.chain_interface = ChainInterface("gnosis")
15
+ # Mock web3 to avoid real connection
16
+ self.chain_interface.web3 = MagicMock()
17
+ self.chain_interface.web3.eth = MagicMock()
18
+
19
+ def test_fee_too_low_fix(self):
20
+ """Test that maxPriorityFeePerGas is forced to at least 1 wei on Gnosis."""
21
+ # 1. Setup EIP-1559 environment (block has baseFeePerGas)
22
+ mock_block = {"baseFeePerGas": 5000}
23
+ self.chain_interface.web3.eth.get_block.return_value = mock_block
24
+
25
+ # 2. Simulate RPC returning 0 priority fee (cause of the error)
26
+ self.chain_interface.web3.eth.max_priority_fee = 0
27
+
28
+ # 3. Setup dummy function for gas estimation
29
+ mock_func = MagicMock()
30
+ mock_func.estimate_gas.return_value = 100_000
31
+
32
+ # 4. Call calculation
33
+ tx_params = {"from": "0x123", "value": 0}
34
+ params = self.chain_interface.calculate_transaction_params(mock_func, tx_params)
35
+
36
+ # 5. Verify the fix
37
+ # Should have EIP-1559 fields
38
+ self.assertIn("maxFeePerGas", params)
39
+ self.assertIn("maxPriorityFeePerGas", params)
40
+ self.assertNotIn("gasPrice", params)
41
+
42
+ # CRITICAL ASSERTION: maxPriorityFeePerGas must be >= 1
43
+ # If the fix works, it should be 1. If it fails (old behavior), it would be 0.
44
+ self.assertEqual(params["maxPriorityFeePerGas"], 1, "Priority fee should be forced to 1 wei")
45
+
46
+ # Verify max fee calculation: (base * 1.5) + priority
47
+ expected_max_fee = int(5000 * 1.5) + 1
48
+ self.assertEqual(params["maxFeePerGas"], expected_max_fee)
49
+
50
+ def test_legacy_fallback(self):
51
+ """Test fallback to legacy gasPrice if baseFeePerGas is missing."""
52
+ # Setup legacy block (no baseFeePerGas)
53
+ self.chain_interface.web3.eth.get_block.return_value = {}
54
+ self.chain_interface.web3.eth.gas_price = 2000000000
55
+
56
+ mock_func = MagicMock()
57
+ mock_func.estimate_gas.return_value = 100_000
58
+
59
+ tx_params = {"from": "0x123", "value": 0}
60
+ params = self.chain_interface.calculate_transaction_params(mock_func, tx_params)
61
+
62
+ self.assertIn("gasPrice", params)
63
+ self.assertNotIn("maxFeePerGas", params)
64
+ self.assertEqual(params["gasPrice"], 2000000000)
65
+
66
+ def test_other_chain_behavior(self):
67
+ """Test that other chains (e.g. Ethereum) don't necessarily upgrade 0 to 1 (unless generic rule applies)."""
68
+ # Our fix in interface.py applies the fallback logic:
69
+ # if max_priority_fee < 1: max_priority_fee = 1
70
+ # This is now generic in the cleaned up code (lines 449-450: if max_priority_fee < 1: max_priority_fee = 1)
71
+ # So it should apply to ALL chains that support EIP-1559.
72
+
73
+ # We'll use Ethereum to verify generic behavior
74
+ eth_interface = ChainInterface("ethereum")
75
+ eth_interface.web3 = MagicMock()
76
+ eth_interface.web3.eth = MagicMock()
77
+
78
+ mock_block = {"baseFeePerGas": 100_000}
79
+ eth_interface.web3.eth.get_block.return_value = mock_block
80
+ eth_interface.web3.eth.max_priority_fee = 0
81
+
82
+ mock_func = MagicMock()
83
+ mock_func.estimate_gas.return_value = 21000
84
+
85
+ params = eth_interface.calculate_transaction_params(mock_func, {"from": "0x123"})
86
+
87
+ self.assertEqual(params["maxPriorityFeePerGas"], 1, "Generic fallback should apply to all chains")
@@ -0,0 +1,85 @@
1
+ """Tests for IPFS module."""
2
+
3
+ from unittest.mock import ANY, MagicMock, patch
4
+
5
+ import pytest
6
+
7
+ import iwa.core.ipfs as ipfs_module
8
+ from iwa.core.ipfs import push_to_ipfs_sync
9
+
10
+
11
+ @pytest.fixture
12
+ def mock_config():
13
+ """Mock config."""
14
+ with patch("iwa.core.ipfs.Config") as mock_c:
15
+ mock_c.return_value.core.ipfs_api_url = "http://fake-ipfs:5001"
16
+ yield mock_c
17
+
18
+
19
+ @pytest.fixture
20
+ def mock_cid_decode():
21
+ """Mock CID.decode."""
22
+ with patch("iwa.core.ipfs.CID") as mock_cid:
23
+ # returns an object that has version, codec, hashfun.name, raw_digest attributes
24
+ mock_decoded = MagicMock()
25
+ mock_decoded.version = 1
26
+ mock_decoded.codec = "raw"
27
+ mock_decoded.hashfun.name = "sha2-256"
28
+ mock_decoded.raw_digest = b"digest"
29
+
30
+ mock_cid.decode.return_value = mock_decoded
31
+ yield mock_cid
32
+
33
+
34
+ def test_push_to_ipfs_sync_uses_session(mock_config, mock_cid_decode):
35
+ """Test push_to_ipfs_sync uses persistent session."""
36
+ # Reset global session for test
37
+ ipfs_module._SYNC_SESSION = None
38
+
39
+ # Mock requests.Session
40
+ with patch("requests.Session") as mock_session_cls:
41
+ mock_session = MagicMock()
42
+ mock_session_cls.return_value = mock_session
43
+
44
+ # Mock response
45
+ mock_response = MagicMock()
46
+ mock_response.status_code = 200
47
+ mock_response.json.return_value = {"Hash": "QmTestHash"}
48
+ mock_session.post.return_value = mock_response
49
+
50
+ # Call function
51
+ cid_str, cid_hex = push_to_ipfs_sync(b"test data")
52
+
53
+ # Verify Session was created
54
+ mock_session_cls.assert_called_once()
55
+
56
+ # Verify post called on session
57
+ mock_session.post.assert_called_once()
58
+
59
+ # Verify session is stored globally
60
+ assert ipfs_module._SYNC_SESSION == mock_session
61
+
62
+ # Verify second call reuses session
63
+ push_to_ipfs_sync(b"test data 2")
64
+ mock_session_cls.assert_called_once() # Should still be 1 call
65
+ assert mock_session.post.call_count == 2
66
+
67
+
68
+ def test_push_to_ipfs_sync_retry_config(mock_cid_decode):
69
+ """Test session matches retry config."""
70
+ # Reset
71
+ ipfs_module._SYNC_SESSION = None
72
+
73
+ with patch("requests.Session") as mock_session_cls:
74
+ mock_session = MagicMock()
75
+ mock_session_cls.return_value = mock_session
76
+ mock_session.post.return_value.status_code = 200
77
+ mock_session.post.return_value.json.return_value = {"Hash": "QmHash"}
78
+
79
+ push_to_ipfs_sync(b"data")
80
+
81
+ # Verify adapter mounting
82
+ # Since we mock the class return value, we check calls on the return value
83
+ assert mock_session.mount.call_count == 2
84
+ mock_session.mount.assert_any_call("https://", ANY)
85
+ mock_session.mount.assert_any_call("http://", ANY)
@@ -0,0 +1,65 @@
1
+ """Tests for Pricing module."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import pytest
6
+ import requests
7
+
8
+ from iwa.core.pricing import PriceService
9
+
10
+
11
+ @pytest.fixture
12
+ def mock_secrets():
13
+ """Mock secrets."""
14
+ with patch("iwa.core.pricing.secrets") as mock_s:
15
+ mock_s.coingecko_api_key.get_secret_value.return_value = "fake_key"
16
+ yield mock_s
17
+
18
+
19
+ @pytest.fixture
20
+ def price_service(mock_secrets):
21
+ """PriceService fixture."""
22
+ return PriceService()
23
+
24
+
25
+ def test_init_session(price_service):
26
+ """Test session initialization."""
27
+ assert isinstance(price_service.session, requests.Session)
28
+
29
+ # Verify adapters are mounted
30
+ assert "https://" in price_service.session.adapters
31
+ assert "http://" in price_service.session.adapters
32
+
33
+ # Verify retry configuration in adapter
34
+ adapter = price_service.session.adapters["https://"]
35
+ assert adapter.max_retries.total == 3
36
+ assert adapter.max_retries.status_forcelist == [429, 500, 502, 503, 504]
37
+
38
+
39
+ def test_close(price_service):
40
+ """Test close method."""
41
+ price_service.session = MagicMock()
42
+ price_service.close()
43
+ price_service.session.close.assert_called_once()
44
+
45
+
46
+ def test_get_token_price_uses_session(price_service):
47
+ """Test get_token_price uses session."""
48
+ price_service.session = MagicMock()
49
+
50
+ # Mock response
51
+ mock_response = MagicMock()
52
+ mock_response.status_code = 200
53
+ mock_response.json.return_value = {"autonolas": {"eur": 5.0}}
54
+ price_service.session.get.return_value = mock_response
55
+
56
+ price = price_service.get_token_price("autonolas", "eur")
57
+
58
+ assert price == 5.0
59
+ price_service.session.get.assert_called()
60
+
61
+ # Verify call args
62
+ args, kwargs = price_service.session.get.call_args
63
+ assert "api.coingecko.com" in args[0]
64
+ assert kwargs["params"]["ids"] == "autonolas"
65
+ assert kwargs["params"]["vs_currencies"] == "eur"
@@ -0,0 +1,100 @@
1
+ """Regression tests for recent fixes."""
2
+
3
+ import unittest
4
+ from pathlib import Path
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ from iwa.core.keys import KeyStorage
8
+ from iwa.core.models import EthereumAddress
9
+ from iwa.core.services.transaction import TransactionService
10
+
11
+
12
+ class TestRegressionFixes(unittest.TestCase):
13
+ """Test regression fixes for fees and serialization."""
14
+
15
+ def test_transaction_service_fee_autofill(self):
16
+ """Test that TransactionService auto-fills fees if missing."""
17
+ # 1. Setup mocks
18
+ mock_key_storage = MagicMock()
19
+ mock_account_service = MagicMock()
20
+
21
+ # Mock account resolution
22
+ mock_account = MagicMock()
23
+ mock_account.address = "0x1234567890123456789012345678901234567890"
24
+ mock_account.tag = "signer"
25
+ mock_account_service.resolve_account.return_value = mock_account
26
+
27
+ service = TransactionService(mock_key_storage, mock_account_service)
28
+
29
+ # 2. Mock ChainInterface to return specific fees
30
+ mock_chain_interface = MagicMock()
31
+ mock_chain_interface.chain.chain_id = 100
32
+ mock_chain_interface.web3.eth.get_transaction_count.return_value = 1
33
+
34
+ # This is what we are testing: get_suggested_fees() provides the safety net
35
+ mock_chain_interface.get_suggested_fees.return_value = {
36
+ "maxFeePerGas": 1500,
37
+ "maxPriorityFeePerGas": 10
38
+ }
39
+
40
+ with patch("iwa.core.services.transaction.ChainInterfaces") as mock_interfaces:
41
+ mock_interfaces.return_value.get.return_value = mock_chain_interface
42
+
43
+ # 3. Prepare transaction WITHOUT fees
44
+ tx = {
45
+ "to": "0x09312C66A14a024B4e903D986Ca7E2C0dDD06227",
46
+ "value": 1000,
47
+ "gas": 21000
48
+ }
49
+
50
+ # 4. Run internal preparation
51
+ service._prepare_transaction(tx, "signer", mock_chain_interface)
52
+
53
+ # 5. Verify fees were auto-filled
54
+ self.assertEqual(tx["maxFeePerGas"], 1500)
55
+ self.assertEqual(tx["maxPriorityFeePerGas"], 10)
56
+ self.assertIn("nonce", tx)
57
+ self.assertEqual(tx["chainId"], 100)
58
+
59
+ def test_key_storage_mode_json_serialization(self):
60
+ """Test that KeyStorage uses mode='json' to serialize EthereumAddress correctly."""
61
+ import tempfile
62
+
63
+ from iwa.core.models import StoredAccount
64
+
65
+ with tempfile.TemporaryDirectory() as tmp_dir:
66
+ tmp_path = Path(tmp_dir) / "wallet.json"
67
+
68
+ # 1. Initialize KeyStorage
69
+ storage = KeyStorage(path=tmp_path, password="test")
70
+
71
+ # 2. Add an account with a real EthereumAddress object
72
+ addr_str = "0x1234567890123456789012345678901234567890"
73
+ addr_obj = EthereumAddress(addr_str)
74
+ acc = StoredAccount(address=addr_obj, tag="test-tag")
75
+ storage.accounts = {addr_obj: acc}
76
+
77
+ # 3. Mock json.dump to see what's being written
78
+ with patch("json.dump") as mock_dump:
79
+ # We need to mock open() as well to prevent real file creation if not needed,
80
+ # but since we are in a temp dir, it's fine.
81
+ storage.save()
82
+
83
+ # 4. Capture the data passed to json.dump
84
+ self.assertTrue(mock_dump.called)
85
+ dumped_data = mock_dump.call_args[0][0]
86
+
87
+ # 5. Verify the address is a plain string in the dump
88
+ accounts = dumped_data["accounts"]
89
+
90
+ for key in accounts.keys():
91
+ # Key serialization in Pydantic v2 model_dump(mode='json')
92
+ self.assertIsInstance(key, str, f"Key {key} should be a string")
93
+ self.assertEqual(key.lower(), addr_str.lower())
94
+
95
+ # Also check the address field inside the value
96
+ self.assertIsInstance(accounts[key]["address"], str)
97
+ self.assertEqual(accounts[key]["address"].lower(), addr_str.lower())
98
+
99
+ if __name__ == "__main__":
100
+ unittest.main()
iwa/core/wallet.py CHANGED
@@ -1,13 +1,13 @@
1
1
  """Wallet module."""
2
2
 
3
3
  from concurrent.futures import ThreadPoolExecutor
4
- from typing import List, Optional, Tuple
4
+ from typing import List, Optional, Tuple, Union
5
5
 
6
6
  from web3.types import Wei
7
7
 
8
8
  from iwa.core.chain import SupportedChain
9
9
  from iwa.core.db import init_db
10
- from iwa.core.keys import KeyStorage
10
+ from iwa.core.keys import EncryptedAccount, KeyStorage
11
11
  from iwa.core.models import EthereumAddress, StoredSafeAccount
12
12
  from iwa.core.services import (
13
13
  AccountService,
@@ -51,7 +51,7 @@ class Wallet:
51
51
  init_db()
52
52
 
53
53
  @property
54
- def master_account(self) -> Optional[StoredSafeAccount]:
54
+ def master_account(self) -> Optional[Union[EncryptedAccount, StoredSafeAccount]]:
55
55
  """Get master account"""
56
56
  return self.account_service.master_account
57
57
 
@@ -33,7 +33,7 @@ async def get_max_sell_amount_wei(
33
33
  safe_address: ChecksumAddress | None = None,
34
34
  app_data: str | None = None,
35
35
  env: "Envs" = "prod",
36
- slippage_tolerance: float = 0.005,
36
+ slippage_tolerance: float = 0.015,
37
37
  ) -> int:
38
38
  """Calculate the estimated sell amount needed to buy a fixed amount of tokens."""
39
39
  if app_data is None:
@@ -95,7 +95,7 @@ async def get_max_buy_amount_wei(
95
95
  safe_address: ChecksumAddress | None = None,
96
96
  app_data: str | None = None,
97
97
  env: "Envs" = "prod",
98
- slippage_tolerance: float = 0.005,
98
+ slippage_tolerance: float = 0.015,
99
99
  ) -> int:
100
100
  """Calculate the maximum buy amount for a given sell amount."""
101
101
  if app_data is None:
@@ -96,13 +96,9 @@ class CowSwap:
96
96
 
97
97
  logger.info(f"Checking order status for UID: {order.uid}")
98
98
 
99
- max_retries = 6
100
- sleep_between_retries = 25
101
- retries = 0
102
-
103
- while retries < max_retries:
104
- retries += 1
99
+ sleep_between_retries = 15
105
100
 
101
+ while True:
106
102
  try:
107
103
  # Use a thread executor for blocking requests.get
108
104
  loop = asyncio.get_event_loop()
@@ -116,47 +112,37 @@ class CowSwap:
116
112
 
117
113
  if response.status_code != HTTP_OK:
118
114
  logger.debug(
119
- f"Order status check {retries}/{max_retries}: HTTP {response.status_code}. Retry in {sleep_between_retries}s"
115
+ f"Order status check: HTTP {response.status_code}. Retry in {sleep_between_retries}s"
120
116
  )
121
117
  await asyncio.sleep(sleep_between_retries)
122
118
  continue
123
119
 
124
120
  order_data = response.json()
125
-
126
121
  status = order_data.get("status", "unknown")
127
122
  valid_to = int(order_data.get("validTo", 0))
128
123
  current_time = int(time.time())
129
124
 
130
- if status == "expired" or (valid_to > 0 and current_time > valid_to):
131
- logger.error(
132
- f"Order expired without execution (Status: {status}, ValidTo: {valid_to}, Now: {current_time})."
133
- )
134
- return None
135
-
125
+ # Log current status
136
126
  executed_sell = int(order_data.get("executedSellAmount", "0"))
137
127
  executed_buy = int(order_data.get("executedBuyAmount", "0"))
138
128
 
139
- if executed_sell > 0 or executed_buy > 0:
129
+ if executed_sell > 0 or executed_buy > 0 or status == "fulfilled":
140
130
  logger.info("Order executed successfully.")
141
- sell_price = order_data.get("quote", {}).get("sellTokenPrice", None)
142
- buy_price = order_data.get("quote", {}).get("buyTokenPrice", None)
143
-
144
- if sell_price is not None:
145
- logger.debug(f"Sell price: ${float(sell_price):.2f}")
131
+ return order_data
146
132
 
147
- if buy_price is not None:
148
- logger.debug(f"Buy price: ${float(buy_price):.2f}")
133
+ if status in ["expired", "cancelled"]:
134
+ logger.error(f"Order {status} without execution.")
135
+ return None
149
136
 
150
- return order_data
137
+ if valid_to > 0 and current_time > valid_to + 60:
138
+ logger.error(
139
+ f"Order timeout: current time {current_time} exceeded valid_to {valid_to} by >60s."
140
+ )
141
+ return None
151
142
 
152
- logger.info(
153
- f"Order pending... ({retries}/{max_retries}). Retry in {sleep_between_retries}s"
154
- )
143
+ logger.info(f"Order status: {status}. Waiting {sleep_between_retries}s...")
155
144
  await asyncio.sleep(sleep_between_retries)
156
145
 
157
- logger.warning("Max retries reached. Order status unknown.")
158
- return None
159
-
160
146
  async def swap(
161
147
  self,
162
148
  amount_wei: Wei,
@@ -255,7 +241,7 @@ class CowSwap:
255
241
  safe_address: ChecksumAddress | None = None,
256
242
  app_data: str | None = None,
257
243
  env: "Envs" = "prod",
258
- slippage_tolerance: float = 0.005,
244
+ slippage_tolerance: float = 0.015,
259
245
  ) -> int:
260
246
  """Calculate the estimated sell amount needed to buy a fixed amount of tokens."""
261
247
  return await get_max_sell_amount_wei(
@@ -278,7 +264,7 @@ class CowSwap:
278
264
  safe_address: ChecksumAddress | None = None,
279
265
  app_data: str | None = None,
280
266
  env: "Envs" = "prod",
281
- slippage_tolerance: float = 0.005,
267
+ slippage_tolerance: float = 0.015,
282
268
  ) -> int:
283
269
  """Calculate the maximum buy amount for a given sell amount."""
284
270
  return await get_max_buy_amount_wei(
@@ -304,7 +290,7 @@ class CowSwap:
304
290
  app_data: str | None = None,
305
291
  valid_to: int | None = None,
306
292
  env: "Envs" = "prod",
307
- slippage_tolerance: float = 0.005,
293
+ slippage_tolerance: float = 0.015,
308
294
  partially_fillable: bool = False,
309
295
  ) -> "CompletedOrder":
310
296
  """Execute a 'Buy' order (Exact Output) on CoW Protocol."""
@@ -89,8 +89,8 @@ def test_init(cowswap, mock_chain):
89
89
  async def test_get_max_sell_amount_wei(cowswap, mock_cowpy_modules):
90
90
  """Test get_max_sell_amount_wei."""
91
91
  amount = await cowswap.get_max_sell_amount_wei(100, "0xSell", "0xBuy")
92
- # mocked sellAmount root is "100", slippage is 0.005 default -> 100 * 1.005 = 100
93
- assert amount == 100
92
+ # mocked sellAmount root is "100", slippage is 0.015 default -> 100 * 1.015 = 101.5 -> int 101
93
+ assert amount == 101
94
94
  mock_cowpy_modules["get_order_quote"].assert_called_once()
95
95
 
96
96
 
@@ -98,8 +98,8 @@ async def test_get_max_sell_amount_wei(cowswap, mock_cowpy_modules):
98
98
  async def test_get_max_buy_amount_wei(cowswap, mock_cowpy_modules):
99
99
  """Test get_max_buy_amount_wei."""
100
100
  amount = await cowswap.get_max_buy_amount_wei(100, "0xSell", "0xBuy")
101
- # mocked buyAmount root is "90", slippage 0.005 -> 90 * 0.995 = 89.55 -> int 89
102
- assert amount == 89
101
+ # mocked buyAmount root is "90", slippage 0.015 -> 90 * 0.985 = 88.65 -> int 88
102
+ assert amount == 88
103
103
  mock_cowpy_modules["get_order_quote"].assert_called_once()
104
104
 
105
105
 
@@ -213,15 +213,24 @@ async def test_check_cowswap_order_expired(cowswap):
213
213
 
214
214
  @pytest.mark.asyncio
215
215
  async def test_check_cowswap_order_timeout(cowswap):
216
- """Test check_cowswap_order timeout."""
216
+ """Test check_cowswap_order timeout after exceeding valid_to."""
217
217
  mock_order = MagicMock()
218
218
  mock_order.url = "http://api/order"
219
219
 
220
220
  with patch("requests.get") as mock_get:
221
221
  mock_get.return_value.status_code = 200
222
- mock_get.return_value.json.return_value = {"status": "open", "executedSellAmount": "0"}
222
+ # Order is always open
223
+ mock_get.return_value.json.return_value = {
224
+ "status": "open",
225
+ "executedSellAmount": "0",
226
+ "validTo": 1000,
227
+ }
223
228
 
224
- # Speed up retry sleep (asyncio.sleep)
225
- with patch("asyncio.sleep", new_callable=AsyncMock):
226
- result = await cowswap.check_cowswap_order(mock_order)
227
- assert result is None
229
+ # Mock time to start at 900 and then jump to 1100 to trigger timeout
230
+ with patch("time.time") as mock_time:
231
+ mock_time.side_effect = [900, 1100]
232
+ # Speed up retry sleep (asyncio.sleep)
233
+ with patch("asyncio.sleep", new_callable=AsyncMock):
234
+ result = await cowswap.check_cowswap_order(mock_order)
235
+ assert result is None
236
+ assert mock_time.call_count >= 2
@@ -767,7 +767,7 @@ class OlasServiceImporter:
767
767
  safe_result = self._import_safe(
768
768
  address=service.safe_address,
769
769
  signers=self._get_agent_signers(service),
770
- tag_suffix="safe", # e.g. trader_zeta_safe
770
+ tag_suffix="multisig", # e.g. trader_zeta_safe
771
771
  service_name=service.service_name
772
772
  )
773
773
  if safe_result[0]:
@@ -785,7 +785,7 @@ class OlasServiceImporter:
785
785
  safe_result = self._import_safe(
786
786
  address=service.service_owner_multisig_address,
787
787
  signers=owner_signers,
788
- tag_suffix="owner_safe", # e.g. trader_zeta_owner_safe
788
+ tag_suffix="owner_multisig", # e.g. trader_zeta_owner_safe
789
789
  service_name=service.service_name
790
790
  )
791
791
  if safe_result[0]:
@@ -878,8 +878,7 @@ class OlasServiceImporter:
878
878
  self.key_storage._password,
879
879
  tag,
880
880
  )
881
- self.key_storage.accounts[encrypted.address] = encrypted
882
- self.key_storage.save()
881
+ self.key_storage.register_account(encrypted)
883
882
  logger.info(f"Imported key {key.address} as '{tag}'")
884
883
  return True, "ok"
885
884
  except Exception as e:
@@ -926,7 +925,7 @@ class OlasServiceImporter:
926
925
  self,
927
926
  address: str,
928
927
  signers: List[str] = None,
929
- tag_suffix: str = "safe",
928
+ tag_suffix: str = "multisig",
930
929
  service_name: Optional[str] = None
931
930
  ) -> Tuple[bool, str]:
932
931
  """Import a generic Safe."""
@@ -960,8 +959,7 @@ class OlasServiceImporter:
960
959
  signers=signers or [],
961
960
  )
962
961
 
963
- self.key_storage.accounts[address] = safe_account
964
- self.key_storage.save()
962
+ self.key_storage.register_account(safe_account)
965
963
  logger.info(f"Imported Safe {address} as '{tag}'")
966
964
  return True, "ok"
967
965