iwa 0.0.2__py3-none-any.whl → 0.0.11__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 +51 -30
- iwa/core/chain/models.py +9 -15
- iwa/core/contracts/contract.py +8 -2
- iwa/core/pricing.py +10 -8
- iwa/core/services/safe.py +13 -8
- iwa/core/services/transaction.py +211 -7
- iwa/core/utils.py +22 -0
- iwa/core/wallet.py +2 -1
- iwa/plugins/gnosis/safe.py +4 -3
- iwa/plugins/gnosis/tests/test_safe.py +9 -7
- iwa/plugins/olas/contracts/abis/service_registry_token_utility.json +926 -0
- iwa/plugins/olas/contracts/service.py +54 -4
- iwa/plugins/olas/contracts/staking.py +2 -3
- iwa/plugins/olas/plugin.py +14 -7
- iwa/plugins/olas/service_manager/lifecycle.py +382 -85
- iwa/plugins/olas/service_manager/mech.py +1 -1
- iwa/plugins/olas/service_manager/staking.py +229 -82
- iwa/plugins/olas/tests/test_olas_contracts.py +6 -2
- iwa/plugins/olas/tests/test_plugin.py +6 -1
- iwa/plugins/olas/tests/test_plugin_full.py +12 -7
- iwa/plugins/olas/tests/test_service_lifecycle.py +1 -4
- iwa/plugins/olas/tests/test_service_manager.py +59 -89
- iwa/plugins/olas/tests/test_service_manager_errors.py +1 -2
- iwa/plugins/olas/tests/test_service_manager_flows.py +5 -15
- iwa/plugins/olas/tests/test_service_manager_validation.py +16 -15
- iwa/tools/list_contracts.py +2 -2
- iwa/web/dependencies.py +1 -3
- iwa/web/routers/accounts.py +1 -2
- iwa/web/routers/olas/admin.py +1 -3
- iwa/web/routers/olas/funding.py +1 -3
- iwa/web/routers/olas/general.py +1 -3
- iwa/web/routers/olas/services.py +53 -21
- iwa/web/routers/olas/staking.py +27 -24
- iwa/web/routers/swap.py +1 -2
- iwa/web/routers/transactions.py +0 -2
- iwa/web/server.py +8 -6
- iwa/web/static/app.js +22 -0
- iwa/web/tests/test_web_endpoints.py +1 -1
- iwa/web/tests/test_web_olas.py +1 -1
- {iwa-0.0.2.dist-info → iwa-0.0.11.dist-info}/METADATA +1 -1
- {iwa-0.0.2.dist-info → iwa-0.0.11.dist-info}/RECORD +58 -56
- tests/test_chain.py +12 -7
- tests/test_chain_interface_coverage.py +3 -2
- tests/test_contract.py +165 -0
- tests/test_keys.py +2 -1
- tests/test_legacy_wallet.py +11 -0
- tests/test_pricing.py +32 -15
- tests/test_safe_coverage.py +3 -3
- tests/test_safe_service.py +3 -6
- tests/test_service_transaction.py +8 -3
- tests/test_staking_router.py +6 -3
- tests/test_transaction_service.py +4 -0
- tools/create_and_stake_service.py +103 -0
- tools/verify_drain.py +1 -4
- {iwa-0.0.2.dist-info → iwa-0.0.11.dist-info}/WHEEL +0 -0
- {iwa-0.0.2.dist-info → iwa-0.0.11.dist-info}/entry_points.txt +0 -0
- {iwa-0.0.2.dist-info → iwa-0.0.11.dist-info}/licenses/LICENSE +0 -0
- {iwa-0.0.2.dist-info → iwa-0.0.11.dist-info}/top_level.txt +0 -0
iwa/core/chain/interface.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""ChainInterface class for blockchain interactions."""
|
|
2
2
|
|
|
3
|
+
import threading
|
|
3
4
|
import time
|
|
4
5
|
from typing import Callable, Dict, Optional, Tuple, TypeVar, Union
|
|
5
6
|
|
|
@@ -45,6 +46,7 @@ class ChainInterface:
|
|
|
45
46
|
)
|
|
46
47
|
|
|
47
48
|
self._initial_block = 0
|
|
49
|
+
self._rotation_lock = threading.Lock()
|
|
48
50
|
self._init_web3()
|
|
49
51
|
|
|
50
52
|
@property
|
|
@@ -154,18 +156,6 @@ class ChainInterface:
|
|
|
154
156
|
print("╚══════════════════════════════════════════════════╝")
|
|
155
157
|
print("")
|
|
156
158
|
|
|
157
|
-
def _init_web3(self):
|
|
158
|
-
"""Initialize Web3 with current RPC."""
|
|
159
|
-
rpc_url = self.chain.rpcs[self._current_rpc_index] if self.chain.rpcs else ""
|
|
160
|
-
raw_web3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": DEFAULT_RPC_TIMEOUT}))
|
|
161
|
-
|
|
162
|
-
# Use duck typing to check if current web3 is a RateLimitedWeb3 wrapper
|
|
163
|
-
# (isinstance check fails when RateLimitedWeb3 is mocked in tests)
|
|
164
|
-
if hasattr(self, "web3") and hasattr(self.web3, "set_backend"):
|
|
165
|
-
self.web3.set_backend(raw_web3)
|
|
166
|
-
else:
|
|
167
|
-
self.web3 = RateLimitedWeb3(raw_web3, self._rate_limiter, self)
|
|
168
|
-
|
|
169
159
|
def _is_rate_limit_error(self, error: Exception) -> bool:
|
|
170
160
|
"""Check if error is a rate limit (429) error."""
|
|
171
161
|
err_text = str(error).lower()
|
|
@@ -269,19 +259,36 @@ class ChainInterface:
|
|
|
269
259
|
|
|
270
260
|
def rotate_rpc(self) -> bool:
|
|
271
261
|
"""Rotate to the next available RPC."""
|
|
272
|
-
|
|
273
|
-
|
|
262
|
+
with self._rotation_lock:
|
|
263
|
+
if not self.chain.rpcs or len(self.chain.rpcs) <= 1:
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
# Simple Round Robin rotation
|
|
267
|
+
self._current_rpc_index = (self._current_rpc_index + 1) % len(self.chain.rpcs)
|
|
268
|
+
# Internal call to _init_web3 already expects to be under lock if called from here,
|
|
269
|
+
# but _init_web3 itself doesn't have a lock. Let's make it consistent.
|
|
270
|
+
self._init_web3_under_lock()
|
|
271
|
+
|
|
272
|
+
logger.info(
|
|
273
|
+
f"Rotated RPC for {self.chain.name} to index {self._current_rpc_index}: {self.chain.rpcs[self._current_rpc_index]}"
|
|
274
|
+
)
|
|
275
|
+
return True
|
|
274
276
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
self._init_web3()
|
|
277
|
+
def _init_web3(self):
|
|
278
|
+
"""Initialize Web3 with current RPC (thread-safe)."""
|
|
279
|
+
with self._rotation_lock:
|
|
280
|
+
self._init_web3_under_lock()
|
|
280
281
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
282
|
+
def _init_web3_under_lock(self):
|
|
283
|
+
"""Internal non-thread-safe web3 initialization."""
|
|
284
|
+
rpc_url = self.chain.rpcs[self._current_rpc_index] if self.chain.rpcs else ""
|
|
285
|
+
raw_web3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": DEFAULT_RPC_TIMEOUT}))
|
|
286
|
+
|
|
287
|
+
# Use duck typing to check if current web3 is a RateLimitedWeb3 wrapper
|
|
288
|
+
if hasattr(self, "web3") and hasattr(self.web3, "set_backend"):
|
|
289
|
+
self.web3.set_backend(raw_web3)
|
|
290
|
+
else:
|
|
291
|
+
self.web3 = RateLimitedWeb3(raw_web3, self._rate_limiter, self)
|
|
285
292
|
|
|
286
293
|
def check_rpc_health(self) -> bool:
|
|
287
294
|
"""Check if the current RPC is healthy."""
|
|
@@ -358,15 +365,29 @@ class ChainInterface:
|
|
|
358
365
|
except Exception:
|
|
359
366
|
return address[:6] + "..." + address[-4:]
|
|
360
367
|
|
|
361
|
-
def get_token_decimals(self, address: EthereumAddress) -> int:
|
|
362
|
-
"""Get token decimals for an address.
|
|
363
|
-
try:
|
|
364
|
-
from iwa.core.contracts.erc20 import ERC20Contract
|
|
368
|
+
def get_token_decimals(self, address: EthereumAddress, fallback_to_18: bool = True) -> Optional[int]:
|
|
369
|
+
"""Get token decimals for an address.
|
|
365
370
|
|
|
366
|
-
|
|
367
|
-
|
|
371
|
+
Args:
|
|
372
|
+
address: Token contract address.
|
|
373
|
+
fallback_to_18: If True, return 18 on error (default).
|
|
374
|
+
If False, return None on error (useful for detecting NFTs).
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
Decimals as int, or None if error and fallback_to_18 is False.
|
|
378
|
+
|
|
379
|
+
"""
|
|
380
|
+
try:
|
|
381
|
+
# Call decimals() directly without with_retry to avoid error logging
|
|
382
|
+
contract = self.web3.eth.contract(
|
|
383
|
+
address=self.web3.to_checksum_address(address),
|
|
384
|
+
abi=[{"constant": True, "inputs": [], "name": "decimals", "outputs": [{"type": "uint8"}], "type": "function"}]
|
|
385
|
+
)
|
|
386
|
+
return contract.functions.decimals().call()
|
|
368
387
|
except Exception:
|
|
369
|
-
|
|
388
|
+
if fallback_to_18:
|
|
389
|
+
return 18
|
|
390
|
+
return None
|
|
370
391
|
|
|
371
392
|
def get_native_balance_wei(self, address: EthereumAddress):
|
|
372
393
|
"""Get the native balance in wei"""
|
iwa/core/chain/models.py
CHANGED
|
@@ -78,13 +78,11 @@ class Gnosis(SupportedChain):
|
|
|
78
78
|
if not self.rpcs and secrets.gnosis_rpc:
|
|
79
79
|
self.rpcs = secrets.gnosis_rpc.get_secret_value().split(",")
|
|
80
80
|
|
|
81
|
-
# Defensive: ensure no comma-separated strings in list
|
|
81
|
+
# Defensive: ensure no comma-separated strings and NO quotes in list
|
|
82
82
|
new_rpcs = []
|
|
83
83
|
for rpc in self.rpcs:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
else:
|
|
87
|
-
new_rpcs.append(rpc)
|
|
84
|
+
parts = [r.strip().strip("'\"") for r in rpc.split(",") if r.strip()]
|
|
85
|
+
new_rpcs.extend(parts)
|
|
88
86
|
self.rpcs = new_rpcs
|
|
89
87
|
|
|
90
88
|
|
|
@@ -107,13 +105,11 @@ class Ethereum(SupportedChain):
|
|
|
107
105
|
if not self.rpcs and secrets.ethereum_rpc:
|
|
108
106
|
self.rpcs = secrets.ethereum_rpc.get_secret_value().split(",")
|
|
109
107
|
|
|
110
|
-
# Defensive: ensure no comma-separated strings in list
|
|
108
|
+
# Defensive: ensure no comma-separated strings and NO quotes in list
|
|
111
109
|
new_rpcs = []
|
|
112
110
|
for rpc in self.rpcs:
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
else:
|
|
116
|
-
new_rpcs.append(rpc)
|
|
111
|
+
parts = [r.strip().strip("'\"") for r in rpc.split(",") if r.strip()]
|
|
112
|
+
new_rpcs.extend(parts)
|
|
117
113
|
self.rpcs = new_rpcs
|
|
118
114
|
|
|
119
115
|
|
|
@@ -136,13 +132,11 @@ class Base(SupportedChain):
|
|
|
136
132
|
if not self.rpcs and secrets.base_rpc:
|
|
137
133
|
self.rpcs = secrets.base_rpc.get_secret_value().split(",")
|
|
138
134
|
|
|
139
|
-
# Defensive: ensure no comma-separated strings in list
|
|
135
|
+
# Defensive: ensure no comma-separated strings and NO quotes in list
|
|
140
136
|
new_rpcs = []
|
|
141
137
|
for rpc in self.rpcs:
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
else:
|
|
145
|
-
new_rpcs.append(rpc)
|
|
138
|
+
parts = [r.strip().strip("'\"") for r in rpc.split(",") if r.strip()]
|
|
139
|
+
new_rpcs.extend(parts)
|
|
146
140
|
self.rpcs = new_rpcs
|
|
147
141
|
|
|
148
142
|
|
iwa/core/contracts/contract.py
CHANGED
|
@@ -190,10 +190,16 @@ class ContractInstance:
|
|
|
190
190
|
Exception: If the call fails, with decoded error information.
|
|
191
191
|
|
|
192
192
|
"""
|
|
193
|
-
method = getattr(self.contract.functions, method_name)
|
|
194
193
|
try:
|
|
194
|
+
|
|
195
|
+
def do_call():
|
|
196
|
+
# Re-evaluate self.contract on each retry to get current provider
|
|
197
|
+
# This is critical for RPC rotation to work correctly
|
|
198
|
+
method = getattr(self.contract.functions, method_name)
|
|
199
|
+
return method(*args).call()
|
|
200
|
+
|
|
195
201
|
return self.chain_interface.with_retry(
|
|
196
|
-
|
|
202
|
+
do_call,
|
|
197
203
|
operation_name=f"call {method_name} on {self.name}",
|
|
198
204
|
)
|
|
199
205
|
except Exception as e:
|
iwa/core/pricing.py
CHANGED
|
@@ -9,17 +9,19 @@ from loguru import logger
|
|
|
9
9
|
|
|
10
10
|
from iwa.core.secrets import secrets
|
|
11
11
|
|
|
12
|
+
# Global cache shared across all PriceService instances
|
|
13
|
+
_PRICE_CACHE: Dict[str, Dict] = {}
|
|
14
|
+
_CACHE_TTL = timedelta(minutes=30)
|
|
15
|
+
|
|
12
16
|
|
|
13
17
|
class PriceService:
|
|
14
18
|
"""Service to fetch token prices from CoinGecko."""
|
|
15
19
|
|
|
16
20
|
BASE_URL = "https://api.coingecko.com/api/v3"
|
|
17
21
|
|
|
18
|
-
def __init__(self
|
|
22
|
+
def __init__(self):
|
|
19
23
|
"""Initialize PriceService."""
|
|
20
24
|
self.secrets = secrets
|
|
21
|
-
self.cache: Dict[str, Dict] = {} # {id_currency: {"price": float, "timestamp": datetime}}
|
|
22
|
-
self.cache_ttl = timedelta(minutes=cache_ttl_minutes)
|
|
23
25
|
self.api_key = (
|
|
24
26
|
self.secrets.coingecko_api_key.get_secret_value()
|
|
25
27
|
if self.secrets.coingecko_api_key
|
|
@@ -39,15 +41,15 @@ class PriceService:
|
|
|
39
41
|
"""
|
|
40
42
|
cache_key = f"{token_id}_{vs_currency}"
|
|
41
43
|
|
|
42
|
-
# Check cache
|
|
43
|
-
if cache_key in
|
|
44
|
-
entry =
|
|
45
|
-
if datetime.now() - entry["timestamp"] <
|
|
44
|
+
# Check global cache
|
|
45
|
+
if cache_key in _PRICE_CACHE:
|
|
46
|
+
entry = _PRICE_CACHE[cache_key]
|
|
47
|
+
if datetime.now() - entry["timestamp"] < _CACHE_TTL:
|
|
46
48
|
return entry["price"]
|
|
47
49
|
|
|
48
50
|
price = self._fetch_price_from_api(token_id, vs_currency)
|
|
49
51
|
if price is not None:
|
|
50
|
-
|
|
52
|
+
_PRICE_CACHE[cache_key] = {"price": price, "timestamp": datetime.now()}
|
|
51
53
|
return price
|
|
52
54
|
|
|
53
55
|
def _fetch_price_from_api(self, token_id: str, vs_currency: str) -> Optional[float]:
|
iwa/core/services/safe.py
CHANGED
|
@@ -11,7 +11,6 @@ from safe_eth.safe.safe_tx import SafeTx
|
|
|
11
11
|
from iwa.core.constants import ZERO_ADDRESS
|
|
12
12
|
from iwa.core.db import log_transaction
|
|
13
13
|
from iwa.core.models import StoredSafeAccount
|
|
14
|
-
from iwa.core.secrets import secrets
|
|
15
14
|
from iwa.core.utils import (
|
|
16
15
|
get_safe_master_copy_address,
|
|
17
16
|
get_safe_proxy_factory_address,
|
|
@@ -99,8 +98,11 @@ class SafeService:
|
|
|
99
98
|
return owner_addresses
|
|
100
99
|
|
|
101
100
|
def _get_ethereum_client(self, chain_name: str) -> EthereumClient:
|
|
102
|
-
|
|
103
|
-
|
|
101
|
+
from iwa.core.chain import ChainInterfaces
|
|
102
|
+
|
|
103
|
+
# Use ChainInterface which has proper RPC rotation and parsing
|
|
104
|
+
chain_interface = ChainInterfaces().get(chain_name)
|
|
105
|
+
return EthereumClient(chain_interface.chain.rpc)
|
|
104
106
|
|
|
105
107
|
def _deploy_safe_contract(
|
|
106
108
|
self,
|
|
@@ -141,7 +143,7 @@ class SafeService:
|
|
|
141
143
|
gas=5_000_000,
|
|
142
144
|
gas_price=gas_price,
|
|
143
145
|
)
|
|
144
|
-
return tx_sent.contract_address, tx_sent.tx_hash.hex()
|
|
146
|
+
return tx_sent.contract_address, f"0x{tx_sent.tx_hash.hex()}"
|
|
145
147
|
|
|
146
148
|
else:
|
|
147
149
|
# Standard random salt via Safe.create
|
|
@@ -153,7 +155,7 @@ class SafeService:
|
|
|
153
155
|
threshold=threshold,
|
|
154
156
|
proxy_factory_address=proxy_factory_address,
|
|
155
157
|
)
|
|
156
|
-
return create_tx.contract_address, create_tx.tx_hash.hex()
|
|
158
|
+
return create_tx.contract_address, f"0x{create_tx.tx_hash.hex()}"
|
|
157
159
|
|
|
158
160
|
def _log_safe_deployment(
|
|
159
161
|
self,
|
|
@@ -248,8 +250,11 @@ class SafeService:
|
|
|
248
250
|
continue
|
|
249
251
|
|
|
250
252
|
for chain in account.chains:
|
|
251
|
-
|
|
252
|
-
|
|
253
|
+
from iwa.core.chain import ChainInterfaces
|
|
254
|
+
|
|
255
|
+
# Use ChainInterface which has proper RPC rotation and parsing
|
|
256
|
+
chain_interface = ChainInterfaces().get(chain)
|
|
257
|
+
ethereum_client = EthereumClient(chain_interface.chain.rpc)
|
|
253
258
|
|
|
254
259
|
code = ethereum_client.w3.eth.get_code(account.address)
|
|
255
260
|
|
|
@@ -304,7 +309,7 @@ class SafeService:
|
|
|
304
309
|
# Execute using the first signer
|
|
305
310
|
safe_tx.execute(signer_keys[0])
|
|
306
311
|
|
|
307
|
-
return safe_tx.tx_hash.hex()
|
|
312
|
+
return f"0x{safe_tx.tx_hash.hex()}"
|
|
308
313
|
finally:
|
|
309
314
|
# SECURITY: Overwrite keys with zeros before clearing (best effort)
|
|
310
315
|
for i in range(len(signer_keys)):
|
iwa/core/services/transaction.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"""Transaction service module."""
|
|
2
2
|
|
|
3
3
|
import time
|
|
4
|
-
from typing import Dict, List, Optional, Tuple
|
|
4
|
+
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
|
|
5
5
|
|
|
6
6
|
from loguru import logger
|
|
7
|
+
from web3 import Web3
|
|
7
8
|
from web3 import exceptions as web3_exceptions
|
|
8
9
|
|
|
9
10
|
from iwa.core.chain import ChainInterfaces
|
|
@@ -11,6 +12,191 @@ from iwa.core.db import log_transaction
|
|
|
11
12
|
from iwa.core.keys import KeyStorage
|
|
12
13
|
from iwa.core.services.account import AccountService
|
|
13
14
|
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from iwa.core.chain import ChainInterface
|
|
17
|
+
|
|
18
|
+
# ERC20 Transfer event signature: Transfer(address indexed from, address indexed to, uint256 value)
|
|
19
|
+
TRANSFER_EVENT_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TransferLogger:
|
|
23
|
+
"""Parse and log transfer events from transaction receipts."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
account_service: AccountService,
|
|
28
|
+
chain_interface: "ChainInterface",
|
|
29
|
+
):
|
|
30
|
+
"""Initialize TransferLogger."""
|
|
31
|
+
self.account_service = account_service
|
|
32
|
+
self.chain_interface = chain_interface
|
|
33
|
+
|
|
34
|
+
def log_transfers(self, receipt: Dict, tx: Dict) -> None:
|
|
35
|
+
"""Log all transfers (ERC20 and native) from a transaction receipt.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
receipt: Transaction receipt containing logs.
|
|
39
|
+
tx: Original transaction dict.
|
|
40
|
+
|
|
41
|
+
"""
|
|
42
|
+
# Log native value transfer if present
|
|
43
|
+
native_value = tx.get("value", 0)
|
|
44
|
+
if native_value and int(native_value) > 0:
|
|
45
|
+
self._log_native_transfer(tx, native_value)
|
|
46
|
+
|
|
47
|
+
# Log ERC20 transfers from event logs
|
|
48
|
+
logs = receipt.get("logs", [])
|
|
49
|
+
if hasattr(receipt, "logs"):
|
|
50
|
+
logs = receipt.logs
|
|
51
|
+
|
|
52
|
+
for log in logs:
|
|
53
|
+
self._process_log(log)
|
|
54
|
+
|
|
55
|
+
def _log_native_transfer(self, tx: Dict, value_wei: int) -> None:
|
|
56
|
+
"""Log a native currency transfer."""
|
|
57
|
+
from_addr = tx.get("from", "")
|
|
58
|
+
to_addr = tx.get("to", "")
|
|
59
|
+
|
|
60
|
+
from_label = self._resolve_address_label(from_addr)
|
|
61
|
+
to_label = self._resolve_address_label(to_addr)
|
|
62
|
+
|
|
63
|
+
native_symbol = self.chain_interface.chain.native_currency
|
|
64
|
+
amount_eth = Web3.from_wei(value_wei, "ether")
|
|
65
|
+
|
|
66
|
+
logger.info(f"[TRANSFER] {amount_eth:.6g} {native_symbol}: {from_label} → {to_label}")
|
|
67
|
+
|
|
68
|
+
def _process_log(self, log) -> None:
|
|
69
|
+
"""Process a single log entry for Transfer events."""
|
|
70
|
+
# Get topics - handle both dict and AttributeDict
|
|
71
|
+
topics = log.get("topics", []) if isinstance(log, dict) else getattr(log, "topics", [])
|
|
72
|
+
|
|
73
|
+
if not topics:
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
# Check if this is a Transfer event
|
|
77
|
+
first_topic = topics[0]
|
|
78
|
+
if isinstance(first_topic, bytes):
|
|
79
|
+
first_topic = "0x" + first_topic.hex()
|
|
80
|
+
elif hasattr(first_topic, "hex"):
|
|
81
|
+
first_topic = first_topic.hex()
|
|
82
|
+
if not first_topic.startswith("0x"):
|
|
83
|
+
first_topic = "0x" + first_topic
|
|
84
|
+
|
|
85
|
+
if first_topic.lower() != TRANSFER_EVENT_TOPIC.lower():
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
# Need at least 3 topics for indexed from/to
|
|
89
|
+
if len(topics) < 3:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
# Extract from/to from indexed topics (last 20 bytes of 32-byte topic)
|
|
94
|
+
from_topic = topics[1]
|
|
95
|
+
to_topic = topics[2]
|
|
96
|
+
|
|
97
|
+
from_addr = self._topic_to_address(from_topic)
|
|
98
|
+
to_addr = self._topic_to_address(to_topic)
|
|
99
|
+
|
|
100
|
+
# Extract amount from data
|
|
101
|
+
data = log.get("data", b"") if isinstance(log, dict) else getattr(log, "data", b"")
|
|
102
|
+
if isinstance(data, str):
|
|
103
|
+
data = bytes.fromhex(data.replace("0x", ""))
|
|
104
|
+
|
|
105
|
+
amount = int.from_bytes(data, "big") if data else 0
|
|
106
|
+
|
|
107
|
+
# Get token address
|
|
108
|
+
token_addr = (
|
|
109
|
+
log.get("address", "") if isinstance(log, dict) else getattr(log, "address", "")
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
self._log_erc20_transfer(token_addr, from_addr, to_addr, amount)
|
|
113
|
+
|
|
114
|
+
except Exception as e:
|
|
115
|
+
logger.debug(f"Failed to parse Transfer event: {e}")
|
|
116
|
+
|
|
117
|
+
def _topic_to_address(self, topic) -> str:
|
|
118
|
+
"""Convert a 32-byte topic to a 20-byte address."""
|
|
119
|
+
if isinstance(topic, bytes):
|
|
120
|
+
# Last 20 bytes
|
|
121
|
+
addr_bytes = topic[-20:]
|
|
122
|
+
return Web3.to_checksum_address("0x" + addr_bytes.hex())
|
|
123
|
+
elif hasattr(topic, "hex"):
|
|
124
|
+
hex_str = topic.hex()
|
|
125
|
+
if not hex_str.startswith("0x"):
|
|
126
|
+
hex_str = "0x" + hex_str
|
|
127
|
+
# Last 40 chars (20 bytes)
|
|
128
|
+
return Web3.to_checksum_address("0x" + hex_str[-40:])
|
|
129
|
+
elif isinstance(topic, str):
|
|
130
|
+
if topic.startswith("0x"):
|
|
131
|
+
topic = topic[2:]
|
|
132
|
+
return Web3.to_checksum_address("0x" + topic[-40:])
|
|
133
|
+
return ""
|
|
134
|
+
|
|
135
|
+
def _log_erc20_transfer(
|
|
136
|
+
self, token_addr: str, from_addr: str, to_addr: str, amount_wei: int
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Log an ERC20 transfer (or NFT transfer if detected)."""
|
|
139
|
+
from_label = self._resolve_address_label(from_addr)
|
|
140
|
+
to_label = self._resolve_address_label(to_addr)
|
|
141
|
+
token_label = self._resolve_token_label(token_addr)
|
|
142
|
+
|
|
143
|
+
# Try to get decimals - if None, it's an NFT (ERC721)
|
|
144
|
+
decimals = self.chain_interface.get_token_decimals(token_addr, fallback_to_18=False)
|
|
145
|
+
|
|
146
|
+
if decimals is not None:
|
|
147
|
+
amount = amount_wei / (10**decimals)
|
|
148
|
+
logger.info(f"[TRANSFER] {amount:.6g} {token_label}: {from_label} → {to_label}")
|
|
149
|
+
else:
|
|
150
|
+
# Likely an NFT (ERC721) - the amount is the token ID
|
|
151
|
+
if amount_wei > 0:
|
|
152
|
+
logger.info(f"[NFT TRANSFER] Token #{amount_wei} {token_label}: {from_label} → {to_label}")
|
|
153
|
+
else:
|
|
154
|
+
logger.debug(f"[NFT TRANSFER] {token_label}: {from_label} → {to_label}")
|
|
155
|
+
|
|
156
|
+
def _resolve_address_label(self, address: str) -> str:
|
|
157
|
+
"""Resolve an address to a human-readable label.
|
|
158
|
+
|
|
159
|
+
Priority:
|
|
160
|
+
1. Known wallet tag (from wallets.json)
|
|
161
|
+
2. Known token name (it's a token contract)
|
|
162
|
+
3. Abbreviated address
|
|
163
|
+
|
|
164
|
+
"""
|
|
165
|
+
if not address:
|
|
166
|
+
return "unknown"
|
|
167
|
+
|
|
168
|
+
# 1. Check known wallets
|
|
169
|
+
tag = self.account_service.get_tag_by_address(address)
|
|
170
|
+
if tag:
|
|
171
|
+
return tag
|
|
172
|
+
|
|
173
|
+
# 2. Check if it's a known token contract
|
|
174
|
+
token_name = self.chain_interface.chain.get_token_name(address)
|
|
175
|
+
if token_name:
|
|
176
|
+
return f"{token_name}_contract"
|
|
177
|
+
|
|
178
|
+
# 3. Fallback to abbreviated address
|
|
179
|
+
return f"{address[:6]}...{address[-4:]}"
|
|
180
|
+
|
|
181
|
+
def _resolve_token_label(self, token_addr: str) -> str:
|
|
182
|
+
"""Resolve a token address to its symbol.
|
|
183
|
+
|
|
184
|
+
Priority:
|
|
185
|
+
1. Known token from chain config
|
|
186
|
+
2. Abbreviated address
|
|
187
|
+
|
|
188
|
+
"""
|
|
189
|
+
if not token_addr:
|
|
190
|
+
return "UNKNOWN"
|
|
191
|
+
|
|
192
|
+
# Check known tokens
|
|
193
|
+
token_name = self.chain_interface.chain.get_token_name(token_addr)
|
|
194
|
+
if token_name:
|
|
195
|
+
return token_name
|
|
196
|
+
|
|
197
|
+
# Fallback to abbreviated address
|
|
198
|
+
return f"{token_addr[:6]}...{token_addr[-4:]}"
|
|
199
|
+
|
|
14
200
|
|
|
15
201
|
class TransactionService:
|
|
16
202
|
"""Manages transaction lifecycle: signing, sending, retrying."""
|
|
@@ -30,7 +216,7 @@ class TransactionService:
|
|
|
30
216
|
"""Sign and send a transaction with retry logic for gas."""
|
|
31
217
|
chain_interface = ChainInterfaces().get(chain_name)
|
|
32
218
|
tx = dict(transaction)
|
|
33
|
-
max_retries =
|
|
219
|
+
max_retries = 10
|
|
34
220
|
|
|
35
221
|
if not self._prepare_transaction(tx, signer_address_or_tag, chain_interface):
|
|
36
222
|
return False, {}
|
|
@@ -39,7 +225,14 @@ class TransactionService:
|
|
|
39
225
|
try:
|
|
40
226
|
signed_txn = self.key_storage.sign_transaction(tx, signer_address_or_tag)
|
|
41
227
|
txn_hash = chain_interface.web3.eth.send_raw_transaction(signed_txn.raw_transaction)
|
|
42
|
-
|
|
228
|
+
|
|
229
|
+
# Use chain_interface.with_retry for waiting for receipt to handle timeouts/RPC errors
|
|
230
|
+
def wait_for_receipt(tx_h=txn_hash):
|
|
231
|
+
return chain_interface.web3.eth.wait_for_transaction_receipt(tx_h)
|
|
232
|
+
|
|
233
|
+
receipt = chain_interface.with_retry(
|
|
234
|
+
wait_for_receipt, operation_name="wait_for_receipt"
|
|
235
|
+
)
|
|
43
236
|
|
|
44
237
|
if receipt and getattr(receipt, "status", None) == 1:
|
|
45
238
|
signer_account = self.account_service.resolve_account(signer_address_or_tag)
|
|
@@ -47,7 +240,7 @@ class TransactionService:
|
|
|
47
240
|
logger.info(f"Transaction sent successfully. Tx Hash: {txn_hash.hex()}")
|
|
48
241
|
|
|
49
242
|
self._log_successful_transaction(
|
|
50
|
-
receipt, tx, signer_account, chain_name, txn_hash, tags
|
|
243
|
+
receipt, tx, signer_account, chain_name, txn_hash, tags, chain_interface
|
|
51
244
|
)
|
|
52
245
|
return True, receipt
|
|
53
246
|
|
|
@@ -89,7 +282,9 @@ class TransactionService:
|
|
|
89
282
|
)
|
|
90
283
|
current_gas = int(tx.get("gas", 30_000))
|
|
91
284
|
tx["gas"] = int(current_gas * 1.5)
|
|
92
|
-
|
|
285
|
+
tx["gas"] = int(current_gas * 1.5)
|
|
286
|
+
# Exponential backoff for gas errors
|
|
287
|
+
time.sleep(min(2**attempt, 30))
|
|
93
288
|
return True
|
|
94
289
|
logger.exception(f"Error sending transaction: {e}")
|
|
95
290
|
return False
|
|
@@ -97,14 +292,18 @@ class TransactionService:
|
|
|
97
292
|
def _handle_generic_error(self, e, chain_interface, attempt, max_retries) -> bool:
|
|
98
293
|
if attempt < max_retries:
|
|
99
294
|
logger.warning(f"Error encountered: {e}. Attempting to rotate RPC...")
|
|
295
|
+
|
|
100
296
|
if chain_interface.rotate_rpc():
|
|
101
297
|
logger.info("Retrying with new RPC...")
|
|
102
|
-
|
|
298
|
+
# Exponential backoff
|
|
299
|
+
time.sleep(min(2**attempt, 30))
|
|
103
300
|
return True
|
|
104
301
|
logger.exception(f"Unexpected error sending transaction: {e}")
|
|
105
302
|
return False
|
|
106
303
|
|
|
107
|
-
def _log_successful_transaction(
|
|
304
|
+
def _log_successful_transaction(
|
|
305
|
+
self, receipt, tx, signer_account, chain_name, txn_hash, tags, chain_interface
|
|
306
|
+
):
|
|
108
307
|
try:
|
|
109
308
|
gas_cost_wei, gas_value_eur = self._calculate_gas_cost(receipt, tx, chain_name)
|
|
110
309
|
final_tags = self._determine_tags(tx, tags)
|
|
@@ -121,6 +320,11 @@ class TransactionService:
|
|
|
121
320
|
gas_value_eur=gas_value_eur,
|
|
122
321
|
tags=final_tags if final_tags else None,
|
|
123
322
|
)
|
|
323
|
+
|
|
324
|
+
# Log transfer events (ERC20 and native value)
|
|
325
|
+
transfer_logger = TransferLogger(self.account_service, chain_interface)
|
|
326
|
+
transfer_logger.log_transfers(receipt, tx)
|
|
327
|
+
|
|
124
328
|
except Exception as log_err:
|
|
125
329
|
logger.warning(f"Failed to log transaction: {log_err}")
|
|
126
330
|
|
iwa/core/utils.py
CHANGED
|
@@ -38,24 +38,46 @@ def get_safe_proxy_factory_address(target_version: str = "1.4.1") -> str:
|
|
|
38
38
|
raise ValueError(f"Did not find proxy factory for version {target_version}")
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
def get_tx_hash(receipt: dict) -> str:
|
|
42
|
+
"""Safely extract transaction hash from receipt (handles bytes/str/None)."""
|
|
43
|
+
if not receipt:
|
|
44
|
+
return "unknown"
|
|
45
|
+
|
|
46
|
+
tx_hash = receipt.get("transactionHash", "")
|
|
47
|
+
if hasattr(tx_hash, "hex"):
|
|
48
|
+
return tx_hash.hex()
|
|
49
|
+
return str(tx_hash) if tx_hash else "unknown"
|
|
50
|
+
|
|
51
|
+
|
|
41
52
|
def configure_logger():
|
|
42
53
|
"""Configure the logger for the application."""
|
|
43
54
|
if hasattr(configure_logger, "configured"):
|
|
44
55
|
return logger
|
|
45
56
|
|
|
57
|
+
import logging
|
|
58
|
+
|
|
46
59
|
from iwa.core.constants import DATA_DIR
|
|
47
60
|
|
|
61
|
+
# Silence noisy third-party loggers (these use stdlib logging, not loguru)
|
|
62
|
+
logging.getLogger("apscheduler.scheduler").setLevel(logging.WARNING)
|
|
63
|
+
logging.getLogger("apscheduler.executors.default").setLevel(logging.WARNING)
|
|
64
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
65
|
+
|
|
48
66
|
logger.remove()
|
|
49
67
|
|
|
50
68
|
# Ensure data directory exists
|
|
51
69
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
52
70
|
|
|
71
|
+
import sys
|
|
72
|
+
|
|
53
73
|
logger.add(
|
|
54
74
|
DATA_DIR / "iwa.log",
|
|
55
75
|
rotation="10 MB",
|
|
56
76
|
level="INFO",
|
|
57
77
|
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}",
|
|
58
78
|
)
|
|
79
|
+
# Restore console logging (stderr) so logs are visible in docker/systemd/frontend streams
|
|
80
|
+
logger.add(sys.stderr, level="INFO")
|
|
59
81
|
# Also keep stderr for console if needed, but Textual captures it?
|
|
60
82
|
# Textual usually captures stderr. Writing to file is safer for debugging.
|
|
61
83
|
# Users previous logs show stdout format?
|
iwa/core/wallet.py
CHANGED
|
@@ -92,7 +92,8 @@ class Wallet:
|
|
|
92
92
|
return addr, t_name, 0.0
|
|
93
93
|
|
|
94
94
|
# Use ThreadPoolExecutor for parallel balance fetching
|
|
95
|
-
|
|
95
|
+
# Limited to 4 workers to avoid overwhelming RPC endpoints
|
|
96
|
+
with ThreadPoolExecutor(max_workers=4) as executor:
|
|
96
97
|
tasks = []
|
|
97
98
|
for addr in accounts_data.keys():
|
|
98
99
|
for t_name in token_names:
|
iwa/plugins/gnosis/safe.py
CHANGED
|
@@ -8,7 +8,6 @@ from safe_eth.safe import Safe, SafeOperationEnum
|
|
|
8
8
|
from safe_eth.safe.safe_tx import SafeTx
|
|
9
9
|
|
|
10
10
|
from iwa.core.models import StoredSafeAccount
|
|
11
|
-
from iwa.core.secrets import secrets
|
|
12
11
|
from iwa.core.utils import configure_logger
|
|
13
12
|
|
|
14
13
|
logger = configure_logger()
|
|
@@ -28,8 +27,10 @@ class SafeMultisig:
|
|
|
28
27
|
if chain_name.lower() not in normalized_chains:
|
|
29
28
|
raise ValueError(f"Safe account is not deployed on chain: {chain_name}")
|
|
30
29
|
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
from iwa.core.chain import ChainInterfaces
|
|
31
|
+
|
|
32
|
+
chain_interface = ChainInterfaces().get(chain_name.lower())
|
|
33
|
+
ethereum_client = EthereumClient(chain_interface.chain.rpc)
|
|
33
34
|
self.multisig = Safe(safe_account.address, ethereum_client)
|
|
34
35
|
self.ethereum_client = ethereum_client
|
|
35
36
|
|