iwa 0.0.60__py3-none-any.whl → 0.0.61__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.
@@ -54,9 +54,41 @@ class ChainInterface:
54
54
 
55
55
  self._initial_block = 0
56
56
  self._rotation_lock = threading.Lock()
57
- self._session = requests.Session()
57
+ self._session = self._create_session()
58
+
59
+ # Enrich with public RPCs from ChainList (skip for Tenderly vNets)
60
+ if not self.is_tenderly:
61
+ self._enrich_rpcs_from_chainlist()
62
+
58
63
  self._init_web3()
59
64
 
65
+ def _create_session(self) -> requests.Session:
66
+ """Create a requests Session with bounded connection pooling.
67
+
68
+ Configures the session with limited pool sizes to prevent file
69
+ descriptor exhaustion during RPC rotations. Connections are reused
70
+ within the pool but won't accumulate unboundedly.
71
+ """
72
+ session = requests.Session()
73
+ # Limit pool size: we only talk to one RPC at a time, but may rotate
74
+ # through multiple during the session lifetime. Keep modest limits.
75
+ adapter = requests.adapters.HTTPAdapter(
76
+ pool_connections=5, # Max different hosts to keep connections to
77
+ pool_maxsize=10, # Max connections per host
78
+ )
79
+ session.mount("https://", adapter)
80
+ session.mount("http://", adapter)
81
+ return session
82
+
83
+ def close(self) -> None:
84
+ """Close the session and release all connections.
85
+
86
+ Call this when the ChainInterface is no longer needed to ensure
87
+ proper cleanup of network resources.
88
+ """
89
+ if hasattr(self, "_session") and self._session:
90
+ self._session.close()
91
+
60
92
  @property
61
93
  def current_rpc(self) -> str:
62
94
  """Get the current active RPC URL."""
@@ -251,6 +283,35 @@ class ChainInterface:
251
283
  ]
252
284
  return any(signal in err_text for signal in quota_signals)
253
285
 
286
+ # -- ChainList enrichment ----------------------------------------------
287
+
288
+ MAX_RPCS = 10 # Cap total RPCs per chain
289
+
290
+ def _enrich_rpcs_from_chainlist(self) -> None:
291
+ """Add validated public RPCs from ChainList to the rotation pool."""
292
+ if len(self.chain.rpcs) >= self.MAX_RPCS:
293
+ return
294
+
295
+ try:
296
+ from iwa.core.chainlist import ChainlistRPC
297
+
298
+ chainlist = ChainlistRPC()
299
+ extra = chainlist.get_validated_rpcs(
300
+ self.chain.chain_id,
301
+ existing_rpcs=self.chain.rpcs,
302
+ max_results=self.MAX_RPCS - len(self.chain.rpcs),
303
+ )
304
+ if extra:
305
+ self.chain.rpcs.extend(extra)
306
+ logger.info(
307
+ f"Enriched {self.chain.name} with {len(extra)} "
308
+ f"ChainList RPCs (total: {len(self.chain.rpcs)})"
309
+ )
310
+ except Exception as e:
311
+ logger.debug(
312
+ f"ChainList enrichment failed for {self.chain.name}: {e}"
313
+ )
314
+
254
315
  # -- Per-RPC health tracking ------------------------------------------
255
316
 
256
317
  def _mark_rpc_backoff(self, index: int, seconds: float) -> None:
iwa/core/chain/manager.py CHANGED
@@ -36,3 +36,11 @@ class ChainInterfaces:
36
36
  for name, interface in self.items():
37
37
  results[name] = interface.check_rpc_health()
38
38
  return results
39
+
40
+ def close_all(self) -> None:
41
+ """Close all chain interface sessions.
42
+
43
+ Call this at application shutdown to release network resources.
44
+ """
45
+ for _, interface in self.items():
46
+ interface.close()
iwa/core/chainlist.py CHANGED
@@ -2,12 +2,158 @@
2
2
 
3
3
  import json
4
4
  import time
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
5
6
  from dataclasses import dataclass
6
- from typing import Any, Dict, List, Optional
7
+ from typing import Any, Dict, List, Optional, Tuple
7
8
 
8
9
  import requests
9
10
 
10
11
  from iwa.core.constants import CACHE_DIR
12
+ from iwa.core.utils import configure_logger
13
+
14
+ logger = configure_logger()
15
+
16
+ # -- RPC probing constants --------------------------------------------------
17
+
18
+ MAX_CHAINLIST_CANDIDATES = 15 # Probe at most this many candidates
19
+ PROBE_TIMEOUT = 5.0 # Seconds per probe request
20
+ MAX_BLOCK_LAG = 10 # Blocks behind majority → considered stale
21
+
22
+
23
+ def _normalize_url(url: str) -> str:
24
+ """Normalize an RPC URL for deduplication (lowercase, strip trailing slash)."""
25
+ return url.rstrip("/").lower()
26
+
27
+
28
+ def _is_template_url(url: str) -> bool:
29
+ """Return True if the URL contains template variables requiring an API key."""
30
+ return "${" in url or "{" in url
31
+
32
+
33
+ def probe_rpc(
34
+ url: str,
35
+ timeout: float = PROBE_TIMEOUT,
36
+ session: Optional[requests.Session] = None,
37
+ ) -> Optional[Tuple[str, float, int]]:
38
+ """Probe an RPC endpoint with eth_blockNumber.
39
+
40
+ Returns ``(url, latency_ms, block_number)`` on success, or ``None``
41
+ if the endpoint is unreachable, slow, or returns invalid data.
42
+
43
+ Args:
44
+ url: The RPC endpoint URL to probe.
45
+ timeout: Request timeout in seconds.
46
+ session: Optional requests.Session for connection reuse. If None,
47
+ creates a temporary session that is properly closed.
48
+
49
+ """
50
+ # Use provided session or create temporary one with proper cleanup
51
+ own_session = session is None
52
+ if own_session:
53
+ session = requests.Session()
54
+
55
+ try:
56
+ start = time.monotonic()
57
+ resp = session.post(
58
+ url,
59
+ json={"jsonrpc": "2.0", "method": "eth_blockNumber", "params": [], "id": 1},
60
+ timeout=timeout,
61
+ )
62
+ latency_ms = (time.monotonic() - start) * 1000
63
+ data = resp.json()
64
+ block_hex = data.get("result")
65
+ if not block_hex or not isinstance(block_hex, str) or block_hex == "0x0":
66
+ return None
67
+ return (url, latency_ms, int(block_hex, 16))
68
+ except Exception:
69
+ return None
70
+ finally:
71
+ if own_session:
72
+ session.close()
73
+
74
+
75
+ def _filter_candidates(
76
+ nodes: "List[RPCNode]",
77
+ existing_normalized: set,
78
+ ) -> List[str]:
79
+ """Filter ChainList nodes to usable HTTPS candidates."""
80
+ candidates: List[str] = []
81
+ for node in nodes:
82
+ url = node.url
83
+ if not url.startswith("https://"):
84
+ continue
85
+ if _is_template_url(url):
86
+ continue
87
+ if _normalize_url(url) in existing_normalized:
88
+ continue
89
+ candidates.append(url)
90
+ if len(candidates) >= MAX_CHAINLIST_CANDIDATES:
91
+ break
92
+ return candidates
93
+
94
+
95
+ def _probe_candidates(
96
+ candidates: List[str],
97
+ ) -> List[Tuple[str, float, int]]:
98
+ """Probe a list of RPC URLs in parallel, returning successful results.
99
+
100
+ Uses a shared session for all probes to enable connection pooling and
101
+ ensure proper cleanup of all connections when probing completes.
102
+ """
103
+ results: List[Tuple[str, float, int]] = []
104
+ # Use a shared session with connection pooling for all probes
105
+ # This prevents FD leaks from individual probe connections
106
+ with requests.Session() as session:
107
+ # Configure connection pool size to match our max workers
108
+ adapter = requests.adapters.HTTPAdapter(
109
+ pool_connections=10,
110
+ pool_maxsize=10,
111
+ max_retries=0, # No retries - we handle failure gracefully
112
+ )
113
+ session.mount("https://", adapter)
114
+ session.mount("http://", adapter)
115
+
116
+ with ThreadPoolExecutor(max_workers=min(len(candidates), 10)) as pool:
117
+ futures = {
118
+ pool.submit(probe_rpc, url, PROBE_TIMEOUT, session): url
119
+ for url in candidates
120
+ }
121
+ for future in as_completed(futures, timeout=15):
122
+ try:
123
+ result = future.result()
124
+ if result is not None:
125
+ results.append(result)
126
+ except Exception:
127
+ pass
128
+ # Session is closed here via context manager, releasing all connections
129
+ return results
130
+
131
+
132
+ def _rank_and_select(
133
+ results: List[Tuple[str, float, int]],
134
+ candidates: List[str],
135
+ chain_id: int,
136
+ max_results: int,
137
+ ) -> List[str]:
138
+ """Rank probed RPCs by latency, filtering stale ones."""
139
+ blocks = sorted(r[2] for r in results)
140
+ median_block = blocks[len(blocks) // 2]
141
+
142
+ valid = [
143
+ (url, latency)
144
+ for url, latency, block in results
145
+ if median_block - block <= MAX_BLOCK_LAG
146
+ ]
147
+ valid.sort(key=lambda x: x[1])
148
+
149
+ selected = [url for url, _ in valid[:max_results]]
150
+ if selected:
151
+ logger.info(
152
+ f"ChainList: validated {len(selected)}/{len(candidates)} "
153
+ f"candidates for chain {chain_id} "
154
+ f"(median block: {median_block})"
155
+ )
156
+ return selected
11
157
 
12
158
 
13
159
  @dataclass
@@ -50,11 +196,12 @@ class ChainlistRPC:
50
196
  except Exception as e:
51
197
  print(f"Error reading Chainlist cache: {e}")
52
198
 
53
- # 2. Fetch from remote
199
+ # 2. Fetch from remote (use session context for proper cleanup)
54
200
  try:
55
- response = requests.get(self.URL, timeout=10)
56
- response.raise_for_status()
57
- self._data = response.json()
201
+ with requests.Session() as session:
202
+ response = session.get(self.URL, timeout=10)
203
+ response.raise_for_status()
204
+ self._data = response.json()
58
205
 
59
206
  # 3. Update local cache
60
207
  if self._data:
@@ -119,3 +266,34 @@ class ChainlistRPC:
119
266
  for node in rpcs
120
267
  if node.url.startswith("wss://") or node.url.startswith("ws://")
121
268
  ]
269
+
270
+ def get_validated_rpcs(
271
+ self,
272
+ chain_id: int,
273
+ existing_rpcs: List[str],
274
+ max_results: int = 5,
275
+ ) -> List[str]:
276
+ """Return ChainList RPCs filtered, probed, and sorted by quality.
277
+
278
+ 1. Fetch HTTPS RPCs from ChainList for *chain_id*.
279
+ 2. Filter out template URLs, duplicates of *existing_rpcs*, and
280
+ websocket endpoints.
281
+ 3. Probe the top candidates in parallel with ``eth_blockNumber``.
282
+ 4. Discard RPCs that are stale (block number lagging behind majority).
283
+ 5. Return up to *max_results* URLs sorted by latency (fastest first).
284
+ """
285
+ nodes = self.get_rpcs(chain_id)
286
+ if not nodes:
287
+ return []
288
+
289
+ existing_normalized = {_normalize_url(u) for u in existing_rpcs}
290
+ candidates = _filter_candidates(nodes, existing_normalized)
291
+ if not candidates:
292
+ return []
293
+
294
+ results = _probe_candidates(candidates)
295
+ if not results:
296
+ return []
297
+
298
+ selected = _rank_and_select(results, candidates, chain_id, max_results)
299
+ return selected
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iwa
3
- Version: 0.0.60
3
+ Version: 0.0.61
4
4
  Summary: A secure, modular, and plugin-based framework for crypto agents and ops
5
5
  Requires-Python: <4.0,>=3.12
6
6
  Description-Content-Type: text/markdown
@@ -1,7 +1,7 @@
1
1
  iwa/__init__.py,sha256=vu12UytYNREtMRvIWp6AfV1GgUe53XWwCMhYyqKAPgo,19
2
2
  iwa/__main__.py,sha256=eJU5Uxeu9Y7shWg5dt5Mcq0pMC4wFVNWjeYGKSf4Apw,88
3
3
  iwa/core/__init__.py,sha256=GJv4LJOXeZ3hgGvbt5I6omkoFkP2A9qhHjpDlOep9ik,24
4
- iwa/core/chainlist.py,sha256=bcbv1P9R-RiCghullse0qVmqkTs1l4_ZOkOHZ0MzjtI,4097
4
+ iwa/core/chainlist.py,sha256=OraylOuaGXR65p7HNkEssjQzQe5as1WxZoRlxQFYVho,10187
5
5
  iwa/core/cli.py,sha256=Qo0SXvgKFOd3Ru-LnX5zEXIaR7r3uwYoqwPhVShEqiQ,8315
6
6
  iwa/core/constants.py,sha256=_CYUVQpR--dRPuxotsmbzQE-22y61tlnjUD7IhlvVVA,997
7
7
  iwa/core/db.py,sha256=WI-mP0tQAmwFPeEi9w7RCa_Mcf_zBfd_7JcbHJwU1aU,10377
@@ -23,8 +23,8 @@ iwa/core/utils.py,sha256=FTYpIdQ1wnugD4lYU4TQ7d7_TlDs4CTUIhEpHGEJph4,4281
23
23
  iwa/core/wallet.py,sha256=xSGFOK5Wzh-ctLGhBMK1BySlXN0Ircpztyk1an21QiQ,13129
24
24
  iwa/core/chain/__init__.py,sha256=XJMmn0ed-_aVkY2iEMKpuTxPgIKBd41dexSVmEZTa-o,1604
25
25
  iwa/core/chain/errors.py,sha256=9SEbhxZ-qASPkzt-DoI51qq0GRJVqRgqgL720gO7a64,1275
26
- iwa/core/chain/interface.py,sha256=ww779Wek8qeIxu5t0v3hcmwXq7dMaxp0TjpW4Eikg8Y,25924
27
- iwa/core/chain/manager.py,sha256=cFEzh6pK5OyVhjhpeMAqhc9RnRDQR1DjIGiGKp-FXBI,1159
26
+ iwa/core/chain/interface.py,sha256=4VA2qRUZ40J38l7edaeGTlOnwcXLxZxFPo1Flitzl3M,28270
27
+ iwa/core/chain/manager.py,sha256=XHwn7ciapFCZVk0rPSJopUqM5Wu3Kpp6XrenkgTE1HA,1397
28
28
  iwa/core/chain/models.py,sha256=WUhAighMKcFdbAUkPU_3dkGbWyAUpRJqXMHLcWFC1xg,5261
29
29
  iwa/core/chain/rate_limiter.py,sha256=Ps1MrR4HHtylxgUAawe6DoC9tuqKagjQdKulqcJD2gs,9093
30
30
  iwa/core/contracts/__init__.py,sha256=P5GFY_pnuI02teqVY2U0t98bn1_SSPAbcAzRMpCdTi4,34
@@ -166,7 +166,7 @@ iwa/web/tests/test_web_endpoints.py,sha256=vA25YghHNB23sbmhD4ciesn_f_okSq0tjlkrS
166
166
  iwa/web/tests/test_web_olas.py,sha256=0CVSsrncOeJ3x0ECV7mVLQV_CXZRrOqGiVjgLIi6hZ8,16308
167
167
  iwa/web/tests/test_web_swap.py,sha256=7A4gBJFL01kIXPtW1E1J17SCsVc_0DmUn-R8kKrnnVA,2974
168
168
  iwa/web/tests/test_web_swap_coverage.py,sha256=zGNrzlhZ_vWDCvWmLcoUwFgqxnrp_ACbo49AtWBS_Kw,5584
169
- iwa-0.0.60.dist-info/licenses/LICENSE,sha256=eIubm_IlBHPYRQlLNZKbBNKhJUUP3JH0A2miZUhAVfI,1078
169
+ iwa-0.0.61.dist-info/licenses/LICENSE,sha256=eIubm_IlBHPYRQlLNZKbBNKhJUUP3JH0A2miZUhAVfI,1078
170
170
  tests/legacy_cow.py,sha256=oOkZvIxL70ReEoD9oHQbOD5GpjIr6AGNHcOCgfPlerU,8389
171
171
  tests/legacy_safe.py,sha256=AssM2g13E74dNGODu_H0Q0y412lgqsrYnEzI97nm_Ts,2972
172
172
  tests/legacy_transaction_retry_logic.py,sha256=D9RqZ7DBu61Xr2djBAodU2p9UE939LL-DnQXswX5iQk,1497
@@ -178,6 +178,7 @@ tests/test_balance_service.py,sha256=wcuCOVszxPy8nPkldAVcEiygcOK3BuQt797fqAJvbp4
178
178
  tests/test_chain.py,sha256=VZoidSojWyt1y4mQdZdoZsjuuDZjLC6neTC-2SF_Q7I,13957
179
179
  tests/test_chain_interface.py,sha256=bgqGM8wJGZjc-BOX6i0K4sh06KCJl-6UAvrwl8x24lA,8324
180
180
  tests/test_chain_interface_coverage.py,sha256=fvrVvw8-DMwdsSFKQHUhpbfutrVRxnnTc-tjB7Bb-jo,3327
181
+ tests/test_chainlist_enrichment.py,sha256=P7WctRhZ0sBTlsHsJXj5hv97CzgaEH556Nir2J8vWpg,12928
181
182
  tests/test_cli.py,sha256=Pl4RC2xp1omiJUnL3Dza6pCmIoO29LJ0vGw33_ZpT5c,3980
182
183
  tests/test_contract.py,sha256=tApHAxsfKGawYJWA9PhTNrOZUE0VVAq79ruIe3KxeWY,14412
183
184
  tests/test_db.py,sha256=dmbrupj0qlUeiiycZ2mzMFjf7HrDa6tcqMPY8zpiKIk,5710
@@ -202,7 +203,7 @@ tests/test_rpc_rate_limit.py,sha256=3P_Nd9voFmz-4r_Et-vw8W-Esbq5elSYmRBSOtJGx1Y,
202
203
  tests/test_rpc_rotation.py,sha256=a1cFKsf0fo-73_MSDnTuU6Zpv7bJHjrCVu3ANe8PXDU,12541
203
204
  tests/test_rpc_view.py,sha256=sgZ53KEHl8VGb7WKYa0VI7Cdxbf8JH1SdroHYbWHjfQ,2031
204
205
  tests/test_safe_coverage.py,sha256=KBxKz64XkK8CgN0N0LTNVKakf8Wg8EpghcBlLmDFmLs,6119
205
- tests/test_safe_executor.py,sha256=V3ovBRY1lOuW5rm8rpm5Ns7jb-rgmHKHpz9pMTqz6c4,14448
206
+ tests/test_safe_executor.py,sha256=HYdyWdoKs3xK-1wJ9QGcLx58Dk2GV4-IpDqE9hUhA8I,22149
206
207
  tests/test_safe_integration.py,sha256=WWAKDio3N-CFyr5RRvphbOPdu3TI9WSM8IesfbFbvWQ,5363
207
208
  tests/test_safe_service.py,sha256=5ULlj0fPZRwg-4fCBJplhm4Msr_Beof7W-Zf_JljZc8,5782
208
209
  tests/test_service_manager_integration.py,sha256=I_BLUzEKrVTyg_8jqsUK0oFD3aQVPCRJ7z0gY8P-j04,2354
@@ -211,7 +212,7 @@ tests/test_service_transaction.py,sha256=IeqYhmRD-pIXffBJrBQwfPx-qnfNEJs0iPM3eCb
211
212
  tests/test_staking_router.py,sha256=cnOtwWeQPu09kecVhlCf1WA4ONqs13OcQJhJCx2EOPY,3067
212
213
  tests/test_staking_simple.py,sha256=NHyZ1pcVQEJGFiGseC5m6Y9Y6FJGnRIFJUwhd1hAV9g,1138
213
214
  tests/test_tables.py,sha256=1KQHgxuizoOrRxpubDdnzk9iaU5Lwyp3bcWP_hZD5uU,2686
214
- tests/test_transaction_service.py,sha256=TXhIleUNOnp3DXi-RrKJ1Y_6dA6de5TQLOc9qndMHm4,5765
215
+ tests/test_transaction_service.py,sha256=q2IQ6cJ6sZtzc_pVCM_dv0vW7LW2sONNrK5Pvrm63rU,12816
215
216
  tests/test_transfer_multisend.py,sha256=PErjNqNwN66TMh4oVa307re64Ucccg1LkXqB0KlkmsI,6677
216
217
  tests/test_transfer_native.py,sha256=cDbb4poV_veIw6eHpokrHe9yUndOjA6rQhrHd_IY3HQ,7445
217
218
  tests/test_transfer_security.py,sha256=gdpC6ybdXQbQgILbAQ0GqjWdwn9AJRNR3B_7TYg0NxI,3617
@@ -222,8 +223,8 @@ tests/test_utils.py,sha256=vkP49rYNI8BRzLpWR3WnKdDr8upeZjZcs7Rx0pjbQMo,1292
222
223
  tests/test_workers.py,sha256=MInwdkFY5LdmFB3o1odIaSD7AQZb3263hNafO1De5PE,2793
223
224
  tools/create_and_stake_service.py,sha256=1xwy_bJQI1j9yIQ968Oc9Db_F6mk1659LuuZntTASDE,3742
224
225
  tools/verify_drain.py,sha256=PkMjblyOOAuQge88FwfEzRtCYeEtJxXhPBmtQYCoQ-8,6743
225
- iwa-0.0.60.dist-info/METADATA,sha256=BpkWW6DmPQhaX7fFo7LtVfNYg3thQWSMiMmR_yGApCc,7337
226
- iwa-0.0.60.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
227
- iwa-0.0.60.dist-info/entry_points.txt,sha256=nwB6kscrfA7M00pYmL2j-sBH6eF6h2ga9IK1BZxdiyQ,241
228
- iwa-0.0.60.dist-info/top_level.txt,sha256=kedS9cRUbm4JE2wYeabIXilhHjN8KCw0IGbqqqsw0Bs,16
229
- iwa-0.0.60.dist-info/RECORD,,
226
+ iwa-0.0.61.dist-info/METADATA,sha256=FLMOGU9L47jQQ45Cd7i64bfZNXCtNidn1ryV4-uBjCA,7337
227
+ iwa-0.0.61.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
228
+ iwa-0.0.61.dist-info/entry_points.txt,sha256=nwB6kscrfA7M00pYmL2j-sBH6eF6h2ga9IK1BZxdiyQ,241
229
+ iwa-0.0.61.dist-info/top_level.txt,sha256=kedS9cRUbm4JE2wYeabIXilhHjN8KCw0IGbqqqsw0Bs,16
230
+ iwa-0.0.61.dist-info/RECORD,,
@@ -0,0 +1,354 @@
1
+ """Tests for ChainList RPC enrichment and quality probing."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import pytest
6
+ import requests
7
+
8
+ from iwa.core.chainlist import (
9
+ ChainlistRPC,
10
+ RPCNode,
11
+ _is_template_url,
12
+ _normalize_url,
13
+ probe_rpc,
14
+ )
15
+
16
+
17
+ @pytest.fixture(autouse=True)
18
+ def mock_chainlist_enrichment():
19
+ """Override conftest — allow real enrichment calls in this test file."""
20
+ yield
21
+
22
+
23
+ class TestNormalizeUrl:
24
+ """Test URL normalization for deduplication."""
25
+
26
+ def test_strips_trailing_slash(self):
27
+ assert _normalize_url("https://rpc.example.com/") == "https://rpc.example.com"
28
+
29
+ def test_lowercases(self):
30
+ assert _normalize_url("https://RPC.Example.COM") == "https://rpc.example.com"
31
+
32
+ def test_no_change_needed(self):
33
+ assert _normalize_url("https://rpc.example.com") == "https://rpc.example.com"
34
+
35
+
36
+ class TestIsTemplateUrl:
37
+ """Test template URL detection."""
38
+
39
+ def test_dollar_brace(self):
40
+ assert _is_template_url("https://rpc.example.com/${API_KEY}") is True
41
+
42
+ def test_plain_brace(self):
43
+ assert _is_template_url("https://rpc.example.com/{api_key}") is True
44
+
45
+ def test_no_template(self):
46
+ assert _is_template_url("https://rpc.example.com") is False
47
+
48
+
49
+ class TestProbeRpc:
50
+ """Test single RPC probing."""
51
+
52
+ @pytest.fixture
53
+ def mock_session(self):
54
+ """Create a mock session for probe_rpc tests."""
55
+ with patch("iwa.core.chainlist.requests.Session") as mock_session_cls:
56
+ mock_session = MagicMock()
57
+ mock_session_cls.return_value = mock_session
58
+ # Make it work as context manager too
59
+ mock_session.__enter__ = MagicMock(return_value=mock_session)
60
+ mock_session.__exit__ = MagicMock(return_value=False)
61
+ yield mock_session
62
+
63
+ def test_success(self, mock_session):
64
+ mock_resp = MagicMock()
65
+ mock_resp.json.return_value = {"jsonrpc": "2.0", "result": "0x1A4B5C", "id": 1}
66
+ mock_session.post.return_value = mock_resp
67
+
68
+ result = probe_rpc("https://rpc.example.com")
69
+
70
+ assert result is not None
71
+ url, latency, block = result
72
+ assert url == "https://rpc.example.com"
73
+ assert latency > 0
74
+ assert block == 0x1A4B5C
75
+ # Verify session was closed
76
+ mock_session.close.assert_called_once()
77
+
78
+ def test_timeout_returns_none(self, mock_session):
79
+ mock_session.post.side_effect = requests.exceptions.Timeout("timed out")
80
+
81
+ result = probe_rpc("https://slow.example.com")
82
+ assert result is None
83
+ # Session still closed on error
84
+ mock_session.close.assert_called_once()
85
+
86
+ def test_connection_error_returns_none(self, mock_session):
87
+ mock_session.post.side_effect = requests.exceptions.ConnectionError("refused")
88
+
89
+ result = probe_rpc("https://dead.example.com")
90
+ assert result is None
91
+ mock_session.close.assert_called_once()
92
+
93
+ def test_zero_block_returns_none(self, mock_session):
94
+ mock_resp = MagicMock()
95
+ mock_resp.json.return_value = {"jsonrpc": "2.0", "result": "0x0", "id": 1}
96
+ mock_session.post.return_value = mock_resp
97
+
98
+ result = probe_rpc("https://rpc.example.com")
99
+ assert result is None
100
+
101
+ def test_null_result_returns_none(self, mock_session):
102
+ mock_resp = MagicMock()
103
+ mock_resp.json.return_value = {"jsonrpc": "2.0", "result": None, "id": 1}
104
+ mock_session.post.return_value = mock_resp
105
+
106
+ result = probe_rpc("https://rpc.example.com")
107
+ assert result is None
108
+
109
+ def test_error_response_returns_none(self, mock_session):
110
+ mock_resp = MagicMock()
111
+ mock_resp.json.return_value = {
112
+ "jsonrpc": "2.0",
113
+ "error": {"code": -32600, "message": "Invalid Request"},
114
+ "id": 1,
115
+ }
116
+ mock_session.post.return_value = mock_resp
117
+
118
+ result = probe_rpc("https://rpc.example.com")
119
+ assert result is None
120
+
121
+ def test_uses_provided_session(self):
122
+ """When a session is provided, use it instead of creating one."""
123
+ provided_session = MagicMock()
124
+ mock_resp = MagicMock()
125
+ mock_resp.json.return_value = {"jsonrpc": "2.0", "result": "0x100", "id": 1}
126
+ provided_session.post.return_value = mock_resp
127
+
128
+ result = probe_rpc("https://rpc.example.com", session=provided_session)
129
+
130
+ assert result is not None
131
+ provided_session.post.assert_called_once()
132
+ # Should NOT close provided session (caller's responsibility)
133
+ provided_session.close.assert_not_called()
134
+
135
+
136
+ class TestGetValidatedRpcs:
137
+ """Test ChainlistRPC.get_validated_rpcs()."""
138
+
139
+ def _make_node(self, url, tracking="none"):
140
+ return RPCNode(url=url, is_working=True, tracking=tracking)
141
+
142
+ @patch.object(ChainlistRPC, "get_rpcs")
143
+ @patch("iwa.core.chainlist.probe_rpc")
144
+ def test_filters_template_urls(self, mock_probe, mock_get_rpcs):
145
+ mock_get_rpcs.return_value = [
146
+ self._make_node("https://rpc.example.com/${API_KEY}"),
147
+ self._make_node("https://good.example.com"),
148
+ ]
149
+ mock_probe.return_value = ("https://good.example.com", 50.0, 1000)
150
+
151
+ cl = ChainlistRPC()
152
+ result = cl.get_validated_rpcs(100, existing_rpcs=[])
153
+
154
+ assert result == ["https://good.example.com"]
155
+
156
+ @patch.object(ChainlistRPC, "get_rpcs")
157
+ @patch("iwa.core.chainlist.probe_rpc")
158
+ def test_filters_non_https(self, mock_probe, mock_get_rpcs):
159
+ mock_get_rpcs.return_value = [
160
+ self._make_node("http://insecure.example.com"),
161
+ self._make_node("wss://ws.example.com"),
162
+ self._make_node("https://good.example.com"),
163
+ ]
164
+ mock_probe.return_value = ("https://good.example.com", 50.0, 1000)
165
+
166
+ cl = ChainlistRPC()
167
+ result = cl.get_validated_rpcs(100, existing_rpcs=[])
168
+
169
+ assert result == ["https://good.example.com"]
170
+
171
+ @patch.object(ChainlistRPC, "get_rpcs")
172
+ @patch("iwa.core.chainlist.probe_rpc")
173
+ def test_deduplicates_existing(self, mock_probe, mock_get_rpcs):
174
+ mock_get_rpcs.return_value = [
175
+ self._make_node("https://already.configured.com"),
176
+ self._make_node("https://new.example.com"),
177
+ ]
178
+ mock_probe.return_value = ("https://new.example.com", 50.0, 1000)
179
+
180
+ cl = ChainlistRPC()
181
+ result = cl.get_validated_rpcs(
182
+ 100, existing_rpcs=["https://already.configured.com/"]
183
+ )
184
+
185
+ assert result == ["https://new.example.com"]
186
+
187
+ @patch.object(ChainlistRPC, "get_rpcs")
188
+ @patch("iwa.core.chainlist.probe_rpc")
189
+ def test_filters_stale_rpcs(self, mock_probe, mock_get_rpcs):
190
+ mock_get_rpcs.return_value = [
191
+ self._make_node("https://fresh.example.com"),
192
+ self._make_node("https://stale.example.com"),
193
+ self._make_node("https://also-fresh.example.com"),
194
+ ]
195
+ # fresh=1000, stale=900 (100 blocks behind), also-fresh=999
196
+ mock_probe.side_effect = [
197
+ ("https://fresh.example.com", 50.0, 1000),
198
+ ("https://stale.example.com", 30.0, 900),
199
+ ("https://also-fresh.example.com", 60.0, 999),
200
+ ]
201
+
202
+ cl = ChainlistRPC()
203
+ result = cl.get_validated_rpcs(100, existing_rpcs=[])
204
+
205
+ # Stale RPC (900) is 100 blocks behind median (999) > MAX_BLOCK_LAG
206
+ assert "https://stale.example.com" not in result
207
+ assert "https://fresh.example.com" in result
208
+ assert "https://also-fresh.example.com" in result
209
+
210
+ @patch.object(ChainlistRPC, "get_rpcs")
211
+ @patch("iwa.core.chainlist.probe_rpc")
212
+ def test_sorts_by_latency(self, mock_probe, mock_get_rpcs):
213
+ mock_get_rpcs.return_value = [
214
+ self._make_node("https://slow.example.com"),
215
+ self._make_node("https://fast.example.com"),
216
+ self._make_node("https://medium.example.com"),
217
+ ]
218
+ mock_probe.side_effect = [
219
+ ("https://slow.example.com", 200.0, 1000),
220
+ ("https://fast.example.com", 10.0, 1000),
221
+ ("https://medium.example.com", 80.0, 1000),
222
+ ]
223
+
224
+ cl = ChainlistRPC()
225
+ result = cl.get_validated_rpcs(100, existing_rpcs=[])
226
+
227
+ assert result == [
228
+ "https://fast.example.com",
229
+ "https://medium.example.com",
230
+ "https://slow.example.com",
231
+ ]
232
+
233
+ @patch.object(ChainlistRPC, "get_rpcs")
234
+ @patch("iwa.core.chainlist.probe_rpc")
235
+ def test_respects_max_results(self, mock_probe, mock_get_rpcs):
236
+ nodes = [self._make_node(f"https://rpc{i}.example.com") for i in range(10)]
237
+ mock_get_rpcs.return_value = nodes
238
+ mock_probe.side_effect = [
239
+ (f"https://rpc{i}.example.com", float(i * 10), 1000) for i in range(10)
240
+ ]
241
+
242
+ cl = ChainlistRPC()
243
+ result = cl.get_validated_rpcs(100, existing_rpcs=[], max_results=3)
244
+
245
+ assert len(result) == 3
246
+
247
+ @patch.object(ChainlistRPC, "get_rpcs")
248
+ def test_returns_empty_on_no_rpcs(self, mock_get_rpcs):
249
+ mock_get_rpcs.return_value = []
250
+
251
+ cl = ChainlistRPC()
252
+ result = cl.get_validated_rpcs(100, existing_rpcs=[])
253
+
254
+ assert result == []
255
+
256
+ @patch.object(ChainlistRPC, "get_rpcs")
257
+ @patch("iwa.core.chainlist.probe_rpc")
258
+ def test_returns_empty_when_all_probes_fail(self, mock_probe, mock_get_rpcs):
259
+ mock_get_rpcs.return_value = [
260
+ self._make_node("https://dead1.example.com"),
261
+ self._make_node("https://dead2.example.com"),
262
+ ]
263
+ mock_probe.return_value = None
264
+
265
+ cl = ChainlistRPC()
266
+ result = cl.get_validated_rpcs(100, existing_rpcs=[])
267
+
268
+ assert result == []
269
+
270
+
271
+ class TestEnrichFromChainlist:
272
+ """Test ChainInterface._enrich_rpcs_from_chainlist().
273
+
274
+ The conftest fixture is overridden in this file so the real
275
+ enrichment method runs during __init__.
276
+ """
277
+
278
+ @patch("iwa.core.chain.interface.Web3")
279
+ def test_skipped_for_tenderly(self, mock_web3):
280
+ from iwa.core.chain.interface import ChainInterface
281
+ from iwa.core.chain.models import SupportedChain
282
+
283
+ chain = MagicMock(spec=SupportedChain)
284
+ chain.name = "TestChain"
285
+ chain.rpcs = ["https://virtual.tenderly.co/test"]
286
+ chain.rpc = "https://virtual.tenderly.co/test"
287
+ chain.chain_id = 100
288
+
289
+ with patch("iwa.core.chainlist.ChainlistRPC") as mock_cl_cls:
290
+ ci = ChainInterface(chain)
291
+
292
+ # is_tenderly=True → enrichment skipped → ChainlistRPC never called
293
+ assert "tenderly" in ci.current_rpc.lower()
294
+ mock_cl_cls.assert_not_called()
295
+
296
+ @patch("iwa.core.chain.interface.Web3")
297
+ def test_enriches_non_tenderly(self, mock_web3):
298
+ from iwa.core.chain.interface import ChainInterface
299
+ from iwa.core.chain.models import SupportedChain
300
+
301
+ chain = MagicMock(spec=SupportedChain)
302
+ chain.name = "TestChain"
303
+ chain.rpcs = ["https://rpc1.example.com"]
304
+ chain.rpc = "https://rpc1.example.com"
305
+ chain.chain_id = 100
306
+
307
+ with patch("iwa.core.chainlist.ChainlistRPC") as mock_cl_cls:
308
+ mock_cl = mock_cl_cls.return_value
309
+ mock_cl.get_validated_rpcs.return_value = [
310
+ "https://extra1.example.com",
311
+ "https://extra2.example.com",
312
+ ]
313
+ ChainInterface(chain)
314
+
315
+ assert len(chain.rpcs) == 3
316
+ assert "https://extra1.example.com" in chain.rpcs
317
+ assert "https://extra2.example.com" in chain.rpcs
318
+ # Original RPC stays first
319
+ assert chain.rpcs[0] == "https://rpc1.example.com"
320
+
321
+ @patch("iwa.core.chain.interface.Web3")
322
+ def test_survives_fetch_failure(self, mock_web3):
323
+ from iwa.core.chain.interface import ChainInterface
324
+ from iwa.core.chain.models import SupportedChain
325
+
326
+ chain = MagicMock(spec=SupportedChain)
327
+ chain.name = "TestChain"
328
+ chain.rpcs = ["https://rpc1.example.com"]
329
+ chain.rpc = "https://rpc1.example.com"
330
+ chain.chain_id = 100
331
+
332
+ with patch("iwa.core.chainlist.ChainlistRPC") as mock_cl_cls:
333
+ mock_cl_cls.side_effect = Exception("network error")
334
+ ChainInterface(chain)
335
+
336
+ # Should still work with original RPCs
337
+ assert chain.rpcs == ["https://rpc1.example.com"]
338
+
339
+ @patch("iwa.core.chain.interface.Web3")
340
+ def test_respects_max_rpcs(self, mock_web3):
341
+ from iwa.core.chain.interface import ChainInterface
342
+ from iwa.core.chain.models import SupportedChain
343
+
344
+ chain = MagicMock(spec=SupportedChain)
345
+ chain.name = "TestChain"
346
+ chain.rpcs = [f"https://rpc{i}.example.com" for i in range(10)]
347
+ chain.rpc = "https://rpc0.example.com"
348
+ chain.chain_id = 100
349
+
350
+ with patch("iwa.core.chainlist.ChainlistRPC") as mock_cl_cls:
351
+ ChainInterface(chain)
352
+
353
+ # Already at MAX_RPCS=10, ChainlistRPC should not be called
354
+ mock_cl_cls.assert_not_called()
@@ -359,3 +359,211 @@ def test_retry_preserves_gas(executor, mock_chain_interface, mock_safe_tx, mock_
359
359
  executor.execute_with_retry("0xSafe", mock_safe_tx, ["key1"])
360
360
 
361
361
  assert mock_safe_tx.safe_tx_gas == original_gas
362
+
363
+
364
+ # =============================================================================
365
+ # Test: Gas estimation (_estimate_safe_tx_gas)
366
+ # =============================================================================
367
+
368
+
369
+ def test_estimate_safe_tx_gas_with_buffer(executor, mock_safe):
370
+ """Test gas estimation applies buffer correctly."""
371
+ mock_safe.estimate_tx_gas.return_value = 100_000
372
+ mock_safe_tx = MagicMock()
373
+ mock_safe_tx.to = "0xDest"
374
+ mock_safe_tx.value = 0
375
+ mock_safe_tx.data = b""
376
+ mock_safe_tx.operation = 0
377
+
378
+ result = executor._estimate_safe_tx_gas(mock_safe, mock_safe_tx)
379
+
380
+ # Default buffer is 1.5, so 100000 * 1.5 = 150000
381
+ assert result == 150_000
382
+
383
+
384
+ def test_estimate_safe_tx_gas_caps_at_10x(executor, mock_safe):
385
+ """Test gas estimation respects x10 cap when base_estimate is provided."""
386
+ mock_safe.estimate_tx_gas.return_value = 500_000 # High estimate
387
+ mock_safe_tx = MagicMock()
388
+ mock_safe_tx.to = "0xDest"
389
+ mock_safe_tx.value = 0
390
+ mock_safe_tx.data = b""
391
+ mock_safe_tx.operation = 0
392
+
393
+ # 500000 * 1.5 = 750000, but base_estimate * 10 = 50000
394
+ result = executor._estimate_safe_tx_gas(mock_safe, mock_safe_tx, base_estimate=5_000)
395
+
396
+ # Should be capped at 5000 * 10 = 50000
397
+ assert result == 50_000
398
+
399
+
400
+ def test_estimate_safe_tx_gas_fallback_on_failure(executor, mock_safe):
401
+ """Test gas estimation uses fallback when estimation fails."""
402
+ mock_safe.estimate_tx_gas.side_effect = Exception("Estimation failed")
403
+ mock_safe_tx = MagicMock()
404
+ mock_safe_tx.to = "0xDest"
405
+ mock_safe_tx.value = 0
406
+ mock_safe_tx.data = b""
407
+ mock_safe_tx.operation = 0
408
+
409
+ result = executor._estimate_safe_tx_gas(mock_safe, mock_safe_tx)
410
+
411
+ assert result == executor.DEFAULT_FALLBACK_GAS
412
+
413
+
414
+ # =============================================================================
415
+ # Test: Error decoding (_decode_revert_reason)
416
+ # =============================================================================
417
+
418
+
419
+ def test_decode_revert_reason_with_hex_data(executor):
420
+ """Test decoding when error contains hex data."""
421
+ # Create an error with hex data that might be decodable
422
+ error = ValueError("execution reverted: 0x08c379a0...")
423
+
424
+ with patch("iwa.core.services.safe_executor.ErrorDecoder") as mock_decoder:
425
+ mock_decoder.return_value.decode.return_value = [("Error", "Insufficient balance", "ERC20")]
426
+ result = executor._decode_revert_reason(error)
427
+
428
+ # Note: Due to hex matching, this should find the data and attempt decode
429
+ assert result == "Insufficient balance (from ERC20)"
430
+
431
+
432
+ def test_decode_revert_reason_no_hex_data(executor):
433
+ """Test decoding when error has no hex data."""
434
+ error = ValueError("Some generic error without hex")
435
+
436
+ result = executor._decode_revert_reason(error)
437
+
438
+ assert result is None
439
+
440
+
441
+ def test_decode_revert_reason_decode_fails(executor):
442
+ """Test decoding when decoder returns None."""
443
+ error = ValueError("error: 0xdeadbeef")
444
+
445
+ with patch("iwa.core.services.safe_executor.ErrorDecoder") as mock_decoder:
446
+ mock_decoder.return_value.decode.return_value = None
447
+ result = executor._decode_revert_reason(error)
448
+
449
+ assert result is None
450
+
451
+
452
+ # =============================================================================
453
+ # Test: Error classification
454
+ # =============================================================================
455
+
456
+
457
+ def test_classify_error_gas_error(executor):
458
+ """Test classification of gas-related errors."""
459
+ error = ValueError("intrinsic gas too low")
460
+ result = executor._classify_error(error)
461
+
462
+ assert result["is_gas_error"] is True
463
+ assert result["is_nonce_error"] is False
464
+
465
+
466
+ def test_classify_error_revert(executor):
467
+ """Test classification of revert errors."""
468
+ error = ValueError("execution reverted: some reason")
469
+ result = executor._classify_error(error)
470
+
471
+ assert result["is_revert"] is True
472
+
473
+
474
+ def test_classify_error_out_of_gas(executor):
475
+ """Test classification of out of gas errors."""
476
+ error = ValueError("out of gas")
477
+ result = executor._classify_error(error)
478
+
479
+ assert result["is_gas_error"] is True
480
+
481
+
482
+ # =============================================================================
483
+ # Test: Transaction failures
484
+ # =============================================================================
485
+
486
+
487
+ def test_transaction_reverts_onchain(executor, mock_chain_interface, mock_safe_tx, mock_safe):
488
+ """Test handling when transaction is mined but reverts (status 0)."""
489
+ with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
490
+ mock_safe_tx.execute.return_value = b"tx_hash"
491
+ # Receipt with status 0 (reverted)
492
+ mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(
493
+ status=0
494
+ )
495
+
496
+ with patch("time.sleep"):
497
+ success, error, receipt = executor.execute_with_retry(
498
+ "0xSafe", mock_safe_tx, ["key1"]
499
+ )
500
+
501
+ assert success is False
502
+ assert "reverted" in error.lower()
503
+
504
+
505
+ def test_check_receipt_status_dict_format(executor):
506
+ """Test receipt status check with dict-style receipt."""
507
+ # Dict-style receipt (not MagicMock)
508
+ receipt_dict = {"status": 1, "gasUsed": 21000}
509
+ assert executor._check_receipt_status(receipt_dict) is True
510
+
511
+ receipt_dict_failed = {"status": 0}
512
+ assert executor._check_receipt_status(receipt_dict_failed) is False
513
+
514
+
515
+ def test_simulation_revert_not_nonce(executor, mock_chain_interface, mock_safe_tx, mock_safe):
516
+ """Test handling when simulation reverts with non-nonce error."""
517
+ with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
518
+ # Simulation fails with generic revert
519
+ mock_safe_tx.call.side_effect = ValueError("execution reverted: insufficient funds")
520
+
521
+ with patch("time.sleep"):
522
+ success, error, receipt = executor.execute_with_retry(
523
+ "0xSafe", mock_safe_tx, ["key1"]
524
+ )
525
+
526
+ assert success is False
527
+ assert "insufficient funds" in error.lower() or "reverted" in error.lower()
528
+
529
+
530
+ def test_gas_error_strategy_triggers_retry(
531
+ executor, mock_chain_interface, mock_safe_tx, mock_safe
532
+ ):
533
+ """Test that gas errors trigger retry with gas increase strategy."""
534
+ with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
535
+ mock_safe_tx.execute.side_effect = [
536
+ ValueError("intrinsic gas too low"),
537
+ b"tx_hash",
538
+ ]
539
+ mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(
540
+ status=1
541
+ )
542
+
543
+ with patch("time.sleep"):
544
+ success, tx_hash, receipt = executor.execute_with_retry(
545
+ "0xSafe", mock_safe_tx, ["key1"]
546
+ )
547
+
548
+ # Should have retried and succeeded
549
+ assert success is True
550
+ assert mock_safe_tx.execute.call_count == 2
551
+
552
+
553
+ def test_rpc_rotation_stops_when_should_not_retry(
554
+ executor, mock_chain_interface, mock_safe_tx, mock_safe
555
+ ):
556
+ """Test that execution stops when RPC handler says not to retry."""
557
+ with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
558
+ mock_safe_tx.execute.side_effect = ValueError("Rate limit exceeded")
559
+ mock_chain_interface._is_rate_limit_error.return_value = True
560
+ mock_chain_interface._handle_rpc_error.return_value = {"should_retry": False}
561
+
562
+ with patch("time.sleep"):
563
+ success, error, receipt = executor.execute_with_retry(
564
+ "0xSafe", mock_safe_tx, ["key1"]
565
+ )
566
+
567
+ assert success is False
568
+ # Only 1 attempt because should_retry=False
569
+ assert mock_safe_tx.execute.call_count == 1
@@ -1,12 +1,17 @@
1
- """Tests for TransactionService."""
1
+ """Tests for TransactionService and TransferLogger."""
2
2
 
3
3
  from unittest.mock import MagicMock, patch
4
4
 
5
5
  import pytest
6
+ from web3 import Web3
6
7
  from web3 import exceptions as web3_exceptions
7
8
 
8
9
  from iwa.core.keys import EncryptedAccount, KeyStorage
9
- from iwa.core.services.transaction import TransactionService
10
+ from iwa.core.services.transaction import (
11
+ TRANSFER_EVENT_TOPIC,
12
+ TransactionService,
13
+ TransferLogger,
14
+ )
10
15
 
11
16
 
12
17
  @pytest.fixture
@@ -177,3 +182,174 @@ def test_sign_and_send_rpc_rotation(
177
182
  assert success is True
178
183
  # Verify retry happened - send_raw_transaction called twice
179
184
  assert chain_interface.web3.eth.send_raw_transaction.call_count == 2
185
+
186
+
187
+ # =============================================================================
188
+ # TransferLogger tests
189
+ # =============================================================================
190
+
191
+ # Real-world address for tests
192
+ _ADDR = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
193
+ _ADDR_LOWER = _ADDR.lower()
194
+ # 32-byte topic with address in last 20 bytes
195
+ _TOPIC_BYTES = b"\x00" * 12 + bytes.fromhex(_ADDR_LOWER[2:])
196
+ _TOPIC_HEX_STR = "0x" + "0" * 24 + _ADDR_LOWER[2:]
197
+
198
+
199
+ @pytest.fixture
200
+ def transfer_logger():
201
+ """Create a TransferLogger with minimal mocks."""
202
+ account_service = MagicMock()
203
+ account_service.get_tag_by_address.return_value = None
204
+ chain_interface = MagicMock()
205
+ chain_interface.chain.native_currency = "xDAI"
206
+ chain_interface.chain.get_token_name.return_value = None
207
+ chain_interface.get_token_decimals.return_value = 18
208
+ return TransferLogger(account_service, chain_interface)
209
+
210
+
211
+ class TestTopicToAddress:
212
+ """Test TransferLogger._topic_to_address with all input types."""
213
+
214
+ def test_bytes_topic(self, transfer_logger):
215
+ """32 bytes → last 20 bytes extracted as address."""
216
+ result = transfer_logger._topic_to_address(_TOPIC_BYTES)
217
+ assert result == Web3.to_checksum_address(_ADDR_LOWER)
218
+
219
+ def test_hex_string_topic(self, transfer_logger):
220
+ """Hex string with 0x prefix → last 40 chars as address."""
221
+ result = transfer_logger._topic_to_address(_TOPIC_HEX_STR)
222
+ assert result == Web3.to_checksum_address(_ADDR_LOWER)
223
+
224
+ def test_hex_string_no_prefix(self, transfer_logger):
225
+ """Hex string without 0x prefix."""
226
+ topic = "0" * 24 + _ADDR_LOWER[2:]
227
+ result = transfer_logger._topic_to_address(topic)
228
+ assert result == Web3.to_checksum_address(_ADDR_LOWER)
229
+
230
+ def test_hexbytes_like_topic(self, transfer_logger):
231
+ """Object with .hex() method (like HexBytes)."""
232
+
233
+ class FakeHexBytes:
234
+ def hex(self):
235
+ return "0" * 24 + _ADDR_LOWER[2:]
236
+
237
+ result = transfer_logger._topic_to_address(FakeHexBytes())
238
+ assert result == Web3.to_checksum_address(_ADDR_LOWER)
239
+
240
+ def test_unsupported_type_returns_empty(self, transfer_logger):
241
+ """Non-bytes, non-str, no .hex() → empty string."""
242
+ result = transfer_logger._topic_to_address(12345)
243
+ assert result == ""
244
+
245
+
246
+ class TestProcessLog:
247
+ """Test TransferLogger._process_log with realistic log structures."""
248
+
249
+ def _make_transfer_log(self, from_addr, to_addr, amount_wei, token_addr="0xToken"):
250
+ """Build a dict-style Transfer event log."""
251
+ from_topic = "0x" + "0" * 24 + from_addr[2:].lower()
252
+ to_topic = "0x" + "0" * 24 + to_addr[2:].lower()
253
+ data = amount_wei.to_bytes(32, "big")
254
+ return {
255
+ "topics": [TRANSFER_EVENT_TOPIC, from_topic, to_topic],
256
+ "data": data,
257
+ "address": token_addr,
258
+ }
259
+
260
+ def test_parses_erc20_transfer(self, transfer_logger):
261
+ """Valid Transfer log is parsed and logged."""
262
+ log = self._make_transfer_log(
263
+ "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
264
+ "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
265
+ 10**18, # 1 token with 18 decimals
266
+ )
267
+ # Should not raise
268
+ transfer_logger._process_log(log)
269
+
270
+ def test_ignores_non_transfer_event(self, transfer_logger):
271
+ """Log with non-Transfer topic is silently skipped."""
272
+ log = {
273
+ "topics": ["0xdeadbeef" + "0" * 56],
274
+ "data": b"",
275
+ "address": "0xToken",
276
+ }
277
+ # Should not raise or log anything
278
+ transfer_logger._process_log(log)
279
+
280
+ def test_ignores_log_with_no_topics(self, transfer_logger):
281
+ """Log with empty topics is skipped."""
282
+ transfer_logger._process_log({"topics": [], "data": b""})
283
+
284
+ def test_ignores_log_with_insufficient_topics(self, transfer_logger):
285
+ """Transfer event with < 3 topics (missing from/to) is skipped."""
286
+ log = {
287
+ "topics": [TRANSFER_EVENT_TOPIC, "0x" + "0" * 64],
288
+ "data": b"",
289
+ "address": "0xToken",
290
+ }
291
+ transfer_logger._process_log(log)
292
+
293
+ def test_handles_bytes_topics(self, transfer_logger):
294
+ """Log with bytes topics (not hex strings)."""
295
+ from_bytes = b"\x00" * 12 + b"\xAA" * 20
296
+ to_bytes = b"\x00" * 12 + b"\xBB" * 20
297
+ event_topic = bytes.fromhex(TRANSFER_EVENT_TOPIC[2:])
298
+ log = {
299
+ "topics": [event_topic, from_bytes, to_bytes],
300
+ "data": (100).to_bytes(32, "big"),
301
+ "address": "0xTokenAddr",
302
+ }
303
+ transfer_logger._process_log(log)
304
+
305
+ def test_handles_string_data(self, transfer_logger):
306
+ """Log with hex-encoded data string instead of bytes."""
307
+ log = self._make_transfer_log(
308
+ "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
309
+ "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
310
+ 0,
311
+ )
312
+ log["data"] = "0x" + "0" * 64 # String instead of bytes
313
+ transfer_logger._process_log(log)
314
+
315
+
316
+ class TestResolveLabels:
317
+ """Test address/token label resolution fallbacks."""
318
+
319
+ def test_address_label_known_wallet(self, transfer_logger):
320
+ """Known wallet tag is preferred."""
321
+ transfer_logger.account_service.get_tag_by_address.return_value = "my_safe"
322
+ result = transfer_logger._resolve_address_label("0xABC")
323
+ assert result == "my_safe"
324
+
325
+ def test_address_label_known_token(self, transfer_logger):
326
+ """Falls back to token contract name."""
327
+ transfer_logger.account_service.get_tag_by_address.return_value = None
328
+ transfer_logger.chain_interface.chain.get_token_name.return_value = "OLAS"
329
+ result = transfer_logger._resolve_address_label("0xOLAS")
330
+ assert result == "OLAS_contract"
331
+
332
+ def test_address_label_abbreviated(self, transfer_logger):
333
+ """Falls back to abbreviated address."""
334
+ result = transfer_logger._resolve_address_label("0xABCDEF1234567890ABCDEF")
335
+ assert result.startswith("0xABCD")
336
+ assert result.endswith("CDEF")
337
+ assert "..." in result
338
+
339
+ def test_address_label_empty(self, transfer_logger):
340
+ """Empty address returns 'unknown'."""
341
+ assert transfer_logger._resolve_address_label("") == "unknown"
342
+
343
+ def test_token_label_known(self, transfer_logger):
344
+ """Known token returns its name."""
345
+ transfer_logger.chain_interface.chain.get_token_name.return_value = "OLAS"
346
+ assert transfer_logger._resolve_token_label("0xOLAS") == "OLAS"
347
+
348
+ def test_token_label_unknown(self, transfer_logger):
349
+ """Unknown token returns abbreviated address."""
350
+ result = transfer_logger._resolve_token_label("0xABCDEF1234567890ABCDEF")
351
+ assert "..." in result
352
+
353
+ def test_token_label_empty(self, transfer_logger):
354
+ """Empty address returns 'UNKNOWN'."""
355
+ assert transfer_logger._resolve_token_label("") == "UNKNOWN"
File without changes