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,647 @@
1
+ import json
2
+ import weakref
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from fw3.deferred import deferred_response
7
+
8
+ from . import abi
9
+ from .account import Account
10
+ from .cache.metadata import AddressMetadataCache
11
+ from .chain import Chain
12
+ from .errors import ABINotFound, NoActiveChain
13
+ from .explorers.lookup import HIGH_PRIORITY, fetch_abi
14
+
15
+
16
+ def _load_abi(abi):
17
+ if isinstance(abi, (str, Path)):
18
+ path = Path(abi)
19
+
20
+ if not path.exists():
21
+ raise FileNotFoundError(f"ABI file not found: {path}")
22
+
23
+ with path.open() as f:
24
+ data = json.load(f)
25
+
26
+ if isinstance(data, list):
27
+ abi_list = data
28
+ elif isinstance(data, dict) and "abi" in data:
29
+ abi_list = data["abi"]
30
+ else:
31
+ raise ValueError("Invalid ABI format: expected list or dict with 'abi' key")
32
+
33
+ elif isinstance(abi, list):
34
+ abi_list = abi
35
+
36
+ else:
37
+ raise TypeError("abi must be a sequence or a path to a JSON file")
38
+
39
+ if not all(isinstance(item, dict) for item in abi_list):
40
+ raise ValueError("ABI must be a sequence of dicts")
41
+
42
+ return abi_list
43
+
44
+
45
+ def _method_class(method_abi: dict) -> type["_ContractMethod"]:
46
+ mutability = method_abi.get("stateMutability")
47
+
48
+ if mutability is None:
49
+ if method_abi.get("constant", False):
50
+ mutability = "view"
51
+ elif method_abi.get("payable", False):
52
+ mutability = "payable"
53
+ else:
54
+ mutability = "nonpayable"
55
+
56
+ if mutability in ("view", "pure"):
57
+ return ContractCall
58
+ return ContractTx
59
+
60
+
61
+ @dataclass
62
+ class _ContractState:
63
+ chain: Chain
64
+ abi_job: object | None = None
65
+ proxy_abi: list[dict] | None = None
66
+ implementation: str | None = None
67
+ implementation_contract: object | None = None
68
+ refresh_abi: bool | None = None
69
+
70
+
71
+ _RESERVED_NAMES = {"abi", "address"}
72
+ _CONTRACT_STATE = weakref.WeakKeyDictionary()
73
+
74
+
75
+ def _install_abi(contract: "Contract", abi_list: list[dict]) -> None:
76
+ state = _CONTRACT_STATE[contract]
77
+
78
+ contract.abi = abi_list
79
+
80
+ function_abis = [i for i in contract.abi if i.get("type", "function") == "function"]
81
+ functions = {}
82
+
83
+ for method_abi in function_abis:
84
+ name = method_abi["name"]
85
+ if name in _RESERVED_NAMES:
86
+ raise ValueError(f"Contract ABI may not define reserved attribute {name!r}")
87
+ functions.setdefault(name, []).append(method_abi)
88
+
89
+ for name, method_abis in functions.items():
90
+ if len(method_abis) == 1:
91
+ method_abi = method_abis[0]
92
+ cls = _method_class(method_abi)
93
+ method = cls(address=contract.address, method_abi=method_abi, chain=state.chain)
94
+ else:
95
+ method = OverloadedMethod(
96
+ address=contract.address, method_abis=method_abis, chain=state.chain
97
+ )
98
+
99
+ setattr(contract, name, method)
100
+
101
+
102
+ def _cache_abi_result(cache, chain_id: int, address: str, result) -> None:
103
+ abi_list, implementation = result
104
+ cache.set(chain_id, address, "abi", abi_list)
105
+ if implementation is not None:
106
+ cache.set(chain_id, address, "implementation", implementation)
107
+
108
+
109
+ def _normalize_implementation(implementation, chain: Chain) -> str | None:
110
+ if implementation is None or implementation is False:
111
+ return None
112
+ return str(Account(implementation, chain=chain))
113
+
114
+
115
+ def _start_implementation_lookup(contract: "Contract", refresh_abi: bool | None) -> None:
116
+ state = _CONTRACT_STATE[contract]
117
+ if state.implementation is None or state.implementation_contract is not None:
118
+ return
119
+ state.implementation_contract = Contract(
120
+ state.implementation,
121
+ chain=state.chain,
122
+ refresh_abi=refresh_abi,
123
+ )
124
+
125
+
126
+ def _resolve_abi(contract: "Contract") -> None:
127
+ state = _CONTRACT_STATE[contract]
128
+
129
+ if state.proxy_abi is None:
130
+ if state.abi_job is None:
131
+ raise AttributeError("abi")
132
+
133
+ state.abi_job.bump_priority(HIGH_PRIORITY)
134
+ try:
135
+ result = state.abi_job.wait()
136
+ except ABINotFound:
137
+ if state.implementation is None:
138
+ raise
139
+ state.proxy_abi = []
140
+ else:
141
+ proxy_abi, implementation = result
142
+ state.proxy_abi = proxy_abi
143
+ if state.implementation is None:
144
+ state.implementation = implementation
145
+ finally:
146
+ state.abi_job = None
147
+
148
+ _start_implementation_lookup(contract, state.refresh_abi)
149
+
150
+ if state.implementation is None:
151
+ _install_abi(contract, state.proxy_abi)
152
+ return
153
+
154
+ _start_implementation_lookup(contract, state.refresh_abi)
155
+ implementation_abi = state.implementation_contract.abi
156
+ _install_abi(contract, abi.overlay_abi(implementation_abi, state.proxy_abi))
157
+
158
+
159
+ class Contract:
160
+ """Contract instance bound to an address and chain."""
161
+
162
+ def __init__(
163
+ self,
164
+ address: Account | str,
165
+ abi: list | str | Path | None = None,
166
+ chain: Chain | int | None = None,
167
+ implementation: Account | str | bool | None = None,
168
+ refresh_abi: bool | None = None,
169
+ ):
170
+ """Create a contract bound to an address with optional ABI resolution.
171
+
172
+ Calls always execute against ``address``. The ABI used for method dispatch may
173
+ come from multiple sources depending on the inputs:
174
+
175
+ - If ``abi`` is provided, it is trusted as complete and no explorer lookup or
176
+ proxy handling is performed.
177
+ - If ``implementation`` is an address, the ABI is taken from that implementation
178
+ and overlaid with any proxy ABI found at ``address``.
179
+ - If ``implementation`` is ``False``, proxy handling is disabled.
180
+ - Otherwise, the ABI is loaded from cache or fetched from an explorer. If the
181
+ contract is identified as a proxy, the implementation ABI is used and overlaid
182
+ with the proxy ABI.
183
+
184
+ Explorer lookups are asynchronous. The constructor returns immediately, and the
185
+ ABI is installed on first access. Until then, the ``abi`` attribute may not be
186
+ present.
187
+
188
+ Args:
189
+ address: Contract address to execute calls against.
190
+ abi: ABI list or path to a JSON ABI file.
191
+ chain: Chain or chain ID. Uses the active default chain if omitted.
192
+ implementation: Proxy override. Address forces an implementation,
193
+ ``False`` disables proxy handling, and ``None`` enables auto-detection.
194
+ refresh_abi: Cache control. ``True`` forces refresh, ``False`` uses cache
195
+ only, and ``None`` uses cache then falls back to explorer.
196
+
197
+ Raises:
198
+ NoActiveChain: If no chain is available.
199
+ FileNotFoundError: If an ABI path does not exist.
200
+ TypeError: If ``abi`` has an unsupported type.
201
+ ValueError: If the ABI format is invalid.
202
+ """
203
+ if chain is None:
204
+ chain, _ = Chain._get_default_chain()
205
+ if chain is None:
206
+ raise NoActiveChain("No chain specified for Contract")
207
+
208
+ chain = Chain(chain)
209
+ self.address = Account(address, chain=chain)
210
+ _CONTRACT_STATE[self] = _ContractState(chain=chain, refresh_abi=refresh_abi)
211
+
212
+ cache = AddressMetadataCache()
213
+
214
+ if abi is not None:
215
+ abi_list = _load_abi(abi)
216
+ _install_abi(self, abi_list)
217
+
218
+ if refresh_abi is True:
219
+ cache.set(chain.id, str(self.address), "abi", abi_list)
220
+ elif refresh_abi is None and cache.get(chain.id, str(self.address), "abi") is None:
221
+ cache.set(chain.id, str(self.address), "abi", abi_list)
222
+ return
223
+
224
+ state = _CONTRACT_STATE[self]
225
+ forced_implementation = _normalize_implementation(implementation, chain)
226
+ resolve_proxy = implementation is None
227
+
228
+ if refresh_abi is not True:
229
+ cached_abi = cache.get(chain.id, str(self.address), "abi")
230
+ cached_implementation = forced_implementation
231
+ if cached_implementation is None and implementation is None:
232
+ cached_implementation = cache.get(chain.id, str(self.address), "implementation")
233
+
234
+ if cached_abi is not None:
235
+ if cached_implementation is None:
236
+ _install_abi(self, _load_abi(cached_abi))
237
+ return
238
+
239
+ state.proxy_abi = _load_abi(cached_abi)
240
+ state.implementation = cached_implementation
241
+ _start_implementation_lookup(self, refresh_abi)
242
+ return
243
+
244
+ if refresh_abi is False:
245
+ return
246
+
247
+ state.implementation = forced_implementation
248
+ if state.implementation is not None:
249
+ _start_implementation_lookup(self, refresh_abi)
250
+
251
+ def on_success(result):
252
+ _cache_abi_result(cache, chain.id, str(self.address), result)
253
+ _, implementation = result
254
+ if state.implementation is None and implementation is not None:
255
+ state.implementation = implementation
256
+ _start_implementation_lookup(self, refresh_abi)
257
+
258
+ state.abi_job = fetch_abi(
259
+ chain.id,
260
+ str(self.address),
261
+ ignore_negative_cache=refresh_abi is True,
262
+ resolve_proxy=resolve_proxy,
263
+ on_success=on_success,
264
+ )
265
+
266
+ def __str__(self):
267
+ return str(self.address)
268
+
269
+ def __getattr__(self, name: str):
270
+ state = _CONTRACT_STATE[self]
271
+ if state.abi_job is None and state.proxy_abi is None:
272
+ raise AttributeError(name)
273
+
274
+ _resolve_abi(self)
275
+
276
+ try:
277
+ return object.__getattribute__(self, name)
278
+ except AttributeError:
279
+ raise AttributeError(name) from None
280
+
281
+
282
+ class _ContractMethod:
283
+ def __init__(self, address: Account, method_abi: dict, chain: Chain):
284
+ self.address = address
285
+ self.chain = chain
286
+ self.method_abi = method_abi
287
+
288
+ @property
289
+ def signature(self) -> str:
290
+ """Return the canonical function signature."""
291
+ return abi.function_signature(self.method_abi)
292
+
293
+ @property
294
+ def selector(self) -> bytes:
295
+ """Return the four-byte function selector."""
296
+ return abi.function_selector(self.method_abi)
297
+
298
+ @property
299
+ def mutability(self) -> str:
300
+ """Return the function state mutability."""
301
+ if "stateMutability" in self.method_abi:
302
+ return self.method_abi["stateMutability"]
303
+
304
+ constant = self.method_abi.get("constant", False)
305
+ payable = self.method_abi.get("payable", False)
306
+
307
+ if constant:
308
+ return "view"
309
+ if payable:
310
+ return "payable"
311
+ return "nonpayable"
312
+
313
+ def call(
314
+ self,
315
+ *args,
316
+ sender: Account = None,
317
+ value: int | str | None = None,
318
+ gas_limit: int | str | None = None,
319
+ block_identifier: str | int | None = None,
320
+ ):
321
+ """Execute the function with ``eth_call``.
322
+
323
+ Args:
324
+ *args: Contract function arguments.
325
+ sender: Optional account to use as ``msg.sender``. Uses the zero address when
326
+ omitted.
327
+ value: Call value in wei.
328
+ gas_limit: Optional gas limit.
329
+ block_identifier: Optional block number or tag.
330
+
331
+ Returns:
332
+ Decoded return value.
333
+ """
334
+ if sender is None:
335
+ sender = Account("0x0000000000000000000000000000000000000000")
336
+ data = self.encode_input(*args)
337
+ resp = sender.call(
338
+ to=str(self.address),
339
+ value=value,
340
+ data=data,
341
+ gas_limit=gas_limit,
342
+ chain=self.chain,
343
+ block_identifier=block_identifier,
344
+ )
345
+ return deferred_response(None, ref_func=lambda h: h.set_value(self.decode_output(resp)))
346
+
347
+ def estimate_gas(self, *args, sender: Account, value: int | str | None = None):
348
+ """Estimate gas for this contract function.
349
+
350
+ Args:
351
+ *args: Contract function arguments.
352
+ sender: Account sending the transaction.
353
+ value: Transaction value in wei.
354
+
355
+ Returns:
356
+ Estimated gas limit.
357
+ """
358
+ data = self.encode_input(*args)
359
+ return sender.estimate_gas(to=self.address, value=value, data=data, chain=self.chain)
360
+
361
+ def transact(
362
+ self,
363
+ *args,
364
+ sender: Account,
365
+ value: int | str | None = None,
366
+ gas_limit: int | str | None = None,
367
+ gas_buffer: float | None = None,
368
+ gas_price: int | str | None = None,
369
+ max_fee_per_gas: int | str | None = None,
370
+ max_priority_fee_per_gas: int | str | None = None,
371
+ nonce: int | str | None = None,
372
+ ):
373
+ """Sign and broadcast a transaction for this contract function.
374
+
375
+ Args:
376
+ *args: Contract function arguments.
377
+ sender: Account sending the transaction.
378
+ value: Transaction value in wei.
379
+ gas_limit: Explicit gas limit. Estimated when omitted.
380
+ gas_buffer: Multiplier applied to the estimated gas limit.
381
+ gas_price: Legacy gas price.
382
+ max_fee_per_gas: EIP-1559 max fee per gas.
383
+ max_priority_fee_per_gas: EIP-1559 max priority fee per gas.
384
+ nonce: Explicit nonce. Queried when omitted.
385
+
386
+ Returns:
387
+ Transaction object for the broadcast transaction.
388
+ """
389
+ data = self.encode_input(*args)
390
+ return sender.transact(
391
+ to=str(self.address),
392
+ value=value,
393
+ data=data,
394
+ gas_limit=gas_limit,
395
+ gas_buffer=gas_buffer,
396
+ gas_price=gas_price,
397
+ max_fee_per_gas=max_fee_per_gas,
398
+ max_priority_fee_per_gas=max_priority_fee_per_gas,
399
+ chain=self.chain,
400
+ nonce=nonce,
401
+ )
402
+
403
+ def decode_input(self, hexstr: str):
404
+ """Decode calldata for this contract function.
405
+
406
+ Args:
407
+ hexstr: Hex-encoded calldata including the function selector.
408
+
409
+ Returns:
410
+ Decoded input values.
411
+ """
412
+ return abi.decode_calldata(self.method_abi, hexstr)
413
+
414
+ def encode_input(self, *args):
415
+ """Encode calldata for this contract function.
416
+
417
+ Args:
418
+ *args: Contract function arguments.
419
+
420
+ Returns:
421
+ Hex-encoded calldata including the function selector.
422
+ """
423
+ return abi.encode_calldata(self.method_abi, args)
424
+
425
+ def decode_output(self, hexstr: str):
426
+ """Decode return data for this contract function.
427
+
428
+ Args:
429
+ hexstr: Hex-encoded return data.
430
+
431
+ Returns:
432
+ Decoded return value.
433
+ """
434
+ return abi.decode_returndata(self.method_abi, hexstr)
435
+
436
+
437
+ class OverloadedMethod:
438
+ """Callable wrapper for a contract function with multiple overloads."""
439
+
440
+ def __init__(self, address: Account, method_abis: list[dict], chain: Chain):
441
+ self.address = address
442
+ self.chain = chain
443
+ self.method_abis = method_abis
444
+
445
+ @property
446
+ def name(self) -> str:
447
+ """Return the overloaded function name."""
448
+ return self.method_abis[0]["name"]
449
+
450
+ @property
451
+ def signatures(self) -> list[str]:
452
+ """Return all available overload signatures."""
453
+ return [abi.function_signature(i) for i in self.method_abis]
454
+
455
+ def _make_method(self, method_abi: dict) -> _ContractMethod:
456
+ cls = _method_class(method_abi)
457
+ return cls(address=self.address, method_abi=method_abi, chain=self.chain)
458
+
459
+ def _input_types(self, method_abi: dict) -> tuple[str, ...]:
460
+ return tuple(i["type"] for i in method_abi.get("inputs", []))
461
+
462
+ def _format_available_overloads(self) -> str:
463
+ return "\n".join(self.signatures)
464
+
465
+ def _resolve_by_args(self, args: tuple) -> _ContractMethod:
466
+ matches = [i for i in self.method_abis if len(i.get("inputs", [])) == len(args)]
467
+
468
+ if len(matches) == 1:
469
+ return self._make_method(matches[0])
470
+
471
+ if not matches:
472
+ raise ValueError(
473
+ f"No matching overload for {self.name} with {len(args)} arguments. "
474
+ f"Available overloads:\n{self._format_available_overloads()}"
475
+ )
476
+
477
+ raise ValueError(
478
+ f"Ambiguous overload for {self.name} with {len(args)} arguments. "
479
+ f"Available overloads:\n{self._format_available_overloads()}"
480
+ )
481
+
482
+ def _normalize_key(self, key) -> tuple[str, ...]:
483
+ if isinstance(key, str):
484
+ if not key:
485
+ return ()
486
+ return tuple(i.strip() for i in key.split(","))
487
+ if isinstance(key, tuple):
488
+ if not all(isinstance(i, str) for i in key):
489
+ raise TypeError("Overload selector tuple must contain only strings")
490
+ return tuple(i.strip() for i in key)
491
+ raise TypeError("Overload selector must be a comma-separated string or tuple of strings")
492
+
493
+ def __getitem__(self, key):
494
+ """Select an overload by input type signature.
495
+
496
+ Args:
497
+ key: Comma-separated input type string or tuple of input type strings.
498
+
499
+ Returns:
500
+ Contract method wrapper for the selected overload.
501
+ """
502
+ input_types = self._normalize_key(key)
503
+ matches = [i for i in self.method_abis if self._input_types(i) == input_types]
504
+
505
+ if not matches:
506
+ raise ValueError(
507
+ f"No overload for {self.name} with input types {input_types}. "
508
+ f"Available overloads:\n{self._format_available_overloads()}"
509
+ )
510
+
511
+ return self._make_method(matches[0])
512
+
513
+ def call(
514
+ self,
515
+ *args,
516
+ sender: Account = None,
517
+ value: int | str | None = None,
518
+ gas_limit: int | str | None = None,
519
+ block_identifier: str | int | None = None,
520
+ ):
521
+ """Call the overload matching the provided arguments."""
522
+ method = self._resolve_by_args(args)
523
+ return method.call(
524
+ *args,
525
+ sender=sender,
526
+ value=value,
527
+ gas_limit=gas_limit,
528
+ block_identifier=block_identifier,
529
+ )
530
+
531
+ def estimate_gas(self, *args, sender: Account, value: int | str | None = None):
532
+ """Estimate gas for the overload matching the provided arguments."""
533
+ method = self._resolve_by_args(args)
534
+ return method.estimate_gas(*args, sender=sender, value=value)
535
+
536
+ def transact(
537
+ self,
538
+ *args,
539
+ sender: Account,
540
+ value: int | str | None = None,
541
+ gas_limit: int | str | None = None,
542
+ gas_buffer: float | None = None,
543
+ gas_price: int | str | None = None,
544
+ max_fee_per_gas: int | str | None = None,
545
+ max_priority_fee_per_gas: int | str | None = None,
546
+ nonce: int | str | None = None,
547
+ ):
548
+ """Broadcast a transaction for the overload matching the provided arguments."""
549
+ method = self._resolve_by_args(args)
550
+ return method.transact(
551
+ *args,
552
+ sender=sender,
553
+ value=value,
554
+ gas_limit=gas_limit,
555
+ gas_buffer=gas_buffer,
556
+ gas_price=gas_price,
557
+ max_fee_per_gas=max_fee_per_gas,
558
+ max_priority_fee_per_gas=max_priority_fee_per_gas,
559
+ nonce=nonce,
560
+ )
561
+
562
+ def __call__(
563
+ self,
564
+ *args,
565
+ sender=None,
566
+ value: int | str | None = None,
567
+ gas_limit: int | str | None = None,
568
+ gas_buffer: float | None = None,
569
+ gas_price: int | str | None = None,
570
+ max_fee_per_gas: int | str | None = None,
571
+ max_priority_fee_per_gas: int | str | None = None,
572
+ nonce: int | str | None = None,
573
+ block_identifier: str | int | None = None,
574
+ ):
575
+ """Call or transact using the overload matching the provided arguments."""
576
+ method = self._resolve_by_args(args)
577
+
578
+ if isinstance(method, ContractCall):
579
+ return method(
580
+ *args,
581
+ sender=sender,
582
+ value=value,
583
+ gas_limit=gas_limit,
584
+ block_identifier=block_identifier,
585
+ )
586
+
587
+ return method(
588
+ *args,
589
+ sender=sender,
590
+ value=value,
591
+ gas_limit=gas_limit,
592
+ gas_buffer=gas_buffer,
593
+ gas_price=gas_price,
594
+ max_fee_per_gas=max_fee_per_gas,
595
+ max_priority_fee_per_gas=max_priority_fee_per_gas,
596
+ nonce=nonce,
597
+ )
598
+
599
+
600
+ class ContractCall(_ContractMethod):
601
+ """Callable wrapper for a view or pure contract function."""
602
+
603
+ def __call__(
604
+ self,
605
+ *args,
606
+ sender=None,
607
+ value: int | str | None = None,
608
+ gas_limit: int | str | None = None,
609
+ block_identifier: str | int | None = None,
610
+ ):
611
+ """Execute the contract function with ``eth_call``."""
612
+ return self.call(
613
+ *args,
614
+ sender=sender,
615
+ value=value,
616
+ gas_limit=gas_limit,
617
+ block_identifier=block_identifier,
618
+ )
619
+
620
+
621
+ class ContractTx(_ContractMethod):
622
+ """Callable wrapper for a nonpayable or payable contract function."""
623
+
624
+ def __call__(
625
+ self,
626
+ *args,
627
+ sender: Account,
628
+ value: int | str | None = None,
629
+ gas_limit: int | str | None = None,
630
+ gas_buffer: float | None = None,
631
+ gas_price: int | str | None = None,
632
+ max_fee_per_gas: int | str | None = None,
633
+ max_priority_fee_per_gas: int | str | None = None,
634
+ nonce: int | str | None = None,
635
+ ):
636
+ """Sign and broadcast a transaction for the contract function."""
637
+ return self.transact(
638
+ *args,
639
+ sender=sender,
640
+ value=value,
641
+ gas_limit=gas_limit,
642
+ gas_buffer=gas_buffer,
643
+ gas_price=gas_price,
644
+ max_fee_per_gas=max_fee_per_gas,
645
+ max_priority_fee_per_gas=max_priority_fee_per_gas,
646
+ nonce=nonce,
647
+ )