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.
- 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.32.dist-info → iwa-0.0.58.dist-info}/METADATA +6 -3
- {iwa-0.0.32.dist-info → iwa-0.0.58.dist-info}/RECORD +64 -54
- {iwa-0.0.32.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.32.dist-info → iwa-0.0.58.dist-info}/entry_points.txt +0 -0
- {iwa-0.0.32.dist-info → iwa-0.0.58.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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(
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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)
|
|
@@ -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
|
|
iwa/plugins/gnosis/cow/quotes.py
CHANGED
|
@@ -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.
|
|
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.
|
|
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:
|
iwa/plugins/gnosis/cow/swap.py
CHANGED
|
@@ -96,13 +96,9 @@ class CowSwap:
|
|
|
96
96
|
|
|
97
97
|
logger.info(f"Checking order status for UID: {order.uid}")
|
|
98
98
|
|
|
99
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
133
|
+
if status in ["expired", "cancelled"]:
|
|
134
|
+
logger.error(f"Order {status} without execution.")
|
|
135
|
+
return None
|
|
149
136
|
|
|
150
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
93
|
-
assert amount ==
|
|
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.
|
|
102
|
-
assert amount ==
|
|
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
|
-
|
|
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
|
-
#
|
|
225
|
-
with patch("
|
|
226
|
-
|
|
227
|
-
|
|
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
|
iwa/plugins/olas/importer.py
CHANGED
|
@@ -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="
|
|
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="
|
|
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.
|
|
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 = "
|
|
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.
|
|
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
|
|