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,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]