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/account.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from getpass import getpass
|
|
4
|
+
from typing import Any
|
|
5
|
+
from weakref import WeakSet
|
|
6
|
+
|
|
7
|
+
import fw3_keypass as kp
|
|
8
|
+
from Crypto.Hash import keccak
|
|
9
|
+
from fw3.formatters import build_transaction_object, normalize_rpc_obj
|
|
10
|
+
from fw3_keypass.crypto.rlp import rlp_encode
|
|
11
|
+
from fw3_keypass.db.core import resolve_db_path
|
|
12
|
+
from fw3_keypass.utils import checksum_address
|
|
13
|
+
|
|
14
|
+
from .chain import Chain
|
|
15
|
+
from .errors import ChainMismatch, NoActiveChain
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Accounts(kp.KeypassDB):
|
|
19
|
+
"""Keypass-backed account database."""
|
|
20
|
+
|
|
21
|
+
_instances = WeakSet()
|
|
22
|
+
|
|
23
|
+
def __init__(self, name_or_path=None, *, create=None, unlock=True, password=None):
|
|
24
|
+
"""Initialize an account database.
|
|
25
|
+
|
|
26
|
+
Behavior depends on whether a path is provided and whether the database already
|
|
27
|
+
exists.
|
|
28
|
+
|
|
29
|
+
Default behavior:
|
|
30
|
+
- If ``name_or_path`` is ``None``, the default database is used.
|
|
31
|
+
- If the default database does not exist, it is created.
|
|
32
|
+
- If the default database exists, it is opened and unlocked.
|
|
33
|
+
- If ``name_or_path`` is provided and exists, it is opened.
|
|
34
|
+
- If ``name_or_path`` is provided and does not exist, it is created only
|
|
35
|
+
when ``create=True``.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
name_or_path: Database name or filesystem path. Uses the default database
|
|
39
|
+
location when omitted.
|
|
40
|
+
create: Whether to create the database if it does not exist. If omitted,
|
|
41
|
+
defaults to ``True`` only for the default database.
|
|
42
|
+
unlock: Whether to unlock an existing database after opening it.
|
|
43
|
+
password: Password used to initialize or unlock the database.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
FileNotFoundError: If the database does not exist and ``create`` is false.
|
|
47
|
+
"""
|
|
48
|
+
path = resolve_db_path(name_or_path)
|
|
49
|
+
if create is None:
|
|
50
|
+
create = name_or_path is None
|
|
51
|
+
if path.exists():
|
|
52
|
+
create = False
|
|
53
|
+
elif create is False:
|
|
54
|
+
raise FileNotFoundError("Database does not exist")
|
|
55
|
+
else:
|
|
56
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
super().__init__(path)
|
|
58
|
+
if create:
|
|
59
|
+
if password is None:
|
|
60
|
+
password = getpass(f"Create password for new Accounts database '{path.stem}': ")
|
|
61
|
+
self.initialize(password)
|
|
62
|
+
elif unlock:
|
|
63
|
+
self.unlock(password)
|
|
64
|
+
self._is_default = resolve_db_path(None) == path
|
|
65
|
+
self._instances.add(self)
|
|
66
|
+
|
|
67
|
+
def _make_account(self, address: str) -> Account:
|
|
68
|
+
return Account(address, db=self)
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def _find_signer(cls, address):
|
|
72
|
+
for accounts in cls._instances:
|
|
73
|
+
try:
|
|
74
|
+
return accounts[address]
|
|
75
|
+
except KeyError:
|
|
76
|
+
continue
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
def __repr__(self) -> str:
|
|
80
|
+
state = "unlocked" if self.is_unlocked else "locked"
|
|
81
|
+
name = f"'{self.path.stem}' " if not self._is_default else ""
|
|
82
|
+
return f"<Accounts {name}{state}>"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Account(kp.Account):
|
|
86
|
+
"""Ethereum account object with optional chain binding."""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
address: str,
|
|
91
|
+
*,
|
|
92
|
+
db=None,
|
|
93
|
+
chain: Chain | int | None = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Initialize an account wrapper.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
address: Account address.
|
|
99
|
+
db: Keypass database that owns the account, if available.
|
|
100
|
+
chain: Chain or chain ID to bind this account to. If omitted, chain-specific
|
|
101
|
+
methods use their ``chain`` argument or the active default chain.
|
|
102
|
+
"""
|
|
103
|
+
super().__init__(address, db=db)
|
|
104
|
+
self._bound_chain = None if chain is None else Chain(chain)
|
|
105
|
+
|
|
106
|
+
def on(self, chain: Chain | int) -> Account:
|
|
107
|
+
"""Return a copy of this account bound to a chain.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
chain: Chain or chain ID to bind.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Account bound to the requested chain.
|
|
114
|
+
"""
|
|
115
|
+
return Account(self.address, db=self._db, chain=chain)
|
|
116
|
+
|
|
117
|
+
def balance(
|
|
118
|
+
self,
|
|
119
|
+
*,
|
|
120
|
+
chain: Chain | int | None = None,
|
|
121
|
+
block_identifier: str | int | None = None,
|
|
122
|
+
) -> int:
|
|
123
|
+
"""Return the native token balance for this account.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
chain: Chain or chain ID to query. Uses the bound or default chain when
|
|
127
|
+
omitted.
|
|
128
|
+
block_identifier: Optional block number or tag.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Balance in wei.
|
|
132
|
+
"""
|
|
133
|
+
w3 = self._resolve_chain(chain).w3
|
|
134
|
+
if block_identifier is None:
|
|
135
|
+
return w3.eth.get_balance(self.address)
|
|
136
|
+
else:
|
|
137
|
+
return w3.eth.get_balance(self.address, block_identifier)
|
|
138
|
+
|
|
139
|
+
def nonce(
|
|
140
|
+
self,
|
|
141
|
+
*,
|
|
142
|
+
chain: Chain | int | None = None,
|
|
143
|
+
block_identifier: str | int | None = None,
|
|
144
|
+
) -> int:
|
|
145
|
+
"""Return the transaction count for this account.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
chain: Chain or chain ID to query. Uses the bound or default chain when
|
|
149
|
+
omitted.
|
|
150
|
+
block_identifier: Optional block number or tag.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Account nonce at the requested block.
|
|
154
|
+
"""
|
|
155
|
+
w3 = self._resolve_chain(chain).w3
|
|
156
|
+
if block_identifier is None:
|
|
157
|
+
return w3.eth.get_transaction_count(self.address)
|
|
158
|
+
else:
|
|
159
|
+
return w3.eth.get_transaction_count(self.address, block_identifier)
|
|
160
|
+
|
|
161
|
+
def bytecode(
|
|
162
|
+
self,
|
|
163
|
+
*,
|
|
164
|
+
chain: Chain | int | None = None,
|
|
165
|
+
block_identifier: str | int | None = None,
|
|
166
|
+
):
|
|
167
|
+
"""Return code stored at this account address.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
chain: Chain or chain ID to query. Uses the bound or default chain when
|
|
171
|
+
omitted.
|
|
172
|
+
block_identifier: Optional block number or tag.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Contract bytecode, or empty bytes for an externally owned account.
|
|
176
|
+
"""
|
|
177
|
+
w3 = self._resolve_chain(chain).w3
|
|
178
|
+
if block_identifier is None:
|
|
179
|
+
return w3.eth.get_code(self.address)
|
|
180
|
+
else:
|
|
181
|
+
return w3.eth.get_code(self.address, block_identifier)
|
|
182
|
+
|
|
183
|
+
def storage(
|
|
184
|
+
self,
|
|
185
|
+
position: int | str,
|
|
186
|
+
*,
|
|
187
|
+
chain: Chain | int | None = None,
|
|
188
|
+
block_identifier: str | int | None = None,
|
|
189
|
+
):
|
|
190
|
+
"""Return a storage slot value for this account address.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
position: Storage slot index.
|
|
194
|
+
chain: Chain or chain ID to query. Uses the bound or default chain when
|
|
195
|
+
omitted.
|
|
196
|
+
block_identifier: Optional block number or tag.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Raw storage slot value.
|
|
200
|
+
"""
|
|
201
|
+
w3 = self._resolve_chain(chain).w3
|
|
202
|
+
if block_identifier is None:
|
|
203
|
+
return w3.eth.get_storage_at(self.address, position)
|
|
204
|
+
else:
|
|
205
|
+
return w3.eth.get_storage_at(self.address, position, block_identifier)
|
|
206
|
+
|
|
207
|
+
def call(
|
|
208
|
+
self,
|
|
209
|
+
to: str | None = None,
|
|
210
|
+
*,
|
|
211
|
+
value: int | str | None = None,
|
|
212
|
+
data: bytes | str | None = None,
|
|
213
|
+
gas_limit: int | str | None = None,
|
|
214
|
+
chain: Chain | int | None = None,
|
|
215
|
+
block_identifier: str | int | None = None,
|
|
216
|
+
) -> Any:
|
|
217
|
+
"""Perform an ``eth_call`` as this account.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
to: Destination address.
|
|
221
|
+
value: Call value in wei.
|
|
222
|
+
data: Call data.
|
|
223
|
+
gas_limit: Optional gas limit.
|
|
224
|
+
chain: Chain or chain ID to query. Uses the bound or default chain when
|
|
225
|
+
omitted.
|
|
226
|
+
block_identifier: Optional block number or tag.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Raw call return data.
|
|
230
|
+
"""
|
|
231
|
+
chain = self._resolve_chain(chain)
|
|
232
|
+
if to is not None:
|
|
233
|
+
to = str(to)
|
|
234
|
+
|
|
235
|
+
tx_kwargs = dict(
|
|
236
|
+
from_=self.address,
|
|
237
|
+
to=to,
|
|
238
|
+
gas=gas_limit,
|
|
239
|
+
value=value,
|
|
240
|
+
data=data,
|
|
241
|
+
chain_id=int(chain),
|
|
242
|
+
block=block_identifier,
|
|
243
|
+
)
|
|
244
|
+
tx_kwargs = {k: v for k, v in tx_kwargs.items() if v is not None}
|
|
245
|
+
return chain.w3.eth.call(**tx_kwargs)
|
|
246
|
+
|
|
247
|
+
def estimate_gas(
|
|
248
|
+
self,
|
|
249
|
+
to: str | None = None,
|
|
250
|
+
value: int | str | None = None,
|
|
251
|
+
*,
|
|
252
|
+
data: bytes | str | None = None,
|
|
253
|
+
chain: Chain | int | None = None,
|
|
254
|
+
) -> int:
|
|
255
|
+
"""Estimate gas for a transaction from this account.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
to: Destination address.
|
|
259
|
+
value: Transaction value in wei.
|
|
260
|
+
data: Transaction calldata.
|
|
261
|
+
chain: Chain or chain ID to query. Uses the bound or default chain when
|
|
262
|
+
omitted.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
Estimated gas limit.
|
|
266
|
+
"""
|
|
267
|
+
chain = self._resolve_chain(chain)
|
|
268
|
+
if to is not None:
|
|
269
|
+
to = str(to)
|
|
270
|
+
|
|
271
|
+
tx_kwargs = dict(from_=self.address, to=to, value=value, data=data, chain_id=int(chain))
|
|
272
|
+
tx_kwargs = {k: v for k, v in tx_kwargs.items() if v is not None}
|
|
273
|
+
return chain.w3.eth.estimate_gas(**tx_kwargs)
|
|
274
|
+
|
|
275
|
+
def transact(
|
|
276
|
+
self,
|
|
277
|
+
to: str | None = None,
|
|
278
|
+
value: int | str | None = None,
|
|
279
|
+
*,
|
|
280
|
+
data: bytes | str | None = None,
|
|
281
|
+
gas_limit: int | str | None = None,
|
|
282
|
+
gas_buffer: float | None = None,
|
|
283
|
+
gas_price: int | str | None = None,
|
|
284
|
+
max_fee_per_gas: int | str | None = None,
|
|
285
|
+
max_priority_fee_per_gas: int | str | None = None,
|
|
286
|
+
nonce: int | str | None = None,
|
|
287
|
+
chain: Chain | int | None = None,
|
|
288
|
+
) -> Any:
|
|
289
|
+
"""Sign and broadcast a transaction from this account.
|
|
290
|
+
|
|
291
|
+
Missing nonce and fee fields are filled from the target chain before signing.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
to: Destination address. May be omitted for contract creation.
|
|
295
|
+
value: Transaction value in wei.
|
|
296
|
+
data: Transaction calldata.
|
|
297
|
+
gas_limit: Explicit gas limit. Estimated when omitted.
|
|
298
|
+
gas_buffer: Multiplier applied to the estimated gas limit.
|
|
299
|
+
gas_price: Legacy gas price.
|
|
300
|
+
max_fee_per_gas: EIP-1559 max fee per gas.
|
|
301
|
+
max_priority_fee_per_gas: EIP-1559 max priority fee per gas.
|
|
302
|
+
nonce: Explicit nonce. Queried when omitted.
|
|
303
|
+
chain: Chain or chain ID to broadcast on. Uses the bound or default chain
|
|
304
|
+
when omitted.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
Transaction object for the broadcast transaction.
|
|
308
|
+
"""
|
|
309
|
+
chain = self._resolve_chain(chain)
|
|
310
|
+
if to is not None:
|
|
311
|
+
to = str(to)
|
|
312
|
+
|
|
313
|
+
with chain.w3.batch_requests():
|
|
314
|
+
if nonce is None:
|
|
315
|
+
nonce = self.nonce(chain=chain)
|
|
316
|
+
if gas_limit is None:
|
|
317
|
+
gas_limit = self.estimate_gas(to=to, value=value, data=data, chain=chain)
|
|
318
|
+
if gas_price is None:
|
|
319
|
+
if max_priority_fee_per_gas is None:
|
|
320
|
+
max_priority_fee_per_gas = chain.priority_fee()
|
|
321
|
+
if max_fee_per_gas is None:
|
|
322
|
+
# query this value last because it flushes the batch queue
|
|
323
|
+
max_fee_per_gas = int(chain.base_fee() * 1.25)
|
|
324
|
+
|
|
325
|
+
if gas_buffer is not None:
|
|
326
|
+
if gas_buffer < 1:
|
|
327
|
+
raise ValueError("Gas buffer must be at least 1")
|
|
328
|
+
gas_limit = int(gas_limit * gas_buffer)
|
|
329
|
+
|
|
330
|
+
if gas_price is None:
|
|
331
|
+
if max_priority_fee_per_gas < 100:
|
|
332
|
+
max_priority_fee_per_gas = 100
|
|
333
|
+
|
|
334
|
+
if max_priority_fee_per_gas > max_fee_per_gas:
|
|
335
|
+
max_fee_per_gas = max_priority_fee_per_gas
|
|
336
|
+
|
|
337
|
+
tx = build_transaction_object(
|
|
338
|
+
from_=self.address,
|
|
339
|
+
to=to,
|
|
340
|
+
gas=gas_limit,
|
|
341
|
+
gas_price=gas_price,
|
|
342
|
+
max_fee_per_gas=max_fee_per_gas,
|
|
343
|
+
max_priority_fee_per_gas=max_priority_fee_per_gas,
|
|
344
|
+
value=value,
|
|
345
|
+
data=data,
|
|
346
|
+
nonce=nonce,
|
|
347
|
+
chain_id=int(chain),
|
|
348
|
+
)
|
|
349
|
+
raw_tx = self.sign_transaction(tx)
|
|
350
|
+
from .transaction import Transaction
|
|
351
|
+
|
|
352
|
+
return Transaction(
|
|
353
|
+
chain.w3.eth.send_raw_transaction(raw_tx.raw_transaction),
|
|
354
|
+
chain=chain,
|
|
355
|
+
allow_unseen=True,
|
|
356
|
+
_txdict=normalize_rpc_obj(tx),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def get_deployment_address(
|
|
360
|
+
self,
|
|
361
|
+
*,
|
|
362
|
+
nonce: int | None = None,
|
|
363
|
+
chain: Chain | int | None = None,
|
|
364
|
+
) -> str:
|
|
365
|
+
"""Compute this account's CREATE deployment address.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
nonce: Nonce to use. If omitted, the current account nonce is queried.
|
|
369
|
+
chain: Chain or chain ID used when ``nonce`` must be queried.
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
Checksummed deployment address.
|
|
373
|
+
|
|
374
|
+
Raises:
|
|
375
|
+
TypeError: If ``nonce`` is not an integer.
|
|
376
|
+
ValueError: If ``nonce`` is negative.
|
|
377
|
+
"""
|
|
378
|
+
if nonce is None:
|
|
379
|
+
nonce = self.nonce(chain=chain)
|
|
380
|
+
|
|
381
|
+
if not isinstance(nonce, int):
|
|
382
|
+
raise TypeError("nonce must be an int")
|
|
383
|
+
if nonce < 0:
|
|
384
|
+
raise ValueError("nonce cannot be negative")
|
|
385
|
+
|
|
386
|
+
encoded = rlp_encode([bytes.fromhex(self._normalized_address[2:]), nonce])
|
|
387
|
+
digest = keccak.new(digest_bits=256, data=encoded).digest()
|
|
388
|
+
return checksum_address("0x" + digest[-20:].hex())
|
|
389
|
+
|
|
390
|
+
def _resolve_chain(
|
|
391
|
+
self,
|
|
392
|
+
chain: Chain | int | None,
|
|
393
|
+
) -> Chain:
|
|
394
|
+
if self._bound_chain is None:
|
|
395
|
+
if chain is None:
|
|
396
|
+
resolved, _ = Chain._get_default_chain()
|
|
397
|
+
if resolved is None:
|
|
398
|
+
raise NoActiveChain("No chain specified for unbound Account")
|
|
399
|
+
else:
|
|
400
|
+
resolved = Chain(chain)
|
|
401
|
+
else:
|
|
402
|
+
if chain is not None:
|
|
403
|
+
if self._bound_chain != Chain(chain):
|
|
404
|
+
raise ChainMismatch(self._bound_chain, chain, "bound Account")
|
|
405
|
+
resolved = self._bound_chain
|
|
406
|
+
|
|
407
|
+
return resolved
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Internal cache package used by fw3-objects."""
|
fw3_objects/cache/db.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Internal SQLite cache storage used by fw3-objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
import threading
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from platformdirs import user_cache_dir
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _default_cache_path() -> Path:
|
|
14
|
+
"""Return the default SQLite cache path."""
|
|
15
|
+
return Path(user_cache_dir("fw3-objects")) / "cache.sqlite"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_cache_db: CacheDB | None = None
|
|
19
|
+
_cache_db_lock = threading.Lock()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_cache_db() -> CacheDB:
|
|
23
|
+
"""Return the process-global cache database."""
|
|
24
|
+
global _cache_db
|
|
25
|
+
|
|
26
|
+
with _cache_db_lock:
|
|
27
|
+
if _cache_db is None:
|
|
28
|
+
_cache_db = CacheDB()
|
|
29
|
+
return _cache_db
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CacheDB:
|
|
33
|
+
"""SQLite cache database."""
|
|
34
|
+
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
"""Initialize the cache database."""
|
|
37
|
+
self.path = _default_cache_path()
|
|
38
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
self._lock = threading.RLock()
|
|
40
|
+
self._conn = sqlite3.connect(self.path, check_same_thread=False)
|
|
41
|
+
self._conn.row_factory = sqlite3.Row
|
|
42
|
+
self._configure()
|
|
43
|
+
self._init_db()
|
|
44
|
+
|
|
45
|
+
def close(self) -> None:
|
|
46
|
+
"""Close the database connection."""
|
|
47
|
+
with self._lock:
|
|
48
|
+
self._conn.close()
|
|
49
|
+
|
|
50
|
+
def execute(self, sql: str, params: tuple[Any, ...] = ()) -> sqlite3.Cursor:
|
|
51
|
+
"""Execute a SQL statement under the database lock."""
|
|
52
|
+
with self._lock:
|
|
53
|
+
return self._conn.execute(sql, params)
|
|
54
|
+
|
|
55
|
+
def commit(self) -> None:
|
|
56
|
+
"""Commit the active transaction."""
|
|
57
|
+
with self._lock:
|
|
58
|
+
self._conn.commit()
|
|
59
|
+
|
|
60
|
+
def _configure(self) -> None:
|
|
61
|
+
with self._lock:
|
|
62
|
+
self._conn.execute("PRAGMA foreign_keys = ON")
|
|
63
|
+
self._conn.execute("PRAGMA journal_mode = WAL")
|
|
64
|
+
|
|
65
|
+
def _init_db(self) -> None:
|
|
66
|
+
with self._lock, self._conn:
|
|
67
|
+
self._create_v1_schema()
|
|
68
|
+
|
|
69
|
+
def _create_v1_schema(self) -> None:
|
|
70
|
+
from .metadata import ADDRESS_METADATA_SCHEMA
|
|
71
|
+
from .rpc import RPC_CACHE_SCHEMA
|
|
72
|
+
|
|
73
|
+
self._conn.executescript("\n".join((RPC_CACHE_SCHEMA, ADDRESS_METADATA_SCHEMA)))
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Internal address metadata cache used for ABI and proxy metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .db import CacheDB, get_cache_db
|
|
10
|
+
|
|
11
|
+
ADDRESS_METADATA_SCHEMA = """
|
|
12
|
+
CREATE TABLE IF NOT EXISTS address_metadata (
|
|
13
|
+
chain_id INTEGER NOT NULL,
|
|
14
|
+
address TEXT NOT NULL,
|
|
15
|
+
document_json TEXT NOT NULL,
|
|
16
|
+
created_at INTEGER NOT NULL,
|
|
17
|
+
updated_at INTEGER NOT NULL,
|
|
18
|
+
PRIMARY KEY (chain_id, address)
|
|
19
|
+
);
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _normalize_address(address: str) -> str:
|
|
24
|
+
return address.lower()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _json_dumps(value: Any) -> str:
|
|
28
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AddressMetadataCache:
|
|
32
|
+
"""Read and write cached metadata about contract addresses."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, db: CacheDB | None = None) -> None:
|
|
35
|
+
"""Initialize the address metadata cache.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
db: Cache database. If omitted, the process-global cache is used.
|
|
39
|
+
"""
|
|
40
|
+
if db is None:
|
|
41
|
+
db = get_cache_db()
|
|
42
|
+
self.db = db
|
|
43
|
+
|
|
44
|
+
def get(self, chain_id: int, address: str, key: str) -> Any | None:
|
|
45
|
+
"""Return cached metadata for an address key, if present."""
|
|
46
|
+
document = self._get_document(chain_id, address)
|
|
47
|
+
return document.get(key)
|
|
48
|
+
|
|
49
|
+
def set(self, chain_id: int, address: str, key: str, value: Any) -> None:
|
|
50
|
+
"""Store metadata for an address key."""
|
|
51
|
+
now = int(time.time())
|
|
52
|
+
address = _normalize_address(address)
|
|
53
|
+
|
|
54
|
+
with self.db._lock, self.db._conn:
|
|
55
|
+
row = self.db._conn.execute(
|
|
56
|
+
"""
|
|
57
|
+
SELECT document_json FROM address_metadata
|
|
58
|
+
WHERE chain_id = ? AND address = ?
|
|
59
|
+
""",
|
|
60
|
+
(int(chain_id), address),
|
|
61
|
+
).fetchone()
|
|
62
|
+
document = {} if row is None else json.loads(row["document_json"])
|
|
63
|
+
document[key] = value
|
|
64
|
+
document_json = _json_dumps(document)
|
|
65
|
+
|
|
66
|
+
self.db._conn.execute(
|
|
67
|
+
"""
|
|
68
|
+
INSERT INTO address_metadata (
|
|
69
|
+
chain_id, address, document_json, created_at, updated_at
|
|
70
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
71
|
+
ON CONFLICT(chain_id, address) DO UPDATE SET
|
|
72
|
+
document_json = excluded.document_json,
|
|
73
|
+
updated_at = excluded.updated_at
|
|
74
|
+
""",
|
|
75
|
+
(int(chain_id), address, document_json, now, now),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def _get_document(self, chain_id: int, address: str) -> dict[str, Any]:
|
|
79
|
+
row = self.db.execute(
|
|
80
|
+
"""
|
|
81
|
+
SELECT document_json FROM address_metadata
|
|
82
|
+
WHERE chain_id = ? AND address = ?
|
|
83
|
+
""",
|
|
84
|
+
(int(chain_id), _normalize_address(address)),
|
|
85
|
+
).fetchone()
|
|
86
|
+
|
|
87
|
+
if row is None:
|
|
88
|
+
return {}
|
|
89
|
+
return json.loads(row["document_json"])
|