micro-web3 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.
- micro_web3/__init__.py +58 -0
- micro_web3/_keccak.py +14 -0
- micro_web3/abi.py +629 -0
- micro_web3/addresses.py +62 -0
- micro_web3/contract.py +135 -0
- micro_web3/errors.py +48 -0
- micro_web3/methods.py +114 -0
- micro_web3/multicall.py +63 -0
- micro_web3/py.typed +0 -0
- micro_web3/rpc.py +143 -0
- micro_web3-0.1.0.dist-info/METADATA +155 -0
- micro_web3-0.1.0.dist-info/RECORD +14 -0
- micro_web3-0.1.0.dist-info/WHEEL +4 -0
- micro_web3-0.1.0.dist-info/licenses/LICENSE +21 -0
micro_web3/addresses.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Address helpers.
|
|
2
|
+
|
|
3
|
+
Decoding follows eth_abi and returns lower-case ``0x`` hex; checksumming is
|
|
4
|
+
opt-in through :func:`to_checksum_address` (EIP-55).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
|
|
9
|
+
from ._keccak import keccak256
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def to_bytes20(value: str | bytes | bytearray) -> bytes:
|
|
13
|
+
"""Validate and return the 20 raw bytes of an address."""
|
|
14
|
+
if isinstance(value, bytearray):
|
|
15
|
+
value = bytes(value)
|
|
16
|
+
if isinstance(value, bytes):
|
|
17
|
+
if len(value) != 20:
|
|
18
|
+
raise ValueError(f"address must be 20 bytes, got {len(value)}")
|
|
19
|
+
return value
|
|
20
|
+
if not isinstance(value, str):
|
|
21
|
+
raise TypeError(f"address must be str or bytes, got {type(value).__name__}")
|
|
22
|
+
digits = value[2:] if value[:2].lower() == "0x" else value
|
|
23
|
+
if len(digits) != 40:
|
|
24
|
+
raise ValueError(f"address must be 20 bytes, got {len(digits) // 2} bytes")
|
|
25
|
+
try:
|
|
26
|
+
return bytes.fromhex(digits)
|
|
27
|
+
except ValueError as exc:
|
|
28
|
+
raise ValueError(f"invalid address: {value!r}") from exc
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def to_checksum_address(value: str | bytes | bytearray) -> str:
|
|
32
|
+
"""Return the EIP-55 mixed-case form of ``value``."""
|
|
33
|
+
if type(value) is str:
|
|
34
|
+
return _checksum_str(value)
|
|
35
|
+
return _checksum(to_bytes20(value))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@lru_cache(maxsize=4096)
|
|
39
|
+
def _checksum_str(value: str) -> str:
|
|
40
|
+
return _checksum(to_bytes20(value))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _checksum(raw: bytes) -> str:
|
|
44
|
+
lower = raw.hex()
|
|
45
|
+
digest = keccak256(lower.encode("ascii")).hex()
|
|
46
|
+
return "0x" + "".join(
|
|
47
|
+
[
|
|
48
|
+
char.upper() if nibble >= "8" else char
|
|
49
|
+
for char, nibble in zip(lower, digest, strict=False)
|
|
50
|
+
]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_address(value: object) -> bool:
|
|
55
|
+
"""True if ``value`` is a well-formed 20-byte address."""
|
|
56
|
+
if not isinstance(value, (str, bytes, bytearray)):
|
|
57
|
+
return False
|
|
58
|
+
try:
|
|
59
|
+
to_bytes20(value)
|
|
60
|
+
except ValueError:
|
|
61
|
+
return False
|
|
62
|
+
return True
|
micro_web3/contract.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""A tiny contract helper on top of the ABI codec."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from functools import cached_property
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .abi import (
|
|
8
|
+
AbiType,
|
|
9
|
+
canonical,
|
|
10
|
+
decode_outputs,
|
|
11
|
+
encode,
|
|
12
|
+
inputs_types,
|
|
13
|
+
outputs_types,
|
|
14
|
+
selector,
|
|
15
|
+
)
|
|
16
|
+
from .addresses import to_checksum_address
|
|
17
|
+
from .errors import AbiError
|
|
18
|
+
from .methods import eth_call
|
|
19
|
+
from .rpc import RpcNode
|
|
20
|
+
|
|
21
|
+
__all__ = ["Contract"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _unwrap(decoded: tuple[Any, ...]) -> Any:
|
|
25
|
+
return decoded[0] if len(decoded) == 1 else decoded
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _Function:
|
|
29
|
+
"""One ABI entry with its types and selector, derived on first use."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, entry: Mapping[str, Any]) -> None:
|
|
32
|
+
self.entry = entry
|
|
33
|
+
|
|
34
|
+
@cached_property
|
|
35
|
+
def inputs(self) -> tuple[AbiType, ...]:
|
|
36
|
+
return inputs_types(self.entry)
|
|
37
|
+
|
|
38
|
+
@cached_property
|
|
39
|
+
def outputs(self) -> tuple[AbiType, ...]:
|
|
40
|
+
return outputs_types(self.entry)
|
|
41
|
+
|
|
42
|
+
@cached_property
|
|
43
|
+
def selector(self) -> bytes:
|
|
44
|
+
return selector(self.entry)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Contract:
|
|
48
|
+
"""An ABI plus (optionally) an address.
|
|
49
|
+
|
|
50
|
+
``Contract.encode`` returns ``0x`` hex like web3's ``encode_abi``, so the
|
|
51
|
+
Etherscan fallback path keeps working unchanged.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
__slots__ = ("_abi", "_functions", "address")
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
address: str | bytes | None,
|
|
59
|
+
abi: Sequence[Mapping[str, Any]],
|
|
60
|
+
) -> None:
|
|
61
|
+
self.address = None if address is None else to_checksum_address(address)
|
|
62
|
+
self.abi = abi
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def abi(self) -> tuple[Mapping[str, Any], ...]:
|
|
66
|
+
return self._abi
|
|
67
|
+
|
|
68
|
+
@abi.setter
|
|
69
|
+
def abi(self, abi: Sequence[Mapping[str, Any]]) -> None:
|
|
70
|
+
self._abi = tuple(abi)
|
|
71
|
+
self._functions: dict[str, tuple[_Function, ...]] = {}
|
|
72
|
+
|
|
73
|
+
def function(self, name: str, args: Sequence[Any] = ()) -> Mapping[str, Any]:
|
|
74
|
+
"""Return the matching ABI entry.
|
|
75
|
+
|
|
76
|
+
Raises :class:`AttributeError` if no function has that name and
|
|
77
|
+
:class:`~micro_web3.errors.AbiError` if the arguments do not fit any
|
|
78
|
+
overload.
|
|
79
|
+
"""
|
|
80
|
+
return self._resolve(name, args)[0].entry
|
|
81
|
+
|
|
82
|
+
def encode(self, name: str, args: Sequence[Any] = ()) -> str:
|
|
83
|
+
"""Encode a call, ``0x`` hex including the 4-byte selector."""
|
|
84
|
+
function, body = self._resolve(name, args)
|
|
85
|
+
return "0x" + (function.selector + body).hex()
|
|
86
|
+
|
|
87
|
+
def output_types(self, name: str, args: Sequence[Any] = ()) -> tuple[str, ...]:
|
|
88
|
+
return tuple(canonical(item) for item in self._outputs(name, args))
|
|
89
|
+
|
|
90
|
+
def decode(self, name: str, args: Sequence[Any], data: str | bytes) -> Any:
|
|
91
|
+
"""Decode a return payload, unwrapping single-output results."""
|
|
92
|
+
return _unwrap(decode_outputs(self._outputs(name, args), data))
|
|
93
|
+
|
|
94
|
+
def call(
|
|
95
|
+
self,
|
|
96
|
+
node: RpcNode,
|
|
97
|
+
name: str,
|
|
98
|
+
args: Sequence[Any] = (),
|
|
99
|
+
block: int | str = "latest",
|
|
100
|
+
) -> Any:
|
|
101
|
+
"""``eth_call`` the function and decode the result."""
|
|
102
|
+
if self.address is None:
|
|
103
|
+
raise ValueError("contract has no address; cannot perform a call")
|
|
104
|
+
function, body = self._resolve(name, args)
|
|
105
|
+
raw = eth_call(node, self.address, "0x" + (function.selector + body).hex(), block)
|
|
106
|
+
return _unwrap(decode_outputs(function.outputs, raw))
|
|
107
|
+
|
|
108
|
+
def _outputs(self, name: str, args: Sequence[Any]) -> tuple[AbiType, ...]:
|
|
109
|
+
return self._resolve(name, args)[0].outputs
|
|
110
|
+
|
|
111
|
+
def _resolve(self, name: str, args: Sequence[Any]) -> tuple[_Function, bytes]:
|
|
112
|
+
"""First overload accepting ``args``, with the encoded arguments."""
|
|
113
|
+
functions = self._functions.get(name)
|
|
114
|
+
if functions is None:
|
|
115
|
+
functions = tuple(
|
|
116
|
+
_Function(entry)
|
|
117
|
+
for entry in self.abi
|
|
118
|
+
if entry.get("type", "function") == "function" and entry.get("name") == name
|
|
119
|
+
)
|
|
120
|
+
if not functions:
|
|
121
|
+
raise AttributeError(f"ABI has no function named {name!r}")
|
|
122
|
+
self._functions[name] = functions
|
|
123
|
+
failures: list[str] = []
|
|
124
|
+
for function in functions:
|
|
125
|
+
try:
|
|
126
|
+
return function, encode(function.inputs, args)
|
|
127
|
+
except AbiError as exc:
|
|
128
|
+
failures.append(str(exc))
|
|
129
|
+
raise AbiError(
|
|
130
|
+
f"no overload of {name!r} accepts {len(args)} argument(s): " + "; ".join(failures),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def __repr__(self) -> str:
|
|
134
|
+
functions = sum(1 for entry in self.abi if entry.get("type", "function") == "function")
|
|
135
|
+
return f"Contract(address={self.address!r}, functions={functions})"
|
micro_web3/errors.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Exception hierarchy.
|
|
2
|
+
|
|
3
|
+
Everything raised by :mod:`micro_web3` derives from :class:`RpcError` so call
|
|
4
|
+
sites can keep a single ``except RpcError`` (the replacement for
|
|
5
|
+
``web3.exceptions.Web3Exception``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RpcError(Exception):
|
|
12
|
+
"""Base class for every error raised by micro_web3."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RpcConnectionError(RpcError):
|
|
16
|
+
"""The node could not be reached, timed out, or spoke non JSON-RPC."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
message: str,
|
|
21
|
+
*,
|
|
22
|
+
endpoint_uri: str | None = None,
|
|
23
|
+
status: int | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
self.endpoint_uri = endpoint_uri
|
|
26
|
+
self.status = status
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RpcResponseError(RpcError):
|
|
31
|
+
"""The node answered with a JSON-RPC ``error`` object.
|
|
32
|
+
|
|
33
|
+
Reverts arrive here: the message contains ``execution reverted`` and the
|
|
34
|
+
revert payload (when the node provides it) is in :attr:`data`.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, code: int, message: str, data: Any = None) -> None:
|
|
38
|
+
self.code = code
|
|
39
|
+
self.message = message
|
|
40
|
+
self.data = data
|
|
41
|
+
text = f"{message} (code {code})"
|
|
42
|
+
if data not in (None, "", "0x"):
|
|
43
|
+
text = f"{text}: {data!r}"
|
|
44
|
+
super().__init__(text)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AbiError(RpcError):
|
|
48
|
+
"""An ABI definition, value, or encoded payload was invalid."""
|
micro_web3/methods.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""High-level RPC helpers: ``eth_call``, ``eth_getLogs``, block number."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .addresses import to_checksum_address
|
|
7
|
+
from .errors import RpcError
|
|
8
|
+
from .rpc import RpcNode
|
|
9
|
+
|
|
10
|
+
_BLOCK_TAGS = frozenset({"latest", "earliest", "pending", "safe", "finalized"})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def to_quantity(value: int | str) -> str:
|
|
14
|
+
"""Normalise a block number or tag to a JSON-RPC quantity.
|
|
15
|
+
|
|
16
|
+
Accepts ``int``, decimal strings, hex strings and block tags. Decimal input
|
|
17
|
+
is accepted on purpose: it lets call sites keep Etherscan-shaped arguments
|
|
18
|
+
and still emit valid ``eth_getLogs`` params.
|
|
19
|
+
"""
|
|
20
|
+
if isinstance(value, bool):
|
|
21
|
+
raise ValueError("block number must not be a bool")
|
|
22
|
+
if isinstance(value, int):
|
|
23
|
+
if value < 0:
|
|
24
|
+
raise ValueError(f"negative block number: {value}")
|
|
25
|
+
return hex(value)
|
|
26
|
+
if not isinstance(value, str):
|
|
27
|
+
raise ValueError(f"invalid block identifier: {value!r}")
|
|
28
|
+
stripped = value.strip()
|
|
29
|
+
if stripped in _BLOCK_TAGS:
|
|
30
|
+
return stripped
|
|
31
|
+
if stripped[:2].lower() == "0x":
|
|
32
|
+
try:
|
|
33
|
+
return hex(int(stripped, 16))
|
|
34
|
+
except ValueError as exc:
|
|
35
|
+
raise ValueError(f"invalid hex block number: {value!r}") from exc
|
|
36
|
+
if stripped.isdigit():
|
|
37
|
+
return hex(int(stripped))
|
|
38
|
+
raise ValueError(f"invalid block identifier: {value!r}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def eth_call(
|
|
42
|
+
node: RpcNode,
|
|
43
|
+
to: str | bytes,
|
|
44
|
+
data: str | bytes,
|
|
45
|
+
block: int | str = "latest",
|
|
46
|
+
) -> str:
|
|
47
|
+
"""Perform one ``eth_call`` and return the raw ``0x`` result."""
|
|
48
|
+
result = node.request(
|
|
49
|
+
"eth_call",
|
|
50
|
+
[
|
|
51
|
+
{"to": to_checksum_address(to), "data": _hex_data(data)},
|
|
52
|
+
to_quantity(block),
|
|
53
|
+
],
|
|
54
|
+
)
|
|
55
|
+
if not isinstance(result, str):
|
|
56
|
+
raise RpcError(f"eth_call returned {type(result).__name__}, expected hex string")
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_logs(
|
|
61
|
+
node: RpcNode,
|
|
62
|
+
address: str | bytes | Sequence[str | bytes] | None = None,
|
|
63
|
+
topics: Sequence[str | Sequence[str] | None] | None = None,
|
|
64
|
+
from_block: int | str | None = None,
|
|
65
|
+
to_block: int | str | None = None,
|
|
66
|
+
) -> list[dict[str, Any]]:
|
|
67
|
+
"""Perform ``eth_getLogs`` and return the node's raw JSON-RPC objects.
|
|
68
|
+
|
|
69
|
+
No normalisation: ``blockNumber``, ``topics`` and ``data`` stay hex strings,
|
|
70
|
+
identical to the Etherscan/Blockscout shapes.
|
|
71
|
+
"""
|
|
72
|
+
params: dict[str, Any] = {}
|
|
73
|
+
if address is not None:
|
|
74
|
+
if isinstance(address, (str, bytes)):
|
|
75
|
+
params["address"] = to_checksum_address(address)
|
|
76
|
+
else:
|
|
77
|
+
params["address"] = [to_checksum_address(item) for item in address]
|
|
78
|
+
if topics is not None:
|
|
79
|
+
params["topics"] = list(topics)
|
|
80
|
+
if from_block is not None:
|
|
81
|
+
params["fromBlock"] = to_quantity(from_block)
|
|
82
|
+
if to_block is not None:
|
|
83
|
+
params["toBlock"] = to_quantity(to_block)
|
|
84
|
+
|
|
85
|
+
result = node.request("eth_getLogs", [params])
|
|
86
|
+
if not isinstance(result, list):
|
|
87
|
+
raise RpcError(f"eth_getLogs returned {type(result).__name__}, expected list")
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_block_number(node: RpcNode) -> int:
|
|
92
|
+
"""Return the head block number."""
|
|
93
|
+
block = node.request("eth_getBlockByNumber", ["latest", False])
|
|
94
|
+
if not isinstance(block, dict):
|
|
95
|
+
raise RpcError("eth_getBlockByNumber returned a non-object result")
|
|
96
|
+
number = block.get("number")
|
|
97
|
+
if isinstance(number, int):
|
|
98
|
+
return number
|
|
99
|
+
if isinstance(number, str):
|
|
100
|
+
try:
|
|
101
|
+
return int(number, 16)
|
|
102
|
+
except ValueError as exc:
|
|
103
|
+
raise RpcError(f"invalid block number from node: {number!r}") from exc
|
|
104
|
+
raise RpcError(f"eth_getBlockByNumber result without number: {block!r}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _hex_data(value: str | bytes) -> str:
|
|
108
|
+
if isinstance(value, (bytes, bytearray)):
|
|
109
|
+
return "0x" + value.hex()
|
|
110
|
+
if isinstance(value, str):
|
|
111
|
+
if value[:2].lower() != "0x":
|
|
112
|
+
return "0x" + value
|
|
113
|
+
return value
|
|
114
|
+
raise ValueError(f"invalid calldata type: {type(value).__name__}")
|
micro_web3/multicall.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Multicall3 batching (``tryAggregate``)."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .contract import Contract
|
|
8
|
+
from .rpc import RpcNode
|
|
9
|
+
|
|
10
|
+
__all__ = ["MULTICALL3_ADDRESS", "try_aggregate"]
|
|
11
|
+
|
|
12
|
+
MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11"
|
|
13
|
+
|
|
14
|
+
_MULTICALL3_ABI: tuple[dict[str, Any], ...] = (
|
|
15
|
+
{
|
|
16
|
+
"type": "function",
|
|
17
|
+
"name": "tryAggregate",
|
|
18
|
+
"stateMutability": "view",
|
|
19
|
+
"inputs": [
|
|
20
|
+
{"name": "requireSuccess", "type": "bool"},
|
|
21
|
+
{
|
|
22
|
+
"name": "calls",
|
|
23
|
+
"type": "tuple[]",
|
|
24
|
+
"components": [
|
|
25
|
+
{"name": "target", "type": "address"},
|
|
26
|
+
{"name": "callData", "type": "bytes"},
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
"outputs": [
|
|
31
|
+
{
|
|
32
|
+
"name": "returnData",
|
|
33
|
+
"type": "tuple[]",
|
|
34
|
+
"components": [
|
|
35
|
+
{"name": "success", "type": "bool"},
|
|
36
|
+
{"name": "returnData", "type": "bytes"},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@lru_cache(maxsize=16)
|
|
45
|
+
def _multicall(address: str) -> Contract:
|
|
46
|
+
return Contract(address, _MULTICALL3_ABI)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def try_aggregate(
|
|
50
|
+
node: RpcNode,
|
|
51
|
+
calls: Sequence[tuple[str | bytes, str | bytes]],
|
|
52
|
+
block: int | str = "latest",
|
|
53
|
+
address: str = MULTICALL3_ADDRESS,
|
|
54
|
+
) -> list[bytes | None]:
|
|
55
|
+
"""Run ``calls`` through Multicall3 ``tryAggregate(false, calls)``.
|
|
56
|
+
|
|
57
|
+
Each call is ``(target, calldata)``. Returns one entry per call: the raw
|
|
58
|
+
return data as ``bytes``, or ``None`` when that individual call reverted.
|
|
59
|
+
"""
|
|
60
|
+
contract = _multicall(address)
|
|
61
|
+
encoded = [(target, call_data) for target, call_data in calls]
|
|
62
|
+
results = contract.call(node, "tryAggregate", [False, encoded], block)
|
|
63
|
+
return [return_data if success else None for success, return_data in results]
|
micro_web3/py.typed
ADDED
|
File without changes
|
micro_web3/rpc.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""JSON-RPC over urllib3.
|
|
2
|
+
|
|
3
|
+
Each node keeps its own connection pool. One HTTP POST per call and no retries:
|
|
4
|
+
fallback across nodes is the caller's job (see rotkehlchen's ``EthereumManager``).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import itertools
|
|
8
|
+
import json
|
|
9
|
+
from collections.abc import Mapping, Sequence
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from urllib3 import PoolManager
|
|
13
|
+
from urllib3.exceptions import HTTPError
|
|
14
|
+
|
|
15
|
+
from .errors import RpcConnectionError, RpcError, RpcResponseError
|
|
16
|
+
|
|
17
|
+
__all__ = ["RpcNode"]
|
|
18
|
+
|
|
19
|
+
_DEFAULT_USER_AGENT = "micro-web3"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RpcNode:
|
|
23
|
+
"""A single JSON-RPC endpoint."""
|
|
24
|
+
|
|
25
|
+
__slots__ = ("_headers", "_ids", "_pool", "endpoint_uri", "timeout")
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
endpoint_uri: str,
|
|
30
|
+
*,
|
|
31
|
+
timeout: float = 4.0,
|
|
32
|
+
headers: Mapping[str, str] | None = None,
|
|
33
|
+
pool_maxsize: int = 10,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._pool = PoolManager(num_pools=1, maxsize=pool_maxsize)
|
|
36
|
+
self.endpoint_uri = endpoint_uri
|
|
37
|
+
self.timeout = timeout
|
|
38
|
+
self._headers = {
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
"Accept": "application/json",
|
|
41
|
+
"User-Agent": _DEFAULT_USER_AGENT,
|
|
42
|
+
}
|
|
43
|
+
if headers:
|
|
44
|
+
self._headers.update(headers)
|
|
45
|
+
self._ids = itertools.count(1)
|
|
46
|
+
|
|
47
|
+
def request(self, method: str, params: Sequence[Any] | Mapping[str, Any] | None = None) -> Any:
|
|
48
|
+
"""Perform one JSON-RPC call and return its ``result``.
|
|
49
|
+
|
|
50
|
+
Raises :class:`RpcConnectionError` if the endpoint cannot be reached and
|
|
51
|
+
:class:`RpcResponseError` if it answers with a JSON-RPC error object.
|
|
52
|
+
"""
|
|
53
|
+
if params is None:
|
|
54
|
+
params_list: list[Any] = []
|
|
55
|
+
elif isinstance(params, Mapping):
|
|
56
|
+
params_list = [params]
|
|
57
|
+
else:
|
|
58
|
+
params_list = list(params)
|
|
59
|
+
|
|
60
|
+
request_id = next(self._ids)
|
|
61
|
+
try:
|
|
62
|
+
payload = json.dumps(
|
|
63
|
+
{"jsonrpc": "2.0", "id": request_id, "method": method, "params": params_list},
|
|
64
|
+
separators=(",", ":"),
|
|
65
|
+
).encode()
|
|
66
|
+
except (TypeError, ValueError) as exc:
|
|
67
|
+
raise RpcError(f"cannot serialise JSON-RPC params for {method}: {exc}") from exc
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
response = self._pool.request(
|
|
71
|
+
"POST",
|
|
72
|
+
self.endpoint_uri,
|
|
73
|
+
body=payload,
|
|
74
|
+
headers=self._headers,
|
|
75
|
+
timeout=self.timeout,
|
|
76
|
+
retries=False,
|
|
77
|
+
)
|
|
78
|
+
body = response.data
|
|
79
|
+
except (HTTPError, OSError) as exc:
|
|
80
|
+
raise RpcConnectionError(
|
|
81
|
+
f"cannot reach {self.endpoint_uri}: {exc}",
|
|
82
|
+
endpoint_uri=self.endpoint_uri,
|
|
83
|
+
) from exc
|
|
84
|
+
|
|
85
|
+
parsed = _parse_json(body)
|
|
86
|
+
if not 200 <= response.status < 300:
|
|
87
|
+
if isinstance(parsed, dict) and isinstance(parsed.get("error"), dict):
|
|
88
|
+
raise _response_error(parsed["error"])
|
|
89
|
+
raise RpcConnectionError(
|
|
90
|
+
f"HTTP {response.status} from {self.endpoint_uri}: {_snippet(body)}",
|
|
91
|
+
endpoint_uri=self.endpoint_uri,
|
|
92
|
+
status=response.status,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if not isinstance(parsed, dict):
|
|
96
|
+
raise RpcError(
|
|
97
|
+
f"invalid JSON-RPC response from {self.endpoint_uri}: {_snippet(body)}",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
error = parsed.get("error")
|
|
101
|
+
if error is not None:
|
|
102
|
+
if not isinstance(error, dict):
|
|
103
|
+
raise RpcError(f"malformed JSON-RPC error from {self.endpoint_uri}: {error!r}")
|
|
104
|
+
raise _response_error(error)
|
|
105
|
+
if "result" not in parsed:
|
|
106
|
+
raise RpcError(f"JSON-RPC response without result from {self.endpoint_uri}")
|
|
107
|
+
return parsed["result"]
|
|
108
|
+
|
|
109
|
+
def is_connected(self) -> bool:
|
|
110
|
+
"""Probe the node with ``web3_clientVersion``; never raises."""
|
|
111
|
+
try:
|
|
112
|
+
version = self.request("web3_clientVersion")
|
|
113
|
+
except (RpcError, OSError, ValueError):
|
|
114
|
+
return False
|
|
115
|
+
return isinstance(version, str) and version != ""
|
|
116
|
+
|
|
117
|
+
def close(self) -> None:
|
|
118
|
+
"""Close the pooled connections. The node reconnects if used again."""
|
|
119
|
+
self._pool.clear()
|
|
120
|
+
|
|
121
|
+
def __repr__(self) -> str:
|
|
122
|
+
return f"RpcNode({self.endpoint_uri!r}, timeout={self.timeout!r})"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _response_error(error: Mapping[str, Any]) -> RpcResponseError:
|
|
126
|
+
code = error.get("code")
|
|
127
|
+
message = error.get("message", "")
|
|
128
|
+
return RpcResponseError(
|
|
129
|
+
code if isinstance(code, int) else -1,
|
|
130
|
+
message if isinstance(message, str) else str(message),
|
|
131
|
+
error.get("data"),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _parse_json(body: bytes) -> Any:
|
|
136
|
+
try:
|
|
137
|
+
return json.loads(body)
|
|
138
|
+
except ValueError:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _snippet(body: bytes, limit: int = 300) -> str:
|
|
143
|
+
return body[:limit].decode("utf-8", "replace")
|