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.
- fastweb3_objects-0.1.0.dist-info/METADATA +233 -0
- fastweb3_objects-0.1.0.dist-info/RECORD +22 -0
- fastweb3_objects-0.1.0.dist-info/WHEEL +5 -0
- fastweb3_objects-0.1.0.dist-info/licenses/LICENSE +21 -0
- fastweb3_objects-0.1.0.dist-info/top_level.txt +1 -0
- fw3_objects/__init__.py +12 -0
- fw3_objects/abi.py +511 -0
- fw3_objects/account.py +407 -0
- fw3_objects/cache/__init__.py +1 -0
- fw3_objects/cache/db.py +73 -0
- fw3_objects/cache/metadata.py +89 -0
- fw3_objects/cache/rpc.py +205 -0
- fw3_objects/chain.py +317 -0
- fw3_objects/contract.py +647 -0
- fw3_objects/errors.py +92 -0
- fw3_objects/events.py +379 -0
- fw3_objects/explorers/__init__.py +1 -0
- fw3_objects/explorers/blockscout.py +83 -0
- fw3_objects/explorers/etherscan.py +96 -0
- fw3_objects/explorers/lookup.py +267 -0
- fw3_objects/monitor.py +125 -0
- fw3_objects/transaction.py +348 -0
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
|