fastweb3-objects 0.1.0__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.
fw3_objects/errors.py ADDED
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class FW3ObjectsError(Exception):
5
+ """Base exception for fw3-objects errors."""
6
+
7
+ pass
8
+
9
+
10
+ class ABITypeError(TypeError):
11
+ """Raised when a Python value has the wrong type for ABI encoding."""
12
+
13
+ pass
14
+
15
+
16
+ class ABIValueError(ValueError):
17
+ """Raised when a Python value is outside ABI bounds or malformed."""
18
+
19
+ pass
20
+
21
+
22
+ class ABINotFound(FW3ObjectsError):
23
+ """Raised when no ABI is available for a contract address."""
24
+
25
+ pass
26
+
27
+
28
+ class ExplorerError(FW3ObjectsError):
29
+ """Base exception for block explorer lookup failures."""
30
+
31
+ pass
32
+
33
+
34
+ class ExplorerConnectionError(ExplorerError):
35
+ """Raised when an explorer request cannot be completed."""
36
+
37
+ pass
38
+
39
+
40
+ class ExplorerRateLimited(ExplorerError):
41
+ """Raised when an explorer provider rate-limits ABI lookup."""
42
+
43
+ def __init__(self, provider: str, retry_after: float | None = None):
44
+ """Initialize an explorer rate-limit error.
45
+
46
+ Args:
47
+ provider: Explorer provider name.
48
+ retry_after: Optional retry delay in seconds.
49
+ """
50
+ self.provider = provider
51
+ self.retry_after = retry_after
52
+ msg = f"{provider} rate limit exceeded"
53
+ if retry_after is not None:
54
+ msg = f"{msg}; retry after {retry_after:g}s"
55
+ super().__init__(msg)
56
+
57
+
58
+ class ChainMismatch(FW3ObjectsError):
59
+ """Raised when an object is used with an incompatible chain."""
60
+
61
+ def __init__(self, active_chain, target_chain, context: str | None = None):
62
+ """Initialize a chain mismatch error.
63
+
64
+ Args:
65
+ active_chain: Currently active or bound chain.
66
+ target_chain: Chain that was requested.
67
+ context: Optional context included in the error message.
68
+ """
69
+ self.active_chain_id = int(active_chain)
70
+ self.target_chain_id = int(target_chain)
71
+ msg = f"Active chain is {self.active_chain_id}, got {self.target_chain_id}"
72
+ if context:
73
+ msg = f"{msg} {context})"
74
+ super().__init__(msg)
75
+
76
+
77
+ class NoActiveChain(FW3ObjectsError):
78
+ """Raised when an operation requires a chain but none was supplied."""
79
+
80
+ pass
81
+
82
+
83
+ class TransactionNotFound(FW3ObjectsError):
84
+ """Raised when a transaction hash cannot be found."""
85
+
86
+ def __init__(self, hash):
87
+ """Initialize a transaction-not-found error.
88
+
89
+ Args:
90
+ hash: Missing transaction hash.
91
+ """
92
+ super().__init__(f"Transaction not found: {hash}")
fw3_objects/events.py ADDED
@@ -0,0 +1,379 @@
1
+ from __future__ import annotations
2
+
3
+ import keyword
4
+ from collections.abc import Iterator, Mapping
5
+
6
+ from .abi import decode_event, event_signature, event_topic
7
+ from .account import Account
8
+ from .chain import Chain
9
+ from .contract import Contract
10
+ from .errors import ABINotFound, ExplorerError, NoActiveChain
11
+
12
+ _EVENT_ARG_RESERVED = {
13
+ "get",
14
+ "items",
15
+ "keys",
16
+ "values",
17
+ }
18
+
19
+
20
+ def _attr_name(name: str) -> str | None:
21
+ if not name.isidentifier():
22
+ return None
23
+ if name.startswith("__") and name.endswith("__"):
24
+ return None
25
+ if keyword.iskeyword(name) or name in _EVENT_ARG_RESERVED:
26
+ return f"{name}_"
27
+ return name
28
+
29
+
30
+ def _address(address: str, chain: Chain | int | None):
31
+ if chain is not None:
32
+ return Contract(address, chain=chain, refresh_abi=False)
33
+ return Account(address)
34
+
35
+
36
+ def _topic_id(value) -> str:
37
+ if isinstance(value, bytes):
38
+ return "0x" + value.hex()
39
+ return value.lower()
40
+
41
+
42
+ def _log_address(raw_log: dict) -> str:
43
+ return str(Account(raw_log["address"])).lower()
44
+
45
+
46
+ class EventArgs(Mapping):
47
+ """Mapping of decoded event argument names to values."""
48
+
49
+ def __init__(self, values: Mapping[str, object]):
50
+ """Initialize decoded event arguments.
51
+
52
+ Args:
53
+ values: Mapping of argument names to decoded values.
54
+ """
55
+ self._values = dict(values)
56
+ self._attrs = {}
57
+
58
+ for name in self._values:
59
+ attr = _attr_name(name)
60
+ if attr is None or attr in self._attrs or hasattr(type(self), attr):
61
+ continue
62
+ self._attrs[attr] = name
63
+
64
+ def __getitem__(self, key):
65
+ """Return an event argument by name or position."""
66
+ if isinstance(key, int):
67
+ return tuple(self._values.values())[key]
68
+ return self._values[key]
69
+
70
+ def __iter__(self) -> Iterator[str]:
71
+ """Iterate over argument names."""
72
+ return iter(self._values)
73
+
74
+ def __len__(self) -> int:
75
+ """Return the number of event arguments."""
76
+ return len(self._values)
77
+
78
+ def __getattr__(self, name: str):
79
+ """Return an argument by attribute name when safe."""
80
+ try:
81
+ return self._values[self._attrs[name]]
82
+ except KeyError:
83
+ raise AttributeError(name) from None
84
+
85
+ def __repr__(self) -> str:
86
+ return repr(self._values)
87
+
88
+
89
+ class Event:
90
+ """Decoded contract event log."""
91
+
92
+ decoded = True
93
+ malformed = False
94
+
95
+ def __init__(self, raw_log: dict, event_abi: dict, *, chain: Chain | int | None = None):
96
+ """Decode a raw log using an event ABI.
97
+
98
+ Args:
99
+ raw_log: RPC log object.
100
+ event_abi: Event ABI item.
101
+ chain: Chain or chain ID used for the emitting address.
102
+ """
103
+ self.raw = raw_log
104
+ self.abi = event_abi
105
+ self.name = event_abi["name"]
106
+ self.signature = event_signature(event_abi)
107
+ self.topic = event_topic(event_abi)
108
+ self.address = _address(raw_log["address"], chain)
109
+ self.topics = tuple(raw_log.get("topics", []))
110
+ self.data = raw_log.get("data", "0x")
111
+ self.log_index = raw_log.get("logIndex")
112
+ self.transaction_hash = raw_log.get("transactionHash")
113
+ self.block_number = raw_log.get("blockNumber")
114
+ self.removed = raw_log.get("removed", False)
115
+ self.args = EventArgs(decode_event(event_abi, raw_log))
116
+ self.chain = Chain(chain) if chain is not None else None
117
+
118
+ def __getitem__(self, key):
119
+ """Return a decoded event argument by name or position."""
120
+ return self.args[key]
121
+
122
+ def __repr__(self) -> str:
123
+ return f"<{type(self).__name__} {self.name} {dict(self.args)!r}>"
124
+
125
+
126
+ class EventGroup:
127
+ """Sequence-like group of events with the same event name."""
128
+
129
+ def __init__(self, events):
130
+ """Initialize a group from decoded events."""
131
+ self._events = tuple(events)
132
+
133
+ def __getitem__(self, key):
134
+ """Return an event by index or, for single-event groups, an argument by name."""
135
+ if isinstance(key, int):
136
+ return self._events[key]
137
+ if isinstance(key, str):
138
+ if len(self._events) != 1:
139
+ raise ValueError(f"Cannot access argument {key!r} on {len(self._events)} events")
140
+ return self._events[0][key]
141
+ raise TypeError("EventGroup indices must be integers or argument names")
142
+
143
+ def __iter__(self):
144
+ """Iterate over grouped events."""
145
+ return iter(self._events)
146
+
147
+ def __len__(self):
148
+ """Return the number of grouped events."""
149
+ return len(self._events)
150
+
151
+ def __bool__(self):
152
+ """Return whether the group contains any events."""
153
+ return bool(self._events)
154
+
155
+ def __getattr__(self, name: str):
156
+ """Return an argument attribute for single-event groups."""
157
+ if len(self._events) != 1:
158
+ raise AttributeError(name)
159
+ try:
160
+ return getattr(self._events[0].args, name)
161
+ except AttributeError:
162
+ raise AttributeError(name) from None
163
+
164
+ def __repr__(self) -> str:
165
+ return f"<{type(self).__name__} {list(self._events)!r}>"
166
+
167
+
168
+ class EventList:
169
+ """Decoded transaction or receipt event logs.
170
+
171
+ Events are available by index, by event name, and as safe attributes when the
172
+ event name is a valid attribute name.
173
+ """
174
+
175
+ def __init__(
176
+ self,
177
+ raw_logs,
178
+ *,
179
+ chain: Chain | int | None = None,
180
+ enrich: bool = False,
181
+ ):
182
+ """Initialize an event list from raw logs.
183
+
184
+ Args:
185
+ raw_logs: Iterable of RPC log objects.
186
+ chain: Chain or chain ID used for ABI lookup and emitting addresses.
187
+ enrich: Whether to refresh missing ABIs through explorer lookup.
188
+ """
189
+ self.raw_logs = tuple(raw_logs)
190
+ self.chain = Chain(chain) if chain is not None else None
191
+ self._events = tuple(self._decode_logs(enrich))
192
+ self._groups = {}
193
+
194
+ for event in self._events:
195
+ if event.name is None:
196
+ continue
197
+ self._groups.setdefault(event.name, []).append(event)
198
+
199
+ self._groups = {name: EventGroup(events) for name, events in self._groups.items()}
200
+ self._attrs = {}
201
+
202
+ for name in self._groups:
203
+ attr = _attr_name(name)
204
+ if attr is None or attr in self._attrs or hasattr(type(self), attr):
205
+ continue
206
+ self._attrs[attr] = name
207
+
208
+ def _decode_logs(self, enrich: bool):
209
+ refresh_abi = None if enrich else False
210
+ topic_maps = self._topic_maps(refresh_abi)
211
+
212
+ for raw_log in self.raw_logs:
213
+ address = _log_address(raw_log)
214
+ topics = raw_log.get("topics", [])
215
+ topic = _topic_id(topics[0]) if topics else None
216
+ topic_map = topic_maps.get(address)
217
+
218
+ if topic_map is None:
219
+ yield UnknownEvent(raw_log, reason="abi_missing", chain=self.chain)
220
+ continue
221
+
222
+ event_abi = topic_map.get(topic)
223
+ if event_abi is None:
224
+ yield UnknownEvent(raw_log, reason="topic_missing", chain=self.chain)
225
+ continue
226
+
227
+ try:
228
+ yield Event(raw_log, event_abi, chain=self.chain)
229
+ except Exception as exc:
230
+ yield MalformedEvent(raw_log, event_abi, exc, chain=self.chain)
231
+
232
+ def _topic_maps(self, refresh_abi: bool | None) -> dict[str, dict[str, dict]]:
233
+ topic_maps = {}
234
+
235
+ for address in {_log_address(log) for log in self.raw_logs}:
236
+ try:
237
+ contract = Contract(address, chain=self.chain, refresh_abi=refresh_abi)
238
+ contract_abi = contract.abi
239
+ except AttributeError:
240
+ continue
241
+ except (ABINotFound, ExplorerError, NoActiveChain):
242
+ continue
243
+
244
+ topic_map = {}
245
+ for item in contract_abi:
246
+ if item.get("type") != "event" or item.get("anonymous", False):
247
+ continue
248
+ topic_map[event_topic(item)] = item
249
+
250
+ topic_maps[address] = topic_map
251
+
252
+ return topic_maps
253
+
254
+ def enrich(self):
255
+ """Refresh ABI data and re-decode events in place.
256
+
257
+ Returns:
258
+ This EventList instance.
259
+
260
+ Raises:
261
+ ValueError: If the event list has no chain.
262
+ """
263
+ if self.chain is None:
264
+ raise ValueError("Cannot enrich events without a chain")
265
+
266
+ enriched = EventList(self.raw_logs, chain=self.chain, enrich=True)
267
+ self._events = enriched._events
268
+ self._groups = enriched._groups
269
+ self._attrs = enriched._attrs
270
+ return self
271
+
272
+ def __getitem__(self, key):
273
+ """Return an event by index or an event group by name."""
274
+ if isinstance(key, int):
275
+ return self._events[key]
276
+ if isinstance(key, str):
277
+ return self._groups[key]
278
+ raise TypeError("EventList indices must be integers or event names")
279
+
280
+ def __iter__(self):
281
+ """Iterate over decoded events."""
282
+ return iter(self._events)
283
+
284
+ def __len__(self):
285
+ """Return the number of events."""
286
+ return len(self._events)
287
+
288
+ def __bool__(self):
289
+ """Return whether the list contains any events."""
290
+ return bool(self._events)
291
+
292
+ def __getattr__(self, name: str):
293
+ """Return an event group by safe event-name attribute."""
294
+ try:
295
+ return self._groups[self._attrs[name]]
296
+ except KeyError:
297
+ raise AttributeError(name) from None
298
+
299
+ def count(self, name: str) -> int:
300
+ """Return the number of events with a given name."""
301
+ return len(self._groups.get(name, ()))
302
+
303
+ def keys(self):
304
+ """Return event names present in the list."""
305
+ return self._groups.keys()
306
+
307
+ def items(self):
308
+ """Return ``(name, EventGroup)`` pairs."""
309
+ return self._groups.items()
310
+
311
+ def values(self):
312
+ """Return event groups present in the list."""
313
+ return self._groups.values()
314
+
315
+ def __repr__(self) -> str:
316
+ return f"<{type(self).__name__} {list(self._events)!r}>"
317
+
318
+
319
+ class UnknownEvent:
320
+ """Raw log that could not be decoded."""
321
+
322
+ decoded = False
323
+ malformed = False
324
+ name = None
325
+ abi = None
326
+
327
+ def __init__(
328
+ self, raw_log: dict, reason: str | None = None, *, chain: Chain | int | None = None
329
+ ):
330
+ """Initialize an undecoded raw log.
331
+
332
+ Args:
333
+ raw_log: RPC log object.
334
+ reason: Reason the log could not be decoded.
335
+ chain: Chain or chain ID associated with the log.
336
+ """
337
+ self.raw = raw_log
338
+ self.reason = reason
339
+ self.args = EventArgs({})
340
+ self.address = Account(raw_log["address"])
341
+ self.topics = tuple(raw_log.get("topics", []))
342
+ self.topic = self.topics[0] if self.topics else None
343
+ self.data = raw_log.get("data", "0x")
344
+ self.log_index = raw_log.get("logIndex")
345
+ self.transaction_hash = raw_log.get("transactionHash")
346
+ self.block_number = raw_log.get("blockNumber")
347
+ self.removed = raw_log.get("removed", False)
348
+ self.chain = Chain(chain) if chain is not None else None
349
+
350
+ def __repr__(self) -> str:
351
+ return f"<{type(self).__name__} address={self.address} topic={self.topic}>"
352
+
353
+
354
+ class MalformedEvent(UnknownEvent):
355
+ """Log whose ABI was found but failed to decode."""
356
+
357
+ malformed = True
358
+
359
+ def __init__(
360
+ self, raw_log: dict, event_abi: dict, error: Exception, *, chain: Chain | int | None = None
361
+ ):
362
+ """Initialize a malformed decoded event placeholder.
363
+
364
+ Args:
365
+ raw_log: RPC log object.
366
+ event_abi: Event ABI item that failed to decode the log.
367
+ error: Decode error.
368
+ chain: Chain or chain ID associated with the log.
369
+ """
370
+ super().__init__(raw_log, reason="malformed", chain=chain)
371
+ self.address = _address(raw_log["address"], chain)
372
+ self.abi = event_abi
373
+ self.name = event_abi.get("name")
374
+ self.signature = event_signature(event_abi)
375
+ self.topic = event_topic(event_abi)
376
+ self.error = error
377
+
378
+ def __repr__(self) -> str:
379
+ return f"<{type(self).__name__} {self.name} error={self.error!r}>"
@@ -0,0 +1 @@
1
+ """Internal explorer lookup package used by contract ABI resolution."""
@@ -0,0 +1,83 @@
1
+ """Internal Blockscout ABI provider used by contract ABI resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import httpx
8
+
9
+ from fw3_objects.errors import (
10
+ ABINotFound,
11
+ ExplorerConnectionError,
12
+ ExplorerError,
13
+ ExplorerRateLimited,
14
+ )
15
+
16
+ BASE_URL = "https://api.blockscout.com/v2/api"
17
+ RATE_LIMIT_COOLDOWN = 0.25
18
+
19
+
20
+ def _retry_after(response: httpx.Response) -> float | None:
21
+ value = response.headers.get("Retry-After")
22
+ if value is None:
23
+ return None
24
+
25
+ try:
26
+ return max(float(value), 0.0)
27
+ except ValueError:
28
+ return None
29
+
30
+
31
+ def get_abi(
32
+ chain_id: int, address: str, api_key: str, *, resolve_proxy: bool = True
33
+ ) -> tuple[list[dict], str | None]:
34
+ params = {
35
+ "chain_id": int(chain_id),
36
+ "module": "contract",
37
+ "action": "getabi",
38
+ "address": address,
39
+ "apikey": api_key,
40
+ }
41
+
42
+ try:
43
+ response = httpx.get(BASE_URL, params=params, timeout=10)
44
+ except httpx.HTTPError as exc:
45
+ raise ExplorerConnectionError("Could not connect to Blockscout") from exc
46
+
47
+ if response.status_code == 429:
48
+ raise ExplorerRateLimited("blockscout", _retry_after(response))
49
+
50
+ try:
51
+ response.raise_for_status()
52
+ except httpx.HTTPStatusError as exc:
53
+ raise ExplorerError(str(exc)) from exc
54
+
55
+ try:
56
+ data = response.json()
57
+ except ValueError as exc:
58
+ raise ExplorerError("Invalid Blockscout response") from exc
59
+
60
+ status = data.get("status")
61
+ result = data.get("result")
62
+
63
+ if status == "0":
64
+ message = str(data.get("message", ""))
65
+ if "rate limit" in message.lower():
66
+ raise ExplorerRateLimited("blockscout", None)
67
+ raise ABINotFound(str(result or message or "ABI not found"))
68
+
69
+ if not result:
70
+ raise ABINotFound("ABI not found")
71
+
72
+ if isinstance(result, str):
73
+ try:
74
+ parsed = json.loads(result)
75
+ except ValueError as exc:
76
+ raise ExplorerError("Invalid Blockscout ABI JSON") from exc
77
+ else:
78
+ parsed = result
79
+
80
+ if not isinstance(parsed, list) or not all(isinstance(item, dict) for item in parsed):
81
+ raise ExplorerError("Invalid Blockscout ABI")
82
+
83
+ return parsed, None
@@ -0,0 +1,96 @@
1
+ """Internal Etherscan ABI provider used by contract ABI resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import httpx
8
+
9
+ from fw3_objects.errors import (
10
+ ABINotFound,
11
+ ExplorerConnectionError,
12
+ ExplorerError,
13
+ ExplorerRateLimited,
14
+ )
15
+
16
+ BASE_URL = "https://api.etherscan.io/v2/api"
17
+ RATE_LIMIT_COOLDOWN = 0.5
18
+
19
+
20
+ def _retry_after(response: httpx.Response) -> float | None:
21
+ value = response.headers.get("Retry-After")
22
+ if value is None:
23
+ return None
24
+
25
+ try:
26
+ return max(float(value), 0.0)
27
+ except ValueError:
28
+ return None
29
+
30
+
31
+ def _parse_abi(result) -> list[dict]:
32
+ if not isinstance(result, str):
33
+ raise ExplorerError("Invalid Etherscan ABI response")
34
+
35
+ try:
36
+ parsed = json.loads(result)
37
+ except ValueError as exc:
38
+ raise ExplorerError("Invalid Etherscan ABI JSON") from exc
39
+
40
+ if not isinstance(parsed, list) or not all(isinstance(item, dict) for item in parsed):
41
+ raise ExplorerError("Invalid Etherscan ABI")
42
+
43
+ return parsed
44
+
45
+
46
+ def get_abi(
47
+ chain_id: int, address: str, api_key: str, *, resolve_proxy: bool = True
48
+ ) -> tuple[list[dict], str | None]:
49
+ params = {
50
+ "chainid": int(chain_id),
51
+ "module": "contract",
52
+ "action": "getsourcecode",
53
+ "address": address,
54
+ "apikey": api_key,
55
+ }
56
+
57
+ try:
58
+ response = httpx.get(BASE_URL, params=params, timeout=10)
59
+ except httpx.HTTPError as exc:
60
+ raise ExplorerConnectionError("Could not connect to Etherscan") from exc
61
+
62
+ if response.status_code == 429:
63
+ raise ExplorerRateLimited("etherscan", _retry_after(response))
64
+
65
+ try:
66
+ response.raise_for_status()
67
+ except httpx.HTTPStatusError as exc:
68
+ raise ExplorerError(str(exc)) from exc
69
+
70
+ try:
71
+ data = response.json()
72
+ except ValueError as exc:
73
+ raise ExplorerError("Invalid Etherscan response") from exc
74
+
75
+ status = data.get("status")
76
+ result = data.get("result")
77
+
78
+ if status == "0":
79
+ message = str(data.get("message", ""))
80
+ if "rate limit" in message.lower():
81
+ raise ExplorerRateLimited("etherscan", None)
82
+ raise ABINotFound(str(result or message or "ABI not found"))
83
+
84
+ if not isinstance(result, list) or not result:
85
+ raise ABINotFound("ABI not found")
86
+
87
+ item = result[0]
88
+ if not isinstance(item, dict):
89
+ raise ExplorerError("Invalid Etherscan source response")
90
+
91
+ abi = _parse_abi(item.get("ABI"))
92
+ implementation = item.get("Implementation")
93
+ if not resolve_proxy or item.get("Proxy") != "1" or not implementation:
94
+ implementation = None
95
+
96
+ return abi, implementation