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,348 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from enum import IntEnum
|
|
5
|
+
from threading import Event, Thread
|
|
6
|
+
|
|
7
|
+
from fw3.errors import RPCError
|
|
8
|
+
|
|
9
|
+
from . import abi
|
|
10
|
+
from .account import Account, Accounts
|
|
11
|
+
from .chain import Chain
|
|
12
|
+
from .errors import NoActiveChain, TransactionNotFound
|
|
13
|
+
from .events import EventList
|
|
14
|
+
|
|
15
|
+
PANIC_REASONS = {
|
|
16
|
+
0x00: "generic compiler panic",
|
|
17
|
+
0x01: "assertion failed",
|
|
18
|
+
0x11: "arithmetic underflow or overflow",
|
|
19
|
+
0x12: "division or modulo by zero",
|
|
20
|
+
0x21: "invalid enum conversion",
|
|
21
|
+
0x22: "invalid storage byte array encoding",
|
|
22
|
+
0x31: "pop on empty array",
|
|
23
|
+
0x32: "array index out of bounds",
|
|
24
|
+
0x41: "memory allocation overflow",
|
|
25
|
+
0x51: "call to uninitialized internal function",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def tx_property(fn):
|
|
30
|
+
def wrapper(self):
|
|
31
|
+
self._await_initial_update()
|
|
32
|
+
return fn(self)
|
|
33
|
+
|
|
34
|
+
return property(wrapper)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TxStatus(IntEnum):
|
|
38
|
+
"""Known transaction lifecycle states."""
|
|
39
|
+
|
|
40
|
+
CONFIRMED = 1
|
|
41
|
+
REVERTED = 0
|
|
42
|
+
PENDING = -1
|
|
43
|
+
DROPPED = -2
|
|
44
|
+
REPLACED = -3
|
|
45
|
+
UNSEEN = -4
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Transaction:
|
|
49
|
+
"""Transaction object that tracks mempool, receipt, and replacement state."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
hash: str,
|
|
54
|
+
chain: Chain | int | None = None,
|
|
55
|
+
*,
|
|
56
|
+
allow_unseen: bool = False,
|
|
57
|
+
_txdict: dict | None = None,
|
|
58
|
+
):
|
|
59
|
+
"""Initialize a transaction watcher.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
hash: Transaction hash.
|
|
63
|
+
chain: Chain or chain ID. Uses the active default chain if omitted.
|
|
64
|
+
allow_unseen: Whether to keep watching if the transaction is not yet visible
|
|
65
|
+
to the node.
|
|
66
|
+
_txdict: Initial transaction data for locally broadcast transactions.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
TypeError: If ``hash`` is not a string.
|
|
70
|
+
ValueError: If ``hash`` is not a valid transaction hash.
|
|
71
|
+
NoActiveChain: If no chain is available.
|
|
72
|
+
"""
|
|
73
|
+
if not isinstance(hash, str):
|
|
74
|
+
raise TypeError("Transaction hash must be a string")
|
|
75
|
+
|
|
76
|
+
if len(hash) != 66 or not hash.startswith("0x"):
|
|
77
|
+
raise ValueError("Invalid transaction hash")
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
int(hash[2:], 16)
|
|
81
|
+
except ValueError:
|
|
82
|
+
raise ValueError("Invalid transaction hash") from None
|
|
83
|
+
|
|
84
|
+
if chain is None:
|
|
85
|
+
chain, _ = Chain._get_default_chain()
|
|
86
|
+
if chain is None:
|
|
87
|
+
raise NoActiveChain("No chain specified for Transaction")
|
|
88
|
+
|
|
89
|
+
self.hash = hash.lower()
|
|
90
|
+
self.chain = Chain(chain)
|
|
91
|
+
|
|
92
|
+
self._transaction = _txdict or {}
|
|
93
|
+
self._receipt = {}
|
|
94
|
+
self._initialized = Event()
|
|
95
|
+
self._finalized = Event()
|
|
96
|
+
self._status = TxStatus.UNSEEN
|
|
97
|
+
self._allow_unseen = allow_unseen or bool(_txdict)
|
|
98
|
+
self._revert_data = None
|
|
99
|
+
self._revert_reason = None
|
|
100
|
+
self._revert_ready = Event()
|
|
101
|
+
self._events = None
|
|
102
|
+
|
|
103
|
+
if _txdict:
|
|
104
|
+
self._initialized.set()
|
|
105
|
+
|
|
106
|
+
self.chain._transaction_monitor.watch(self)
|
|
107
|
+
|
|
108
|
+
def _await_initial_update(self):
|
|
109
|
+
if not self._initialized.is_set():
|
|
110
|
+
self._initialized.wait()
|
|
111
|
+
if self._status == TxStatus.UNSEEN and not self._allow_unseen:
|
|
112
|
+
raise TransactionNotFound(self.hash)
|
|
113
|
+
|
|
114
|
+
@tx_property
|
|
115
|
+
def sender(self):
|
|
116
|
+
"""Return the sender account, if known."""
|
|
117
|
+
account = self._transaction.get("from")
|
|
118
|
+
if account is not None:
|
|
119
|
+
account = Accounts._find_signer(account) or Account(account, chain=self.chain)
|
|
120
|
+
return account
|
|
121
|
+
|
|
122
|
+
@tx_property
|
|
123
|
+
def receiver(self):
|
|
124
|
+
"""Return the receiver account, if any."""
|
|
125
|
+
# TODO if receiver is a contract, can we return `Contract` instead?
|
|
126
|
+
account = self._transaction.get("to")
|
|
127
|
+
if account is not None:
|
|
128
|
+
account = Accounts._find_signer(account) or Account(account, chain=self.chain)
|
|
129
|
+
return account
|
|
130
|
+
|
|
131
|
+
@tx_property
|
|
132
|
+
def value(self):
|
|
133
|
+
"""Return the transaction value in wei, if known."""
|
|
134
|
+
return self._transaction.get("value")
|
|
135
|
+
|
|
136
|
+
@tx_property
|
|
137
|
+
def nonce(self):
|
|
138
|
+
"""Return the transaction nonce, if known."""
|
|
139
|
+
return self._transaction.get("nonce")
|
|
140
|
+
|
|
141
|
+
@tx_property
|
|
142
|
+
def gas(self):
|
|
143
|
+
"""Return the gas limit, if known."""
|
|
144
|
+
return self._transaction.get("gas")
|
|
145
|
+
|
|
146
|
+
@tx_property
|
|
147
|
+
def gas_price(self):
|
|
148
|
+
"""Return the legacy gas price, if present."""
|
|
149
|
+
return self._transaction.get("gasPrice")
|
|
150
|
+
|
|
151
|
+
@tx_property
|
|
152
|
+
def max_fee_per_gas(self):
|
|
153
|
+
"""Return the EIP-1559 max fee per gas, if present."""
|
|
154
|
+
return self._transaction.get("maxFeePerGas")
|
|
155
|
+
|
|
156
|
+
@tx_property
|
|
157
|
+
def max_priority_fee_per_gas(self):
|
|
158
|
+
"""Return the EIP-1559 max priority fee per gas, if present."""
|
|
159
|
+
return self._transaction.get("maxPriorityFeePerGas")
|
|
160
|
+
|
|
161
|
+
@tx_property
|
|
162
|
+
def input(self):
|
|
163
|
+
"""Return the transaction input data, if known."""
|
|
164
|
+
return self._transaction.get("input")
|
|
165
|
+
|
|
166
|
+
@tx_property
|
|
167
|
+
def type(self):
|
|
168
|
+
"""Return the transaction type, if known."""
|
|
169
|
+
return self._transaction.get("type")
|
|
170
|
+
|
|
171
|
+
@tx_property
|
|
172
|
+
def block_hash(self):
|
|
173
|
+
"""Return the containing block hash, if confirmed."""
|
|
174
|
+
return self._receipt.get("blockHash") or self._transaction.get("blockHash")
|
|
175
|
+
|
|
176
|
+
@tx_property
|
|
177
|
+
def block_number(self):
|
|
178
|
+
"""Return the containing block number, if confirmed."""
|
|
179
|
+
return self._receipt.get("blockNumber") or self._transaction.get("blockNumber")
|
|
180
|
+
|
|
181
|
+
@tx_property
|
|
182
|
+
def transaction_index(self):
|
|
183
|
+
"""Return the transaction index within its block, if confirmed."""
|
|
184
|
+
return self._receipt.get("transactionIndex") or self._transaction.get("transactionIndex")
|
|
185
|
+
|
|
186
|
+
@tx_property
|
|
187
|
+
def status(self):
|
|
188
|
+
"""Return the current transaction status."""
|
|
189
|
+
status = self._receipt.get("status")
|
|
190
|
+
if status is not None:
|
|
191
|
+
return TxStatus(status)
|
|
192
|
+
return self._status
|
|
193
|
+
|
|
194
|
+
@tx_property
|
|
195
|
+
def gas_used(self):
|
|
196
|
+
"""Return gas used by this transaction, if confirmed."""
|
|
197
|
+
return self._receipt.get("gasUsed")
|
|
198
|
+
|
|
199
|
+
@tx_property
|
|
200
|
+
def cumulative_gas_used(self):
|
|
201
|
+
"""Return cumulative gas used in the containing block, if confirmed."""
|
|
202
|
+
return self._receipt.get("cumulativeGasUsed")
|
|
203
|
+
|
|
204
|
+
@tx_property
|
|
205
|
+
def effective_gas_price(self):
|
|
206
|
+
"""Return the effective gas price paid, if confirmed."""
|
|
207
|
+
return self._receipt.get("effectiveGasPrice")
|
|
208
|
+
|
|
209
|
+
@tx_property
|
|
210
|
+
def contract_address(self):
|
|
211
|
+
"""Return the created contract address for deployment transactions."""
|
|
212
|
+
return self._receipt.get("contractAddress")
|
|
213
|
+
|
|
214
|
+
@tx_property
|
|
215
|
+
def logs(self):
|
|
216
|
+
"""Return raw receipt logs, if available."""
|
|
217
|
+
return self._receipt.get("logs")
|
|
218
|
+
|
|
219
|
+
@tx_property
|
|
220
|
+
def logs_bloom(self):
|
|
221
|
+
"""Return the receipt logs bloom, if available."""
|
|
222
|
+
return self._receipt.get("logsBloom")
|
|
223
|
+
|
|
224
|
+
@tx_property
|
|
225
|
+
def events(self):
|
|
226
|
+
"""Return decoded receipt events.
|
|
227
|
+
|
|
228
|
+
The EventList is created lazily on first access and then reused.
|
|
229
|
+
"""
|
|
230
|
+
if self._events is None:
|
|
231
|
+
self._events = EventList(self.logs or (), chain=self.chain)
|
|
232
|
+
return self._events
|
|
233
|
+
|
|
234
|
+
@tx_property
|
|
235
|
+
def revert_data(self):
|
|
236
|
+
"""Return raw revert data for reverted transactions, when available."""
|
|
237
|
+
self._revert_ready.wait()
|
|
238
|
+
return self._revert_data
|
|
239
|
+
|
|
240
|
+
@tx_property
|
|
241
|
+
def revert_reason(self):
|
|
242
|
+
"""Return the decoded revert reason for reverted transactions, when available."""
|
|
243
|
+
self._revert_ready.wait()
|
|
244
|
+
return self._revert_reason
|
|
245
|
+
|
|
246
|
+
def confirmations(self):
|
|
247
|
+
"""Return the number of confirmations for this transaction."""
|
|
248
|
+
block_number = self.block_number
|
|
249
|
+
if block_number is None:
|
|
250
|
+
return 0
|
|
251
|
+
return max(0, self.chain.height() - block_number + 1)
|
|
252
|
+
|
|
253
|
+
def wait(self, required_confs=1):
|
|
254
|
+
"""Block until the transaction reaches the requested confirmations.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
required_confs: Number of confirmations to wait for. Values below one return
|
|
258
|
+
immediately.
|
|
259
|
+
"""
|
|
260
|
+
if required_confs < 1:
|
|
261
|
+
return
|
|
262
|
+
self._finalized.wait()
|
|
263
|
+
if required_confs > 1:
|
|
264
|
+
while self.confirmations() < required_confs:
|
|
265
|
+
time.sleep(1)
|
|
266
|
+
|
|
267
|
+
def _resolve_revert_reason(self):
|
|
268
|
+
def run():
|
|
269
|
+
try:
|
|
270
|
+
tx_kwargs = {
|
|
271
|
+
"from_": self._transaction.get("from"),
|
|
272
|
+
"to": self._transaction.get("to"),
|
|
273
|
+
"value": self._transaction.get("value"),
|
|
274
|
+
"data": self._transaction.get("input"),
|
|
275
|
+
"gas": self._transaction.get("gas"),
|
|
276
|
+
"block": max(0, self.block_number - 1),
|
|
277
|
+
}
|
|
278
|
+
tx_kwargs = {k: v for k, v in tx_kwargs.items() if v is not None}
|
|
279
|
+
|
|
280
|
+
# convert to string to ensure the Handle finalizes and see the error
|
|
281
|
+
str(self.chain.w3.eth.call(**tx_kwargs))
|
|
282
|
+
except RPCError as exc:
|
|
283
|
+
self._revert_data = exc.details
|
|
284
|
+
self._revert_reason = _decode_revert_reason(exc.details.data)
|
|
285
|
+
finally:
|
|
286
|
+
self._revert_ready.set()
|
|
287
|
+
|
|
288
|
+
Thread(target=run, daemon=True).start()
|
|
289
|
+
|
|
290
|
+
def replace(self, increment=1.125):
|
|
291
|
+
"""Replace a pending transaction with a higher-fee transaction.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
increment: Fee multiplier used when bumping gas fields.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Replacement Transaction object.
|
|
298
|
+
|
|
299
|
+
Raises:
|
|
300
|
+
ValueError: If the transaction is finalized or no signer is available.
|
|
301
|
+
"""
|
|
302
|
+
if self._finalized.is_set():
|
|
303
|
+
raise ValueError(f"Cannot replace transaction with status {self.status.name}")
|
|
304
|
+
|
|
305
|
+
sender = self.sender
|
|
306
|
+
if not sender.has_private_key:
|
|
307
|
+
raise ValueError("Cannot replace transaction because no signer was found for sender")
|
|
308
|
+
|
|
309
|
+
kwargs = {
|
|
310
|
+
"to": self.receiver,
|
|
311
|
+
"value": self.value,
|
|
312
|
+
"data": self.input,
|
|
313
|
+
"gas_limit": self.gas,
|
|
314
|
+
"nonce": self.nonce,
|
|
315
|
+
"chain": self.chain,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if self.gas_price is not None:
|
|
319
|
+
kwargs["gas_price"] = _bump_fee(self.gas_price, increment)
|
|
320
|
+
else:
|
|
321
|
+
kwargs["max_fee_per_gas"] = _bump_fee(self.max_fee_per_gas, increment)
|
|
322
|
+
kwargs["max_priority_fee_per_gas"] = _bump_fee(self.max_priority_fee_per_gas, increment)
|
|
323
|
+
|
|
324
|
+
return sender.transact(**kwargs)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _bump_fee(original, increment):
|
|
328
|
+
return max(int(original * increment), original + 1)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _decode_revert_reason(data):
|
|
332
|
+
if not isinstance(data, str):
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
# Panic(uint256)
|
|
336
|
+
if data.startswith("0x4e487b71"):
|
|
337
|
+
code = abi.decode("(uint256)", bytes.fromhex(data[10:]))[0]
|
|
338
|
+
|
|
339
|
+
reason = PANIC_REASONS.get(code)
|
|
340
|
+
|
|
341
|
+
if reason:
|
|
342
|
+
return f"Panic(0x{code:x}): {reason}"
|
|
343
|
+
|
|
344
|
+
return f"Panic(0x{code:x})"
|
|
345
|
+
|
|
346
|
+
# Error(string)
|
|
347
|
+
if data.startswith("0x08c379a0"):
|
|
348
|
+
return abi.decode("(string)", bytes.fromhex(data[10:]))[0]
|