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.
Files changed (58) hide show
  1. iwa/core/chain/interface.py +51 -30
  2. iwa/core/chain/models.py +9 -15
  3. iwa/core/contracts/contract.py +8 -2
  4. iwa/core/pricing.py +10 -8
  5. iwa/core/services/safe.py +13 -8
  6. iwa/core/services/transaction.py +211 -7
  7. iwa/core/utils.py +22 -0
  8. iwa/core/wallet.py +2 -1
  9. iwa/plugins/gnosis/safe.py +4 -3
  10. iwa/plugins/gnosis/tests/test_safe.py +9 -7
  11. iwa/plugins/olas/contracts/abis/service_registry_token_utility.json +926 -0
  12. iwa/plugins/olas/contracts/service.py +54 -4
  13. iwa/plugins/olas/contracts/staking.py +2 -3
  14. iwa/plugins/olas/plugin.py +14 -7
  15. iwa/plugins/olas/service_manager/lifecycle.py +382 -85
  16. iwa/plugins/olas/service_manager/mech.py +1 -1
  17. iwa/plugins/olas/service_manager/staking.py +229 -82
  18. iwa/plugins/olas/tests/test_olas_contracts.py +6 -2
  19. iwa/plugins/olas/tests/test_plugin.py +6 -1
  20. iwa/plugins/olas/tests/test_plugin_full.py +12 -7
  21. iwa/plugins/olas/tests/test_service_lifecycle.py +1 -4
  22. iwa/plugins/olas/tests/test_service_manager.py +59 -89
  23. iwa/plugins/olas/tests/test_service_manager_errors.py +1 -2
  24. iwa/plugins/olas/tests/test_service_manager_flows.py +5 -15
  25. iwa/plugins/olas/tests/test_service_manager_validation.py +16 -15
  26. iwa/tools/list_contracts.py +2 -2
  27. iwa/web/dependencies.py +1 -3
  28. iwa/web/routers/accounts.py +1 -2
  29. iwa/web/routers/olas/admin.py +1 -3
  30. iwa/web/routers/olas/funding.py +1 -3
  31. iwa/web/routers/olas/general.py +1 -3
  32. iwa/web/routers/olas/services.py +53 -21
  33. iwa/web/routers/olas/staking.py +27 -24
  34. iwa/web/routers/swap.py +1 -2
  35. iwa/web/routers/transactions.py +0 -2
  36. iwa/web/server.py +8 -6
  37. iwa/web/static/app.js +22 -0
  38. iwa/web/tests/test_web_endpoints.py +1 -1
  39. iwa/web/tests/test_web_olas.py +1 -1
  40. {iwa-0.0.2.dist-info → iwa-0.0.11.dist-info}/METADATA +1 -1
  41. {iwa-0.0.2.dist-info → iwa-0.0.11.dist-info}/RECORD +58 -56
  42. tests/test_chain.py +12 -7
  43. tests/test_chain_interface_coverage.py +3 -2
  44. tests/test_contract.py +165 -0
  45. tests/test_keys.py +2 -1
  46. tests/test_legacy_wallet.py +11 -0
  47. tests/test_pricing.py +32 -15
  48. tests/test_safe_coverage.py +3 -3
  49. tests/test_safe_service.py +3 -6
  50. tests/test_service_transaction.py +8 -3
  51. tests/test_staking_router.py +6 -3
  52. tests/test_transaction_service.py +4 -0
  53. tools/create_and_stake_service.py +103 -0
  54. tools/verify_drain.py +1 -4
  55. {iwa-0.0.2.dist-info → iwa-0.0.11.dist-info}/WHEEL +0 -0
  56. {iwa-0.0.2.dist-info → iwa-0.0.11.dist-info}/entry_points.txt +0 -0
  57. {iwa-0.0.2.dist-info → iwa-0.0.11.dist-info}/licenses/LICENSE +0 -0
  58. {iwa-0.0.2.dist-info → iwa-0.0.11.dist-info}/top_level.txt +0 -0
@@ -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
- if not self.chain.rpcs or len(self.chain.rpcs) <= 1:
273
- return False
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
- # Simple Round Robin rotation
276
- # We don't check health here because the health check itself might consume rate limits
277
- # or fail flakily. Better to just switch and try the operation.
278
- self._current_rpc_index = (self._current_rpc_index + 1) % len(self.chain.rpcs)
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
- logger.info(
282
- f"Rotated RPC for {self.chain.name} to index {self._current_rpc_index}: {self.chain.rpcs[self._current_rpc_index]}"
283
- )
284
- return True
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
- erc20 = ERC20Contract(address, self.chain.name.lower())
367
- return erc20.decimals if erc20.decimals is not None else 18
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
- return 18
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
- if "," in rpc:
85
- new_rpcs.extend([r.strip() for r in rpc.split(",") if r.strip()])
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
- if "," in rpc:
114
- new_rpcs.extend([r.strip() for r in rpc.split(",") if r.strip()])
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
- if "," in rpc:
143
- new_rpcs.extend([r.strip() for r in rpc.split(",") if r.strip()])
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
 
@@ -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
- lambda: method(*args).call(),
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, cache_ttl_minutes: int = 5):
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 self.cache:
44
- entry = self.cache[cache_key]
45
- if datetime.now() - entry["timestamp"] < self.cache_ttl:
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
- self.cache[cache_key] = {"price": price, "timestamp": datetime.now()}
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
- rpc_secret = getattr(secrets, f"{chain_name}_rpc")
103
- return EthereumClient(rpc_secret.get_secret_value())
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
- rpc_secret = getattr(secrets, f"{chain}_rpc")
252
- ethereum_client = EthereumClient(rpc_secret.get_secret_value())
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)):
@@ -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 = 3
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
- receipt = chain_interface.web3.eth.wait_for_transaction_receipt(txn_hash)
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
- time.sleep(0.5 * attempt)
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
- time.sleep(0.5 * attempt)
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(self, receipt, tx, signer_account, chain_name, txn_hash, tags):
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
- with ThreadPoolExecutor(max_workers=20) as executor:
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:
@@ -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
- rpc_secret = getattr(secrets, f"{chain_name.lower()}_rpc")
32
- ethereum_client = EthereumClient(rpc_secret.get_secret_value())
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