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
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Internal asynchronous ABI lookup worker used by Contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import itertools
|
|
6
|
+
import os
|
|
7
|
+
import queue
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from fw3_objects.errors import ABINotFound, ExplorerError, ExplorerRateLimited
|
|
13
|
+
|
|
14
|
+
from . import blockscout, etherscan
|
|
15
|
+
|
|
16
|
+
LOW_PRIORITY = 10
|
|
17
|
+
HIGH_PRIORITY = 0
|
|
18
|
+
NEGATIVE_ABI_TTL = 30
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ABILookupJob:
|
|
23
|
+
chain_id: int
|
|
24
|
+
address: str
|
|
25
|
+
ignore_negative_cache: bool = False
|
|
26
|
+
resolve_proxy: bool = True
|
|
27
|
+
priority: int = LOW_PRIORITY
|
|
28
|
+
_event: threading.Event = field(default_factory=threading.Event, init=False)
|
|
29
|
+
_lock: threading.RLock = field(default_factory=threading.RLock, init=False)
|
|
30
|
+
_result: tuple[list[dict], str | None] | None = field(default=None, init=False)
|
|
31
|
+
_error: BaseException | None = field(default=None, init=False)
|
|
32
|
+
_callbacks: list = field(default_factory=list, init=False)
|
|
33
|
+
|
|
34
|
+
def add_done_callback(self, callback) -> None:
|
|
35
|
+
with self._lock:
|
|
36
|
+
if not self._event.is_set():
|
|
37
|
+
self._callbacks.append(callback)
|
|
38
|
+
return
|
|
39
|
+
result = self._result
|
|
40
|
+
|
|
41
|
+
if result is not None:
|
|
42
|
+
callback(result)
|
|
43
|
+
|
|
44
|
+
def bump_priority(self, priority: int) -> None:
|
|
45
|
+
with self._lock:
|
|
46
|
+
if self._event.is_set() or priority >= self.priority:
|
|
47
|
+
return
|
|
48
|
+
self.priority = priority
|
|
49
|
+
_enqueue(self)
|
|
50
|
+
|
|
51
|
+
def wait(self) -> tuple[list[dict], str | None]:
|
|
52
|
+
self._event.wait()
|
|
53
|
+
if self._error is not None:
|
|
54
|
+
raise self._error
|
|
55
|
+
return self._result
|
|
56
|
+
|
|
57
|
+
def set_result(self, result: tuple[list[dict], str | None]) -> None:
|
|
58
|
+
with self._lock:
|
|
59
|
+
self._result = result
|
|
60
|
+
callbacks = tuple(self._callbacks)
|
|
61
|
+
self._callbacks.clear()
|
|
62
|
+
self._event.set()
|
|
63
|
+
|
|
64
|
+
for callback in callbacks:
|
|
65
|
+
try:
|
|
66
|
+
callback(result)
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
def set_error(self, error: BaseException) -> None:
|
|
71
|
+
with self._lock:
|
|
72
|
+
self._error = error
|
|
73
|
+
self._callbacks.clear()
|
|
74
|
+
self._event.set()
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def done(self) -> bool:
|
|
78
|
+
return self._event.is_set()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_sequence = itertools.count()
|
|
82
|
+
_queue: queue.PriorityQueue[tuple[int, int, ABILookupJob]] = queue.PriorityQueue()
|
|
83
|
+
_pending: dict[tuple[int, str, bool], ABILookupJob] = {}
|
|
84
|
+
_negative_cache: dict[tuple[int, str, bool], float] = {}
|
|
85
|
+
_rate_limited_until: dict[str, float] = {}
|
|
86
|
+
_state_lock = threading.RLock()
|
|
87
|
+
_worker_started = False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def fetch_abi(
|
|
91
|
+
chain_id: int,
|
|
92
|
+
address: str,
|
|
93
|
+
*,
|
|
94
|
+
priority: int = LOW_PRIORITY,
|
|
95
|
+
ignore_negative_cache: bool = False,
|
|
96
|
+
resolve_proxy: bool = True,
|
|
97
|
+
on_success=None,
|
|
98
|
+
) -> ABILookupJob:
|
|
99
|
+
chain_id = int(chain_id)
|
|
100
|
+
address = address.lower()
|
|
101
|
+
key = (chain_id, address, resolve_proxy)
|
|
102
|
+
|
|
103
|
+
with _state_lock:
|
|
104
|
+
existing = _pending.get(key)
|
|
105
|
+
if existing is not None and not existing.done and not ignore_negative_cache:
|
|
106
|
+
if on_success is not None:
|
|
107
|
+
existing.add_done_callback(on_success)
|
|
108
|
+
existing.bump_priority(priority)
|
|
109
|
+
return existing
|
|
110
|
+
|
|
111
|
+
if not ignore_negative_cache:
|
|
112
|
+
expires_at = _negative_cache.get(key)
|
|
113
|
+
if expires_at is not None and expires_at > time.monotonic():
|
|
114
|
+
job = ABILookupJob(
|
|
115
|
+
chain_id,
|
|
116
|
+
address,
|
|
117
|
+
ignore_negative_cache=ignore_negative_cache,
|
|
118
|
+
resolve_proxy=resolve_proxy,
|
|
119
|
+
priority=priority,
|
|
120
|
+
)
|
|
121
|
+
job.set_error(ABINotFound(f"ABI not found for {address} on chain {chain_id}"))
|
|
122
|
+
return job
|
|
123
|
+
if expires_at is not None:
|
|
124
|
+
_negative_cache.pop(key, None)
|
|
125
|
+
|
|
126
|
+
job = ABILookupJob(
|
|
127
|
+
chain_id,
|
|
128
|
+
address,
|
|
129
|
+
ignore_negative_cache=ignore_negative_cache,
|
|
130
|
+
resolve_proxy=resolve_proxy,
|
|
131
|
+
priority=priority,
|
|
132
|
+
)
|
|
133
|
+
if on_success is not None:
|
|
134
|
+
job.add_done_callback(on_success)
|
|
135
|
+
_pending[key] = job
|
|
136
|
+
_ensure_worker_started()
|
|
137
|
+
|
|
138
|
+
_enqueue(job)
|
|
139
|
+
return job
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _ensure_worker_started() -> None:
|
|
143
|
+
global _worker_started
|
|
144
|
+
|
|
145
|
+
if _worker_started:
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
thread = threading.Thread(target=_worker, name="fw3-objects-abi-lookup", daemon=True)
|
|
149
|
+
thread.start()
|
|
150
|
+
_worker_started = True
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _enqueue(job: ABILookupJob) -> None:
|
|
154
|
+
_queue.put((job.priority, next(_sequence), job))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _worker() -> None:
|
|
158
|
+
while True:
|
|
159
|
+
priority, _, job = _queue.get()
|
|
160
|
+
try:
|
|
161
|
+
if job.done or priority != job.priority:
|
|
162
|
+
continue
|
|
163
|
+
_run_job(job)
|
|
164
|
+
finally:
|
|
165
|
+
_queue.task_done()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _run_job(job: ABILookupJob) -> None:
|
|
169
|
+
key = (job.chain_id, job.address, job.resolve_proxy)
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
result = _fetch_abi(job.chain_id, job.address, resolve_proxy=job.resolve_proxy)
|
|
173
|
+
except BaseException as exc:
|
|
174
|
+
job.set_error(exc)
|
|
175
|
+
with _state_lock:
|
|
176
|
+
should_cache = _pending.get(key) is job
|
|
177
|
+
if should_cache and not job.ignore_negative_cache and isinstance(exc, ABINotFound):
|
|
178
|
+
_negative_cache[key] = time.monotonic() + NEGATIVE_ABI_TTL
|
|
179
|
+
else:
|
|
180
|
+
job.set_result(result)
|
|
181
|
+
finally:
|
|
182
|
+
with _state_lock:
|
|
183
|
+
if _pending.get(key) is job:
|
|
184
|
+
_pending.pop(key, None)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _fetch_abi(
|
|
188
|
+
chain_id: int, address: str, *, resolve_proxy: bool = True
|
|
189
|
+
) -> tuple[list[dict], str | None]:
|
|
190
|
+
providers = _providers()
|
|
191
|
+
if not providers:
|
|
192
|
+
raise ABINotFound(
|
|
193
|
+
"No block explorer API keys configured, set ETHERSCAN_API_KEY or BLOCKSCOUT_API_KEY"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
errors = []
|
|
197
|
+
|
|
198
|
+
while providers:
|
|
199
|
+
ready, blocked = _partition_ready(providers)
|
|
200
|
+
|
|
201
|
+
for name, func, api_key, cooldown in ready:
|
|
202
|
+
try:
|
|
203
|
+
return func(chain_id, address, api_key, resolve_proxy=resolve_proxy)
|
|
204
|
+
except ExplorerRateLimited as exc:
|
|
205
|
+
retry_after = exc.retry_after if exc.retry_after is not None else cooldown
|
|
206
|
+
with _state_lock:
|
|
207
|
+
_rate_limited_until[name] = time.monotonic() + retry_after
|
|
208
|
+
errors.append(exc)
|
|
209
|
+
except (ABINotFound, ExplorerError) as exc:
|
|
210
|
+
errors.append(exc)
|
|
211
|
+
|
|
212
|
+
providers = blocked
|
|
213
|
+
if not providers:
|
|
214
|
+
break
|
|
215
|
+
|
|
216
|
+
sleep_for = (
|
|
217
|
+
min(_rate_limited_until.get(name, 0) for name, *_ in providers) - time.monotonic()
|
|
218
|
+
)
|
|
219
|
+
if sleep_for > 0:
|
|
220
|
+
time.sleep(sleep_for)
|
|
221
|
+
|
|
222
|
+
if errors:
|
|
223
|
+
if all(isinstance(error, ExplorerRateLimited) for error in errors):
|
|
224
|
+
raise errors[-1]
|
|
225
|
+
|
|
226
|
+
non_not_found_errors = [error for error in errors if not isinstance(error, ABINotFound)]
|
|
227
|
+
if non_not_found_errors:
|
|
228
|
+
raise non_not_found_errors[-1]
|
|
229
|
+
|
|
230
|
+
raise ABINotFound(f"ABI not found for {address} on chain {chain_id}")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _providers():
|
|
234
|
+
providers = []
|
|
235
|
+
|
|
236
|
+
# quietly support ETHERSCAN_TOKEN for brownie users
|
|
237
|
+
etherscan_key = os.getenv("ETHERSCAN_API_KEY") or os.getenv("ETHERSCAN_TOKEN")
|
|
238
|
+
if etherscan_key:
|
|
239
|
+
providers.append(
|
|
240
|
+
("etherscan", etherscan.get_abi, etherscan_key, etherscan.RATE_LIMIT_COOLDOWN)
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
blockscout_key = os.getenv("BLOCKSCOUT_API_KEY")
|
|
244
|
+
if blockscout_key:
|
|
245
|
+
providers.append(
|
|
246
|
+
("blockscout", blockscout.get_abi, blockscout_key, blockscout.RATE_LIMIT_COOLDOWN)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return providers
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _partition_ready(providers):
|
|
253
|
+
now = time.monotonic()
|
|
254
|
+
ready = []
|
|
255
|
+
blocked = []
|
|
256
|
+
|
|
257
|
+
with _state_lock:
|
|
258
|
+
for provider in providers:
|
|
259
|
+
name = provider[0]
|
|
260
|
+
expires_at = _rate_limited_until.get(name)
|
|
261
|
+
if expires_at is None or expires_at <= now:
|
|
262
|
+
_rate_limited_until.pop(name, None)
|
|
263
|
+
ready.append(provider)
|
|
264
|
+
else:
|
|
265
|
+
blocked.append(provider)
|
|
266
|
+
|
|
267
|
+
return ready, blocked
|
fw3_objects/monitor.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Internal transaction monitoring process used by Chain and Transaction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from threading import Event, RLock, Thread
|
|
7
|
+
from weakref import WeakSet
|
|
8
|
+
|
|
9
|
+
from .transaction import TxStatus
|
|
10
|
+
|
|
11
|
+
MAX_BATCH_SIZE = 100
|
|
12
|
+
POLL_INTERVAL = 1.0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TransactionMonitor:
|
|
16
|
+
"""Internal transaction polling process for a Chain.
|
|
17
|
+
|
|
18
|
+
Users do not instantiate this class directly. A Chain creates one lazily when a
|
|
19
|
+
Transaction is watched, and Transaction objects use it to receive mempool,
|
|
20
|
+
receipt, replacement, and drop updates.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, chain):
|
|
24
|
+
self.chain = chain
|
|
25
|
+
self._watched = WeakSet()
|
|
26
|
+
self._lock = RLock()
|
|
27
|
+
self._wake = Event()
|
|
28
|
+
self._thread = None
|
|
29
|
+
self.last_error = None
|
|
30
|
+
|
|
31
|
+
def watch(self, tx):
|
|
32
|
+
with self._lock:
|
|
33
|
+
self._watched.add(tx)
|
|
34
|
+
if self._thread is None:
|
|
35
|
+
self._thread = Thread(target=self._run, daemon=True)
|
|
36
|
+
self._thread.start()
|
|
37
|
+
self._wake.set()
|
|
38
|
+
|
|
39
|
+
def _run(self):
|
|
40
|
+
while True:
|
|
41
|
+
started_at = time.monotonic()
|
|
42
|
+
self._poll_once()
|
|
43
|
+
elapsed = time.monotonic() - started_at
|
|
44
|
+
self._wake.wait(max(0, POLL_INTERVAL - elapsed))
|
|
45
|
+
self._wake.clear()
|
|
46
|
+
|
|
47
|
+
def _poll_once(self):
|
|
48
|
+
with self._lock:
|
|
49
|
+
watched = tuple(self._watched)
|
|
50
|
+
|
|
51
|
+
if not watched:
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
transactions_per_batch = max(1, MAX_BATCH_SIZE // 3)
|
|
55
|
+
for batch in _chunks(watched, transactions_per_batch):
|
|
56
|
+
try:
|
|
57
|
+
self._poll_batch(batch)
|
|
58
|
+
except Exception as exc:
|
|
59
|
+
self.last_error = exc
|
|
60
|
+
|
|
61
|
+
def _poll_batch(self, watched):
|
|
62
|
+
w3 = self.chain.w3
|
|
63
|
+
known = [tx for tx in watched if tx._transaction.get("from")]
|
|
64
|
+
senders = {tx._transaction["from"] for tx in known}
|
|
65
|
+
|
|
66
|
+
with w3.batch_requests():
|
|
67
|
+
tx_data = {tx: w3.eth.get_transaction_by_hash(tx.hash) for tx in watched}
|
|
68
|
+
receipts = {tx: w3.eth.get_transaction_receipt(tx.hash) for tx in watched}
|
|
69
|
+
latest_nonces = {
|
|
70
|
+
sender: w3.eth.get_transaction_count(sender, "latest") for sender in senders
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for tx in watched:
|
|
74
|
+
txdict = tx_data[tx]
|
|
75
|
+
receipt = receipts[tx]
|
|
76
|
+
|
|
77
|
+
if bool(receipt):
|
|
78
|
+
# receipt is available, transaction has confirmed.
|
|
79
|
+
if bool(txdict):
|
|
80
|
+
tx._transaction = dict(txdict)
|
|
81
|
+
tx._receipt = dict(receipt)
|
|
82
|
+
tx._status = TxStatus(receipt["status"])
|
|
83
|
+
tx._finalized.set()
|
|
84
|
+
if tx._status == TxStatus.REVERTED:
|
|
85
|
+
tx._resolve_revert_reason()
|
|
86
|
+
|
|
87
|
+
elif bool(txdict):
|
|
88
|
+
# receipt not available, but transaction is. the transaction
|
|
89
|
+
# is currently sitting in a the public mempool.
|
|
90
|
+
tx._transaction = dict(txdict)
|
|
91
|
+
tx._status = TxStatus.PENDING
|
|
92
|
+
|
|
93
|
+
else:
|
|
94
|
+
# receipt and transaction are both unavailable from node
|
|
95
|
+
sender = tx._transaction.get("from")
|
|
96
|
+
if sender and latest_nonces.get(sender, 0) > tx._transaction["nonce"]:
|
|
97
|
+
# we know the sender and nonce, because the transaction was either
|
|
98
|
+
# previously seen publicly or seeded locally. the sender's nonce has
|
|
99
|
+
# advanced beyond the nonce of this transaction, but the transaction
|
|
100
|
+
# did not confirm. finalize as replaced by another transaction.
|
|
101
|
+
tx._status = TxStatus.REPLACED
|
|
102
|
+
tx._finalized.set()
|
|
103
|
+
elif tx._status != TxStatus.UNSEEN:
|
|
104
|
+
# transaction was previously seen, but we cannot say for sure
|
|
105
|
+
# that it was replaced - only that it is no longer available from
|
|
106
|
+
# the node. mark it as dropped.
|
|
107
|
+
tx._status = TxStatus.DROPPED
|
|
108
|
+
elif not tx._allow_unseen:
|
|
109
|
+
# transaction was never seen and caller did not ask us to keep
|
|
110
|
+
# watching unseen hashes. finalize as not found.
|
|
111
|
+
tx._finalized.set()
|
|
112
|
+
|
|
113
|
+
tx._initialized.set()
|
|
114
|
+
|
|
115
|
+
remove = {i for i in watched if i._finalized.is_set()}
|
|
116
|
+
|
|
117
|
+
if remove:
|
|
118
|
+
with self._lock:
|
|
119
|
+
self._watched.difference_update(remove)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _chunks(values, size):
|
|
123
|
+
values = tuple(values)
|
|
124
|
+
for idx in range(0, len(values), size):
|
|
125
|
+
yield values[idx : idx + size]
|