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/abi.py ADDED
@@ -0,0 +1,511 @@
1
+ """Utilities for ABI encoding, decoding, and signature handling."""
2
+
3
+ import re
4
+
5
+ from Crypto.Hash import keccak
6
+ from eth.codecs import abi as _abi
7
+ from fw3_keypass.utils import checksum_address
8
+
9
+ from .errors import ABITypeError, ABIValueError
10
+
11
+ _ARRAY_RE = re.compile(r"\[(\d*)\]")
12
+ _INT_RE = re.compile(r"^(u?int)(\d*)$")
13
+ _BYTES_RE = re.compile(r"^bytes(\d*)$")
14
+ _HEX_RE = re.compile(r"^[0-9a-fA-F]*$")
15
+ _DECIMAL_INT_RE = re.compile(r"^-?[0-9]+$")
16
+ _HEX_INT_RE = re.compile(r"^-?0[xX][0-9a-fA-F]+$")
17
+ _ADDRESS_RE = re.compile(r"^0x[0-9a-fA-F]{40}$")
18
+ _DYNAMIC_TYPES = {"bytes", "string"}
19
+
20
+
21
+ def _abi_item_type(item: dict) -> str:
22
+ item_type = item["type"]
23
+
24
+ if not item_type.startswith("tuple"):
25
+ return item_type
26
+
27
+ suffix = item_type[5:]
28
+ components = item.get("components", [])
29
+ inner = ",".join(_abi_item_type(component) for component in components)
30
+ return f"({inner}){suffix}"
31
+
32
+
33
+ def _abi_schema(items: list[dict]) -> str:
34
+ types = ",".join(_abi_item_type(item) for item in items)
35
+ return f"({types})"
36
+
37
+
38
+ def _split_array_type(item_type: str) -> tuple[str, list[int | None]]:
39
+ if "[" not in item_type:
40
+ return item_type, []
41
+
42
+ base = item_type[: item_type.index("[")]
43
+ suffix = item_type[len(base) :]
44
+ dims = []
45
+
46
+ matches = list(_ARRAY_RE.finditer(suffix))
47
+ if not matches or "".join(i.group(0) for i in matches) != suffix:
48
+ raise ABIValueError(f"Invalid ABI array type: {item_type}")
49
+
50
+ for match in matches:
51
+ size = match.group(1)
52
+ dims.append(None if size == "" else int(size))
53
+
54
+ return base, dims
55
+
56
+
57
+ def _coerce_args(items: list[dict], values: tuple) -> tuple:
58
+ if len(items) != len(values):
59
+ raise ABITypeError(f"Expected {len(items)} arguments, got {len(values)}")
60
+ return tuple(_coerce_value(item, value) for item, value in zip(items, values, strict=True))
61
+
62
+
63
+ def _coerce_value(item: dict, value):
64
+ base_type, dims = _split_array_type(item["type"])
65
+
66
+ if dims:
67
+ return _coerce_array(item, value, base_type, dims)
68
+
69
+ if base_type == "address":
70
+ return _coerce_address(value)
71
+ if base_type == "bool":
72
+ return _coerce_bool(value)
73
+ if base_type == "string":
74
+ return _coerce_string(value)
75
+ if base_type == "bytes":
76
+ return _coerce_dynamic_bytes(value)
77
+ if base_type.startswith("bytes"):
78
+ return _coerce_fixed_bytes(base_type, value)
79
+ if base_type.startswith("uint") or base_type.startswith("int"):
80
+ return _coerce_int(base_type, value)
81
+ if base_type == "tuple":
82
+ return _coerce_tuple(item, value)
83
+
84
+ raise ABIValueError(f"Unsupported ABI type: {item['type']}")
85
+
86
+
87
+ def _coerce_array(item: dict, value, base_type: str, dims: list[int | None]):
88
+ if not isinstance(value, (list, tuple)):
89
+ raise ABITypeError(f"Expected list or tuple for {item['type']}")
90
+
91
+ size = dims[-1]
92
+ if size is not None and len(value) != size:
93
+ raise ABIValueError(f"Expected array of length {size} for {item['type']}, got {len(value)}")
94
+
95
+ child = dict(item)
96
+ child["type"] = base_type + "".join("[]" if i is None else f"[{i}]" for i in dims[:-1])
97
+ return tuple(_coerce_value(child, i) for i in value)
98
+
99
+
100
+ def _coerce_tuple(item: dict, value):
101
+ if not isinstance(value, (list, tuple)):
102
+ raise ABITypeError("Expected list or tuple for tuple ABI argument")
103
+ return _coerce_args(item.get("components", []), tuple(value))
104
+
105
+
106
+ def _coerce_address(value) -> str:
107
+ from .account import Account
108
+ from .contract import Contract
109
+
110
+ if isinstance(value, Contract):
111
+ value = value.address.address
112
+
113
+ if isinstance(value, Account):
114
+ value = value.address
115
+
116
+ if not isinstance(value, str):
117
+ raise ABITypeError("Expected address string or Account")
118
+ if not _ADDRESS_RE.fullmatch(value):
119
+ raise ABIValueError(f"Invalid address: {value}")
120
+
121
+ return checksum_address(value)
122
+
123
+
124
+ def _coerce_bool(value) -> bool:
125
+ if not isinstance(value, bool):
126
+ raise ABITypeError("Expected bool")
127
+ return value
128
+
129
+
130
+ def _coerce_string(value) -> str:
131
+ if not isinstance(value, str):
132
+ raise ABITypeError("Expected string")
133
+ return value
134
+
135
+
136
+ def _coerce_dynamic_bytes(value) -> bytes:
137
+ if isinstance(value, bytes):
138
+ return value
139
+ if isinstance(value, str):
140
+ return _coerce_hexbytes(value)
141
+ raise ABITypeError("Expected bytes or 0x-prefixed hex string")
142
+
143
+
144
+ def _coerce_fixed_bytes(item_type: str, value) -> bytes:
145
+ match = _BYTES_RE.fullmatch(item_type)
146
+ if match is None or match.group(1) == "":
147
+ raise ABIValueError(f"Invalid ABI bytes type: {item_type}")
148
+
149
+ size = int(match.group(1))
150
+ if size < 1 or size > 32:
151
+ raise ABIValueError(f"Invalid ABI bytes size: {size}")
152
+
153
+ value = _coerce_dynamic_bytes(value)
154
+ if len(value) != size:
155
+ raise ABIValueError(f"Expected {item_type} value of length {size}, got {len(value)}")
156
+ return value
157
+
158
+
159
+ def _coerce_hexbytes(value: str) -> bytes:
160
+ if not value.startswith("0x"):
161
+ raise ABITypeError("Expected 0x-prefixed hex string")
162
+
163
+ value = value[2:]
164
+ if len(value) % 2:
165
+ raise ABIValueError("Hex string must contain an even number of digits")
166
+ if not _HEX_RE.fullmatch(value):
167
+ raise ABIValueError("Invalid hex string")
168
+ return bytes.fromhex(value)
169
+
170
+
171
+ def _coerce_int(item_type: str, value) -> int:
172
+ match = _INT_RE.fullmatch(item_type)
173
+ if match is None:
174
+ raise ABIValueError(f"Invalid ABI integer type: {item_type}")
175
+
176
+ signed = match.group(1) == "int"
177
+ bits = int(match.group(2) or 256)
178
+ if bits < 8 or bits > 256 or bits % 8:
179
+ raise ABIValueError(f"Invalid ABI integer size: {bits}")
180
+
181
+ if isinstance(value, bool):
182
+ raise ABITypeError(f"Expected {item_type}")
183
+ if isinstance(value, int):
184
+ coerced = value
185
+ elif isinstance(value, float):
186
+ coerced = _coerce_float_int(item_type, value)
187
+ elif isinstance(value, str):
188
+ coerced = _coerce_string_int(item_type, value)
189
+ else:
190
+ raise ABITypeError(f"Expected {item_type}")
191
+
192
+ if signed:
193
+ lower = -(2 ** (bits - 1))
194
+ upper = 2 ** (bits - 1) - 1
195
+ else:
196
+ lower = 0
197
+ upper = 2**bits - 1
198
+
199
+ if coerced < lower or coerced > upper:
200
+ raise ABIValueError(f"{item_type} value {coerced} is outside bounds [{lower}, {upper}]")
201
+ return coerced
202
+
203
+
204
+ def _coerce_float_int(item_type: str, value: float) -> int:
205
+ try:
206
+ coerced = int(value)
207
+ except (OverflowError, ValueError) as exc:
208
+ raise ABIValueError(f"Invalid {item_type} value: {value}") from exc
209
+
210
+ if value != coerced:
211
+ raise ABIValueError(f"Expected integral float for {item_type}")
212
+ return coerced
213
+
214
+
215
+ def _coerce_string_int(item_type: str, value: str) -> int:
216
+ try:
217
+ if _HEX_INT_RE.fullmatch(value):
218
+ return int(value, 16)
219
+ if _DECIMAL_INT_RE.fullmatch(value):
220
+ return int(value, 10)
221
+ raise ValueError
222
+ except ValueError as exc:
223
+ raise ABIValueError(f"Invalid {item_type} string: {value}") from exc
224
+
225
+
226
+ def encode(schema: str, values: tuple) -> bytes:
227
+ """Encode values using an ABI schema string.
228
+
229
+ Args:
230
+ schema: Parenthesized ABI schema, such as ``"(address,uint256)"``.
231
+ values: Values to encode.
232
+
233
+ Returns:
234
+ ABI-encoded bytes.
235
+ """
236
+ return _abi.encode(schema, values)
237
+
238
+
239
+ def decode(schema: str, data: bytes):
240
+ """Decode ABI-encoded bytes using an ABI schema string.
241
+
242
+ Args:
243
+ schema: Parenthesized ABI schema, such as ``"(address,uint256)"``.
244
+ data: ABI-encoded bytes.
245
+
246
+ Returns:
247
+ Decoded values.
248
+ """
249
+ return _abi.decode(schema, data)
250
+
251
+
252
+ def _coerce_hex(value, name: str) -> str:
253
+ if isinstance(value, bytes):
254
+ return "0x" + value.hex()
255
+ if not isinstance(value, str):
256
+ raise ABITypeError(f"{name} must be bytes or a hex string")
257
+ if not value.startswith("0x"):
258
+ raise ABIValueError(f"{name} must be 0x-prefixed")
259
+ try:
260
+ bytes.fromhex(value[2:])
261
+ except ValueError as exc:
262
+ raise ABIValueError(f"{name} must be valid hex") from exc
263
+ return value.lower()
264
+
265
+
266
+ def _hex_to_bytes(value, name: str) -> bytes:
267
+ value = _coerce_hex(value, name)
268
+ return bytes.fromhex(value[2:])
269
+
270
+
271
+ def _is_dynamic_indexed_type(item: dict) -> bool:
272
+ item_type = item["type"]
273
+ base_type, dims = _split_array_type(item_type)
274
+ return bool(dims) or base_type in _DYNAMIC_TYPES or base_type == "tuple"
275
+
276
+
277
+ def _normalize_decoded_value(item: dict, value):
278
+ item_type = item["type"]
279
+ base_type, dims = _split_array_type(item_type)
280
+
281
+ if dims:
282
+ child = dict(item)
283
+ child["type"] = base_type + "".join("[]" if i is None else f"[{i}]" for i in dims[:-1])
284
+ return tuple(_normalize_decoded_value(child, i) for i in value)
285
+
286
+ if base_type == "address":
287
+ return checksum_address(value)
288
+ if base_type == "tuple":
289
+ return tuple(
290
+ _normalize_decoded_value(component, item_value)
291
+ for component, item_value in zip(item.get("components", []), value, strict=True)
292
+ )
293
+ return value
294
+
295
+
296
+ def event_signature(event_abi: dict) -> str:
297
+ """Return the canonical signature for an event ABI item.
298
+
299
+ Args:
300
+ event_abi: Event ABI item.
301
+
302
+ Returns:
303
+ Signature string such as ``"Transfer(address,address,uint256)"``.
304
+
305
+ Raises:
306
+ ABIValueError: If the ABI item is not an event.
307
+ """
308
+ if event_abi.get("type") != "event":
309
+ raise ABIValueError("ABI item is not an event")
310
+
311
+ name = event_abi["name"]
312
+ inputs = event_abi.get("inputs", [])
313
+ types = ",".join(_abi_item_type(i) for i in inputs)
314
+ return f"{name}({types})"
315
+
316
+
317
+ def event_topic(event_abi: dict) -> str:
318
+ """Return the topic hash for an event ABI item.
319
+
320
+ Args:
321
+ event_abi: Event ABI item.
322
+
323
+ Returns:
324
+ Hex-encoded keccak256 hash of the event signature.
325
+ """
326
+ k = keccak.new(digest_bits=256)
327
+ k.update(event_signature(event_abi).encode())
328
+ return "0x" + k.hexdigest()
329
+
330
+
331
+ def decode_event(event_abi: dict, raw_log: dict) -> dict[str, object]:
332
+ """Decode a raw log using an event ABI item.
333
+
334
+ Indexed dynamic values are returned as their topic hash, matching Ethereum log
335
+ semantics.
336
+
337
+ Args:
338
+ event_abi: Event ABI item.
339
+ raw_log: RPC log object containing ``topics`` and ``data``.
340
+
341
+ Returns:
342
+ Mapping of event argument names to decoded values.
343
+
344
+ Raises:
345
+ ABIValueError: If the ABI item, topic count, or event topic is invalid.
346
+ """
347
+ if event_abi.get("type") != "event":
348
+ raise ABIValueError("ABI item is not an event")
349
+ if event_abi.get("anonymous", False):
350
+ raise ABIValueError("Anonymous events are not supported")
351
+
352
+ topics = tuple(_coerce_hex(i, "topic") for i in raw_log.get("topics", []))
353
+ expected_topic = event_topic(event_abi)
354
+ inputs = event_abi.get("inputs", [])
355
+ indexed = [i for i in inputs if i.get("indexed", False)]
356
+ non_indexed = [i for i in inputs if not i.get("indexed", False)]
357
+
358
+ expected_topics = 1 + len(indexed)
359
+ if len(topics) != expected_topics:
360
+ raise ABIValueError(f"Expected {expected_topics} topics, got {len(topics)}")
361
+ if topics[0] != expected_topic:
362
+ raise ABIValueError(f"Topic {topics[0]} does not match event topic {expected_topic}")
363
+
364
+ values = {}
365
+ topic_index = 1
366
+ data_values = decode(_abi_schema(non_indexed), _hex_to_bytes(raw_log.get("data", "0x"), "data"))
367
+ data_iter = iter(data_values)
368
+
369
+ for item in inputs:
370
+ name = item.get("name", "")
371
+ if item.get("indexed", False):
372
+ topic = topics[topic_index]
373
+ topic_index += 1
374
+ if _is_dynamic_indexed_type(item):
375
+ value = topic
376
+ else:
377
+ decoded = decode(_abi_schema([item]), _hex_to_bytes(topic, "topic"))[0]
378
+ value = _normalize_decoded_value(item, decoded)
379
+ else:
380
+ value = _normalize_decoded_value(item, next(data_iter))
381
+ values[name] = value
382
+
383
+ return values
384
+
385
+
386
+ def function_signature(method_abi: dict) -> str:
387
+ """Return the canonical signature for a function ABI item.
388
+
389
+ Args:
390
+ method_abi: Function ABI item.
391
+
392
+ Returns:
393
+ Signature string such as ``"balanceOf(address)"``.
394
+ """
395
+ name = method_abi["name"]
396
+ inputs = method_abi.get("inputs", [])
397
+ types = ",".join(i["type"] for i in inputs)
398
+ return f"{name}({types})"
399
+
400
+
401
+ def function_selector(method_abi: dict) -> bytes:
402
+ """Return the four-byte selector for a function ABI item.
403
+
404
+ Args:
405
+ method_abi: Function ABI item.
406
+
407
+ Returns:
408
+ First four bytes of the keccak256 function signature hash.
409
+ """
410
+ k = keccak.new(digest_bits=256)
411
+ k.update(function_signature(method_abi).encode())
412
+ return k.digest()[:4]
413
+
414
+
415
+ def overlay_abi(implementation_abi: list[dict], proxy_abi: list[dict]) -> list[dict]:
416
+ """Overlay proxy ABI items on top of implementation ABI items.
417
+
418
+ Function selectors from the proxy ABI take precedence over matching
419
+ implementation selectors. Non-function ABI items from both lists are preserved.
420
+
421
+ Args:
422
+ implementation_abi: ABI for the implementation contract.
423
+ proxy_abi: ABI for the proxy contract.
424
+
425
+ Returns:
426
+ Combined ABI list.
427
+ """
428
+ items = []
429
+ functions = {}
430
+
431
+ for item in [*implementation_abi, *proxy_abi]:
432
+ if item.get("type", "function") != "function":
433
+ items.append(item)
434
+ continue
435
+
436
+ selector = function_selector(item)
437
+ if selector in functions:
438
+ items[functions[selector]] = item
439
+ else:
440
+ functions[selector] = len(items)
441
+ items.append(item)
442
+
443
+ return items
444
+
445
+
446
+ def decode_calldata(method_abi: dict, hexstr: str):
447
+ """Decode function calldata for a specific ABI item.
448
+
449
+ Args:
450
+ method_abi: Function ABI item.
451
+ hexstr: Hex-encoded calldata including the function selector.
452
+
453
+ Returns:
454
+ Decoded input values.
455
+
456
+ Raises:
457
+ ValueError: If calldata is too short or has the wrong selector.
458
+ """
459
+ data = bytes.fromhex(hexstr.removeprefix("0x"))
460
+
461
+ if len(data) < 4:
462
+ raise ValueError("Input data is shorter than a function selector")
463
+
464
+ selector = data[:4]
465
+ expected_selector = function_selector(method_abi)
466
+ if selector != expected_selector:
467
+ raise ValueError(
468
+ f"Input selector 0x{selector.hex()} does not match "
469
+ f"method selector 0x{expected_selector.hex()}"
470
+ )
471
+
472
+ schema = _abi_schema(method_abi.get("inputs", []))
473
+ return decode(schema, data[4:])
474
+
475
+
476
+ def encode_calldata(method_abi: dict, args: tuple):
477
+ """Encode function calldata for a specific ABI item.
478
+
479
+ Args:
480
+ method_abi: Function ABI item.
481
+ args: Function arguments.
482
+
483
+ Returns:
484
+ Hex-encoded calldata including the function selector.
485
+ """
486
+ inputs = method_abi.get("inputs", [])
487
+ schema = _abi_schema(inputs)
488
+ coerced = _coerce_args(inputs, args)
489
+ data = function_selector(method_abi) + encode(schema, coerced)
490
+ return f"0x{data.hex()}"
491
+
492
+
493
+ def decode_returndata(method_abi: dict, hexstr: str):
494
+ """Decode function return data for a specific ABI item.
495
+
496
+ Args:
497
+ method_abi: Function ABI item.
498
+ hexstr: Hex-encoded return data.
499
+
500
+ Returns:
501
+ ``None`` for no outputs, a single value for one output, or a tuple for
502
+ multiple outputs.
503
+ """
504
+ schema = _abi_schema(method_abi.get("outputs", []))
505
+ values = decode(schema, bytes.fromhex(hexstr.removeprefix("0x")))
506
+
507
+ if len(values) == 0:
508
+ return None
509
+ if len(values) == 1:
510
+ return values[0]
511
+ return values