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/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."""
@@ -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"])