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.
- iwa/core/chain/interface.py +130 -11
- iwa/core/chain/models.py +15 -3
- iwa/core/chain/rate_limiter.py +48 -12
- iwa/core/chainlist.py +15 -10
- iwa/core/cli.py +4 -1
- iwa/core/contracts/cache.py +1 -1
- iwa/core/contracts/contract.py +1 -0
- iwa/core/contracts/decoder.py +10 -4
- iwa/core/http.py +31 -0
- iwa/core/ipfs.py +21 -7
- iwa/core/keys.py +65 -15
- iwa/core/models.py +58 -13
- iwa/core/pricing.py +10 -6
- iwa/core/rpc_monitor.py +1 -0
- iwa/core/secrets.py +27 -0
- iwa/core/services/account.py +1 -1
- iwa/core/services/balance.py +0 -23
- iwa/core/services/safe.py +72 -45
- iwa/core/services/safe_executor.py +350 -0
- iwa/core/services/transaction.py +43 -13
- iwa/core/services/transfer/erc20.py +14 -3
- iwa/core/services/transfer/native.py +14 -31
- iwa/core/services/transfer/swap.py +1 -0
- iwa/core/tests/test_gnosis_fee.py +91 -0
- iwa/core/tests/test_ipfs.py +85 -0
- iwa/core/tests/test_pricing.py +65 -0
- iwa/core/tests/test_regression_fixes.py +97 -0
- iwa/core/utils.py +2 -0
- iwa/core/wallet.py +6 -4
- 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/constants.py +15 -5
- iwa/plugins/olas/contracts/activity_checker.py +3 -3
- iwa/plugins/olas/contracts/staking.py +0 -1
- iwa/plugins/olas/events.py +15 -13
- iwa/plugins/olas/importer.py +29 -25
- iwa/plugins/olas/models.py +0 -3
- iwa/plugins/olas/plugin.py +16 -14
- iwa/plugins/olas/service_manager/drain.py +16 -9
- iwa/plugins/olas/service_manager/lifecycle.py +23 -12
- iwa/plugins/olas/service_manager/staking.py +15 -10
- iwa/plugins/olas/tests/test_importer_error_handling.py +13 -0
- iwa/plugins/olas/tests/test_olas_archiving.py +83 -0
- iwa/plugins/olas/tests/test_olas_integration.py +49 -29
- iwa/plugins/olas/tests/test_olas_view.py +5 -1
- iwa/plugins/olas/tests/test_service_manager.py +15 -17
- iwa/plugins/olas/tests/test_service_manager_errors.py +6 -5
- iwa/plugins/olas/tests/test_service_manager_flows.py +7 -6
- iwa/plugins/olas/tests/test_service_manager_rewards.py +5 -1
- iwa/plugins/olas/tests/test_service_staking.py +64 -38
- iwa/tools/drain_accounts.py +61 -0
- iwa/tools/list_contracts.py +2 -0
- iwa/tools/reset_env.py +2 -1
- iwa/tools/test_chainlist.py +5 -1
- iwa/tui/screens/wallets.py +2 -4
- iwa/web/routers/accounts.py +1 -1
- iwa/web/routers/olas/services.py +10 -5
- 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.59.dist-info}/METADATA +6 -3
- {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/RECORD +82 -71
- {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/WHEEL +1 -1
- tests/test_balance_service.py +0 -43
- tests/test_chain.py +13 -5
- 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 +103 -0
- tests/test_rpc_efficiency.py +4 -1
- tests/test_rpc_rate_limit.py +34 -0
- tests/test_rpc_rotation.py +59 -11
- tests/test_safe_coverage.py +37 -23
- tests/test_safe_executor.py +361 -0
- tests/test_safe_integration.py +153 -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.59.dist-info}/entry_points.txt +0 -0
- {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/licenses/LICENSE +0 -0
- {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/top_level.txt +0 -0
|
@@ -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,97 @@
|
|
|
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 = {"to": "0x09312C66A14a024B4e903D986Ca7E2C0dDD06227", "value": 1000, "gas": 21000}
|
|
45
|
+
|
|
46
|
+
# 4. Run internal preparation
|
|
47
|
+
service._prepare_transaction(tx, "signer", mock_chain_interface)
|
|
48
|
+
|
|
49
|
+
# 5. Verify fees were auto-filled
|
|
50
|
+
self.assertEqual(tx["maxFeePerGas"], 1500)
|
|
51
|
+
self.assertEqual(tx["maxPriorityFeePerGas"], 10)
|
|
52
|
+
self.assertIn("nonce", tx)
|
|
53
|
+
self.assertEqual(tx["chainId"], 100)
|
|
54
|
+
|
|
55
|
+
def test_key_storage_mode_json_serialization(self):
|
|
56
|
+
"""Test that KeyStorage uses mode='json' to serialize EthereumAddress correctly."""
|
|
57
|
+
import tempfile
|
|
58
|
+
|
|
59
|
+
from iwa.core.models import StoredAccount
|
|
60
|
+
|
|
61
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
62
|
+
tmp_path = Path(tmp_dir) / "wallet.json"
|
|
63
|
+
|
|
64
|
+
# 1. Initialize KeyStorage
|
|
65
|
+
storage = KeyStorage(path=tmp_path, password="test")
|
|
66
|
+
|
|
67
|
+
# 2. Add an account with a real EthereumAddress object
|
|
68
|
+
addr_str = "0x1234567890123456789012345678901234567890"
|
|
69
|
+
addr_obj = EthereumAddress(addr_str)
|
|
70
|
+
acc = StoredAccount(address=addr_obj, tag="test-tag")
|
|
71
|
+
storage.accounts = {addr_obj: acc}
|
|
72
|
+
|
|
73
|
+
# 3. Mock json.dump to see what's being written
|
|
74
|
+
with patch("json.dump") as mock_dump:
|
|
75
|
+
# We need to mock open() as well to prevent real file creation if not needed,
|
|
76
|
+
# but since we are in a temp dir, it's fine.
|
|
77
|
+
storage.save()
|
|
78
|
+
|
|
79
|
+
# 4. Capture the data passed to json.dump
|
|
80
|
+
self.assertTrue(mock_dump.called)
|
|
81
|
+
dumped_data = mock_dump.call_args[0][0]
|
|
82
|
+
|
|
83
|
+
# 5. Verify the address is a plain string in the dump
|
|
84
|
+
accounts = dumped_data["accounts"]
|
|
85
|
+
|
|
86
|
+
for key in accounts.keys():
|
|
87
|
+
# Key serialization in Pydantic v2 model_dump(mode='json')
|
|
88
|
+
self.assertIsInstance(key, str, f"Key {key} should be a string")
|
|
89
|
+
self.assertEqual(key.lower(), addr_str.lower())
|
|
90
|
+
|
|
91
|
+
# Also check the address field inside the value
|
|
92
|
+
self.assertIsInstance(accounts[key]["address"], str)
|
|
93
|
+
self.assertEqual(accounts[key]["address"].lower(), addr_str.lower())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
unittest.main()
|
iwa/core/utils.py
CHANGED
|
@@ -85,9 +85,11 @@ def configure_logger():
|
|
|
85
85
|
configure_logger.configured = True
|
|
86
86
|
return logger
|
|
87
87
|
|
|
88
|
+
|
|
88
89
|
def get_version(package_name: str) -> str:
|
|
89
90
|
"""Get package version."""
|
|
90
91
|
from importlib.metadata import PackageNotFoundError, version
|
|
92
|
+
|
|
91
93
|
try:
|
|
92
94
|
return version(package_name)
|
|
93
95
|
except PackageNotFoundError:
|
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,
|
|
@@ -37,7 +37,9 @@ class Wallet:
|
|
|
37
37
|
self.balance_service = BalanceService(self.key_storage, self.account_service)
|
|
38
38
|
self.safe_service = SafeService(self.key_storage, self.account_service)
|
|
39
39
|
# self.transaction_manager = TransactionManager(self.key_storage, self.account_service)
|
|
40
|
-
self.transaction_service = TransactionService(
|
|
40
|
+
self.transaction_service = TransactionService(
|
|
41
|
+
self.key_storage, self.account_service, self.safe_service
|
|
42
|
+
)
|
|
41
43
|
|
|
42
44
|
self.transfer_service = TransferService(
|
|
43
45
|
self.key_storage,
|
|
@@ -51,7 +53,7 @@ class Wallet:
|
|
|
51
53
|
init_db()
|
|
52
54
|
|
|
53
55
|
@property
|
|
54
|
-
def master_account(self) -> Optional[StoredSafeAccount]:
|
|
56
|
+
def master_account(self) -> Optional[Union[EncryptedAccount, StoredSafeAccount]]:
|
|
55
57
|
"""Get master account"""
|
|
56
58
|
return self.account_service.master_account
|
|
57
59
|
|
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/constants.py
CHANGED
|
@@ -92,8 +92,12 @@ OLAS_CONTRACTS: Dict[str, Dict[str, EthereumAddress]] = {
|
|
|
92
92
|
OLAS_TRADER_STAKING_CONTRACTS: Dict[str, Dict[str, EthereumAddress]] = {
|
|
93
93
|
"gnosis": {
|
|
94
94
|
# === LEGACY (no marketplace) ===
|
|
95
|
-
"Hobbyist 1 Legacy (100 OLAS)": EthereumAddress(
|
|
96
|
-
|
|
95
|
+
"Hobbyist 1 Legacy (100 OLAS)": EthereumAddress(
|
|
96
|
+
"0x389B46C259631Acd6a69Bde8B6cEe218230bAE8C"
|
|
97
|
+
),
|
|
98
|
+
"Hobbyist 2 Legacy (500 OLAS)": EthereumAddress(
|
|
99
|
+
"0x238EB6993b90A978ec6AAD7530D6429c949C08DA"
|
|
100
|
+
),
|
|
97
101
|
"Expert Legacy (1k OLAS)": EthereumAddress("0x5344B7DD311e5d3DdDd46A4f71481Bd7b05AAA3e"),
|
|
98
102
|
"Expert 2 Legacy (1k OLAS)": EthereumAddress("0xb964e44c126410df341ae04B13aB10A985fE3513"),
|
|
99
103
|
"Expert 3 Legacy (2k OLAS)": EthereumAddress("0x80faD33Cadb5F53f9D29F02Db97D682E8B101618"),
|
|
@@ -103,9 +107,15 @@ OLAS_TRADER_STAKING_CONTRACTS: Dict[str, Dict[str, EthereumAddress]] = {
|
|
|
103
107
|
"Expert 7 Legacy (10k OLAS)": EthereumAddress("0xD7A3C8b975f71030135f1a66E9e23164d54fF455"),
|
|
104
108
|
"Expert 8 Legacy (2k OLAS)": EthereumAddress("0x356C108D49C5eebd21c84c04E9162de41933030c"),
|
|
105
109
|
"Expert 9 Legacy (10k OLAS)": EthereumAddress("0x17dBAe44BC5618Cc254055B386A29576b4F87015"),
|
|
106
|
-
"Expert 10 Legacy (10k OLAS)": EthereumAddress(
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
"Expert 10 Legacy (10k OLAS)": EthereumAddress(
|
|
111
|
+
"0xB0ef657b8302bd2c74B6E6D9B2b4b39145b19c6f"
|
|
112
|
+
),
|
|
113
|
+
"Expert 11 Legacy (10k OLAS)": EthereumAddress(
|
|
114
|
+
"0x3112c1613eAC3dBAE3D4E38CeF023eb9E2C91CF7"
|
|
115
|
+
),
|
|
116
|
+
"Expert 12 Legacy (10k OLAS)": EthereumAddress(
|
|
117
|
+
"0xF4a75F476801B3fBB2e7093aCDcc3576593Cc1fc"
|
|
118
|
+
),
|
|
109
119
|
# === MM v1 (old marketplace 0x4554fE75...) ===
|
|
110
120
|
"Expert 15 MM v1 (10k OLAS)": EthereumAddress("0x88eB38FF79fBa8C19943C0e5Acfa67D5876AdCC1"),
|
|
111
121
|
"Expert 16 MM v1 (10k OLAS)": EthereumAddress("0x6c65430515c70a3f5E62107CC301685B7D46f991"),
|
|
@@ -48,7 +48,6 @@ class ActivityCheckerContract(ContractInstance):
|
|
|
48
48
|
self._agent_mech: Optional[EthereumAddress] = None
|
|
49
49
|
self._liveness_ratio: Optional[int] = None
|
|
50
50
|
|
|
51
|
-
|
|
52
51
|
def get_multisig_nonces(self, multisig: EthereumAddress) -> Tuple[int, int]:
|
|
53
52
|
"""Get the nonces for a multisig address.
|
|
54
53
|
|
|
@@ -64,7 +63,6 @@ class ActivityCheckerContract(ContractInstance):
|
|
|
64
63
|
nonces = self.contract.functions.getMultisigNonces(multisig).call()
|
|
65
64
|
return (nonces[0], nonces[1])
|
|
66
65
|
|
|
67
|
-
|
|
68
66
|
@property
|
|
69
67
|
def mech_marketplace(self) -> Optional[EthereumAddress]:
|
|
70
68
|
"""Get the mech marketplace address."""
|
|
@@ -83,7 +81,9 @@ class ActivityCheckerContract(ContractInstance):
|
|
|
83
81
|
try:
|
|
84
82
|
agent_mech_function = getattr(self.contract.functions, "agentMech", None)
|
|
85
83
|
self._agent_mech = (
|
|
86
|
-
agent_mech_function().call()
|
|
84
|
+
agent_mech_function().call()
|
|
85
|
+
if agent_mech_function
|
|
86
|
+
else DEFAULT_MECH_CONTRACT_ADDRESS
|
|
87
87
|
)
|
|
88
88
|
except Exception:
|
|
89
89
|
self._agent_mech = DEFAULT_MECH_CONTRACT_ADDRESS
|
|
@@ -82,7 +82,6 @@ class StakingContract(ContractInstance):
|
|
|
82
82
|
self._activity_checker: Optional[ActivityCheckerContract] = None
|
|
83
83
|
self._activity_checker_address: Optional[EthereumAddress] = None
|
|
84
84
|
|
|
85
|
-
|
|
86
85
|
def get_requirements(self) -> Dict[str, Union[str, int]]:
|
|
87
86
|
"""Get the contract requirements for token and deposits.
|
|
88
87
|
|
iwa/plugins/olas/events.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""Event-based cache invalidation for Olas contracts."""
|
|
2
2
|
|
|
3
|
-
|
|
4
3
|
from loguru import logger
|
|
5
4
|
|
|
6
5
|
from iwa.core.contracts.cache import ContractCache
|
|
@@ -75,7 +74,7 @@ class OlasEventInvalidator:
|
|
|
75
74
|
except Exception as e:
|
|
76
75
|
logger.error(f"Error in OlasEventInvalidator: {e}")
|
|
77
76
|
|
|
78
|
-
time.sleep(10)
|
|
77
|
+
time.sleep(10) # check every 10 seconds
|
|
79
78
|
|
|
80
79
|
def _check_events(self, from_block: int, to_block: int):
|
|
81
80
|
"""Check for relevant events in the block range."""
|
|
@@ -101,14 +100,19 @@ class OlasEventInvalidator:
|
|
|
101
100
|
StakingContract, self.staking_addresses[0], self.chain_name
|
|
102
101
|
)
|
|
103
102
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
103
|
+
logs = self.web3.eth.get_logs(
|
|
104
|
+
{
|
|
105
|
+
"fromBlock": from_block,
|
|
106
|
+
"toBlock": to_block,
|
|
107
|
+
"address": self.staking_addresses,
|
|
108
|
+
"topics": [
|
|
109
|
+
self.web3.keccak(
|
|
110
|
+
text="Checkpoint(uint256,uint256,uint256,uint256,uint256,uint256,uint256,uint256)"
|
|
111
|
+
).hex()
|
|
112
|
+
],
|
|
113
|
+
# Note: signature might vary, safer to use the event object if ABI allows
|
|
114
|
+
}
|
|
115
|
+
)
|
|
112
116
|
|
|
113
117
|
# If we used the contract event object to filter, it handles the topic generation:
|
|
114
118
|
# logs = checkpoint_event_abi.get_logs(fromBlock=from_block, toBlock=to_block)
|
|
@@ -130,9 +134,7 @@ class OlasEventInvalidator:
|
|
|
130
134
|
# self.contract_cache.invalidate(StakingContract, addr, self.chain_name)
|
|
131
135
|
|
|
132
136
|
# Option B: Get instance and clear specific cache (safe public access)
|
|
133
|
-
instance = self.contract_cache.get_if_cached(
|
|
134
|
-
StakingContract, addr, self.chain_name
|
|
135
|
-
)
|
|
137
|
+
instance = self.contract_cache.get_if_cached(StakingContract, addr, self.chain_name)
|
|
136
138
|
if instance:
|
|
137
139
|
instance.clear_epoch_cache()
|
|
138
140
|
logger.debug(f"Cleared epoch cache for {addr}")
|