Files
Dario Gabriel LipicarandClaude Opus 5 35fc0365ce feat: the full eth*/op* mirror, generated — and CI to gate it
Grows the typed surface from 8 methods to 60: all 30 eth_* the library
dispatches, plus the 30 op_* mirrors. Everything was already reachable through
rpc(); what was missing was discoverability — `lm methods`, the LIDL contract,
and a caller's type checker.

Generated, not hand-written. Each wrapper is three lines over the same shared
path, so sixty-one of them by hand is sixty-one chances to transpose an
argument; the table is extracted from the library's OWN dispatch table (the
`case $name` in c_frontend.nim), including each parameter's real type — which
is how ethGetBlockByNumber gets a bool, ethFeeHistory a uint64 and a list, and
eth_call an object rather than everything being a string.

They are still committed as literal text: the module's code generator parses
verified_proxy_impl.h as TEXT to build the LIDL contract, so anything hidden
behind a macro would simply not exist to it. `--check` proves the committed
blocks still match, and fails on a one-character edit (verified).

eth_syncing is deliberately excluded from the typed surface — the runtime
issues it as its own keep-alive and a wrapper would invite callers to fight it.
Still reachable through rpc().

CI is new for this repo, which had none: build on Linux and macOS, unit tests
(the check derivation runs the suite as part of building it), and the codegen
drift check. Named explicitly rather than via `nix flake check`, which would
also evaluate the x86_64-windows pseudo-system.

Verified live on sepolia through a real logoscore daemon: ethGasPrice,
ethMaxPriorityFeePerGas, ethBlobBaseFee, ethGetBlockTransactionCountByNumber,
ethGetUncleCountByBlockNumber and ethGetBlockByNumber all answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 23:09:04 -03:00

199 lines
7.0 KiB
Python

#!/usr/bin/env python3
"""Generate the typed eth_*/op_* wrappers from the library's own dispatch table.
The wrappers are three lines each over one shared `rpc()` path, so hand-writing
sixty-one of them would be sixty-one chances to transpose an argument. They are
generated instead — but COMMITTED as literal text, because the module's code
generator parses verified_proxy_impl.h as TEXT to build the LIDL contract and
would not see anything hidden behind a macro.
Run with --check in CI to prove the committed blocks still match.
The table below was extracted mechanically from nimbus-eth1's
nimbus_verified_proxy/library/c_frontend.nim (the `case $name` inside proxyCall)
at the revision this module pins. Re-extract with --from-source <c_frontend.nim>
after bumping that input.
"""
import argparse
import json
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
SRC = HERE.parent / "src"
BEGIN = "// BEGIN GENERATED RPC WRAPPERS -- edit tools/gen_rpc_methods.py, not this"
END = "// END GENERATED RPC WRAPPERS"
# Parameter names, per RPC method, in order. Only the eth_ spellings are listed;
# the op_ mirror reuses them. Names reach the LIDL contract and `lm methods`, so
# they are the API's documentation as much as its signature.
PARAM_NAMES = {
"getBalance": ["address", "blockTag"],
"getCode": ["address", "blockTag"],
"getTransactionCount": ["address", "blockTag"],
"getStorageAt": ["address", "slot", "blockTag"],
"getBlockByNumber": ["blockTag", "fullTransactions"],
"getBlockByHash": ["blockHash", "fullTransactions"],
"getUncleCountByBlockNumber": ["blockTag"],
"getUncleCountByBlockHash": ["blockHash"],
"getBlockTransactionCountByNumber": ["blockTag"],
"getBlockTransactionCountByHash": ["blockHash"],
"getTransactionByBlockNumberAndIndex": ["blockTag", "index"],
"getTransactionByBlockHashAndIndex": ["blockHash", "index"],
"getTransactionByHash": ["txHash"],
"getTransactionReceipt": ["txHash"],
"getBlockReceipts": ["blockTag"],
"call": ["txArgs", "blockTag", "optimisticStateFetch"],
"estimateGas": ["txArgs", "blockTag", "optimisticStateFetch"],
"createAccessList": ["txArgs", "blockTag", "optimisticStateFetch"],
"getLogs": ["filterOptions"],
"newFilter": ["filterOptions"],
"uninstallFilter": ["filterId"],
"getFilterLogs": ["filterId"],
"getFilterChanges": ["filterId"],
"feeHistory": ["blockCount", "newestBlock", "rewardPercentiles"],
"sendRawTransaction": ["txHexBytes"],
}
# The one JSON parameter that is an ARRAY rather than an object. Everything else
# reaching the library as a raw JSON node is a transaction or filter object.
JSON_LISTS = {("feeHistory", 2)}
CPP_TYPE = {
"str": "const std::string& ",
"bool": "bool ",
"u64": "uint64_t ",
}
# Methods this module drives itself; a typed wrapper would invite callers to
# fight the runtime for control of them.
SKIP = {"eth_syncing"}
def extract(path):
body = Path(path).read_text()
body = body[body.index("proc proxyCall("):]
out = []
for block in re.split(r'\n of "', body)[1:]:
name = block.split('"', 1)[0]
seg = block.split('\n of "')[0]
kinds = []
for m in re.finditer(
r"parsedParams\[(\d+)\]\.(getStr|getBool|getBiggestInt)\(\)"
r"|\(\$parsedParams\[(\d+)\]\)", seg):
if m.group(2):
kinds.append((int(m.group(1)),
{"getStr": "str", "getBool": "bool",
"getBiggestInt": "u64"}[m.group(2)]))
else:
kinds.append((int(m.group(3)), "json"))
out.append({"rpc": name, "params": [k for _, k in sorted(set(kinds))]})
return out
def cpp_name(rpc):
prefix, rest = rpc.split("_", 1)
return prefix + rest[0].upper() + rest[1:]
def stem(rpc):
return rpc.split("_", 1)[1]
def signature(rpc, params):
base = stem(rpc)
names = PARAM_NAMES.get(base, [f"arg{i}" for i in range(len(params))])
args = []
for i, kind in enumerate(params):
if kind == "json":
t = "const LogosList& " if (base, i) in JSON_LISTS else "const LogosMap& "
else:
t = CPP_TYPE[kind]
args.append(t + names[i])
return f"StdLogosResult {cpp_name(rpc)}({', '.join(args)})"
def doc(rpc, params):
base = stem(rpc)
lines = [f"/// `{rpc}`, verified."]
if base in ("call", "estimateGas", "createAccessList"):
lines.append("///")
lines.append("/// `optimisticStateFetch` is upstream's own extension to the standard")
lines.append("/// JSON-RPC signature, not a parameter callers will know from elsewhere.")
if rpc.startswith("op_"):
lines.append("///")
lines.append("/// Requires an OP-Stack network and `opExecutionApiUrls`; otherwise the")
lines.append("/// library answers with a clear error rather than a wrong value.")
return lines
def emit_header(table):
out = [BEGIN]
for e in table:
out += [" " + l for l in doc(e["rpc"], e["params"])]
out.append(f" {signature(e['rpc'], e['params'])};")
out.append("")
out.append(" " + END)
return "\n".join(out)
def emit_impl(table):
out = [BEGIN]
for e in table:
base, params = stem(e["rpc"]), e["params"]
names = PARAM_NAMES.get(base, [f"arg{i}" for i in range(len(params))])
sig = signature(e["rpc"], params).replace(
"StdLogosResult ", "StdLogosResult VerifiedProxyImpl::", 1)
args = ", ".join(names)
out.append(f"{sig} {{")
out.append(f' return rpc("{e["rpc"]}", json::array({{{args}}}));')
out.append("}")
out.append("")
out.append(END)
return "\n".join(out)
def splice(path, block):
text = Path(path).read_text()
i, j = text.index(BEGIN), text.index(END) + len(END)
return text[:i] + block.strip() + text[j:]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--from-source", help="path to c_frontend.nim to re-extract the table")
ap.add_argument("--check", action="store_true", help="fail if the committed files differ")
a = ap.parse_args()
tbl_path = HERE / "rpc_methods.json"
if a.from_source:
table = extract(a.from_source)
tbl_path.write_text(json.dumps(table, indent=1) + "\n")
table = json.loads(tbl_path.read_text())
table = [e for e in table if e["rpc"] not in SKIP]
targets = {
SRC / "verified_proxy_impl.h": emit_header(table),
SRC / "verified_proxy_rpc.cpp": emit_impl(table),
}
bad = False
for path, block in targets.items():
new = splice(path, block)
if a.check:
if new != path.read_text():
print(f"DRIFT: {path.name} does not match the generator", file=sys.stderr)
bad = True
else:
path.write_text(new)
if a.check and bad:
print("run: python3 tools/gen_rpc_methods.py", file=sys.stderr)
return 1
print(f"{len(table)} wrappers {'checked' if a.check else 'generated'}")
return 0
if __name__ == "__main__":
sys.exit(main())