From 053e83b7046ce58c0af9593fa183553d674637b1 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:10:42 +0400 Subject: [PATCH] resolver: multicall reads, keep-alive, workers and structured logs (#1883) * resolver: queue up to 128 pending connections * resolver: reuse connections to the node * resolver: read each round in one multicall * resolver: run worker processes, log durations * resolver: keep smp-server connections alive * resolver: test with pytest, lock dependencies * resolver: structured logs and real client addresses * resolver: refuse requests with a body * resolver: harden pool, health and odd answers * resolver: pin images, rotate logs * resolver: stricter body check, redact RPC URL * resolver: ship .env as .env.example * resolver: pass settings from .env * resolver: name the image snrc-resolve:local --- .github/workflows/build.yml | 17 +- .gitignore | 4 + scripts/resolver/{.env => .env.example} | 16 +- scripts/resolver/README.md | 101 ++- scripts/resolver/docker-compose.yml | 29 +- scripts/resolver/service/.dockerignore | 5 + scripts/resolver/service/.python-version | 1 + scripts/resolver/service/Dockerfile | 25 +- scripts/resolver/service/pyproject.toml | 9 + scripts/resolver/service/snrc-resolve.py | 672 ++++++++++++-- scripts/resolver/service/test_snrc_resolve.py | 840 +++++++++++++++++- scripts/resolver/service/uv.lock | 156 ++++ 12 files changed, 1770 insertions(+), 105 deletions(-) rename scripts/resolver/{.env => .env.example} (58%) create mode 100644 scripts/resolver/service/.dockerignore create mode 100644 scripts/resolver/service/.python-version create mode 100644 scripts/resolver/service/uv.lock diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 63b24ec4e..6d445b8b1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -320,15 +320,14 @@ jobs: - name: Clone project uses: actions/checkout@v3 - - name: Set up Python - # Matches the runtime stage of scripts/resolver/service/Dockerfile. - uses: actions/setup-python@v5 - with: - python-version: "3.13" + - name: Set up uv + # uv takes Python from .python-version, the version of the Dockerfile's runtime stage. + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 - - name: Install resolver dependencies - # Must match scripts/resolver/service/pyproject.toml. - run: python -m pip install "eth-hash[pycryptodome]>=0.7" + - name: Lint + working-directory: scripts/resolver/service + run: uv run --frozen ruff check . - name: Test - run: python -m unittest discover -s scripts/resolver/service -v + working-directory: scripts/resolver/service + run: uv run --frozen pytest -v diff --git a/.gitignore b/.gitignore index 9550e48c0..34cfd4f8a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ *.lock +# the resolver's Docker image and CI install exactly these +!/scripts/resolver/service/uv.lock +# local overrides of scripts/resolver/.env.example +/scripts/resolver/.env *.db *.db.bak *.session.sql diff --git a/scripts/resolver/.env b/scripts/resolver/.env.example similarity index 58% rename from scripts/resolver/.env rename to scripts/resolver/.env.example index 3114c3365..f3bcdcc00 100644 --- a/scripts/resolver/.env +++ b/scripts/resolver/.env.example @@ -1,5 +1,8 @@ +# Copy to .env (next to docker-compose.yml) only to change a setting; without it +# docker-compose.yml uses the values shown here. + # ============================================================================ -# Required settings — the stack will not start without these. +# Chain settings. # ============================================================================ # Ethereum network: mainnet (the SNRC `.testing` contracts live on mainnet) @@ -14,9 +17,16 @@ NETWORK=mainnet TRUSTED_NODE_URL=https://mainnet-checkpoint-sync.attestant.io # ============================================================================ -# Optional overrides — sensible defaults are baked into docker-compose.yml, -# so leave these commented unless you need to change them. +# Optional overrides — leave commented unless you need to change them. # ============================================================================ # Nimbus NAT (default: any). For a stable public node set an explicit IP: # NAT=extip:1.2.3.4 # your public IPv4: curl -s ifconfig.me + +# Resolver (defaults in the README's "Load and scaling" and "Logs" sections) +# SNRC_WORKERS=4 # worker processes; default CPU count, at most 4 +# SNRC_RPC_TIMEOUT=5 # seconds per request to reth +# SNRC_LOG_FORMAT=json # default text (key=value) +# SNRC_LOG_COLOR=always # coloured `docker compose logs` +# SNRC_LOG_LEVEL=debug # also log health checks and idle connections +# SNRC_TRUSTED_PROXIES=172.16.0.0/12 # behind a reverse proxy on the host; see README diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index f8f5dde8b..6ba327c6a 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -19,15 +19,15 @@ against **Ethereum mainnet** (where the `.testing` contracts live): ## 1. Configure -Edit `.env` — the defaults work as-is; override only if needed: +The defaults in `docker-compose.yml` work as-is: ```sh NETWORK=mainnet # default TRUSTED_NODE_URL=https://mainnet-checkpoint-sync.attestant.io # default ``` -Everything else (NAT) has a working default baked into `docker-compose.yml`; -uncomment the hints in `.env` only to override. +To override these or NAT, copy `.env.example` to `.env` and edit it; `.env` is +not committed. ## 2. Run @@ -121,6 +121,15 @@ standalone for local dev (no Docker), via [`uv`](https://docs.astral.sh/uv/): uv run scripts/resolver/service/snrc-resolve.py # defaults to local reth + mainnet .testing ``` +Tests and lint run from `scripts/resolver/service`: + +```sh +uv run pytest +uv run ruff check . +``` + +Dependencies are locked in `uv.lock`; the Docker image installs exactly those. + Three routes, versioned separately from the protocol so each only changes when its own shape does: @@ -243,7 +252,7 @@ wrong, `error` and `status` hold the same value, so one field is enough to read. `upstreamError` says only which exception type the RPC call raised. The text goes to the resolver's log instead, because `SNRC_RPC` can carry a provider key -and urlopen puts the URL it failed on into the message. It is also the answer +and the exception can carry the URL it failed on. It is also the answer when a registrar, controller or oracle address has no contract behind it: the empty reply is refused rather than read as zero, which would make every name look free. @@ -374,3 +383,87 @@ here. To override any of them, set `SNRC_REGISTRY_`, `SNRC_REGISTRAR_` or `SNRC_CONTROLLER_` on the `resolver` service in `docker-compose.yml`, or as env vars when you run the script directly. + +### Load and scaling + +A lookup reads the chain in at most three rounds: the name's status and +registry entries, then its record from its resolver, then prices for a name +that is free. The node runs the calls of a JSON-RPC batch one after another, +so the contract reads of a round go to the chain as one `eth_call` to +[Multicall3](https://github.com/mds1/multicall). A node that is slow for a +moment therefore delays a lookup a few times, not once per read. Connections to +the node are kept open and reused. + +The resolver answers HTTP/1.1, so each smp-server keeps its connections to it +open instead of connecting for every lookup. An idle connection is closed after +60 s; the smp-server drops its own idle ones after 30 s, so it closes them first. + +Each access log line ends with how long the request took, so slow requests +show up in `docker compose logs resolver`. + +| Variable | Default | Meaning | +|---|---|---| +| `SNRC_WORKERS` | CPU count, at most 4 | processes sharing the port. If one exits, the others stop, so the container restarts | +| `SNRC_RPC_TIMEOUT` | `5` | seconds to wait for each request to the node; the smp-server gives up after 3 | +| `SNRC_MULTICALL` | `0xcA11bde05977b3631167028862bE2a173976CA11` | Multicall3 address. If it does not answer, each round is sent as a plain batch and the log says so once | + +### Logs + +Each event is one line: UTC time, level, event name, then `key=value` fields. + +``` +2026-09-26T08:06:19.992Z INFO request client=203.0.113.7 worker=12 method=GET path=/v2/resolve/[4fdd…].testing status=200 bytes=701 ms=35 +2026-09-26T08:06:20.051Z WARN upstream_error name=foobar.testing error=ConnectionRefusedError message="[Errno 111] Connection refused" +``` + +| Event | Level | Meaning | +|---|---|---| +| `listening` | info | started: address, workers, RPC endpoint, registries, trusted proxies | +| `request` | info | one answered request; `/health` only at debug | +| `upstream_error` | warn | a read from the node failed; the caller got 502 | +| `multicall_unavailable` | warn | Multicall3 did not answer; rounds are sent as plain batches | +| `client_gone` | warn | the caller hung up before the answer, usually an smp-server past its timeout | +| `http_error` | warn | a malformed request; idle keep-alive timeouts only at debug | +| `request_failed` | error | a request failed unexpectedly, with its traceback | +| `worker_exited`, `worker_failed` | error | a worker stopped; the others stop too, so the container restarts | +| `stopping` | info | stopped by a signal | + +| Variable | Default | Meaning | +|---|---|---| +| `SNRC_LOG_FORMAT` | `text` | `text` (`key=value`) or `json`, one object per line | +| `SNRC_LOG_COLOR` | `auto` | `auto` colours only a terminal, `always`, `never`. Docker output is no terminal, so set `always` for coloured `docker compose logs` | +| `SNRC_LOG_LEVEL` | `info` | `debug` also logs health checks and idle connections closing | +| `SNRC_TRUSTED_PROXIES` | none | addresses or CIDRs of reverse proxies whose `X-Forwarded-For` names the client | + +With docker compose, set these, and those in "Load and scaling", in `.env` +(`.env.example` lists them). + +Behind a reverse proxy such as Caddy on the host, the resolver sees the Docker +network's gateway rather than the client. Trust that gateway, and the logged +client is the last `X-Forwarded-For` address no trusted proxy added, so a caller +through the proxy cannot claim another address. Everything else on the host that +connects to `127.0.0.1:8000`, the smp-servers included, arrives through the same +gateway and could set the logged address too; run the proxy in the compose network +and trust only its address to avoid that. + +The gateway is +`docker network inspect resolver_default --format '{{(index .IPAM.Config 0).Gateway}}'`, +with the network named after the compose project. It changes when the network is +recreated, as `docker compose down` does, so trust one of, in `.env`: + +- `SNRC_TRUSTED_PROXIES=172.16.0.0/12`: Docker's default pools for compose + networks, whatever subnet it picks. This also trusts every other container on the + host. Add the gateway too if Docker ever gives the network a `192.168.x.x` one. +- a pinned subnet, so the gateway stays fixed, and only that gateway: + + ```yaml + networks: + default: + ipam: + config: + - subnet: 172.30.0.0/24 + gateway: 172.30.0.1 + ``` + + in `docker-compose.yml`, with `SNRC_TRUSTED_PROXIES=172.30.0.1`, on a subnet no + other network on the host uses. diff --git a/scripts/resolver/docker-compose.yml b/scripts/resolver/docker-compose.yml index 24a90488e..1e716357b 100644 --- a/scripts/resolver/docker-compose.yml +++ b/scripts/resolver/docker-compose.yml @@ -38,14 +38,14 @@ services: - sh - -c - | - if [ -d /home/user/nimbus-eth2/build/data/${NETWORK}/db ]; then + if [ -d /home/user/nimbus-eth2/build/data/${NETWORK:-mainnet}/db ]; then echo "Nimbus data dir already initialised — skipping checkpoint sync"; exit 0; fi; /home/user/nimbus-eth2/build/nimbus_beacon_node trustedNodeSync \ - --network=${NETWORK} \ - --data-dir=/home/user/nimbus-eth2/build/data/${NETWORK} \ - --trusted-node-url=${TRUSTED_NODE_URL} \ + --network=${NETWORK:-mainnet} \ + --data-dir=/home/user/nimbus-eth2/build/data/${NETWORK:-mainnet} \ + --trusted-node-url=${TRUSTED_NODE_URL:-https://mainnet-checkpoint-sync.attestant.io} \ --backfill=false restart: "no" @@ -69,8 +69,8 @@ services: echo "Reth data already initialised — skipping snapshot download"; exit 0; fi; - echo "Downloading Reth ${NETWORK} --minimal snapshot..."; - reth download --datadir /data --chain ${NETWORK} --minimal && \ + echo "Downloading Reth ${NETWORK:-mainnet} --minimal snapshot..."; + reth download --datadir /data --chain ${NETWORK:-mainnet} --minimal && \ touch /data/.snapshot-done && \ echo "Snapshot download complete" restart: "no" @@ -92,7 +92,7 @@ services: command: > node --datadir /data - --chain ${NETWORK} + --chain ${NETWORK:-mainnet} --minimal --authrpc.jwtsecret /jwt/jwt.hex --authrpc.addr 0.0.0.0 --authrpc.port 8551 @@ -117,8 +117,8 @@ services: - "9000:9000/udp" - "127.0.0.1:5052:5052" command: > - --network=${NETWORK} - --data-dir=/home/user/nimbus-eth2/build/data/${NETWORK} + --network=${NETWORK:-mainnet} + --data-dir=/home/user/nimbus-eth2/build/data/${NETWORK:-mainnet} --el=http://reth:8551 --jwt-secret=/jwt/jwt.hex --non-interactive @@ -131,6 +131,7 @@ services: # smp-server points its [NAMES] resolver_endpoint at this URL. # To change the host port, edit the LEFT side of the port mapping below. resolver: + image: snrc-resolve:local build: context: ./service dockerfile: Dockerfile @@ -156,8 +157,18 @@ services: # SNRC_REGISTRAR_SIMPLEX: 0x... # SNRC_CONTROLLER_TESTING: 0x... # SNRC_CONTROLLER_SIMPLEX: 0x... + # Set in .env (see .env.example); unset or empty means the resolver's default. + SNRC_WORKERS: ${SNRC_WORKERS:-} + SNRC_RPC_TIMEOUT: ${SNRC_RPC_TIMEOUT:-} + SNRC_LOG_FORMAT: ${SNRC_LOG_FORMAT:-} + SNRC_LOG_COLOR: ${SNRC_LOG_COLOR:-} + SNRC_LOG_LEVEL: ${SNRC_LOG_LEVEL:-} + SNRC_TRUSTED_PROXIES: ${SNRC_TRUSTED_PROXIES:-} ports: - "127.0.0.1:8000:8000" + # a line per lookup; the local driver keeps 5 rotated files of 20 MB + logging: + driver: local restart: unless-stopped volumes: diff --git a/scripts/resolver/service/.dockerignore b/scripts/resolver/service/.dockerignore new file mode 100644 index 000000000..b2eaa118b --- /dev/null +++ b/scripts/resolver/service/.dockerignore @@ -0,0 +1,5 @@ +.venv +**/__pycache__ +.pytest_cache +.ruff_cache +test_snrc_resolve.py diff --git a/scripts/resolver/service/.python-version b/scripts/resolver/service/.python-version new file mode 100644 index 000000000..24ee5b1be --- /dev/null +++ b/scripts/resolver/service/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/scripts/resolver/service/Dockerfile b/scripts/resolver/service/Dockerfile index 8df898aac..e129bc76c 100644 --- a/scripts/resolver/service/Dockerfile +++ b/scripts/resolver/service/Dockerfile @@ -1,9 +1,10 @@ # syntax=docker/dockerfile:1.7 # ---------- builder ---------- -# Use the official uv image (Astral) on top of a slim Python base. -# uv resolves and installs the lockfile-free pyproject.toml in seconds and -# produces a portable .venv we can copy into the runtime stage. -FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder +# The official uv image (Astral) on top of a slim Python base installs the +# locked dependencies into a portable .venv for the runtime stage. +# Pinned to the uv that wrote uv.lock (its bookworm images stopped at 0.9.30), on the +# runtime's Debian release, so the .venv's Python path exists there. +FROM ghcr.io/astral-sh/uv:0.12.16-python3.13-trixie-slim AS builder ENV UV_LINK_MODE=copy \ UV_COMPILE_BYTECODE=1 \ @@ -12,17 +13,17 @@ ENV UV_LINK_MODE=copy \ WORKDIR /app -# Install deps first (separate layer) — script edits won't bust this cache. -COPY pyproject.toml ./ +# Dependencies first (separate layer), exactly as locked: script edits don't bust this cache. +COPY pyproject.toml uv.lock .python-version ./ RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-dev --no-install-project + uv sync --frozen --no-dev --no-install-project # Script is added after the dep layer for cache friendliness. COPY snrc-resolve.py ./ # ---------- runtime ---------- # Slim runtime — only the venv + script. No uv, no apt. -FROM python:3.13-slim AS runtime +FROM python:3.13-slim-trixie AS runtime ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ @@ -33,15 +34,15 @@ RUN groupadd --system --gid 10001 snrc && \ useradd --system --uid 10001 --gid snrc --no-create-home --shell /usr/sbin/nologin snrc WORKDIR /app -COPY --from=builder --chown=snrc:snrc /app /app +# owned by root, so the service cannot rewrite its own code +COPY --from=builder /app /app USER snrc:snrc EXPOSE 8000 -# Liveness check hits the script's own /health route. ThreadingHTTPServer is -# fast enough that 3s is generous for a localhost probe; restart if it stops -# responding entirely. +# Liveness check hits the script's own /health route. Three failures mark the +# container unhealthy; compose does not restart it for that, but `docker ps` shows it. HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD ["python", "-c", "import urllib.request, sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).status == 200 else 1)"] diff --git a/scripts/resolver/service/pyproject.toml b/scripts/resolver/service/pyproject.toml index cba4a289b..dcb68fb65 100644 --- a/scripts/resolver/service/pyproject.toml +++ b/scripts/resolver/service/pyproject.toml @@ -9,5 +9,14 @@ dependencies = [ "eth-hash[pycryptodome]>=0.7", ] +[dependency-groups] +dev = [ + "pytest>=8", + "ruff>=0.6", +] + [tool.uv] package = false + +[tool.ruff.lint] +select = ["E9", "F", "B"] diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 42881a2a8..741e512f9 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -46,6 +46,15 @@ Environment: (default: mainnet for .testing, empty for .simplex) SNRC_PORT Listen port (default: 8000) SNRC_BIND Bind address (default: 0.0.0.0) + SNRC_WORKERS Worker processes sharing the port (default: CPU count, at most 4) + SNRC_RPC_TIMEOUT Seconds to wait for each RPC request (default: 5) + SNRC_MULTICALL Multicall3 contract that runs a round of reads as one call + (default: 0xcA11bde05977b3631167028862bE2a173976CA11) + SNRC_LOG_FORMAT text (key=value) or json (default: text) + SNRC_LOG_COLOR auto (on a terminal), always or never (default: auto) + SNRC_LOG_LEVEL debug, info, warning or error (default: info) + SNRC_TRUSTED_PROXIES Comma-separated addresses or CIDRs of reverse proxies whose + X-Forwarded-For names the client (default: none) Each TLD is a separate SNRC deployment with its own ENSRegistry; the resolver dispatches by the queried name's rightmost label. @@ -64,19 +73,45 @@ Unrecognised payloads fall back to `0x`-prefixed raw hex. """ import hashlib +import ipaddress import json +import logging import os +import queue +import signal +import socket import sys +import threading import time +from contextlib import contextmanager +from datetime import datetime, timezone +from functools import lru_cache +from http.client import BadStatusLine, HTTPConnection, HTTPSConnection from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.error import HTTPError from urllib.parse import unquote, urlparse -from urllib.request import Request, urlopen from eth_hash.auto import keccak RPC = os.environ.get("SNRC_RPC", "http://127.0.0.1:8545") BIND = os.environ.get("SNRC_BIND", "0.0.0.0") PORT = int(os.environ.get("SNRC_PORT", "8000")) +# The smp-server gives up after 3 s, so a slower call only holds a thread. +RPC_TIMEOUT_S = float(os.environ.get("SNRC_RPC_TIMEOUT", "") or 5) +# The node and the beacon client usually share the host, so not every core. +MAX_DEFAULT_WORKERS = 4 +WORKERS = int(os.environ.get("SNRC_WORKERS", "") or min(MAX_DEFAULT_WORKERS, os.cpu_count() or 1)) +# Multicall3, at this address on mainnet and most chains. A node runs a JSON-RPC +# batch one call after another, so a round of reads is sent as one eth_call. +MULTICALL = os.environ.get("SNRC_MULTICALL", "") or "0xcA11bde05977b3631167028862bE2a173976CA11" +LOG_FORMAT = os.environ.get("SNRC_LOG_FORMAT", "") or "text" +LOG_COLOR = os.environ.get("SNRC_LOG_COLOR", "") or "auto" +LOG_LEVEL = os.environ.get("SNRC_LOG_LEVEL", "") or "info" +# Peers whose X-Forwarded-For is believed, such as the Docker gateway a reverse proxy on the host +# connects through. Anyone else could put any address there. +TRUSTED_PROXIES = tuple( + ipaddress.ip_network(p.strip(), strict=False) for p in os.environ.get("SNRC_TRUSTED_PROXIES", "").split(",") if p.strip() +) # Each TLD is its own SNRC deployment with its own ENSRegistry. Dispatch # happens on the rightmost label of the queried name. Empty / unset means @@ -121,34 +156,291 @@ COIN_ETH = 60 COIN_BTC = 0 COIN_XMR = 128 COIN_DOT = 354 +RECORD_COINS = (COIN_ETH, COIN_BTC, COIN_XMR, COIN_DOT) ZERO_ADDR = "0x0000000000000000000000000000000000000000" # The registry prices in attoUSD (1e-18 USD); the protocol carries US cents. +# ---------- Logging ---------- + +LOGGER = logging.getLogger("snrc_resolve") +LEVEL_NAMES = {logging.DEBUG: "DEBUG", logging.INFO: "INFO", logging.WARNING: "WARN", logging.ERROR: "ERROR"} +# ANSI SGR codes +DIM, BOLD, GREEN, YELLOW, RED = "2", "1", "32", "33", "31" +LEVEL_COLORS = {"DEBUG": DIM, "INFO": GREEN, "WARN": YELLOW, "ERROR": RED} +STATUS_COLORS = {2: GREEN, 3: GREEN, 4: YELLOW, 5: RED} + + +def log_event(level: int, name: str, /, exc_info=None, **fields): + LOGGER.log(level, name, exc_info=exc_info, extra={"fields": fields}) + + +def _log_time(record: logging.LogRecord) -> str: + return datetime.fromtimestamp(record.created, timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def _log_value(value) -> str: + """A logfmt value: bare when it cannot be misread, JSON-quoted otherwise.""" + if value is None: + return "-" + s = str(value) + if not s or any(c in s for c in ' ="\\') or not s.isprintable(): + return json.dumps(s) + return s + + +class TextFormatter(logging.Formatter): + def __init__(self, color: bool): + super().__init__() + self.color = color + + def _paint(self, code: str, text: str) -> str: + return f"\033[{code}m{text}\033[0m" if self.color and code else text + + def format(self, record: logging.LogRecord) -> str: + level = LEVEL_NAMES.get(record.levelno, record.levelname) + fields = " ".join( + self._paint(DIM, f"{k}=") + self._paint(STATUS_COLORS.get(v // 100, "") if k == "status" and isinstance(v, int) else "", _log_value(v)) + for k, v in getattr(record, "fields", {}).items() + ) + line = f"{self._paint(DIM, _log_time(record))} {self._paint(LEVEL_COLORS.get(level, ''), f'{level:<5}')} {self._paint(BOLD, record.getMessage())}" + if fields: + line += " " + fields + if record.exc_info: + line += "\n" + self.formatException(record.exc_info) + return line + + +class JsonFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + out = { + "time": _log_time(record), + "level": LEVEL_NAMES.get(record.levelno, record.levelname).lower(), + "event": record.getMessage(), + **getattr(record, "fields", {}), + } + if record.exc_info: + out["exception"] = self.formatException(record.exc_info) + return json.dumps(out, default=str) + + +def setup_logging(stream=None): + """Sends the resolver's events to stderr in the configured format.""" + stream = stream or sys.stderr + if LOG_FORMAT not in ("text", "json"): + raise ValueError(f"SNRC_LOG_FORMAT must be text or json, not {LOG_FORMAT!r}") + if LOG_COLOR not in ("auto", "always", "never"): + raise ValueError(f"SNRC_LOG_COLOR must be auto, always or never, not {LOG_COLOR!r}") + level = logging.getLevelName(LOG_LEVEL.upper()) + if not isinstance(level, int): + raise ValueError(f"SNRC_LOG_LEVEL must be debug, info, warning or error, not {LOG_LEVEL!r}") + color = LOG_COLOR == "always" or (LOG_COLOR == "auto" and stream.isatty()) + handler = logging.StreamHandler(stream) + handler.setFormatter(JsonFormatter() if LOG_FORMAT == "json" else TextFormatter(color)) + LOGGER.handlers[:] = [handler] + LOGGER.setLevel(level) + LOGGER.propagate = False + + # ---------- RPC + ABI helpers (mirrors ens-lookup.py shape) ---------- +RPC_URL = urlparse(RPC) +# Set a non-default User-Agent; Cloudflare-fronted public RPCs (drpc, +# publicnode, etc.) reject `Python-urllib/3.x` with 403. +RPC_HEADERS = {"Content-Type": "application/json", "User-Agent": "snrc-resolve/1.0"} + +# Idle keep-alive connections to SNRC_RPC. A connection per call costs most of +# a lookup's CPU and leaves a TIME_WAIT socket per call, which exhausts local +# ports at a few dozen lookups per second. +# Idle connections kept per worker; more are closed after use. 4 workers stay well +# under reth's default limit of 500 connections. +RPC_POOL_SIZE = 32 +_rpc_pool = queue.LifoQueue(maxsize=RPC_POOL_SIZE) + + +def _new_rpc_connection(): + conn_class = HTTPSConnection if RPC_URL.scheme == "https" else HTTPConnection + return conn_class(RPC_URL.hostname, RPC_URL.port, timeout=RPC_TIMEOUT_S) + + +def _post_rpc(conn, body: bytes) -> bytes: + path = (RPC_URL.path or "/") + (f"?{RPC_URL.query}" if RPC_URL.query else "") + conn.request("POST", path, body, RPC_HEADERS) + res = conn.getresponse() + data = res.read() + # HTTPError, as urlopen raised: RuntimeError means the call itself failed + if not 200 <= res.status < 300: + raise HTTPError(RPC, res.status, res.reason, res.headers, None) + return data + + +def _post_pooled(conn, body: bytes) -> bytes: + try: + data = _post_rpc(conn, body) + except BaseException: + conn.close() + raise + try: + _rpc_pool.put_nowait(conn) + except queue.Full: + conn.close() + return data + + +def _send_rpc(payload) -> object: + body = json.dumps(payload).encode() + try: + idle = _rpc_pool.get_nowait() + except queue.Empty: + return json.loads(_post_pooled(_new_rpc_connection(), body)) + try: + return json.loads(_post_pooled(idle, body)) + except (ConnectionError, BadStatusLine): + # the node closed the idle connection; every call is a read, so resending is safe + return json.loads(_post_pooled(_new_rpc_connection(), body)) + + +# Reads prefetched for the request being answered, keyed by _read_key. Only set +# inside request_reads(), so code called outside a request reads one call at a time. +_request = threading.local() +_UNREAD = object() + + +def _read_key(method, params) -> str: + return method + json.dumps(params, sort_keys=True) + + +@contextmanager +def request_reads(): + _request.reads = {} + try: + yield + finally: + del _request.reads + + def rpc(method, params): - body = json.dumps( - {"jsonrpc": "2.0", "method": method, "params": params, "id": 1} - ).encode() - # Set a non-default User-Agent; Cloudflare-fronted public RPCs (drpc, - # publicnode, etc.) reject `Python-urllib/3.x` with 403. - req = Request( - RPC, - data=body, - headers={ - "Content-Type": "application/json", - "User-Agent": "snrc-resolve/1.0", - }, - ) - res = json.loads(urlopen(req, timeout=15).read()) + reads = getattr(_request, "reads", None) + if reads is not None: + read = reads.get(_read_key(method, params), _UNREAD) + if isinstance(read, RuntimeError): + raise read + if read is not _UNREAD: + return read + res = _send_rpc({"jsonrpc": "2.0", "method": method, "params": params, "id": 1}) if "error" in res: raise RuntimeError(res["error"]) return res["result"] +def _send_batch(requests): + """Answers of a JSON-RPC batch in request order, None for a request left + unanswered; all None when the node does not batch.""" + res = _send_rpc([{"jsonrpc": "2.0", "id": i, "method": m, "params": p} for i, (m, p) in enumerate(requests)]) + # a node that does not batch answers with a single error object + by_id = {r.get("id"): r for r in res if isinstance(r, dict)} if isinstance(res, list) else {} + return [by_id.get(i) for i in range(len(requests))] + + +def _is_latest_call(method, params) -> bool: + return method == "eth_call" and params[1] == "latest" + + +_multicall_failed_logged = False + + +def _remember(reads, requests, answers): + for (m, p), r in zip(requests, answers, strict=True): + if r is not None and ("result" in r or "error" in r): + reads[_read_key(m, p)] = RuntimeError(r["error"]) if "error" in r else r["result"] + + +def prefetch(requests): + """Sends reads the request will make in one round trip, with its contract + reads as one multicall. A read left unanswered is made on its own when the + code gets to it.""" + global _multicall_failed_logged + reads = getattr(_request, "reads", None) + if reads is None: + return + todo = [(m, p) for m, p in requests if _read_key(m, p) not in reads] + if len(todo) < 2: + return + calls = [(m, p) for m, p in todo if _is_latest_call(m, p)] + if len(calls) < 2: + _remember(reads, todo, _send_batch(todo)) + return + others = [(m, p) for m, p in todo if not _is_latest_call(m, p)] + multicall = eth_call_read(MULTICALL, encode_aggregate3([(p[0]["to"], p[0]["data"]) for _, p in calls])) + answers = _send_batch(others + [multicall]) + if all(r is None for r in answers): + return + _remember(reads, others, answers[:-1]) + try: + result = (answers[-1] or {}).get("result") + if not isinstance(result, str): + raise ValueError(f"multicall answered {answers[-1]!r}") + results = decode_aggregate3(result) + if len(results) != len(calls): + raise ValueError("multicall answered a different number of calls") + except (KeyError, TypeError, ValueError) as e: + if not _multicall_failed_logged: + _multicall_failed_logged = True + log_event(logging.WARNING, "multicall_unavailable", multicall=MULTICALL, error=repr(e), fallback="batch") + _remember(reads, calls, _send_batch(calls)) + return + for (m, p), (success, data) in zip(calls, results, strict=True): + reads[_read_key(m, p)] = "0x" + data.hex() if success else RuntimeError("execution reverted") + + +AGGREGATE3 = "0x82ad56cb" # aggregate3((address,bool,bytes)[]) + + +def encode_aggregate3(calls) -> str: + """Calldata for Multicall3.aggregate3 with every call allowed to fail.""" + tuples = [] + for to, data in calls: + b = bytes.fromhex(data[2:]) + tuples.append( + int(to, 16).to_bytes(32, "big") + + (1).to_bytes(32, "big") + + (0x60).to_bytes(32, "big") + + len(b).to_bytes(32, "big") + + b + + b"\x00" * ((-len(b)) % 32) + ) + offsets, at = [], 32 * len(tuples) + for t in tuples: + offsets.append(at.to_bytes(32, "big")) + at += len(t) + body = (0x20).to_bytes(32, "big") + len(tuples).to_bytes(32, "big") + b"".join(offsets) + b"".join(tuples) + return AGGREGATE3 + body.hex() + + +def decode_aggregate3(hex_data: str): + """Multicall3.aggregate3's (bool success, bytes returnData)[].""" + raw = bytes.fromhex(hex_data[2:] if hex_data.startswith("0x") else hex_data) + + def word(at: int) -> int: + if at + 32 > len(raw): + raise ValueError("multicall answer is truncated") + return int.from_bytes(raw[at:at + 32], "big") + + array = word(0) + base = array + 32 + out = [] + for i in range(word(array)): + item = base + word(base + 32 * i) + data = item + word(item + 32) + length = word(data) + if data + 32 + length > len(raw): + raise ValueError("multicall answer is truncated") + out.append((word(item) != 0, raw[data + 32:data + 32 + length])) + return out + + def namehash(name: str) -> bytes: node = b"\x00" * 32 if name: @@ -184,11 +476,14 @@ def node_of(name: str) -> bytes: # ---------- Registration status ---------- +BLOCK_READ = ("eth_getBlockByNumber", ["latest", False]) + + def head_block(): """How far behind the node is. Unlike expiry, this is the one thing that has to be measured against the host clock: a node that stops still has a block.""" try: - block = rpc("eth_getBlockByNumber", ["latest", False]) + block = rpc(*BLOCK_READ) return { "blockNumber": decode_uint(block["number"]), "chainLagSeconds": int(time.time()) - decode_uint(block["timestamp"]), @@ -199,13 +494,13 @@ def head_block(): def chain_now() -> int: """Expiry is compared against the block timestamp, never the host clock.""" - block = rpc("eth_getBlockByNumber", ["latest", False]) + block = rpc(*BLOCK_READ) return decode_uint(block["timestamp"]) def grace_period(registrar: str) -> int: """A deployment can configure a different window, so it is read on chain.""" - return decode_uint(eth_call(registrar, selector("GRACE_PERIOD()"))) + return decode_uint(eth_call(*grace_call(registrar))) def expiry_status(expires: int, grace: int, now: int) -> str: @@ -225,8 +520,7 @@ def reservation_reason(tld: str, token: int) -> int: controller = CONTROLLERS.get(tld) if not controller: return 0 - raw = eth_call(controller, selector("reservedNames(bytes32)") + encode_uint(token)) - return decode_uint(raw) + return decode_uint(eth_call(*reserved_call(controller, token))) def pricing_params(tld: str): @@ -235,7 +529,8 @@ def pricing_params(tld: str): controller = CONTROLLERS.get(tld) if not controller: return None - oracle = decode_address(eth_call(controller, selector("prices()"))) + prefetch([eth_call_read(*prices_call(controller)), eth_call_read(*min_length_call(controller))]) + oracle = decode_address(eth_call(*prices_call(controller))) if oracle == ZERO_ADDR: return None try: @@ -247,6 +542,8 @@ def pricing_params(tld: str): SECONDS_PER_YEAR = 31536000 +# an ENS-shaped oracle prices names by length up to six letters +LETTER_TIERS = range(1, 7) ATTO_PER_CENT = 10**16 @@ -255,13 +552,15 @@ def read_oracle_prices(controller: str, oracle: str): protocol carries. An ENS-shaped oracle prices in attoUSD per second and charges a premium on lapsed names that it does not expose, so a quote from it is only safe for a name that was never registered.""" + # either oracle shape is answered in the same round trip + prefetch([eth_call_read(*prices_call(oracle))] + [eth_call_read(*letter_price_call(oracle, n)) for n in LETTER_TIERS]) try: - base, tiers = decode_prices(eth_call(oracle, selector("prices()"))) + base, tiers = decode_prices(eth_call(*prices_call(oracle))) premium_unknown = False except RuntimeError: base, tiers = decode_letter_prices(oracle) premium_unknown = True - min_len = decode_uint(eth_call(controller, selector("minCharLength()"))) + min_len = decode_uint(eth_call(*min_length_call(controller))) return { # lengths the registry refuses are left out rather than priced at zero "registrationPrices": {n: c for n, c in tiers.items() if n >= min_len}, @@ -277,9 +576,9 @@ def decode_letter_prices(oracle: str): the six-letter tier stops at five, and charges its highest tier for anything longer, which is what basePrice means here.""" tiers = {} - for n in range(1, 7): + for n in LETTER_TIERS: try: - rate = decode_uint(eth_call(oracle, selector(f"price{n}Letter()"))) + rate = decode_uint(eth_call(*letter_price_call(oracle, n))) except RuntimeError: if n <= 5: raise @@ -301,6 +600,9 @@ def decode_prices(hex_data: str): base = int.from_bytes(raw[:32], "big") at = int.from_bytes(raw[32:64], "big") count = int.from_bytes(raw[at:at + 32], "big") + # the count comes from the answer, so it is checked against the answer's length + if at + 32 + count * 64 > len(raw): + raise RuntimeError("prices(): short response") tiers = {} for i in range(count): item = at + 32 + i * 64 @@ -328,9 +630,7 @@ def name_status(name: str): # The 2LD's label is that key at any depth. node_of decodes a bracket only # in a two-label name, so a bracket subname gets a status but no record. token = label_token(labels[-2]) - expires = decode_uint( - eth_call(registrar, selector("nameExpires(uint256)") + encode_uint(token)) - ) + expires = decode_uint(eth_call(*expires_call(registrar, token))) grace = grace_period(registrar) if expires else 0 now = chain_now() status = expiry_status(expires, grace, now) @@ -358,12 +658,17 @@ def name_status(name: str): return out +@lru_cache(maxsize=None) def selector(signature: str) -> str: return "0x" + keccak(signature.encode())[:4].hex() +def eth_call_read(to: str, data: str): + return "eth_call", [{"to": to, "data": data}, "latest"] + + def eth_call(to: str, data: str) -> str: - result = rpc("eth_call", [{"to": to, "data": data}, "latest"]) + result = rpc(*eth_call_read(to, data)) if result == "0x": raise RuntimeError(f"empty return from {to}: no contract at that address?") return result @@ -385,7 +690,7 @@ def registered_label(registrar: str, token: int): """The plaintext label the registrar recorded at registration, keyed by the hash of that label. None when the name was registered without registerWithLabel, so the registrar cannot name it.""" - raw = decode_bytes(eth_call(registrar, selector("labelOf(uint256)") + encode_uint(token))) + raw = decode_bytes(eth_call(*label_call(registrar, token))) return raw.decode("utf-8", errors="replace") if raw else None @@ -427,7 +732,7 @@ def encode_text_call(node: bytes, key: str) -> str: def text(resolver: str, node: bytes, key: str) -> str: - raw = decode_bytes(eth_call(resolver, encode_text_call(node, key))) + raw = decode_bytes(eth_call(*text_call(resolver, node, key))) return raw.decode("utf-8", errors="replace") if raw else "" @@ -446,7 +751,7 @@ def addr_multicoin(resolver: str, node: bytes, coin_type: int): payload doesn't match any recognised on-chain shape. Returns None when the record is unset.""" try: - raw = decode_bytes(eth_call(resolver, encode_addr_multicoin_call(node, coin_type))) + raw = decode_bytes(eth_call(*addr_call(resolver, node, coin_type))) except RuntimeError: return None if not raw: @@ -465,6 +770,81 @@ def addr_multicoin(resolver: str, node: bytes, coin_type: int): return "0x" + raw.hex() +# ---------- Contract calls, as (to, data) ---------- +# Shared by the reads and prefetch, so a prefetched read is found by the code that makes it. + + +def expires_call(registrar: str, token: int): + return registrar, selector("nameExpires(uint256)") + encode_uint(token) + + +def grace_call(registrar: str): + return registrar, selector("GRACE_PERIOD()") + + +def label_call(registrar: str, token: int): + return registrar, selector("labelOf(uint256)") + encode_uint(token) + + +def reserved_call(controller: str, token: int): + return controller, selector("reservedNames(bytes32)") + encode_uint(token) + + +def prices_call(contract: str): + return contract, selector("prices()") + + +def letter_price_call(oracle: str, letters: int): + return oracle, selector(f"price{letters}Letter()") + + +def min_length_call(controller: str): + return controller, selector("minCharLength()") + + +def resolver_call(registry: str, node: bytes): + return registry, selector("resolver(bytes32)") + node.hex() + + +def owner_call(registry: str, node: bytes): + return registry, selector("owner(bytes32)") + node.hex() + + +def text_call(resolver: str, node: bytes, key: str): + return resolver, encode_text_call(node, key) + + +def addr_call(resolver: str, node: bytes, coin_type: int): + return resolver, encode_addr_multicoin_call(node, coin_type) + + +def lookup_reads(name: str): + """The reads a lookup makes before it knows the name's resolver.""" + labels = name.split(".") + tld = labels[-1] + reads = [] + registrar = REGISTRARS.get(tld) + if registrar and len(labels) >= 2: + token = label_token(labels[-2]) + reads += [BLOCK_READ, eth_call_read(*expires_call(registrar, token)), eth_call_read(*grace_call(registrar))] + if len(labels) == 2 and is_encoded_labelhash(labels[0]): + reads.append(eth_call_read(*label_call(registrar, token))) + if CONTROLLERS.get(tld): + reads.append(eth_call_read(*reserved_call(CONTROLLERS[tld], token))) + registry = REGISTRIES.get(tld) + if registry: + node = node_of(name) + reads += [eth_call_read(*resolver_call(registry, node)), eth_call_read(*owner_call(registry, node))] + return reads + + +def record_reads(resolver: str, node: bytes): + """The reads of a name's record from its resolver.""" + return [eth_call_read(*text_call(resolver, node, k)) for k in TEXT_KEYS] + [ + eth_call_read(*addr_call(resolver, node, coin)) for coin in RECORD_COINS + ] + + # ---------- Coin-specific address encoders ---------- # Each takes raw bytes as stored under ENSIP-9 and returns the canonical # user-facing string for that chain (EIP-55 for ETH, bech32/base58check @@ -665,9 +1045,9 @@ def split_links(value: str) -> list: def upstream_error(subject: dict, e: Exception) -> dict: - """urlopen puts the failing URL into its message and SNRC_RPC can carry a - provider key, so the text goes to the log and only the type to the caller.""" - print(f"upstream error: {type(e).__name__}: {e}", file=sys.stderr) + """The exception can carry the failing URL and SNRC_RPC can carry a provider + key, so the text goes to the log and only the type to the caller.""" + log_event(logging.WARNING, "upstream_error", **subject, error=type(e).__name__, message=str(e)) return { **subject, "error": "upstreamError", @@ -680,9 +1060,8 @@ def name_record(name: str): has one, with every field unset.""" registry = REGISTRIES[name.rsplit(".", 1)[-1]] node = node_of(name) - node_hex = node.hex() - resolver_addr = decode_address(eth_call(registry, selector("resolver(bytes32)") + node_hex)) - owner = decode_address(eth_call(registry, selector("owner(bytes32)") + node_hex)) + resolver_addr = decode_address(eth_call(*resolver_call(registry, node))) + owner = decode_address(eth_call(*owner_call(registry, node))) rec = { "name": canonical_name(name), "nickname": "", @@ -699,6 +1078,7 @@ def name_record(name: str): } if resolver_addr == ZERO_ADDR: return rec + prefetch(record_reads(resolver_addr, node)) texts = {} for k in TEXT_KEYS: try: @@ -734,6 +1114,7 @@ def registration(name: str): tld = name.rsplit(".", 1)[-1] if not REGISTRIES.get(tld): return 400, {"name": name, "error": "tldNotConfigured"} + prefetch(lookup_reads(name)) reg = name_status(name) status = reg["status"] if status in ("registered", "grace"): @@ -788,7 +1169,7 @@ def resolve(name: str): } node = node_of(name) - node_hex = node.hex() + prefetch(lookup_reads(name)) # Before the resolver lookup, so a lapsed name is not reported as noResolver. reg = name_status(name) @@ -806,13 +1187,12 @@ def resolve(name: str): } return (404 if reg["status"] == "unregistered" else 410), body - resolver_raw = eth_call(registry, selector("resolver(bytes32)") + node_hex) - resolver_addr = decode_address(resolver_raw) + resolver_addr = decode_address(eth_call(*resolver_call(registry, node))) if resolver_addr == ZERO_ADDR: # A registered name always resolves: with no resolver set the record is # still returned with every field unset, so "taken until " stays # answerable. For a subname, no owner means nobody created it. - owner = decode_address(eth_call(registry, selector("owner(bytes32)") + node_hex)) + owner = decode_address(eth_call(*owner_call(registry, node))) if len(name.split(".")) > 2 and owner == ZERO_ADDR: return 404, { "name": name, @@ -837,9 +1217,9 @@ def resolve(name: str): **reg, } - owner_raw = eth_call(registry, selector("owner(bytes32)") + node_hex) - owner = decode_address(owner_raw) + owner = decode_address(eth_call(*owner_call(registry, node))) + prefetch(record_reads(resolver_addr, node)) texts = {} for k in TEXT_KEYS: try: @@ -878,12 +1258,34 @@ def resolve(name: str): # ---------- HTTP layer ---------- class Handler(BaseHTTPRequestHandler): + # the smp-server reuses a connection only after an HTTP/1.1 response + protocol_version = "HTTP/1.1" + # idle seconds before a kept-alive connection is closed; the smp-server closes + # its idle ones after 30-35 s, so it is the one to close them + timeout = 60 + + def setup(self): + super().setup() + # headers and body go out in separate writes, which Nagle holds for the client's delayed ACK + self.request.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + + def handle_one_request(self): + # on a kept-alive connection these still hold the previous request's values + self._started = self._sent = self.path = self.headers = None + super().handle_one_request() + def do_GET(self): # noqa: N802 - http.server contract + self._started = time.monotonic() + if self._has_body(): + # an unread body would be taken for the next request on this connection, which a + # reverse proxy reusing it would pass to another client (request smuggling) + self._respond(400, {"error": "unexpectedBody"}, close=True) + return path = urlparse(self.path).path parts = [unquote(p) for p in path.split("/") if p] if parts == ["health"]: - self._respond(200, {"ok": True, "rpc": RPC, "registries": REGISTRIES, **head_block()}) + self._respond(200, {"ok": True, "rpc": public_endpoint(RPC), "registries": REGISTRIES, **head_block()}) return if len(parts) == 3 and parts[0] == "v2" and parts[1] == "resolve": @@ -892,7 +1294,8 @@ class Handler(BaseHTTPRequestHandler): self._respond(400, {"name": name, "error": "notFullyQualified"}) return try: - status, body = registration(name) + with request_reads(): + status, body = registration(name) except Exception as e: # surface upstream errors as 502 status, body = 502, upstream_error({"name": name}, e) self._respond(status, body) @@ -915,7 +1318,8 @@ class Handler(BaseHTTPRequestHandler): ) return try: - status, body = resolve(name) + with request_reads(): + status, body = resolve(name) except Exception as e: # surface upstream errors as 502 status, body = 502, upstream_error({"name": name}, e) self._respond(status, body) @@ -930,34 +1334,176 @@ class Handler(BaseHTTPRequestHandler): }, ) - def _respond(self, status: int, body: dict): + def _has_body(self) -> bool: + # a malformed header line ends parsing, leaving the rest, Content-Length included, as payload + if self.headers.defects or self.headers.get_payload(): + return True + if self.headers.get_all("Transfer-Encoding"): + return True + return any(length.strip() != "0" for length in self.headers.get_all("Content-Length") or []) + + def _respond(self, status: int, body: dict, close: bool = False): data = json.dumps(body, indent=2).encode() + # send_response logs the request, so the size is known before it + self._sent = len(data) self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(data))) + if close: + # also sets close_connection, so the server closes it after this response + self.send_header("Connection", "close") self.end_headers() self.wfile.write(data) + def address_string(self) -> str: + forwarded = self.headers.get_all("X-Forwarded-For") if self.headers else None + return client_address(self.client_address[0], ",".join(forwarded) if forwarded else None) + + def log_request(self, code="-", size="-"): + started = self._started + path = urlparse(self.path).path if self.path else None + log_event( + # the container's health check runs every 30 s + logging.DEBUG if path == "/health" else logging.INFO, + "request", + client=self.address_string(), + worker=os.getpid(), + method=getattr(self, "command", None), + path=unquote(self.path) if self.path else None, + status=getattr(code, "value", code), + bytes=self._sent, + ms=round((time.monotonic() - started) * 1000) if started else None, + ) + + def log_error(self, fmt, *args): + message = fmt % args + # a kept-alive connection left idle is closed on purpose + level = logging.DEBUG if message.startswith("Request timed out") else logging.WARNING + log_event(level, "http_error", client=self.address_string(), worker=os.getpid(), message=message) + def log_message(self, fmt, *args): - # Quiet the default per-request access log; route to stderr in one line. - sys.stderr.write(f"{self.address_string()} - {fmt % args}\n") + log_event(logging.INFO, "http", client=self.address_string(), message=fmt % args) + + +class ResolverServer(ThreadingHTTPServer): + # socketserver's default of 5 drops simultaneous connections, each retried by + # TCP after 1 s, and the smp-server gives up after 3 s. The kernel caps it at somaxconn. + request_queue_size = 128 + + def handle_error(self, request, client_address): + error = sys.exc_info()[1] + if isinstance(error, ConnectionError): + # the smp-server hung up, usually after its own timeout + log_event(logging.WARNING, "client_gone", client=client_address[0], worker=os.getpid(), error=type(error).__name__) + else: + log_event(logging.ERROR, "request_failed", client=client_address[0], worker=os.getpid(), exc_info=True) + + +def public_endpoint(url: str) -> str: + """The RPC endpoint without credentials, path or query, where a provider key would be.""" + parsed = urlparse(url) + return f"{parsed.scheme}://{parsed.netloc.rpartition('@')[2]}" + + +def client_address(peer: str, forwarded_for: str | None) -> str: + """The peer, or behind trusted proxies the last X-Forwarded-For address none of them added.""" + if not forwarded_for or not _trusted(peer): + return peer + hops = [hop.strip() for hop in forwarded_for.split(",")] + for hop in reversed(hops): + # an address it cannot read, and anything claimed before it, is not believed + if _address(hop) is None: + return peer + if not _trusted(hop): + return hop + return hops[0] + + +def _address(text: str): + try: + ip = ipaddress.ip_address(text) + except ValueError: + return None + return ip.ipv4_mapped or ip if ip.version == 6 else ip + + +def _trusted(text: str) -> bool: + ip = _address(text) + return ip is not None and any(ip in net for net in TRUSTED_PROXIES) + + +def serve(reuse_port: bool): + server = ResolverServer((BIND, PORT), Handler, bind_and_activate=False) + server.allow_reuse_port = reuse_port + try: + server.server_bind() + server.server_activate() + server.serve_forever() + finally: + server.server_close() + + +def stop_on(signum, _frame): + log_event(logging.INFO, "stopping", signal=signal.Signals(signum).name) + sys.exit(0) + + +def supervise(workers: int): + """Runs the workers, each with its own socket on the shared port. One worker + exiting stops the others, so the container restarts instead of running short.""" + children = [] + + def stop(*_): + for pid in children: + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + pass + + def stop_and_exit(signum, frame): + stop() + stop_on(signum, frame) + + # before forking, so a signal during startup cannot orphan the workers + signal.signal(signal.SIGTERM, stop_and_exit) + signal.signal(signal.SIGINT, stop_and_exit) + for _ in range(workers): + pid = os.fork() + if pid == 0: + signal.signal(signal.SIGTERM, signal.SIG_DFL) + signal.signal(signal.SIGINT, signal.SIG_DFL) + code = 0 + try: + serve(reuse_port=True) + except BaseException: + log_event(logging.ERROR, "worker_failed", worker=os.getpid(), exc_info=True) + code = 1 + os._exit(code) + children.append(pid) + pid, status = os.wait() + log_event(logging.ERROR, "worker_exited", worker=pid, status=status, action="stopping") + stop() + sys.exit(1) def main(): - server = ThreadingHTTPServer((BIND, PORT), Handler) - sys.stderr.write( - f"snrc-resolve listening on {BIND}:{PORT}\n" - f" RPC = {RPC}\n" - f" Registries:\n" + setup_logging() + log_event( + logging.INFO, + "listening", + bind=BIND, + port=PORT, + workers=WORKERS, + rpc=public_endpoint(RPC), + registries=",".join(f"{tld}={addr or '-'}" for tld, addr in REGISTRIES.items()), + trusted_proxies=",".join(map(str, TRUSTED_PROXIES)) or None, ) - for tld, addr in REGISTRIES.items(): - sys.stderr.write(f" .{tld:<8s} = {addr or '(not configured)'}\n") - sys.stderr.write(" GET /v2/resolve/ GET /v1/resolve/ GET /health\n") - try: - server.serve_forever() - except KeyboardInterrupt: - sys.stderr.write("\nshutting down\n") - server.server_close() + if WORKERS > 1: + supervise(WORKERS) + else: + signal.signal(signal.SIGTERM, stop_on) + signal.signal(signal.SIGINT, stop_on) + serve(reuse_port=False) if __name__ == "__main__": diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 4b0555ed1..55ea17e43 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -1,15 +1,30 @@ #!/usr/bin/env python3 """Unit tests for snrc-resolve helpers. -Run with `python3 -m unittest scripts/resolver/service/test_snrc_resolve.py`. +Run with `uv run pytest` from scripts/resolver/service. """ import contextlib +import http.client import importlib.util import io +import ipaddress +import json +import logging import os +import queue +import re +import signal +import socket +import subprocess +import sys +import threading import time import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.error import HTTPError +from urllib.parse import urlparse +from urllib.request import Request, urlopen # snrc-resolve.py has a hyphen, so import it via importlib instead of `import`. _HERE = os.path.dirname(os.path.abspath(__file__)) @@ -636,6 +651,25 @@ class PricingTests(unittest.TestCase): self.assertEqual(body["basePrice"], self.BASE) +class DecodePricesTests(unittest.TestCase): + def test_a_count_longer_than_the_answer_is_refused(self): + """The count comes from the oracle; decoding it unchecked could loop for ever.""" + huge = "0x" + snrc.encode_uint(200) + snrc.encode_uint(0x40) + snrc.encode_uint(2**64) + outcome = [] + + def decode(): + try: + snrc.decode_prices(huge) + except RuntimeError as e: + outcome.append(e) + + worker = threading.Thread(target=decode, daemon=True) + worker.start() + worker.join(5) + self.assertFalse(worker.is_alive(), "decoding did not stop") + self.assertRegex(str(outcome[0]), "short response") + + class EnsOracleTests(unittest.TestCase): """.testing runs an ENS-shaped oracle: it prices in attoUSD per second and charges a premium on lapsed names that it does not expose.""" @@ -777,13 +811,17 @@ class ErrorCodeTests(unittest.TestCase): self.assertNotEqual(body["error"], body["message"]) def test_an_upstream_failure_does_not_echo_the_exception(self): - with contextlib.redirect_stderr(io.StringIO()) as log: + with self.assertLogs("snrc_resolve", "WARNING") as logs: body = snrc.upstream_error( {"name": "alice.testing"}, RuntimeError("http://user:secret@rpc.example/kEy8 refused"), ) # the operator still sees the detail in the log - self.assertIn("secret", log.getvalue()) + [record] = logs.records + self.assertEqual(record.getMessage(), "upstream_error") + self.assertEqual(record.fields["name"], "alice.testing") + self.assertEqual(record.fields["error"], "RuntimeError") + self.assertIn("secret", record.fields["message"]) self.assertEqual(body["error"], "upstreamError") self.assertIn("RuntimeError", body["message"]) self.assertNotIn("secret", body["message"]) @@ -1035,5 +1073,797 @@ class RegistrationV2Tests(unittest.TestCase): self.assertEqual(res["lastBlockTs"], self.now) self.assertEqual(res["registration"]["type"], "available") -if __name__ == "__main__": - unittest.main() + +@unittest.skipUnless(sys.platform.startswith("linux"), "Linux drops SYNs on a full accept queue") +class ListenBacklogTests(unittest.TestCase): + """The smp-server opens a connection per lookup and gives up after 3 s, so a + burst the accept queue cannot hold fails: TCP retries a dropped SYN after 1 s.""" + + BURST = 50 + + def test_a_burst_of_connections_is_queued_while_the_server_is_busy(self): + # never accepts, so every connection must wait in the queue + server = snrc.ResolverServer(("127.0.0.1", 0), snrc.Handler) + clients = [] + try: + for i in range(self.BURST): + c = socket.socket() + clients.append(c) + c.settimeout(0.5) + try: + c.connect(server.server_address) + except TimeoutError: + self.fail(f"connection {i + 1} of {self.BURST} was not queued") + finally: + for c in clients: + c.close() + server.server_close() + + +def _word(value: int) -> bytes: + return value.to_bytes(32, "big") + + +def _decode_aggregate3_calls(data: str): + """Multicall3.aggregate3 calldata back to (to, data) pairs, written apart + from the resolver's encoder so the two check each other.""" + raw = bytes.fromhex(data[len(snrc.AGGREGATE3):]) + + def word(at): + return int.from_bytes(raw[at:at + 32], "big") + + array = word(0) + base = array + 32 + calls = [] + for i in range(word(array)): + item = base + word(base + 32 * i) + call = item + word(item + 64) + calls.append(("0x" + raw[item + 12:item + 32].hex(), "0x" + raw[call + 32:call + 32 + word(call)].hex())) + return calls + + +def _encode_aggregate3_results(results) -> str: + tuples = [ + _word(int(ok)) + _word(0x40) + _word(len(data)) + data + b"\x00" * ((-len(data)) % 32) + for ok, data in results + ] + offsets, at = b"", 32 * len(tuples) + for t in tuples: + offsets += _word(at) + at += len(t) + return "0x" + (_word(0x20) + _word(len(tuples)) + offsets + b"".join(tuples)).hex() + + +class FakeChain: + """Contract state for one registered name and one free name. Any other + call reverts, as a view function asked for something unset does.""" + + REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b" + REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" + CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714" + ORACLE = "0x1e0c9a2b9d1a4c8f7b3e5d6a9c2f4b8e1d7a3c50" + OWNER = "0xd83bd7e0e6b8a4c1f2593a7b0c4e8d1a6f9b2c37" + RESOLVER = "0x80fa2b1c3d4e5f60718293a4b5c6d7e8f9012345" + GRACE = 90 * 86400 + TEXTS = {"nickname": "Acme", "url": "https://acme.example", "simplex.channel": "https://a.example/c#1;https://b.example/c#2"} + + def __init__(self): + self.now = int(time.time()) + acme, free = snrc.label_token("acme"), snrc.label_token("free") + node = snrc.node_of("acme.testing") + abi_bytes = RegistrationV2Tests._abi_bytes + prices = RegistrationV2Tests._prices_return(RegistrationV2Tests()) + self.answers = { + snrc.expires_call(self.REGISTRAR, acme): "0x" + snrc.encode_uint(self.now + 3600), + snrc.expires_call(self.REGISTRAR, free): "0x" + snrc.encode_uint(0), + snrc.grace_call(self.REGISTRAR): "0x" + snrc.encode_uint(self.GRACE), + snrc.label_call(self.REGISTRAR, acme): abi_bytes(b"acme"), + snrc.reserved_call(self.CONTROLLER, acme): "0x" + snrc.encode_uint(0), + snrc.reserved_call(self.CONTROLLER, free): "0x" + snrc.encode_uint(0), + snrc.resolver_call(self.REGISTRY, node): "0x" + snrc.encode_uint(int(self.RESOLVER, 16)), + snrc.owner_call(self.REGISTRY, node): "0x" + snrc.encode_uint(int(self.OWNER, 16)), + snrc.addr_call(self.RESOLVER, node, snrc.COIN_ETH): abi_bytes(bytes.fromhex(self.OWNER[2:])), + snrc.prices_call(self.CONTROLLER): "0x" + snrc.encode_uint(int(self.ORACLE, 16)), + snrc.prices_call(self.ORACLE): prices, + snrc.min_length_call(self.CONTROLLER): "0x" + snrc.encode_uint(3), + } + for key, value in self.TEXTS.items(): + self.answers[snrc.text_call(self.RESOLVER, node, key)] = abi_bytes(value.encode()) + + def call(self, to, data): + answer = self.answers.get((to.lower(), data)) + if answer is None: + raise RuntimeError("execution reverted") + return answer + + +class FakeNode(ThreadingHTTPServer): + """A JSON-RPC node over HTTP/1.1 keep-alive, serving FakeChain, with + batches and Multicall3, each of which a test can take away.""" + + daemon_threads = True + + def __init__(self): + super().__init__(("127.0.0.1", 0), _FakeNodeHandler) + self.chain = FakeChain() + self.block = 100 + self.requests = 0 + self.connections = 0 + self.batch = True + self.multicall = True + self.multicall_null = False + self.status = 200 + self.hang_up = False + self.drop_after_reply = False + threading.Thread(target=self.serve_forever, args=(0.05,), daemon=True).start() + + @property + def url(self): + return f"http://127.0.0.1:{self.server_address[1]}/" + + def stop(self): + self.shutdown() + self.server_close() + + def answer(self, req): + out = {"jsonrpc": "2.0", "id": req.get("id")} + method, params = req["method"], req["params"] + if method == "eth_blockNumber": + out["result"] = hex(self.block) + elif method == "eth_getBlockByNumber": + out["result"] = {"number": hex(self.block), "timestamp": hex(self.chain.now)} + elif method == "eth_call" and params[0]["to"].lower() == snrc.MULTICALL.lower(): + if not self.multicall: + out["error"] = {"code": -32000, "message": "no contract code"} + elif self.multicall_null: + out["result"] = None + else: + results = [] + for to, data in _decode_aggregate3_calls(params[0]["data"]): + try: + results.append((True, bytes.fromhex(self.chain.call(to, data)[2:]))) + except RuntimeError: + results.append((False, b"")) + out["result"] = _encode_aggregate3_results(results) + elif method == "eth_call": + try: + out["result"] = self.chain.call(params[0]["to"], params[0]["data"]) + except RuntimeError: + out["error"] = {"code": 3, "message": "execution reverted"} + else: + out["error"] = {"code": -32601, "message": "method not found"} + return out + + +class _FakeNodeHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def setup(self): + super().setup() + # headers and body go out in separate writes, which Nagle holds for the client's delayed ACK + self.request.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + self.server.connections += 1 + + def do_POST(self): # noqa: N802 - http.server contract + request = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + node = self.server + node.requests += 1 + if node.hang_up: + self.close_connection = True + return + if node.status != 200: + reply = {"error": "unavailable"} + elif isinstance(request, list): + reply = [node.answer(r) for r in request] if node.batch else {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "batch not supported"}} + else: + reply = node.answer(request) + data = json.dumps(reply).encode() + self.send_response(node.status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + # closes without `Connection: close`, as a node dropping an idle connection does + self.close_connection = node.drop_after_reply + + def log_message(self, fmt, *args): + pass + + +class FakeNodeTestCase(unittest.TestCase): + """Points the resolver at a FakeNode and at FakeChain's contracts.""" + + def setUp(self): + self.node = FakeNode() + self._saved = (snrc.RPC, snrc.RPC_URL, snrc.REGISTRIES, snrc.REGISTRARS, snrc.CONTROLLERS) + snrc.RPC = self.node.url + snrc.RPC_URL = urlparse(snrc.RPC) + snrc.REGISTRIES = {"testing": FakeChain.REGISTRY, "simplex": ""} + snrc.REGISTRARS = {"testing": FakeChain.REGISTRAR} + snrc.CONTROLLERS = {"testing": FakeChain.CONTROLLER} + snrc._multicall_failed_logged = False + self._drain_pool() + + def tearDown(self): + self._drain_pool() + snrc.RPC, snrc.RPC_URL, snrc.REGISTRIES, snrc.REGISTRARS, snrc.CONTROLLERS = self._saved + self.node.stop() + + def _drain_pool(self): + while not snrc._rpc_pool.empty(): + snrc._rpc_pool.get_nowait().close() + + def requests_made(self, action): + before = self.node.requests + result = action() + return result, self.node.requests - before + + +class RpcTransportTests(FakeNodeTestCase): + """A lookup makes several reads, and a new connection per read costs CPU + and leaves a TIME_WAIT socket each, which exhausts local ports under load.""" + + def test_reads_share_one_connection(self): + for _ in range(18): + self.assertEqual(snrc.rpc("eth_blockNumber", []), hex(self.node.block)) + self.assertEqual(self.node.connections, 1) + + def test_a_connection_the_node_closed_is_replaced(self): + self.node.drop_after_reply = True + for _ in range(3): + self.assertEqual(snrc.rpc("eth_blockNumber", []), hex(self.node.block)) + self.assertEqual(self.node.connections, 3) + + def test_a_fresh_connection_that_fails_is_not_retried(self): + """Only a pooled connection can be stale; a new one failing means the + node is down, and resending would only double the wait.""" + self.node.hang_up = True + with self.assertRaises(ConnectionError): + snrc.rpc("eth_blockNumber", []) + self.assertEqual(self.node.requests, 1) + + def test_a_node_failure_is_not_a_reverted_call(self): + """Callers read RuntimeError as the call reverting and fall back to an + empty value, so a node failure must not look like one.""" + self.node.status = 502 + with self.assertRaises(HTTPError) as cm: + snrc.rpc("eth_blockNumber", []) + self.assertNotIsInstance(cm.exception, RuntimeError) + self.assertEqual(cm.exception.code, 502) + + def test_a_reverted_call_is_a_runtime_error_and_keeps_the_connection(self): + with self.assertRaises(RuntimeError): + snrc.eth_call("0x" + "11" * 20, "0xdeadbeef") + snrc.rpc("eth_blockNumber", []) + self.assertEqual(self.node.connections, 1) + + +class RpcPoolTests(FakeNodeTestCase): + def test_connections_beyond_the_pool_are_closed_after_use(self): + """Every worker would otherwise keep its peak concurrency open to the node.""" + saved, snrc._rpc_pool = snrc._rpc_pool, queue.LifoQueue(maxsize=1) + try: + body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "eth_blockNumber", "params": []}).encode() + kept, extra = snrc._new_rpc_connection(), snrc._new_rpc_connection() + snrc._post_pooled(kept, body) + # returning a connection to a full pool must not wait for a free slot + done = threading.Thread(target=snrc._post_pooled, args=(extra, body), daemon=True) + done.start() + done.join(5) + self.assertFalse(done.is_alive(), "returning a connection to a full pool blocked") + self.assertEqual(snrc._rpc_pool.qsize(), 1) + self.assertIsNotNone(kept.sock) + self.assertIsNone(extra.sock) + finally: + while not snrc._rpc_pool.empty(): + snrc._rpc_pool.get_nowait().close() + snrc._rpc_pool = saved + + +class BatchedReadsTests(FakeNodeTestCase): + """Inside a request a round of reads is one round trip, and its contract + reads one multicall, because a node runs the calls of a JSON-RPC batch one + after another. Answers must be exactly those of reading one call at a time.""" + + def batched(self, answer, name): + def action(): + with snrc.request_reads(): + return answer(name) + return self.requests_made(action) + + def assert_same_answer(self, answer, name, round_trips): + one_by_one, one_by_one_trips = self.requests_made(lambda: answer(name)) + batched, batched_trips = self.batched(answer, name) + self.assertEqual(batched, one_by_one) + self.assertEqual(batched_trips, round_trips) + self.assertGreater(one_by_one_trips, round_trips) + return batched + + def test_a_registered_name_takes_two_round_trips(self): + status, body = self.assert_same_answer(snrc.registration, "acme.testing", 2) + record = body["registration"]["nameRecord"] + self.assertEqual(record["nickname"], "Acme") + self.assertEqual(record["simplexChannel"], ["https://a.example/c#1", "https://b.example/c#2"]) + self.assertIsNone(record["btc"]) + + def test_a_hashed_query_is_named_from_the_same_round_trip(self): + hashed = "[" + snrc.keccak(b"acme").hex() + "].testing" + status, body = self.assert_same_answer(snrc.registration, hashed, 2) + self.assertEqual(body["registration"]["nameRecord"]["name"], "acme.testing") + + def test_an_available_name_is_priced_in_three_round_trips(self): + status, body = self.assert_same_answer(snrc.registration, "free.testing", 3) + self.assertEqual(body["registration"]["type"], "available") + + def test_v1_answers_the_same(self): + self.assert_same_answer(snrc.resolve, "acme.testing", 2) + + def test_without_multicall_a_round_is_still_one_batch(self): + self.node.multicall = False + with self.assertLogs("snrc_resolve", "WARNING") as logs: + self.assert_same_answer(snrc.registration, "acme.testing", 4) + [record] = logs.records + self.assertEqual((record.getMessage(), record.fields["fallback"]), ("multicall_unavailable", "batch")) + + def test_a_multicall_without_a_result_falls_back_to_a_batch(self): + self.node.multicall_null = True + with self.assertLogs("snrc_resolve", "WARNING") as logs: + self.assert_same_answer(snrc.registration, "acme.testing", 4) + self.assertEqual([r.getMessage() for r in logs.records], ["multicall_unavailable"]) + + def test_an_answer_with_neither_result_nor_error_is_not_taken_as_one(self): + reads = {} + snrc._remember(reads, [("eth_blockNumber", [])], [{"jsonrpc": "2.0", "id": 0}]) + self.assertEqual(reads, {}) + + def test_a_node_that_does_not_batch_is_read_one_call_at_a_time(self): + self.node.batch = False + one_by_one, one_by_one_trips = self.requests_made(lambda: snrc.registration("acme.testing")) + with self.assertNoLogs("snrc_resolve"): + batched, batched_trips = self.batched(snrc.registration, "acme.testing") + self.assertEqual(batched, one_by_one) + # one refused batch per round, then the reads the batch would have made + self.assertEqual(batched_trips, one_by_one_trips + 2) + + def test_a_read_reverted_in_the_multicall_is_a_reverted_call(self): + with snrc.request_reads(): + snrc.prefetch([snrc.eth_call_read(*snrc.grace_call(FakeChain.REGISTRAR)), snrc.eth_call_read(FakeChain.REGISTRY, "0xdeadbeef")]) + _, trips = self.requests_made(lambda: self.assertRaises(RuntimeError, snrc.eth_call, FakeChain.REGISTRY, "0xdeadbeef")) + self.assertEqual(trips, 0) + + def test_outside_a_request_nothing_is_prefetched(self): + _, trips = self.requests_made(lambda: snrc.prefetch(snrc.lookup_reads("acme.testing"))) + self.assertEqual(trips, 0) + + +class Aggregate3Tests(unittest.TestCase): + def test_calls_are_encoded_as_multicall3_reads_them(self): + calls = [(FakeChain.REGISTRY, "0x0178b8bf" + "11" * 32), (FakeChain.RESOLVER, "0x59d1d43c" + "22" * 100)] + data = snrc.encode_aggregate3(calls) + self.assertTrue(data.startswith(snrc.AGGREGATE3)) + self.assertEqual(_decode_aggregate3_calls(data), calls) + + def test_results_are_decoded_with_their_success_flags(self): + results = [(True, b"\x01" * 40), (False, b""), (True, b"")] + self.assertEqual(snrc.decode_aggregate3(_encode_aggregate3_results(results)), results) + + def test_a_truncated_answer_is_refused(self): + whole = _encode_aggregate3_results([(True, b"\x01" * 40)]) + with self.assertRaises(ValueError): + snrc.decode_aggregate3(whole[:-64]) + with self.assertRaises(ValueError): + snrc.decode_aggregate3("0x") + + +class RequestLogTests(unittest.TestCase): + """Each request is one event with the client it came from, behind a reverse + proxy the client the proxy names rather than the proxy itself.""" + + def setUp(self): + self.server = snrc.ResolverServer(("127.0.0.1", 0), snrc.Handler) + threading.Thread(target=self.server.serve_forever, args=(0.05,), daemon=True).start() + self._saved = snrc.TRUSTED_PROXIES + + def tearDown(self): + snrc.TRUSTED_PROXIES = self._saved + self.server.shutdown() + self.server.server_close() + + def request(self, path, headers=None, level="INFO"): + url = f"http://127.0.0.1:{self.server.server_address[1]}{path}" + with self.assertLogs("snrc_resolve", level) as logs: + try: + urlopen(Request(url, headers=headers or {}), timeout=5).read() + except HTTPError: + pass + deadline = time.monotonic() + 2 + while not any(r.getMessage() == "request" for r in logs.records) and time.monotonic() < deadline: + time.sleep(0.01) + [record] = [r for r in logs.records if r.getMessage() == "request"] + return record + + def test_a_request_is_logged_with_its_outcome(self): + record = self.request("/v2/resolve/x.simplex") + fields = record.fields + self.assertEqual(record.levelname, "INFO") + self.assertEqual( + (fields["client"], fields["method"], fields["path"], fields["status"], fields["worker"]), + ("127.0.0.1", "GET", "/v2/resolve/x.simplex", 400, os.getpid()), + ) + self.assertGreater(fields["bytes"], 0) + self.assertGreaterEqual(fields["ms"], 0) + + def test_a_trusted_proxy_names_the_client(self): + snrc.TRUSTED_PROXIES = (ipaddress.ip_network("127.0.0.1/32"),) + record = self.request("/v2/resolve/x.simplex", {"X-Forwarded-For": "203.0.113.7"}) + self.assertEqual(record.fields["client"], "203.0.113.7") + + def test_any_other_peer_cannot_name_itself(self): + record = self.request("/v2/resolve/x.simplex", {"X-Forwarded-For": "203.0.113.7"}) + self.assertEqual(record.fields["client"], "127.0.0.1") + + def test_a_bad_request_is_not_logged_with_the_previous_ones_fields(self): + """On a kept-alive connection the handler still held the last request's path and size.""" + with self.assertLogs("snrc_resolve", "INFO") as logs: + with socket.create_connection(self.server.server_address, timeout=5) as sock: + sock.sendall(b"GET /v2/resolve/x.simplex HTTP/1.1\r\nHost: x\r\n\r\n") + sock.recv(65536) + sock.sendall(b"BOGUS\r\n\r\n") + while sock.recv(65536): + pass + first, second = [r for r in logs.records if r.getMessage() == "request"] + self.assertEqual(first.fields["path"], "/v2/resolve/x.simplex") + self.assertEqual((second.fields["status"], second.fields["path"], second.fields["bytes"], second.fields["ms"]), (400, None, None, None)) + + def test_health_checks_are_logged_only_at_debug(self): + """The container checks /health every 30 s.""" + record = self.request("/health", level="DEBUG") + self.assertEqual(record.levelname, "DEBUG") + + +class RequestBodyTests(unittest.TestCase): + """Behind a reverse proxy that reuses connections, a body left unread would be + answered as another request, and that answer given to the proxy's next client.""" + + def setUp(self): + self.server = snrc.ResolverServer(("127.0.0.1", 0), snrc.Handler) + threading.Thread(target=self.server.serve_forever, args=(0.05,), daemon=True).start() + self._log = contextlib.redirect_stderr(io.StringIO()) + self._log.__enter__() + + def tearDown(self): + self._log.__exit__(None, None, None) + self.server.shutdown() + self.server.server_close() + + def exchange(self, head: bytes, body: bytes = b"") -> bytes: + with socket.create_connection(self.server.server_address, timeout=5) as sock: + sock.sendall(head + body) + data = b"" + while chunk := sock.recv(65536): + data += chunk + if data.count(b"HTTP/1.1 ") > 1: + break + return data + + SMUGGLED = b"GET /v2/resolve/y.simplex HTTP/1.1\r\nHost: x\r\n\r\n" + + def test_a_body_is_refused_and_the_connection_closed(self): + data = self.exchange(b"GET /v2/resolve/x.simplex HTTP/1.1\r\nHost: x\r\nContent-Length: %d\r\n\r\n" % len(self.SMUGGLED), self.SMUGGLED) + self.assertEqual(data.count(b"HTTP/1.1 "), 1) + self.assertIn(b"HTTP/1.1 400 ", data) + self.assertIn(b"Connection: close", data) + self.assertNotIn(b"y.simplex", data) + + def test_a_length_after_a_malformed_header_line_is_still_seen(self): + """Python stops parsing headers at a malformed line and keeps the rest as payload.""" + data = self.exchange(b"GET /v2/resolve/x.simplex HTTP/1.1\r\nX-Junk : 1\r\nContent-Length: %d\r\n\r\n" % len(self.SMUGGLED), self.SMUGGLED) + self.assertEqual(data.count(b"HTTP/1.1 "), 1) + self.assertIn(b"unexpectedBody", data) + self.assertNotIn(b"y.simplex", data) + + def test_a_chunked_body_is_refused_and_the_connection_closed(self): + chunked = b"%x\r\n" % len(self.SMUGGLED) + self.SMUGGLED + b"\r\n0\r\n\r\n" + data = self.exchange(b"GET /v2/resolve/x.simplex HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n", chunked) + self.assertEqual(data.count(b"HTTP/1.1 "), 1) + self.assertNotIn(b"y.simplex", data) + + def test_an_empty_body_keeps_the_connection(self): + conn = http.client.HTTPConnection(*self.server.server_address, timeout=5) + answers = [] + for _ in range(2): + conn.request("GET", "/v2/resolve/x.simplex", headers={"Content-Length": "0"}) + res = conn.getresponse() + answers.append((res.status, json.loads(res.read())["error"], conn.sock)) + conn.close() + self.assertEqual([a[:2] for a in answers], [(400, "tldNotConfigured")] * 2) + self.assertIs(answers[0][2], answers[1][2]) + + +class PublicEndpointTests(unittest.TestCase): + def test_credentials_path_and_query_are_not_shown(self): + self.assertEqual(snrc.public_endpoint("https://user:pw@rpc.example:8443/v2/KEY?apikey=K"), "https://rpc.example:8443") + + def test_a_plain_endpoint_is_unchanged(self): + self.assertEqual(snrc.public_endpoint("http://reth:8545"), "http://reth:8545") + + def test_an_ipv6_host_keeps_its_brackets(self): + self.assertEqual(snrc.public_endpoint("http://u:p@[::1]:8545/key"), "http://[::1]:8545") + + def test_a_malformed_port_does_not_break_health(self): + self.assertEqual(snrc.public_endpoint("http://reth:port"), "http://reth:port") + + +class ClientAddressTests(unittest.TestCase): + PROXY = "172.18.0.1" + + def setUp(self): + self._saved = snrc.TRUSTED_PROXIES + snrc.TRUSTED_PROXIES = (ipaddress.ip_network("172.16.0.0/12"),) + + def tearDown(self): + snrc.TRUSTED_PROXIES = self._saved + + def test_a_peer_that_is_no_proxy_is_the_client(self): + self.assertEqual(snrc.client_address("198.51.100.9", "203.0.113.7"), "198.51.100.9") + + def test_without_the_header_the_proxy_is_the_client(self): + self.assertEqual(snrc.client_address(self.PROXY, None), self.PROXY) + + def test_the_address_the_proxy_added_is_the_client(self): + self.assertEqual(snrc.client_address(self.PROXY, "203.0.113.7"), "203.0.113.7") + + def test_addresses_a_client_sent_itself_are_not_believed(self): + """A proxy appends the address it saw, so only the last untrusted one is known.""" + self.assertEqual(snrc.client_address(self.PROXY, "6.6.6.6, 203.0.113.7"), "203.0.113.7") + + def test_trusted_proxies_in_a_chain_are_skipped(self): + self.assertEqual(snrc.client_address(self.PROXY, "203.0.113.7, 172.18.0.5"), "203.0.113.7") + + def test_an_address_that_does_not_parse_is_not_believed(self): + self.assertEqual(snrc.client_address(self.PROXY, "203.0.113.7, not-an-ip"), self.PROXY) + + def test_an_ipv4_mapped_peer_is_matched_as_ipv4(self): + self.assertEqual(snrc.client_address("::ffff:172.18.0.1", "203.0.113.7"), "203.0.113.7") + + def test_an_ipv6_client_is_kept(self): + self.assertEqual(snrc.client_address(self.PROXY, "2001:db8::7"), "2001:db8::7") + + +class RequestErrorTests(unittest.TestCase): + def setUp(self): + self.server = snrc.ResolverServer(("127.0.0.1", 0), snrc.Handler) + + def tearDown(self): + self.server.server_close() + + def test_a_client_that_hung_up_is_a_warning_without_a_traceback(self): + """What an smp-server that gave up after its timeout leaves behind.""" + with self.assertLogs("snrc_resolve", "WARNING") as logs: + try: + raise BrokenPipeError + except BrokenPipeError: + self.server.handle_error(None, ("198.51.100.9", 4000)) + [record] = logs.records + self.assertEqual((record.levelname, record.getMessage(), record.fields["error"]), ("WARNING", "client_gone", "BrokenPipeError")) + self.assertIsNone(record.exc_info) + + def test_a_failure_is_an_error_with_its_traceback(self): + with self.assertLogs("snrc_resolve", "ERROR") as logs: + try: + raise KeyError("boom") + except KeyError: + self.server.handle_error(None, ("198.51.100.9", 4000)) + [record] = logs.records + self.assertEqual((record.getMessage(), record.fields["client"]), ("request_failed", "198.51.100.9")) + self.assertIsNotNone(record.exc_info) + + +class KeepAliveTests(unittest.TestCase): + """The smp-server keeps a resolver connection only after an HTTP/1.1 + response, and otherwise connects for every lookup.""" + + def setUp(self): + self.server = snrc.ResolverServer(("127.0.0.1", 0), snrc.Handler) + threading.Thread(target=self.server.serve_forever, args=(0.05,), daemon=True).start() + self._saved_timeout = snrc.Handler.timeout + self._log = contextlib.redirect_stderr(io.StringIO()) + self._log.__enter__() + + def tearDown(self): + self._log.__exit__(None, None, None) + snrc.Handler.timeout = self._saved_timeout + self.server.shutdown() + self.server.server_close() + + def test_requests_share_one_connection_without_delay(self): + conn = http.client.HTTPConnection(*self.server.server_address, timeout=5) + start = time.monotonic() + for i in range(20): + conn.request("GET", "/v2/resolve/x.simplex") + res = conn.getresponse() + res.read() + self.assertEqual((res.version, res.status), (11, 400)) + if i == 0: + sock = conn.sock + self.assertIs(conn.sock, sock) + conn.close() + # a response held by Nagle for the delayed ACK takes ~40 ms, 20 of them over 0.8 s + self.assertLess(time.monotonic() - start, 0.5) + + def test_idle_connections_outlast_the_smp_servers(self): + """http-client drops a connection idle for 30 s, checked every 5 s. A + resolver that closed sooner would race the client reusing it, and one + that never closed would keep a thread per dead connection.""" + self.assertIsNotNone(self._saved_timeout) + self.assertGreater(self._saved_timeout, 35) + + def test_an_idle_connection_is_closed(self): + snrc.Handler.timeout = 0.2 + with socket.create_connection(self.server.server_address, timeout=5) as sock: + time.sleep(0.5) + self.assertEqual(sock.recv(1), b"") + + +@unittest.skipUnless(sys.platform.startswith("linux"), "reads worker processes from /proc") +class WorkerProcessesTests(unittest.TestCase): + """Workers share the port, and the service stops as a whole, so the + container restarts rather than serving on fewer workers.""" + + def setUp(self): + self.node = FakeNode() + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + self.port = s.getsockname()[1] + env = dict(os.environ, SNRC_RPC=self.node.url, SNRC_BIND="127.0.0.1", SNRC_PORT=str(self.port), SNRC_WORKERS="2", + SNRC_REGISTRY_TESTING=FakeChain.REGISTRY, SNRC_REGISTRAR_TESTING=FakeChain.REGISTRAR, SNRC_CONTROLLER_TESTING=FakeChain.CONTROLLER) + self.service = subprocess.Popen([sys.executable, os.path.join(_HERE, "snrc-resolve.py")], env=env, stderr=subprocess.PIPE, text=True) + self.workers = self._wait_for_workers(2) + + def tearDown(self): + if self.service.poll() is None: + self.service.kill() + self.service.wait() + self.service.stderr.close() + for pid in self.workers: + with contextlib.suppress(ProcessLookupError): + os.kill(pid, signal.SIGKILL) + self.node.stop() + + def _wait_for_workers(self, count): + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + workers = [int(p) for p in os.listdir("/proc") if p.isdigit() and self._parent(p) == self.service.pid] + if len(workers) == count and self._serving(): + return workers + time.sleep(0.05) + self.fail("workers did not start") + + @staticmethod + def _parent(pid): + try: + with open(f"/proc/{pid}/stat") as f: + return int(f.read().rsplit(")", 1)[1].split()[1]) + except (FileNotFoundError, ProcessLookupError): + return None + + def _serving(self): + try: + with socket.create_connection(("127.0.0.1", self.port), timeout=1): + return True + except OSError: + return False + + def _gone(self, pid): + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if self._parent(pid) != self.service.pid: + return True + time.sleep(0.05) + return False + + def test_workers_answer_on_the_shared_port(self): + for _ in range(20): + with urlopen(f"http://127.0.0.1:{self.port}/v2/resolve/acme.testing", timeout=5) as res: + self.assertEqual(json.loads(res.read())["registration"]["type"], "registered") + + def test_stopping_the_service_stops_every_worker(self): + self.service.send_signal(signal.SIGTERM) + self.assertEqual(self.service.wait(timeout=5), 0) + self.assertTrue(all(self._gone(pid) for pid in self.workers)) + self.assertRegex(self.service.stderr.read(), r"INFO stopping signal=SIGTERM") + + def test_a_worker_exiting_stops_the_service(self): + os.kill(self.workers[0], signal.SIGKILL) + self.assertEqual(self.service.wait(timeout=5), 1) + self.assertTrue(self._gone(self.workers[1])) + + +TIME = r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z" + + +class LogTestCase(unittest.TestCase): + def setUp(self): + self._saved = (snrc.LOG_FORMAT, snrc.LOG_COLOR, snrc.LOG_LEVEL) + snrc.LOG_FORMAT, snrc.LOG_COLOR, snrc.LOG_LEVEL = "text", "never", "info" + self.out = io.StringIO() + + def tearDown(self): + snrc.LOG_FORMAT, snrc.LOG_COLOR, snrc.LOG_LEVEL = self._saved + snrc.LOGGER.handlers[:] = [] + snrc.LOGGER.setLevel(logging.NOTSET) + snrc.LOGGER.propagate = True + + def emit(self, level=logging.INFO, name="request", **fields): + snrc.setup_logging(self.out) + snrc.log_event(level, name, **fields) + return self.out.getvalue() + + +class TextFormatTests(LogTestCase): + def test_a_line_is_utc_time_level_event_and_fields(self): + line = self.emit(client="203.0.113.7", path="/v2/resolve/[4fdd].testing", status=200, ms=7) + self.assertRegex(line, rf"^{TIME} INFO request client=203\.0\.113\.7 path=/v2/resolve/\[4fdd\]\.testing status=200 ms=7\n$") + + def test_values_that_would_be_misread_are_quoted(self): + line = self.emit(message='says "hi" here', empty="", missing=None, eq="a=b") + self.assertIn('message="says \\"hi\\" here" empty="" missing=- eq="a=b"', line) + + def test_levels_are_named_in_five_columns(self): + self.assertRegex(self.emit(logging.WARNING, "upstream_error"), rf"^{TIME} WARN upstream_error\n$") + + def test_below_the_configured_level_nothing_is_written(self): + self.assertEqual(self.emit(logging.DEBUG), "") + + def test_an_exception_follows_its_line(self): + snrc.setup_logging(self.out) + try: + raise KeyError("boom") + except KeyError: + snrc.log_event(logging.ERROR, "request_failed", exc_info=True) + self.assertRegex(self.out.getvalue(), rf"(?s)^{TIME} ERROR request_failed\nTraceback .*KeyError: 'boom'\n$") + + +class ColorTests(LogTestCase): + def test_colours_mark_the_level_and_the_status_class(self): + snrc.LOG_COLOR = "always" + line = self.emit(status=503) + self.assertIn("\033[32mINFO ", line) + self.assertIn("\033[31m503\033[0m", line) + + def test_auto_leaves_output_that_is_no_terminal_plain(self): + snrc.LOG_COLOR = "auto" + self.assertNotIn("\033[", self.emit(status=200)) + + def test_never_is_plain(self): + self.assertNotIn("\033[", self.emit(status=200)) + + +class JsonFormatTests(LogTestCase): + def test_a_line_is_one_json_object(self): + snrc.LOG_FORMAT = "json" + record = json.loads(self.emit(client="203.0.113.7", status=200, missing=None)) + self.assertRegex(record.pop("time"), rf"^{TIME}$") + self.assertEqual(record, {"level": "info", "event": "request", "client": "203.0.113.7", "status": 200, "missing": None}) + + def test_an_exception_is_a_field(self): + snrc.LOG_FORMAT = "json" + snrc.setup_logging(self.out) + try: + raise KeyError("boom") + except KeyError: + snrc.log_event(logging.ERROR, "request_failed", exc_info=True) + self.assertIn("KeyError: 'boom'", json.loads(self.out.getvalue())["exception"]) + + +class SetupTests(LogTestCase): + def test_unknown_settings_are_refused_at_start(self): + for setting, value in (("LOG_FORMAT", "yaml"), ("LOG_COLOR", "sometimes"), ("LOG_LEVEL", "loud")): + with self.subTest(setting=setting): + saved = getattr(snrc, setting) + setattr(snrc, setting, value) + try: + with self.assertRaisesRegex(ValueError, re.escape(f"SNRC_{setting}")): + snrc.setup_logging(self.out) + finally: + setattr(snrc, setting, saved) diff --git a/scripts/resolver/service/uv.lock b/scripts/resolver/service/uv.lock new file mode 100644 index 000000000..22c70cd3c --- /dev/null +++ b/scripts/resolver/service/uv.lock @@ -0,0 +1,156 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "eth-hash" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/f5/c67fc24f2f676aa9b7ab29679d44f113f314c817207cd4319353356f62da/eth_hash-0.8.0.tar.gz", hash = "sha256:b009752b620da2e9c7668014849d1f5fadbe4f138603f1871cc5d4ca706896b1", size = 12225, upload-time = "2026-03-25T16:36:55.099Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/87/b36792150ca0b28e4df683a34be15a61461ca0e349e5b5cf3ec8f694edb9/eth_hash-0.8.0-py3-none-any.whl", hash = "sha256:523718a51b369ab89866b929a5c93c52978cd866ea309192ad980dd8271f9fac", size = 7965, upload-time = "2026-03-25T16:36:54.205Z" }, +] + +[package.optional-dependencies] +pycryptodome = [ + { name = "pycryptodome" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, +] + +[[package]] +name = "snrc-resolve" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "eth-hash", extra = ["pycryptodome"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "eth-hash", extras = ["pycryptodome"], specifier = ">=0.7" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8" }, + { name = "ruff", specifier = ">=0.6" }, +]