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.
- brawny/__init__.py +106 -0
- brawny/_context.py +232 -0
- brawny/_rpc/__init__.py +38 -0
- brawny/_rpc/broadcast.py +172 -0
- brawny/_rpc/clients.py +98 -0
- brawny/_rpc/context.py +49 -0
- brawny/_rpc/errors.py +252 -0
- brawny/_rpc/gas.py +158 -0
- brawny/_rpc/manager.py +982 -0
- brawny/_rpc/selector.py +156 -0
- brawny/accounts.py +534 -0
- brawny/alerts/__init__.py +132 -0
- brawny/alerts/abi_resolver.py +530 -0
- brawny/alerts/base.py +152 -0
- brawny/alerts/context.py +271 -0
- brawny/alerts/contracts.py +635 -0
- brawny/alerts/encoded_call.py +201 -0
- brawny/alerts/errors.py +267 -0
- brawny/alerts/events.py +680 -0
- brawny/alerts/function_caller.py +364 -0
- brawny/alerts/health.py +185 -0
- brawny/alerts/routing.py +118 -0
- brawny/alerts/send.py +364 -0
- brawny/api.py +660 -0
- brawny/chain.py +93 -0
- brawny/cli/__init__.py +16 -0
- brawny/cli/app.py +17 -0
- brawny/cli/bootstrap.py +37 -0
- brawny/cli/commands/__init__.py +41 -0
- brawny/cli/commands/abi.py +93 -0
- brawny/cli/commands/accounts.py +632 -0
- brawny/cli/commands/console.py +495 -0
- brawny/cli/commands/contract.py +139 -0
- brawny/cli/commands/health.py +112 -0
- brawny/cli/commands/init_project.py +86 -0
- brawny/cli/commands/intents.py +130 -0
- brawny/cli/commands/job_dev.py +254 -0
- brawny/cli/commands/jobs.py +308 -0
- brawny/cli/commands/logs.py +87 -0
- brawny/cli/commands/maintenance.py +182 -0
- brawny/cli/commands/migrate.py +51 -0
- brawny/cli/commands/networks.py +253 -0
- brawny/cli/commands/run.py +249 -0
- brawny/cli/commands/script.py +209 -0
- brawny/cli/commands/signer.py +248 -0
- brawny/cli/helpers.py +265 -0
- brawny/cli_templates.py +1445 -0
- brawny/config/__init__.py +74 -0
- brawny/config/models.py +404 -0
- brawny/config/parser.py +633 -0
- brawny/config/routing.py +55 -0
- brawny/config/validation.py +246 -0
- brawny/daemon/__init__.py +14 -0
- brawny/daemon/context.py +69 -0
- brawny/daemon/core.py +702 -0
- brawny/daemon/loops.py +327 -0
- brawny/db/__init__.py +78 -0
- brawny/db/base.py +986 -0
- brawny/db/base_new.py +165 -0
- brawny/db/circuit_breaker.py +97 -0
- brawny/db/global_cache.py +298 -0
- brawny/db/mappers.py +182 -0
- brawny/db/migrate.py +349 -0
- brawny/db/migrations/001_init.sql +186 -0
- brawny/db/migrations/002_add_included_block.sql +7 -0
- brawny/db/migrations/003_add_broadcast_at.sql +10 -0
- brawny/db/migrations/004_broadcast_binding.sql +20 -0
- brawny/db/migrations/005_add_retry_after.sql +9 -0
- brawny/db/migrations/006_add_retry_count_column.sql +11 -0
- brawny/db/migrations/007_add_gap_tracking.sql +18 -0
- brawny/db/migrations/008_add_transactions.sql +72 -0
- brawny/db/migrations/009_add_intent_metadata.sql +5 -0
- brawny/db/migrations/010_add_nonce_gap_index.sql +9 -0
- brawny/db/migrations/011_add_job_logs.sql +24 -0
- brawny/db/migrations/012_add_claimed_by.sql +5 -0
- brawny/db/ops/__init__.py +29 -0
- brawny/db/ops/attempts.py +108 -0
- brawny/db/ops/blocks.py +83 -0
- brawny/db/ops/cache.py +93 -0
- brawny/db/ops/intents.py +296 -0
- brawny/db/ops/jobs.py +110 -0
- brawny/db/ops/logs.py +97 -0
- brawny/db/ops/nonces.py +322 -0
- brawny/db/postgres.py +2535 -0
- brawny/db/postgres_new.py +196 -0
- brawny/db/queries.py +584 -0
- brawny/db/sqlite.py +2733 -0
- brawny/db/sqlite_new.py +191 -0
- brawny/history.py +126 -0
- brawny/interfaces.py +136 -0
- brawny/invariants.py +155 -0
- brawny/jobs/__init__.py +26 -0
- brawny/jobs/base.py +287 -0
- brawny/jobs/discovery.py +233 -0
- brawny/jobs/job_validation.py +111 -0
- brawny/jobs/kv.py +125 -0
- brawny/jobs/registry.py +283 -0
- brawny/keystore.py +484 -0
- brawny/lifecycle.py +551 -0
- brawny/logging.py +290 -0
- brawny/metrics.py +594 -0
- brawny/model/__init__.py +53 -0
- brawny/model/contexts.py +319 -0
- brawny/model/enums.py +70 -0
- brawny/model/errors.py +194 -0
- brawny/model/events.py +93 -0
- brawny/model/startup.py +20 -0
- brawny/model/types.py +483 -0
- brawny/networks/__init__.py +96 -0
- brawny/networks/config.py +269 -0
- brawny/networks/manager.py +423 -0
- brawny/obs/__init__.py +67 -0
- brawny/obs/emit.py +158 -0
- brawny/obs/health.py +175 -0
- brawny/obs/heartbeat.py +133 -0
- brawny/reconciliation.py +108 -0
- brawny/scheduler/__init__.py +19 -0
- brawny/scheduler/poller.py +472 -0
- brawny/scheduler/reorg.py +632 -0
- brawny/scheduler/runner.py +708 -0
- brawny/scheduler/shutdown.py +371 -0
- brawny/script_tx.py +297 -0
- brawny/scripting.py +251 -0
- brawny/startup.py +76 -0
- brawny/telegram.py +393 -0
- brawny/testing.py +108 -0
- brawny/tx/__init__.py +41 -0
- brawny/tx/executor.py +1071 -0
- brawny/tx/fees.py +50 -0
- brawny/tx/intent.py +423 -0
- brawny/tx/monitor.py +628 -0
- brawny/tx/nonce.py +498 -0
- brawny/tx/replacement.py +456 -0
- brawny/tx/utils.py +26 -0
- brawny/utils.py +205 -0
- brawny/validation.py +69 -0
- brawny-0.1.13.dist-info/METADATA +156 -0
- brawny-0.1.13.dist-info/RECORD +141 -0
- brawny-0.1.13.dist-info/WHEEL +5 -0
- brawny-0.1.13.dist-info/entry_points.txt +2 -0
- brawny-0.1.13.dist-info/top_level.txt +1 -0
brawny/alerts/events.py
ADDED
|
@@ -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
|