brawny 0.1.13__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 (141) hide show
  1. brawny/__init__.py +106 -0
  2. brawny/_context.py +232 -0
  3. brawny/_rpc/__init__.py +38 -0
  4. brawny/_rpc/broadcast.py +172 -0
  5. brawny/_rpc/clients.py +98 -0
  6. brawny/_rpc/context.py +49 -0
  7. brawny/_rpc/errors.py +252 -0
  8. brawny/_rpc/gas.py +158 -0
  9. brawny/_rpc/manager.py +982 -0
  10. brawny/_rpc/selector.py +156 -0
  11. brawny/accounts.py +534 -0
  12. brawny/alerts/__init__.py +132 -0
  13. brawny/alerts/abi_resolver.py +530 -0
  14. brawny/alerts/base.py +152 -0
  15. brawny/alerts/context.py +271 -0
  16. brawny/alerts/contracts.py +635 -0
  17. brawny/alerts/encoded_call.py +201 -0
  18. brawny/alerts/errors.py +267 -0
  19. brawny/alerts/events.py +680 -0
  20. brawny/alerts/function_caller.py +364 -0
  21. brawny/alerts/health.py +185 -0
  22. brawny/alerts/routing.py +118 -0
  23. brawny/alerts/send.py +364 -0
  24. brawny/api.py +660 -0
  25. brawny/chain.py +93 -0
  26. brawny/cli/__init__.py +16 -0
  27. brawny/cli/app.py +17 -0
  28. brawny/cli/bootstrap.py +37 -0
  29. brawny/cli/commands/__init__.py +41 -0
  30. brawny/cli/commands/abi.py +93 -0
  31. brawny/cli/commands/accounts.py +632 -0
  32. brawny/cli/commands/console.py +495 -0
  33. brawny/cli/commands/contract.py +139 -0
  34. brawny/cli/commands/health.py +112 -0
  35. brawny/cli/commands/init_project.py +86 -0
  36. brawny/cli/commands/intents.py +130 -0
  37. brawny/cli/commands/job_dev.py +254 -0
  38. brawny/cli/commands/jobs.py +308 -0
  39. brawny/cli/commands/logs.py +87 -0
  40. brawny/cli/commands/maintenance.py +182 -0
  41. brawny/cli/commands/migrate.py +51 -0
  42. brawny/cli/commands/networks.py +253 -0
  43. brawny/cli/commands/run.py +249 -0
  44. brawny/cli/commands/script.py +209 -0
  45. brawny/cli/commands/signer.py +248 -0
  46. brawny/cli/helpers.py +265 -0
  47. brawny/cli_templates.py +1445 -0
  48. brawny/config/__init__.py +74 -0
  49. brawny/config/models.py +404 -0
  50. brawny/config/parser.py +633 -0
  51. brawny/config/routing.py +55 -0
  52. brawny/config/validation.py +246 -0
  53. brawny/daemon/__init__.py +14 -0
  54. brawny/daemon/context.py +69 -0
  55. brawny/daemon/core.py +702 -0
  56. brawny/daemon/loops.py +327 -0
  57. brawny/db/__init__.py +78 -0
  58. brawny/db/base.py +986 -0
  59. brawny/db/base_new.py +165 -0
  60. brawny/db/circuit_breaker.py +97 -0
  61. brawny/db/global_cache.py +298 -0
  62. brawny/db/mappers.py +182 -0
  63. brawny/db/migrate.py +349 -0
  64. brawny/db/migrations/001_init.sql +186 -0
  65. brawny/db/migrations/002_add_included_block.sql +7 -0
  66. brawny/db/migrations/003_add_broadcast_at.sql +10 -0
  67. brawny/db/migrations/004_broadcast_binding.sql +20 -0
  68. brawny/db/migrations/005_add_retry_after.sql +9 -0
  69. brawny/db/migrations/006_add_retry_count_column.sql +11 -0
  70. brawny/db/migrations/007_add_gap_tracking.sql +18 -0
  71. brawny/db/migrations/008_add_transactions.sql +72 -0
  72. brawny/db/migrations/009_add_intent_metadata.sql +5 -0
  73. brawny/db/migrations/010_add_nonce_gap_index.sql +9 -0
  74. brawny/db/migrations/011_add_job_logs.sql +24 -0
  75. brawny/db/migrations/012_add_claimed_by.sql +5 -0
  76. brawny/db/ops/__init__.py +29 -0
  77. brawny/db/ops/attempts.py +108 -0
  78. brawny/db/ops/blocks.py +83 -0
  79. brawny/db/ops/cache.py +93 -0
  80. brawny/db/ops/intents.py +296 -0
  81. brawny/db/ops/jobs.py +110 -0
  82. brawny/db/ops/logs.py +97 -0
  83. brawny/db/ops/nonces.py +322 -0
  84. brawny/db/postgres.py +2535 -0
  85. brawny/db/postgres_new.py +196 -0
  86. brawny/db/queries.py +584 -0
  87. brawny/db/sqlite.py +2733 -0
  88. brawny/db/sqlite_new.py +191 -0
  89. brawny/history.py +126 -0
  90. brawny/interfaces.py +136 -0
  91. brawny/invariants.py +155 -0
  92. brawny/jobs/__init__.py +26 -0
  93. brawny/jobs/base.py +287 -0
  94. brawny/jobs/discovery.py +233 -0
  95. brawny/jobs/job_validation.py +111 -0
  96. brawny/jobs/kv.py +125 -0
  97. brawny/jobs/registry.py +283 -0
  98. brawny/keystore.py +484 -0
  99. brawny/lifecycle.py +551 -0
  100. brawny/logging.py +290 -0
  101. brawny/metrics.py +594 -0
  102. brawny/model/__init__.py +53 -0
  103. brawny/model/contexts.py +319 -0
  104. brawny/model/enums.py +70 -0
  105. brawny/model/errors.py +194 -0
  106. brawny/model/events.py +93 -0
  107. brawny/model/startup.py +20 -0
  108. brawny/model/types.py +483 -0
  109. brawny/networks/__init__.py +96 -0
  110. brawny/networks/config.py +269 -0
  111. brawny/networks/manager.py +423 -0
  112. brawny/obs/__init__.py +67 -0
  113. brawny/obs/emit.py +158 -0
  114. brawny/obs/health.py +175 -0
  115. brawny/obs/heartbeat.py +133 -0
  116. brawny/reconciliation.py +108 -0
  117. brawny/scheduler/__init__.py +19 -0
  118. brawny/scheduler/poller.py +472 -0
  119. brawny/scheduler/reorg.py +632 -0
  120. brawny/scheduler/runner.py +708 -0
  121. brawny/scheduler/shutdown.py +371 -0
  122. brawny/script_tx.py +297 -0
  123. brawny/scripting.py +251 -0
  124. brawny/startup.py +76 -0
  125. brawny/telegram.py +393 -0
  126. brawny/testing.py +108 -0
  127. brawny/tx/__init__.py +41 -0
  128. brawny/tx/executor.py +1071 -0
  129. brawny/tx/fees.py +50 -0
  130. brawny/tx/intent.py +423 -0
  131. brawny/tx/monitor.py +628 -0
  132. brawny/tx/nonce.py +498 -0
  133. brawny/tx/replacement.py +456 -0
  134. brawny/tx/utils.py +26 -0
  135. brawny/utils.py +205 -0
  136. brawny/validation.py +69 -0
  137. brawny-0.1.13.dist-info/METADATA +156 -0
  138. brawny-0.1.13.dist-info/RECORD +141 -0
  139. brawny-0.1.13.dist-info/WHEEL +5 -0
  140. brawny-0.1.13.dist-info/entry_points.txt +2 -0
  141. brawny-0.1.13.dist-info/top_level.txt +1 -0
@@ -0,0 +1,680 @@
1
+ """Event decoding for the Alerts extension.
2
+
3
+ Provides receipt-scoped event decoding with named attribute access.
4
+ Events are ONLY decoded from receipt logs, never from block-wide scans.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import TYPE_CHECKING, Any, Iterator
11
+
12
+ from eth_abi import decode as abi_decode
13
+ from eth_utils import event_abi_to_log_topic, to_checksum_address
14
+ from hexbytes import HexBytes
15
+
16
+ from brawny.alerts.errors import (
17
+ EventNotFoundError,
18
+ ReceiptRequiredError,
19
+ )
20
+
21
+ if TYPE_CHECKING:
22
+ from brawny.jobs.base import TxReceipt
23
+
24
+
25
+ class AttributeDict(dict):
26
+ """Dictionary that allows attribute-style access to values.
27
+
28
+ Used for accessing decoded event arguments by name.
29
+
30
+ Example:
31
+ event.args.amount # Instead of event.args['amount']
32
+ """
33
+
34
+ def __getattr__(self, name: str) -> Any:
35
+ try:
36
+ return self[name]
37
+ except KeyError:
38
+ raise AttributeError(f"No attribute or key '{name}'")
39
+
40
+ def __setattr__(self, name: str, value: Any) -> None:
41
+ self[name] = value
42
+
43
+
44
+ @dataclass
45
+ class LogEntry:
46
+ """Raw log entry from transaction receipt."""
47
+
48
+ address: str
49
+ topics: list[str]
50
+ data: str
51
+ block_number: int
52
+ transaction_hash: str
53
+ log_index: int
54
+ block_hash: str = ""
55
+ removed: bool = False
56
+
57
+ @classmethod
58
+ def from_dict(cls, d: dict[str, Any]) -> LogEntry:
59
+ """Create from receipt log dictionary."""
60
+ return cls(
61
+ address=d.get("address", ""),
62
+ topics=[t if isinstance(t, str) else t.hex() for t in d.get("topics", [])],
63
+ data=d.get("data", "0x"),
64
+ block_number=d.get("blockNumber", 0),
65
+ transaction_hash=d.get("transactionHash", ""),
66
+ log_index=d.get("logIndex", 0),
67
+ block_hash=d.get("blockHash", ""),
68
+ removed=d.get("removed", False),
69
+ )
70
+
71
+
72
+ @dataclass
73
+ class DecodedEvent:
74
+ """A decoded event from transaction receipt logs.
75
+
76
+ Attributes:
77
+ name: Event name (e.g., 'Transfer')
78
+ args: Named access to event parameters via AttributeDict
79
+ address: Contract address that emitted the event
80
+ tx_hash: Transaction hash
81
+ log_index: Position in the transaction logs
82
+ block_number: Block number containing the transaction
83
+ raw_log: Original log entry for advanced use
84
+ """
85
+
86
+ name: str
87
+ args: AttributeDict
88
+ address: str
89
+ tx_hash: str
90
+ log_index: int
91
+ block_number: int
92
+ raw_log: LogEntry
93
+
94
+
95
+ class EventAccessor:
96
+ """Provides access to decoded events from receipt logs.
97
+
98
+ Events are scoped to:
99
+ 1. Only from ctx.receipt.logs (not block-wide)
100
+ 2. Only from the specific contract address
101
+
102
+ Usage:
103
+ vault.events["Deposit"][0] # Index access
104
+ vault.events.one("Deposit") # Assert exactly one
105
+ vault.events.first("Deposit") # First or None
106
+ vault.events.all("Deposit") # List of all matching
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ address: str,
112
+ abi: list[dict[str, Any]],
113
+ receipt: TxReceipt | None,
114
+ ) -> None:
115
+ self._address = to_checksum_address(address)
116
+ self._abi = abi
117
+ self._receipt = receipt
118
+ self._event_abis = self._build_event_map()
119
+ self._decoded_cache: dict[str, list[DecodedEvent]] | None = None
120
+
121
+ def _build_event_map(self) -> dict[str, dict[str, Any]]:
122
+ """Build mapping from event name to ABI entry."""
123
+ events = {}
124
+ for item in self._abi:
125
+ if item.get("type") == "event":
126
+ name = item.get("name", "")
127
+ if name:
128
+ events[name] = item
129
+ return events
130
+
131
+ def _ensure_receipt(self) -> None:
132
+ """Ensure receipt is available for event access."""
133
+ if self._receipt is None:
134
+ raise ReceiptRequiredError()
135
+
136
+ def _decode_all_events(self) -> dict[str, list[DecodedEvent]]:
137
+ """Decode all events from receipt logs for this contract."""
138
+ if self._decoded_cache is not None:
139
+ return self._decoded_cache
140
+
141
+ self._ensure_receipt()
142
+ result: dict[str, list[DecodedEvent]] = {}
143
+
144
+ for log_dict in self._receipt.logs:
145
+ log = LogEntry.from_dict(log_dict)
146
+
147
+ # Filter by contract address
148
+ if to_checksum_address(log.address) != self._address:
149
+ continue
150
+
151
+ # Skip if no topics (anonymous event)
152
+ if not log.topics:
153
+ continue
154
+
155
+ topic0 = log.topics[0]
156
+ if topic0.startswith("0x"):
157
+ topic0 = topic0[2:]
158
+ topic0 = topic0.lower()
159
+
160
+ # Find matching event ABI
161
+ for event_name, event_abi in self._event_abis.items():
162
+ expected_sig = event_abi_to_log_topic(event_abi)
163
+ expected_sig_hex = expected_sig.hex().lower()
164
+
165
+ if topic0 == expected_sig_hex:
166
+ try:
167
+ decoded = self._decode_log(log, event_name, event_abi)
168
+ if event_name not in result:
169
+ result[event_name] = []
170
+ result[event_name].append(decoded)
171
+ except Exception:
172
+ # Skip logs that fail to decode
173
+ continue
174
+ break
175
+
176
+ self._decoded_cache = result
177
+ return result
178
+
179
+ def _decode_log(
180
+ self,
181
+ log: LogEntry,
182
+ event_name: str,
183
+ event_abi: dict[str, Any],
184
+ ) -> DecodedEvent:
185
+ """Decode a single log entry."""
186
+ inputs = event_abi.get("inputs", [])
187
+
188
+ # Separate indexed and non-indexed parameters
189
+ indexed_inputs = [i for i in inputs if i.get("indexed", False)]
190
+ non_indexed_inputs = [i for i in inputs if not i.get("indexed", False)]
191
+
192
+ # Decode indexed parameters from topics (skip topic0 which is signature)
193
+ indexed_values = []
194
+ for i, param in enumerate(indexed_inputs):
195
+ topic_index = i + 1 # +1 because topic0 is the event signature
196
+ if topic_index < len(log.topics):
197
+ topic = log.topics[topic_index]
198
+ if topic.startswith("0x"):
199
+ topic = topic[2:]
200
+ # Indexed parameters are 32 bytes each
201
+ topic_bytes = bytes.fromhex(topic)
202
+ indexed_values.append(
203
+ self._decode_indexed_param(param["type"], topic_bytes)
204
+ )
205
+ else:
206
+ indexed_values.append(None)
207
+
208
+ # Decode non-indexed parameters from data
209
+ non_indexed_values = []
210
+ if non_indexed_inputs and log.data and log.data != "0x":
211
+ data_bytes = bytes.fromhex(log.data[2:] if log.data.startswith("0x") else log.data)
212
+ if data_bytes:
213
+ types = [i["type"] for i in non_indexed_inputs]
214
+ try:
215
+ non_indexed_values = list(abi_decode(types, data_bytes))
216
+ except Exception:
217
+ non_indexed_values = [None] * len(non_indexed_inputs)
218
+
219
+ # Build args dict
220
+ args = AttributeDict()
221
+ indexed_idx = 0
222
+ non_indexed_idx = 0
223
+
224
+ for param in inputs:
225
+ name = param.get("name", f"arg{indexed_idx + non_indexed_idx}")
226
+ if param.get("indexed", False):
227
+ value = indexed_values[indexed_idx] if indexed_idx < len(indexed_values) else None
228
+ indexed_idx += 1
229
+ else:
230
+ value = non_indexed_values[non_indexed_idx] if non_indexed_idx < len(non_indexed_values) else None
231
+ non_indexed_idx += 1
232
+
233
+ # Convert bytes to hex strings for readability
234
+ if isinstance(value, bytes):
235
+ value = HexBytes(value)
236
+ args[name] = value
237
+
238
+ tx_hash = log.transaction_hash
239
+ if isinstance(tx_hash, bytes):
240
+ tx_hash = f"0x{tx_hash.hex()}"
241
+
242
+ return DecodedEvent(
243
+ name=event_name,
244
+ args=args,
245
+ address=self._address,
246
+ tx_hash=tx_hash,
247
+ log_index=log.log_index,
248
+ block_number=log.block_number,
249
+ raw_log=log,
250
+ )
251
+
252
+ def _decode_indexed_param(self, param_type: str, data: bytes) -> Any:
253
+ """Decode an indexed parameter from topic bytes."""
254
+ # For dynamic types (string, bytes, arrays), indexed params are just keccak256 hashes
255
+ if param_type in ("string", "bytes") or param_type.endswith("[]"):
256
+ return HexBytes(data)
257
+
258
+ # For static types, decode normally
259
+ try:
260
+ decoded = abi_decode([param_type], data.rjust(32, b"\x00"))
261
+ return decoded[0] if decoded else None
262
+ except Exception:
263
+ return HexBytes(data)
264
+
265
+ def __getitem__(self, event_name: str) -> list[DecodedEvent]:
266
+ """Get all events with the given name.
267
+
268
+ Args:
269
+ event_name: Name of the event (e.g., 'Transfer')
270
+
271
+ Returns:
272
+ List of decoded events, in log_index order
273
+
274
+ Raises:
275
+ KeyError: If event name is not in the ABI
276
+ ReceiptRequiredError: If accessed outside alert_confirmed context
277
+ """
278
+ if event_name not in self._event_abis:
279
+ available = list(self._event_abis.keys())
280
+ raise KeyError(
281
+ f"Event '{event_name}' not found in ABI for {self._address}. "
282
+ f"Available events: {available}"
283
+ )
284
+
285
+ decoded = self._decode_all_events()
286
+ return decoded.get(event_name, [])
287
+
288
+ def one(self, event_name: str) -> DecodedEvent:
289
+ """Get exactly one event with the given name.
290
+
291
+ Args:
292
+ event_name: Name of the event
293
+
294
+ Returns:
295
+ The single decoded event
296
+
297
+ Raises:
298
+ EventNotFoundError: If zero or more than one event found
299
+ """
300
+ events = self[event_name]
301
+ if len(events) == 0:
302
+ decoded = self._decode_all_events()
303
+ available = list(decoded.keys())
304
+ raise EventNotFoundError(event_name, self._address, available)
305
+ if len(events) > 1:
306
+ raise EventNotFoundError(
307
+ event_name,
308
+ self._address,
309
+ [f"{event_name} (found {len(events)}, expected 1)"],
310
+ )
311
+ return events[0]
312
+
313
+ def first(self, event_name: str) -> DecodedEvent | None:
314
+ """Get the first event with the given name, or None.
315
+
316
+ Args:
317
+ event_name: Name of the event
318
+
319
+ Returns:
320
+ The first decoded event or None if not found
321
+ """
322
+ events = self[event_name]
323
+ return events[0] if events else None
324
+
325
+ def get(self, event_name: str, default: DecodedEvent | None = None) -> DecodedEvent | None:
326
+ """Get the first event with the given name, or return default.
327
+
328
+ Args:
329
+ event_name: Name of the event
330
+ default: Value to return when no events are found
331
+ """
332
+ events = self[event_name]
333
+ return events[0] if events else default
334
+
335
+ def count(self, event_name: str) -> int:
336
+ """Count events with the given name."""
337
+ return len(self[event_name])
338
+
339
+ def all(self, event_name: str) -> list[DecodedEvent]:
340
+ """Get all events with the given name.
341
+
342
+ Alias for __getitem__ with clearer intent.
343
+
344
+ Args:
345
+ event_name: Name of the event
346
+
347
+ Returns:
348
+ List of decoded events (may be empty)
349
+ """
350
+ return self[event_name]
351
+
352
+ def __iter__(self) -> Iterator[DecodedEvent]:
353
+ """Iterate over all decoded events from this contract."""
354
+ decoded = self._decode_all_events()
355
+ all_events: list[DecodedEvent] = []
356
+ for event_list in decoded.values():
357
+ all_events.extend(event_list)
358
+ # Sort by log_index for consistent ordering
359
+ all_events.sort(key=lambda e: e.log_index)
360
+ return iter(all_events)
361
+
362
+ def __len__(self) -> int:
363
+ """Return total number of decoded events from this contract."""
364
+ decoded = self._decode_all_events()
365
+ return sum(len(events) for events in decoded.values())
366
+
367
+ @property
368
+ def available(self) -> list[str]:
369
+ """List all event names that were found in the receipt."""
370
+ decoded = self._decode_all_events()
371
+ return list(decoded.keys())
372
+
373
+ @property
374
+ def defined(self) -> list[str]:
375
+ """List all event names defined in the ABI."""
376
+ return list(self._event_abis.keys())
377
+
378
+
379
+ # =============================================================================
380
+ # Brownie-Compatible Event Containers
381
+ # =============================================================================
382
+
383
+
384
+ class _EventItem:
385
+ """Container for one or more events with the same name.
386
+
387
+ Brownie-compatible access patterns:
388
+ events["Transfer"][0] # First Transfer event
389
+ events["Transfer"]["amount"] # Field from first Transfer
390
+ len(events["Transfer"]) # Count of Transfer events
391
+ "amount" in events["Transfer"] # Check field exists
392
+ """
393
+
394
+ def __init__(
395
+ self,
396
+ name: str,
397
+ events: list[dict[str, Any]],
398
+ addresses: list[str],
399
+ positions: list[int],
400
+ ):
401
+ self.name = name
402
+ self._events = events
403
+ self._addresses = addresses
404
+ self.pos = tuple(positions)
405
+
406
+ @property
407
+ def address(self) -> str | None:
408
+ """Contract address. None if events from multiple addresses."""
409
+ unique = set(self._addresses)
410
+ return self._addresses[0] if len(unique) == 1 else None
411
+
412
+ def __getitem__(self, key: int | str) -> Any:
413
+ if isinstance(key, int):
414
+ return self._events[key]
415
+ return self._events[0][key]
416
+
417
+ def __len__(self) -> int:
418
+ return len(self._events)
419
+
420
+ def __contains__(self, key: str) -> bool:
421
+ return key in self._events[0] if self._events else False
422
+
423
+ def __iter__(self) -> Iterator[dict[str, Any]]:
424
+ return iter(self._events)
425
+
426
+ def __eq__(self, other: object) -> bool:
427
+ if isinstance(other, _EventItem):
428
+ return self._events == other._events
429
+ return False
430
+
431
+ def keys(self):
432
+ return self._events[0].keys() if self._events else []
433
+
434
+ def values(self):
435
+ return self._events[0].values() if self._events else []
436
+
437
+ def items(self):
438
+ return self._events[0].items() if self._events else []
439
+
440
+ def __repr__(self) -> str:
441
+ if len(self._events) == 1:
442
+ return f"<{self.name} {dict(self._events[0])}>"
443
+ return f"<{self.name} [{len(self._events)} events]>"
444
+
445
+
446
+ class EventDict:
447
+ """Brownie-compatible event container.
448
+
449
+ Hybrid dict/list access:
450
+ events[0] # First event by position
451
+ events["Transfer"] # All Transfer events (_EventItem)
452
+ events["Transfer"][0] # First Transfer event
453
+ len(events) # Total event count
454
+ "Transfer" in events # Check if event type exists
455
+ for event in events: # Iterate all events
456
+ """
457
+
458
+ def __init__(self, events: list[dict[str, Any]] | None = None):
459
+ self._ordered: list[_EventItem] = []
460
+ self._by_name: dict[str, _EventItem] = {}
461
+
462
+ if events:
463
+ self._build_index(events)
464
+
465
+ def _build_index(self, events: list[dict[str, Any]]) -> None:
466
+ """Build internal indexes from decoded events."""
467
+ by_name: dict[str, list[dict[str, Any]]] = {}
468
+ by_name_addrs: dict[str, list[str]] = {}
469
+ by_name_pos: dict[str, list[int]] = {}
470
+
471
+ for i, event in enumerate(events):
472
+ name = event.get("_name", "Unknown")
473
+ if name not in by_name:
474
+ by_name[name] = []
475
+ by_name_addrs[name] = []
476
+ by_name_pos[name] = []
477
+
478
+ # Extract event args (everything except _name and _address)
479
+ args = {k: v for k, v in event.items() if not k.startswith("_")}
480
+
481
+ by_name[name].append(args)
482
+ by_name_addrs[name].append(event.get("_address", ""))
483
+ by_name_pos[name].append(i)
484
+
485
+ # Build _EventItem for each name
486
+ for name in by_name:
487
+ item = _EventItem(
488
+ name=name,
489
+ events=by_name[name],
490
+ addresses=by_name_addrs[name],
491
+ positions=by_name_pos[name],
492
+ )
493
+ self._by_name[name] = item
494
+
495
+ # Build ordered list (one _EventItem per event occurrence)
496
+ for i, event in enumerate(events):
497
+ name = event.get("_name", "Unknown")
498
+ idx = by_name_pos[name].index(i)
499
+ single_item = _EventItem(
500
+ name=name,
501
+ events=[by_name[name][idx]],
502
+ addresses=[by_name_addrs[name][idx]],
503
+ positions=[i],
504
+ )
505
+ self._ordered.append(single_item)
506
+
507
+ def __getitem__(self, key: int | str) -> _EventItem:
508
+ if isinstance(key, int):
509
+ return self._ordered[key]
510
+ if key not in self._by_name:
511
+ raise KeyError(f"Event '{key}' not found. Available: {list(self._by_name.keys())}")
512
+ return self._by_name[key]
513
+
514
+ def __len__(self) -> int:
515
+ return len(self._ordered)
516
+
517
+ def __contains__(self, key: str) -> bool:
518
+ return key in self._by_name
519
+
520
+ def __iter__(self) -> Iterator[_EventItem]:
521
+ return iter(self._ordered)
522
+
523
+ def __bool__(self) -> bool:
524
+ return len(self._ordered) > 0
525
+
526
+ def count(self, name: str) -> int:
527
+ """Count events of a specific type."""
528
+ if name not in self._by_name:
529
+ return 0
530
+ return len(self._by_name[name])
531
+
532
+ def keys(self):
533
+ return self._by_name.keys()
534
+
535
+ def values(self):
536
+ return self._by_name.values()
537
+
538
+ def items(self):
539
+ return self._by_name.items()
540
+
541
+ def __repr__(self) -> str:
542
+ if not self._ordered:
543
+ return "<EventDict (empty)>"
544
+ names = [f"{k}({len(v)})" for k, v in self._by_name.items()]
545
+ return f"<EventDict {', '.join(names)}>"
546
+
547
+
548
+ def decode_logs(
549
+ logs: list[dict[str, Any]],
550
+ contract_system: Any,
551
+ ) -> EventDict:
552
+ """Decode receipt logs into EventDict.
553
+
554
+ Args:
555
+ logs: Raw logs from transaction receipt
556
+ contract_system: For ABI resolution
557
+
558
+ Returns:
559
+ EventDict with all decoded events
560
+ """
561
+ if not logs:
562
+ return EventDict([])
563
+
564
+ # Get unique addresses and resolve ABIs
565
+ addresses = {log.get("address") for log in logs if log.get("address")}
566
+ abis_by_addr: dict[str, list[dict[str, Any]]] = {}
567
+
568
+ for addr in addresses:
569
+ if addr:
570
+ try:
571
+ resolved = contract_system.resolver().resolve(addr)
572
+ abis_by_addr[addr.lower()] = resolved.abi
573
+ except Exception:
574
+ abis_by_addr[addr.lower()] = []
575
+
576
+ # Build topic -> event ABI mapping
577
+ topic_to_event: dict[bytes, tuple[str, dict[str, Any]]] = {}
578
+ for addr, abi in abis_by_addr.items():
579
+ for item in abi:
580
+ if item.get("type") == "event":
581
+ topic = event_abi_to_log_topic(item)
582
+ topic_to_event[topic] = (addr, item)
583
+
584
+ # Decode each log
585
+ decoded_events: list[dict[str, Any]] = []
586
+ for log in logs:
587
+ topics = log.get("topics", [])
588
+ if not topics:
589
+ continue
590
+
591
+ # Get first topic (event signature)
592
+ topic0 = topics[0]
593
+ if isinstance(topic0, (bytes, HexBytes)):
594
+ topic0_bytes = bytes(topic0)
595
+ elif isinstance(topic0, str):
596
+ topic0_bytes = bytes.fromhex(topic0[2:] if topic0.startswith("0x") else topic0)
597
+ else:
598
+ continue
599
+
600
+ if topic0_bytes not in topic_to_event:
601
+ continue
602
+
603
+ _, event_abi = topic_to_event[topic0_bytes]
604
+
605
+ try:
606
+ decoded = _decode_single_event(log, event_abi)
607
+ decoded["_address"] = log.get("address", "")
608
+ decoded["_name"] = event_abi["name"]
609
+ decoded_events.append(decoded)
610
+ except Exception:
611
+ continue
612
+
613
+ return EventDict(decoded_events)
614
+
615
+
616
+ def _decode_single_event(log: dict[str, Any], event_abi: dict[str, Any]) -> dict[str, Any]:
617
+ """Decode a single event log into a flat dict."""
618
+ topics = log.get("topics", [])
619
+ data = log.get("data", "0x")
620
+
621
+ if isinstance(data, (bytes, HexBytes)):
622
+ data_bytes = bytes(data)
623
+ elif isinstance(data, str):
624
+ data_bytes = bytes.fromhex(data[2:] if data.startswith("0x") else data) if data and data != "0x" else b""
625
+ else:
626
+ data_bytes = b""
627
+
628
+ # Separate indexed and non-indexed inputs
629
+ indexed_inputs = [inp for inp in event_abi.get("inputs", []) if inp.get("indexed")]
630
+ non_indexed_inputs = [inp for inp in event_abi.get("inputs", []) if not inp.get("indexed")]
631
+
632
+ # Decode indexed params from topics (skip topic[0] which is signature)
633
+ indexed_values: list[Any] = []
634
+ for i, inp in enumerate(indexed_inputs):
635
+ if i + 1 < len(topics):
636
+ topic = topics[i + 1]
637
+ if isinstance(topic, str):
638
+ topic = bytes.fromhex(topic[2:] if topic.startswith("0x") else topic)
639
+ elif isinstance(topic, (bytes, HexBytes)):
640
+ topic = bytes(topic)
641
+
642
+ # For dynamic types, indexed params are just hashes
643
+ if inp["type"] in ("string", "bytes") or inp["type"].endswith("[]"):
644
+ indexed_values.append(HexBytes(topic))
645
+ else:
646
+ try:
647
+ decoded = abi_decode([inp["type"]], topic.rjust(32, b"\x00"))
648
+ indexed_values.append(decoded[0])
649
+ except Exception:
650
+ indexed_values.append(HexBytes(topic))
651
+ else:
652
+ indexed_values.append(None)
653
+
654
+ # Decode non-indexed params from data
655
+ non_indexed_values: list[Any] = []
656
+ if non_indexed_inputs and data_bytes:
657
+ types = [inp["type"] for inp in non_indexed_inputs]
658
+ try:
659
+ non_indexed_values = list(abi_decode(types, data_bytes))
660
+ except Exception:
661
+ non_indexed_values = [None] * len(non_indexed_inputs)
662
+
663
+ # Build result dict
664
+ result: dict[str, Any] = {}
665
+ idx_i, non_idx_i = 0, 0
666
+ for inp in event_abi.get("inputs", []):
667
+ name = inp.get("name", f"arg{idx_i + non_idx_i}")
668
+ if inp.get("indexed"):
669
+ value = indexed_values[idx_i] if idx_i < len(indexed_values) else None
670
+ idx_i += 1
671
+ else:
672
+ value = non_indexed_values[non_idx_i] if non_idx_i < len(non_indexed_values) else None
673
+ non_idx_i += 1
674
+
675
+ # Convert bytes to HexBytes for consistency
676
+ if isinstance(value, bytes) and not isinstance(value, HexBytes):
677
+ value = HexBytes(value)
678
+ result[name] = value
679
+
680
+ return result