qweb3 1.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.
qweb3/__init__.py ADDED
@@ -0,0 +1,107 @@
1
+ """qweb3 — Quantova Post-Quantum Web3 Client SDK (Python).
2
+
3
+ A synchronous client for the Quantova Layer-1 blockchain: post-quantum wallets
4
+ and signing (Falcon, Dilithium, SPHINCS+), the full ``q_*`` JSON-RPC namespace,
5
+ a QVM contract layer with ABI codec and event-log decoding, QNS ``.q`` name
6
+ resolution, dynamic fee/gas estimation, batch requests, real-time event hooks,
7
+ and REST-gateway access.
8
+
9
+ Quick start::
10
+
11
+ from qweb3 import QWeb3
12
+ q = QWeb3("http://127.0.0.1:9944", rest_url="http://127.0.0.1:8080")
13
+ block = q.rpc.block_number()
14
+ fees = q.fees.estimate()
15
+
16
+ Post-quantum signing requires Quantova's crypto backend; install it and call
17
+ ``qweb3.crypto_backend.set_backend(...)`` (see that module's docstring).
18
+ """
19
+
20
+ from . import abi, crypto_backend
21
+ from .abi import (
22
+ decode_function_result,
23
+ decode_parameters,
24
+ encode_function_call,
25
+ encode_parameters,
26
+ event_topic,
27
+ function_selector,
28
+ )
29
+ from .batch import BatchRequest, BatchResult
30
+ from .contract import QContract
31
+ from .errors import (
32
+ ConnectionErrorQ,
33
+ InvalidArgumentError,
34
+ QWeb3Error,
35
+ RpcError,
36
+ TransactionError,
37
+ )
38
+ from .fee import FeeOracle
39
+ from .hooks import EventHooks, TxTracker
40
+ from .qns import QNS
41
+ from .rest import QRestClient
42
+ from .rpc import QRpcClient
43
+ from .signer import QuantumSigner
44
+ from .utils import AddressUtils, FormatUtils, HexUtils, ValidationUtils
45
+ from .wallet import Account, QuantumWallet
46
+
47
+ __version__ = "1.1.0"
48
+
49
+
50
+ class QWeb3:
51
+ """High-level facade wiring the RPC client, wallet, fees, hooks, and helpers."""
52
+
53
+ def __init__(self, url: str = "http://127.0.0.1:9944", *,
54
+ rest_url: str = None, session=None) -> None:
55
+ self.url = url
56
+ self.rpc = QRpcClient(url, session=session)
57
+ self.rest = QRestClient(rest_url, session=session) if rest_url else None
58
+ self.wallet = QuantumWallet()
59
+ self.signer = QuantumSigner
60
+ self.abi = abi
61
+ self.fees = FeeOracle(rpc=self.rpc, rest_client=self.rest)
62
+ self.hooks = EventHooks(rpc=self.rpc, rest_client=self.rest)
63
+
64
+ def contract(self, abi_list, address: str) -> QContract:
65
+ return QContract(abi_list, address, rpc=self.rpc, wallet=self.wallet, rest_client=self.rest)
66
+
67
+ def qns(self, registry_address: str, **kwargs) -> QNS:
68
+ return QNS(registry_address, rpc=self.rpc, wallet=self.wallet,
69
+ rest_client=self.rest, **kwargs)
70
+
71
+ def batch(self) -> BatchRequest:
72
+ return BatchRequest(self.url)
73
+
74
+
75
+ __all__ = [
76
+ "QWeb3",
77
+ "QRpcClient",
78
+ "QRestClient",
79
+ "QuantumWallet",
80
+ "Account",
81
+ "QuantumSigner",
82
+ "QContract",
83
+ "QNS",
84
+ "BatchRequest",
85
+ "BatchResult",
86
+ "FeeOracle",
87
+ "EventHooks",
88
+ "TxTracker",
89
+ "AddressUtils",
90
+ "HexUtils",
91
+ "FormatUtils",
92
+ "ValidationUtils",
93
+ "QWeb3Error",
94
+ "ConnectionErrorQ",
95
+ "InvalidArgumentError",
96
+ "RpcError",
97
+ "TransactionError",
98
+ "abi",
99
+ "crypto_backend",
100
+ "function_selector",
101
+ "event_topic",
102
+ "encode_parameters",
103
+ "decode_parameters",
104
+ "encode_function_call",
105
+ "decode_function_result",
106
+ "__version__",
107
+ ]
qweb3/_keccak.py ADDED
@@ -0,0 +1,73 @@
1
+ """Pure-Python keccak-256 (Ethereum/Solidity padding, 0x01).
2
+
3
+ This is the default selector/topic hash used by the QVM ABI codec, which speaks
4
+ the standard Solidity ABI. It is dependency-free so the codec works out of the
5
+ box; it can be overridden via ``qweb3.abi.set_keccak`` (for example to use a
6
+ faster C implementation, or the hash provided by Quantova's crypto backend).
7
+
8
+ Note: this is keccak-256, not SHA3-256. They differ only in the padding byte.
9
+ Quantova hashes *transactions/state* with SHA3-256 (handled in the signing
10
+ layer); contract-call *selectors* use keccak-256, which is what this provides.
11
+ """
12
+
13
+ from typing import List
14
+
15
+ _RC = [
16
+ 0x0000000000000001, 0x0000000000008082, 0x800000000000808A, 0x8000000080008000,
17
+ 0x000000000000808B, 0x0000000080000001, 0x8000000080008081, 0x8000000000008009,
18
+ 0x000000000000008A, 0x0000000000000088, 0x0000000080008009, 0x000000008000000A,
19
+ 0x000000008000808B, 0x800000000000008B, 0x8000000000008089, 0x8000000000008003,
20
+ 0x8000000000008002, 0x8000000000000080, 0x000000000000800A, 0x800000008000000A,
21
+ 0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008,
22
+ ]
23
+ _ROT = [
24
+ [0, 36, 3, 41, 18],
25
+ [1, 44, 10, 45, 2],
26
+ [62, 6, 43, 15, 61],
27
+ [28, 55, 25, 21, 56],
28
+ [27, 20, 39, 8, 14],
29
+ ]
30
+ _MASK = (1 << 64) - 1
31
+
32
+
33
+ def _rotl(x: int, n: int) -> int:
34
+ return ((x << n) | (x >> (64 - n))) & _MASK
35
+
36
+
37
+ def _keccak_f(s: List[List[int]]) -> None:
38
+ for rnd in range(24):
39
+ c = [s[x][0] ^ s[x][1] ^ s[x][2] ^ s[x][3] ^ s[x][4] for x in range(5)]
40
+ d = [c[(x + 4) % 5] ^ _rotl(c[(x + 1) % 5], 1) for x in range(5)]
41
+ for x in range(5):
42
+ for y in range(5):
43
+ s[x][y] ^= d[x]
44
+ b = [[0] * 5 for _ in range(5)]
45
+ for x in range(5):
46
+ for y in range(5):
47
+ b[y][(2 * x + 3 * y) % 5] = _rotl(s[x][y], _ROT[x][y])
48
+ for x in range(5):
49
+ for y in range(5):
50
+ s[x][y] = (b[x][y] ^ ((~b[(x + 1) % 5][y]) & b[(x + 2) % 5][y])) & _MASK
51
+ s[0][0] ^= _RC[rnd]
52
+
53
+
54
+ def keccak256(data: bytes) -> bytes:
55
+ """Return the 32-byte keccak-256 digest of ``data``."""
56
+ rate = 136 # bytes for keccak-256
57
+ msg = bytearray(data)
58
+ pad_len = rate - (len(msg) % rate)
59
+ msg += bytearray(pad_len)
60
+ msg[len(data)] ^= 0x01 # keccak padding (Ethereum/Solidity)
61
+ msg[-1] ^= 0x80
62
+
63
+ s = [[0] * 5 for _ in range(5)]
64
+ for off in range(0, len(msg), rate):
65
+ for i in range(rate // 8):
66
+ lane = int.from_bytes(msg[off + i * 8: off + i * 8 + 8], "little")
67
+ s[i % 5][i // 5] ^= lane
68
+ _keccak_f(s)
69
+
70
+ out = bytearray()
71
+ for i in range(4): # 4 lanes * 8 bytes = 32 bytes
72
+ out += (s[i % 5][i // 5]).to_bytes(8, "little")
73
+ return bytes(out)
qweb3/abi.py ADDED
@@ -0,0 +1,341 @@
1
+ """ABI encoder & decoder for the Quantova Virtual Machine (QVM).
2
+
3
+ The QVM runs Solidity compiled to PolkaVM via pallet-revive and speaks the
4
+ standard Ethereum/Solidity ABI at the contract-call boundary: keccak-256 4-byte
5
+ function selectors and 32-byte-word ("head/tail") argument encoding. This module
6
+ turns method arguments into hex calldata and decodes return data and event
7
+ topics/data back into Python values.
8
+
9
+ The keccak implementation defaults to the bundled pure-Python one and can be
10
+ swapped via :func:`set_keccak` (e.g. to use Quantova's crypto backend).
11
+
12
+ Supported types: ``uint<M>`` / ``int<M>`` (default 256), ``address``, ``bool``,
13
+ ``bytes``, ``bytes<N>``, ``string``, and one level of dynamic (``T[]``) or fixed
14
+ (``T[k]``) arrays of those.
15
+ """
16
+
17
+ from typing import Any, Callable, Dict, List, Sequence
18
+
19
+ from ._keccak import keccak256
20
+
21
+ _keccak: Callable[[bytes], bytes] = keccak256
22
+
23
+
24
+ def set_keccak(fn: Callable[[bytes], bytes]) -> None:
25
+ """Override the keccak-256 implementation (takes/returns ``bytes``)."""
26
+ global _keccak
27
+ _keccak = fn
28
+
29
+
30
+ # --------------------------------------------------------------------------- #
31
+ # type parsing
32
+ # --------------------------------------------------------------------------- #
33
+
34
+ def parse_type(type_str: str) -> Dict[str, Any]:
35
+ if type_str.endswith("]"):
36
+ idx = type_str.rfind("[")
37
+ base = parse_type(type_str[:idx])
38
+ inner = type_str[idx + 1:-1]
39
+ length = int(inner) if inner != "" else None
40
+ return {"kind": "array", "base": base, "length": length,
41
+ "dynamic": length is None or base["dynamic"]}
42
+ if type_str == "address":
43
+ return {"kind": "address", "dynamic": False}
44
+ if type_str == "bool":
45
+ return {"kind": "bool", "dynamic": False}
46
+ if type_str == "string":
47
+ return {"kind": "string", "dynamic": True}
48
+ if type_str == "bytes":
49
+ return {"kind": "bytes", "dynamic": True}
50
+ if type_str.startswith("bytes"):
51
+ n = int(type_str[5:])
52
+ if not (1 <= n <= 32):
53
+ raise ValueError(f"invalid fixed bytes size: {type_str}")
54
+ return {"kind": "fbytes", "size": n, "dynamic": False}
55
+ if type_str.startswith("uint"):
56
+ bits = int(type_str[4:]) if type_str[4:] else 256
57
+ return {"kind": "uint", "bits": bits, "dynamic": False}
58
+ if type_str.startswith("int"):
59
+ bits = int(type_str[3:]) if type_str[3:] else 256
60
+ return {"kind": "int", "bits": bits, "dynamic": False}
61
+ raise ValueError(f"unsupported ABI type: {type_str}")
62
+
63
+
64
+ def canonical_type(type_str: str) -> str:
65
+ t = parse_type(type_str)
66
+ return _canonical(t)
67
+
68
+
69
+ def _canonical(t: Dict[str, Any]) -> str:
70
+ k = t["kind"]
71
+ if k == "array":
72
+ return _canonical(t["base"]) + "[" + ("" if t["length"] is None else str(t["length"])) + "]"
73
+ if k == "uint":
74
+ return "uint" + str(t["bits"])
75
+ if k == "int":
76
+ return "int" + str(t["bits"])
77
+ if k == "fbytes":
78
+ return "bytes" + str(t["size"])
79
+ return {"address": "address", "bool": "bool", "string": "string", "bytes": "bytes"}[k]
80
+
81
+
82
+ def _is_dynamic(t: Dict[str, Any]) -> bool:
83
+ if t["kind"] in ("string", "bytes"):
84
+ return True
85
+ if t["kind"] == "array":
86
+ return t["length"] is None or t["base"]["dynamic"]
87
+ return False
88
+
89
+
90
+ def _head_size(t: Dict[str, Any]) -> int:
91
+ if _is_dynamic(t):
92
+ return 32
93
+ if t["kind"] == "array" and t["length"] is not None:
94
+ return t["length"] * _head_size(t["base"])
95
+ return 32
96
+
97
+
98
+ # --------------------------------------------------------------------------- #
99
+ # word helpers (two's complement)
100
+ # --------------------------------------------------------------------------- #
101
+
102
+ def _int_to_word(value: int, signed: bool) -> bytes:
103
+ v = int(value)
104
+ mod = 1 << 256
105
+ if v < 0:
106
+ if not signed:
107
+ raise ValueError("negative value for unsigned type")
108
+ v = (mod + (v % mod)) % mod
109
+ if v >= mod:
110
+ raise ValueError("value exceeds 256 bits")
111
+ return v.to_bytes(32, "big")
112
+
113
+
114
+ def _word_to_int(word: bytes, signed: bool) -> int:
115
+ v = int.from_bytes(word, "big")
116
+ if signed and v >= (1 << 255):
117
+ v -= 1 << 256
118
+ return v
119
+
120
+
121
+ # --------------------------------------------------------------------------- #
122
+ # encoding
123
+ # --------------------------------------------------------------------------- #
124
+
125
+ def _encode_value(t: Dict[str, Any], value: Any) -> bytes:
126
+ k = t["kind"]
127
+ if k == "uint":
128
+ return _int_to_word(value, False)
129
+ if k == "int":
130
+ return _int_to_word(value, True)
131
+ if k == "bool":
132
+ return _int_to_word(1 if value else 0, False)
133
+ if k == "address":
134
+ # Accept a Q-branded account address ("Q1...") as well as a 0x contract address.
135
+ if isinstance(value, str) and value[:2].upper() == "Q1":
136
+ from .utils import AddressUtils
137
+ value = AddressUtils.to_node_address(value)
138
+ b = HexBytes(value)
139
+ if len(b) != 20:
140
+ raise ValueError("address must be 20 bytes")
141
+ return b"\x00" * 12 + b
142
+ if k == "fbytes":
143
+ b = HexBytes(value)
144
+ if len(b) != t["size"]:
145
+ raise ValueError(f"bytes{t['size']} must be {t['size']} bytes")
146
+ return b + b"\x00" * (32 - len(b))
147
+ if k in ("bytes", "string"):
148
+ data = value.encode("utf-8") if k == "string" else HexBytes(value)
149
+ length = _int_to_word(len(data), False)
150
+ padded = data + b"\x00" * ((32 - len(data) % 32) % 32)
151
+ return length + padded
152
+ if k == "array":
153
+ if not isinstance(value, (list, tuple)):
154
+ raise ValueError("expected array value")
155
+ if t["length"] is not None and len(value) != t["length"]:
156
+ raise ValueError(f"fixed array expected {t['length']} items, got {len(value)}")
157
+ parts = [_EncodedItem(t["base"], _encode_value(t["base"], v)) for v in value]
158
+ body = _pack(t["base"], parts)
159
+ if t["length"] is None:
160
+ return _int_to_word(len(value), False) + body
161
+ return body
162
+ raise ValueError(f"cannot encode kind {k}")
163
+
164
+
165
+ class _EncodedItem:
166
+ __slots__ = ("t", "data")
167
+
168
+ def __init__(self, t, data):
169
+ self.t = t
170
+ self.data = data
171
+
172
+
173
+ def _pack(t: Dict[str, Any], parts: List[_EncodedItem]) -> bytes:
174
+ if not _is_dynamic(t):
175
+ return b"".join(p.data for p in parts)
176
+ heads, tails = [], []
177
+ offset = len(parts) * 32
178
+ for p in parts:
179
+ heads.append(_int_to_word(offset, False))
180
+ tails.append(p.data)
181
+ offset += len(p.data)
182
+ return b"".join(heads) + b"".join(tails)
183
+
184
+
185
+ def encode_parameters(types: Sequence[str], values: Sequence[Any]) -> bytes:
186
+ if len(types) != len(values):
187
+ raise ValueError(f"type/value count mismatch: {len(types)} vs {len(values)}")
188
+ descs = [parse_type(t) for t in types]
189
+ encoded = [_encode_value(d, v) for d, v in zip(descs, values)]
190
+
191
+ heads, tails = [], []
192
+ offset = sum(_head_size(d) for d in descs)
193
+ for d, e in zip(descs, encoded):
194
+ if _is_dynamic(d):
195
+ heads.append(_int_to_word(offset, False))
196
+ tails.append(e)
197
+ offset += len(e)
198
+ else:
199
+ heads.append(e)
200
+ return b"".join(heads) + b"".join(tails)
201
+
202
+
203
+ def function_selector(signature: str) -> str:
204
+ """4-byte selector for a signature like ``transfer(address,uint256)``."""
205
+ return "0x" + _keccak(signature.encode("utf-8"))[:4].hex()
206
+
207
+
208
+ def build_signature(name: str, inputs: Sequence[dict]) -> str:
209
+ types = [canonical_type(i["type"]) for i in (inputs or [])]
210
+ return f"{name}({','.join(types)})"
211
+
212
+
213
+ def encode_function_call(fn_abi: dict, args: Sequence[Any]) -> str:
214
+ sig = build_signature(fn_abi["name"], fn_abi.get("inputs"))
215
+ selector = function_selector(sig)
216
+ types = [i["type"] for i in (fn_abi.get("inputs") or [])]
217
+ return selector + encode_parameters(types, args or []).hex()
218
+
219
+
220
+ def event_topic(signature: str) -> str:
221
+ """keccak-256 topic hash of an event signature."""
222
+ return "0x" + _keccak(signature.encode("utf-8")).hex()
223
+
224
+
225
+ # --------------------------------------------------------------------------- #
226
+ # decoding
227
+ # --------------------------------------------------------------------------- #
228
+
229
+ def _decode_value(t: Dict[str, Any], view: bytes, base: int) -> Any:
230
+ k = t["kind"]
231
+ if k == "uint":
232
+ return _word_to_int(view[base:base + 32], False)
233
+ if k == "int":
234
+ return _word_to_int(view[base:base + 32], True)
235
+ if k == "bool":
236
+ return _word_to_int(view[base:base + 32], False) != 0
237
+ if k == "address":
238
+ return "0x" + view[base + 12:base + 32].hex()
239
+ if k == "fbytes":
240
+ return "0x" + view[base:base + t["size"]].hex()
241
+ if k in ("string", "bytes"):
242
+ offset = _word_to_int(view[base:base + 32], False)
243
+ length = _word_to_int(view[offset:offset + 32], False)
244
+ data = view[offset + 32:offset + 32 + length]
245
+ return data.decode("utf-8") if k == "string" else "0x" + data.hex()
246
+ if k == "array":
247
+ if _is_dynamic(t):
248
+ offset = _word_to_int(view[base:base + 32], False)
249
+ return _decode_array_at(t, view, offset)
250
+ out = []
251
+ cur = base
252
+ for _ in range(t["length"]):
253
+ out.append(_decode_value(t["base"], view, cur))
254
+ cur += _head_size(t["base"])
255
+ return out
256
+ raise ValueError(f"cannot decode kind {k}")
257
+
258
+
259
+ def _decode_array_at(t: Dict[str, Any], view: bytes, offset: int) -> list:
260
+ if t["length"] is None:
261
+ count = _word_to_int(view[offset:offset + 32], False)
262
+ body = view[offset + 32:]
263
+ return _decode_items(t["base"], body, count)
264
+ body = view[offset:]
265
+ return _decode_items(t["base"], body, t["length"])
266
+
267
+
268
+ # [QW3PY-001] Absolute upper bound on any decoded array/element count, mirroring
269
+ # eth-abi's guard against attacker-controlled length fields causing huge allocations.
270
+ _MAX_ARRAY_COUNT = 1_000_000
271
+
272
+
273
+ def _check_array_count(count: int, body: bytes) -> None:
274
+ """[QW3PY-001] Validate an attacker-controlled element count against the
275
+ remaining buffer before iterating. Each element occupies at least one 32-byte
276
+ head word, so the buffer can hold at most len(body) // 32 elements; also impose
277
+ a hard absolute cap to bound work even when the buffer is large."""
278
+ if count < 0:
279
+ raise ValueError("array length exceeds buffer")
280
+ if count > _MAX_ARRAY_COUNT or count > len(body) // 32:
281
+ raise ValueError("array length exceeds buffer")
282
+
283
+
284
+ def _decode_items(base_t: Dict[str, Any], body: bytes, count: int) -> list:
285
+ _check_array_count(count, body)
286
+ out = []
287
+ if _is_dynamic(base_t):
288
+ for i in range(count):
289
+ item_offset = _word_to_int(body[i * 32:i * 32 + 32], False)
290
+ out.append(_decode_value_absolute(base_t, body, item_offset))
291
+ else:
292
+ cur = 0
293
+ for _ in range(count):
294
+ out.append(_decode_value(base_t, body, cur))
295
+ cur += _head_size(base_t)
296
+ return out
297
+
298
+
299
+ def _decode_value_absolute(t: Dict[str, Any], view: bytes, start: int) -> Any:
300
+ if t["kind"] in ("string", "bytes"):
301
+ length = _word_to_int(view[start:start + 32], False)
302
+ data = view[start + 32:start + 32 + length]
303
+ return data.decode("utf-8") if t["kind"] == "string" else "0x" + data.hex()
304
+ if t["kind"] == "array":
305
+ return _decode_array_at(t, view, start)
306
+ return _decode_value(t, view, start)
307
+
308
+
309
+ def decode_parameters(types: Sequence[str], data) -> list:
310
+ view = HexBytes(data)
311
+ descs = [parse_type(t) for t in types]
312
+ out = []
313
+ head = 0
314
+ for d in descs:
315
+ out.append(_decode_value(d, view, head))
316
+ head += _head_size(d)
317
+ return out
318
+
319
+
320
+ def decode_function_result(fn_abi: dict, data) -> Any:
321
+ types = [o["type"] for o in (fn_abi.get("outputs") or [])]
322
+ if not types:
323
+ return None
324
+ decoded = decode_parameters(types, data)
325
+ return decoded[0] if len(decoded) == 1 else decoded
326
+
327
+
328
+ # --------------------------------------------------------------------------- #
329
+ # small helper
330
+ # --------------------------------------------------------------------------- #
331
+
332
+ def HexBytes(value) -> bytes:
333
+ """Coerce a 0x-hex string or bytes-like into ``bytes``."""
334
+ if isinstance(value, (bytes, bytearray)):
335
+ return bytes(value)
336
+ if isinstance(value, str):
337
+ s = value[2:] if value.startswith(("0x", "0X")) else value
338
+ if len(s) % 2:
339
+ raise ValueError(f"invalid hex length: {value}")
340
+ return bytes.fromhex(s)
341
+ raise TypeError("expected hex string or bytes")
qweb3/batch.py ADDED
@@ -0,0 +1,97 @@
1
+ """Batch requests portal.
2
+
3
+ Groups multiple ``q_*`` JSON-RPC calls into a single payload (a JSON-RPC 2.0
4
+ batch array) to cut round-trips. Results come back in insertion order with
5
+ per-call success/error, so one failing call doesn't sink the batch.
6
+
7
+ batch = BatchRequest("http://127.0.0.1:9944")
8
+ batch.add("q_blockNumber").add("q_getBalance", [addr, "latest"])
9
+ results = batch.execute() # [BatchResult(...), ...]
10
+ """
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Any, List, Optional
14
+
15
+ try:
16
+ import requests as _requests
17
+ except Exception: # pragma: no cover
18
+ _requests = None
19
+
20
+
21
+ @dataclass
22
+ class BatchResult:
23
+ success: bool
24
+ result: Any = None
25
+ error: Optional[str] = None
26
+
27
+
28
+ class BatchRequest:
29
+ def __init__(self, endpoint: str = "http://127.0.0.1:9944",
30
+ session: Optional[Any] = None, timeout: int = 30) -> None:
31
+ self.endpoint = endpoint
32
+ self.timeout = timeout
33
+ self._calls: List[dict] = []
34
+ self._next_id = 1
35
+ if session is not None:
36
+ self.session = session
37
+ else:
38
+ if _requests is None:
39
+ raise RuntimeError("the 'requests' package is required (or inject a session)")
40
+ self.session = _requests.Session()
41
+
42
+ def add(self, method: str, params: Optional[list] = None) -> "BatchRequest":
43
+ self._calls.append({"id": self._next_id, "method": method, "params": params or []})
44
+ self._next_id += 1
45
+ return self
46
+
47
+ def __len__(self) -> int:
48
+ return len(self._calls)
49
+
50
+ def reset(self) -> "BatchRequest":
51
+ self._calls = []
52
+ self._next_id = 1
53
+ return self
54
+
55
+ def execute(self) -> List[BatchResult]:
56
+ if not self._calls:
57
+ return []
58
+ payload = [{"jsonrpc": "2.0", "id": c["id"], "method": c["method"], "params": c["params"]}
59
+ for c in self._calls]
60
+ try:
61
+ resp = self.session.post(self.endpoint, json=payload,
62
+ headers={"content-type": "application/json"},
63
+ timeout=self.timeout)
64
+ data = resp.json()
65
+ except Exception as exc:
66
+ out = [BatchResult(False, error=f"batch request failed: {exc}") for _ in self._calls]
67
+ self.reset()
68
+ return out
69
+
70
+ by_id = {}
71
+ if isinstance(data, list):
72
+ for r in data:
73
+ by_id[r.get("id")] = r
74
+ elif isinstance(data, dict) and data.get("id") is not None:
75
+ by_id[data["id"]] = data
76
+
77
+ out: List[BatchResult] = []
78
+ for c in self._calls:
79
+ r = by_id.get(c["id"])
80
+ if r is None:
81
+ out.append(BatchResult(False, error=f"no response for {c['method']} (id {c['id']})"))
82
+ elif r.get("error"):
83
+ err = r["error"]
84
+ out.append(BatchResult(False, error=err.get("message") if isinstance(err, dict) else str(err)))
85
+ else:
86
+ out.append(BatchResult(True, result=r.get("result")))
87
+ self.reset()
88
+ return out
89
+
90
+ def execute_or_throw(self) -> list:
91
+ results = self.execute()
92
+ values = []
93
+ for r in results:
94
+ if not r.success:
95
+ raise RuntimeError(r.error)
96
+ values.append(r.result)
97
+ return values