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.
@@ -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__}")
@@ -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")