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.
@@ -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]