mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-10-05 20:57:16 +00:00
* 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
1511 lines
55 KiB
Python
Executable File
1511 lines
55 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# /// script
|
|
# requires-python = ">=3.11"
|
|
# dependencies = [
|
|
# "eth-hash[pycryptodome]>=0.7",
|
|
# ]
|
|
# ///
|
|
"""SimpleX Namespace (SNRC) resolver — REST API.
|
|
|
|
Resolves names like `alice.testing` / `bob.simplex` against the SNRC
|
|
deployment on Ethereum mainnet (or any compatible ENS-shaped registry)
|
|
and returns a flat JSON document with these fields:
|
|
|
|
name, nickname, website, location,
|
|
simplexContact, simplexChannel, -- list[str], primary first
|
|
eth, btc, xmr, dot,
|
|
owner, resolver
|
|
|
|
`simplexContact` and `simplexChannel` are arrays so a name can advertise
|
|
multiple SMP servers for redundancy. Clients SHOULD try the URLs in the
|
|
order returned. The on-chain text record stores them as a single
|
|
`LINK_SEPARATOR` (`;`)-joined string; this resolver splits and trims into a list.
|
|
|
|
All keys are valid Haskell record-field identifiers (lowercase initial,
|
|
no dots), so consumers can derive aeson FromJSON instances directly
|
|
without a key-rewriting layer.
|
|
|
|
Usage:
|
|
./snrc-resolve.py # serve on :8000
|
|
|
|
curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq .
|
|
curl -s 'http://127.0.0.1:8000/resolve/[<64-hex labelhash>].testing' | jq .
|
|
curl -s http://127.0.0.1:8000/health
|
|
|
|
Environment:
|
|
SNRC_RPC JSON-RPC endpoint (default: http://127.0.0.1:8545)
|
|
SNRC_REGISTRY_TESTING ENSRegistry for the .testing deployment
|
|
(default: mainnet,
|
|
0x58fc46996d975c57883564648bda5206d1a0102b)
|
|
SNRC_REGISTRY_SIMPLEX ENSRegistry for the .simplex deployment
|
|
(default: empty — TLD not yet deployed)
|
|
SNRC_REGISTRAR_<TLD> BaseRegistrar (ERC-721) for the TLD; expiry and status
|
|
(default: mainnet for .testing, empty for .simplex)
|
|
SNRC_CONTROLLER_<TLD> SimplexController (proxy) for the TLD; reservations,
|
|
and through its `prices()` oracle what registering costs
|
|
(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.
|
|
|
|
Dependencies are declared inline (PEP 723) at the top of this file. Run with:
|
|
uv run snrc-resolve.py # uv resolves & caches deps; one-line setup
|
|
python snrc-resolve.py # if eth-hash[pycryptodome] is already installed
|
|
|
|
Addresses are returned in each chain's canonical presentation:
|
|
eth EIP-55 mixed-case checksummed hex (e.g. 0xEa65A0…1572)
|
|
btc bech32(m) for segwit/taproot, base58check for P2PKH/P2SH
|
|
(e.g. bc1q… / 1A1zP1…)
|
|
dot SS58 with Polkadot network prefix 0 (e.g. 15oF4u…)
|
|
xmr Monero base58 (e.g. 4Aux5y…)
|
|
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 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
|
|
# "not deployed" — requests for that TLD return 400 with a clear error.
|
|
# `... or "..."` makes the script's defaults the single source of truth:
|
|
# unset AND empty-string both fall through to the literal. docker-compose
|
|
# can therefore pass `SNRC_REGISTRY_TESTING=${SNRC_REGISTRY_TESTING:-}`
|
|
# without duplicating the registry address.
|
|
REGISTRIES = {
|
|
"testing": os.environ.get("SNRC_REGISTRY_TESTING", "")
|
|
or "0x58fc46996d975c57883564648bda5206d1a0102b", # mainnet .testing
|
|
"simplex": os.environ.get("SNRC_REGISTRY_SIMPLEX", ""), # not deployed yet
|
|
}
|
|
|
|
REGISTRARS = {
|
|
"testing": os.environ.get("SNRC_REGISTRAR_TESTING", "")
|
|
or "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a", # mainnet .testing
|
|
"simplex": os.environ.get("SNRC_REGISTRAR_SIMPLEX", ""), # not deployed yet
|
|
}
|
|
|
|
CONTROLLERS = {
|
|
"testing": os.environ.get("SNRC_CONTROLLER_TESTING", "")
|
|
# Proxy address, not SimplexControllerImpl: storage is held by the proxy.
|
|
# Recorded in deployments.json as ETHRegistrarController.
|
|
or "0xeeb9b6bf5fb68fb726005f7ba549c2f4b32f2dad", # mainnet .testing
|
|
"simplex": os.environ.get("SNRC_CONTROLLER_SIMPLEX", ""), # not deployed yet
|
|
}
|
|
|
|
# `reservedNames` holds a SimplexController.Reason; 0 means not reserved. A
|
|
# controller from before the enum stores a bool, whose `true` decodes as 1,
|
|
# which is why 1 reads as "internal".
|
|
RESERVED_REASONS = {
|
|
1: ("internal", "reserved for SimpleX"),
|
|
2: ("trademark", "reserved to protect a trademark"),
|
|
3: ("community", "reserved for the community"),
|
|
}
|
|
# a Reason added to the contract after this resolver: still reserved, unworded
|
|
UNKNOWN_REASON = ("unknown", "reserved")
|
|
|
|
# SLIP-44 coin types (https://github.com/satoshilabs/slips/blob/master/slip-0044.md)
|
|
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):
|
|
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:
|
|
for label in reversed(name.split(".")):
|
|
node = keccak(node + keccak(label.encode()))
|
|
return node
|
|
|
|
|
|
# ENS's encoding for a label whose preimage is unknown. Brackets are outside
|
|
# the normalised character set, so it cannot collide with a registrable name.
|
|
ENCODED_LABELHASH_LEN = 66 # "[" + 64 hex + "]"
|
|
|
|
|
|
def is_encoded_labelhash(label: str) -> bool:
|
|
return (
|
|
len(label) == ENCODED_LABELHASH_LEN
|
|
and label.startswith("[")
|
|
and label.endswith("]")
|
|
and all(c in "0123456789abcdef" for c in label[1:-1])
|
|
)
|
|
|
|
|
|
def node_of(name: str) -> bytes:
|
|
"""namehash, decoding a second-level labelhash so `[hash].tld` reaches the
|
|
node its name does. Only a second-level name is ever hashed; a bracket
|
|
anywhere else is hashed as written."""
|
|
labels = name.split(".")
|
|
if len(labels) != 2 or not is_encoded_labelhash(labels[0]):
|
|
return namehash(name)
|
|
return keccak(namehash(labels[1]) + bytes.fromhex(labels[0][1:-1]))
|
|
|
|
|
|
# ---------- 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(*BLOCK_READ)
|
|
return {
|
|
"blockNumber": decode_uint(block["number"]),
|
|
"chainLagSeconds": int(time.time()) - decode_uint(block["timestamp"]),
|
|
}
|
|
except Exception:
|
|
return {"blockNumber": None, "chainLagSeconds": None}
|
|
|
|
|
|
def chain_now() -> int:
|
|
"""Expiry is compared against the block timestamp, never the host clock."""
|
|
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(*grace_call(registrar)))
|
|
|
|
|
|
def expiry_status(expires: int, grace: int, now: int) -> str:
|
|
"""The registrar's `available(id)` is not enough on its own: it is also
|
|
true for a name nobody registered, since 0 + GRACE_PERIOD < now."""
|
|
if expires == 0:
|
|
return "unregistered"
|
|
if expires > now:
|
|
return "registered"
|
|
if expires + grace >= now:
|
|
return "grace"
|
|
return "expired"
|
|
|
|
|
|
def reservation_reason(tld: str, token: int) -> int:
|
|
"""The SimplexController.Reason held for the name, 0 when not reserved."""
|
|
controller = CONTROLLERS.get(tld)
|
|
if not controller:
|
|
return 0
|
|
return decode_uint(eth_call(*reserved_call(controller, token)))
|
|
|
|
|
|
def pricing_params(tld: str):
|
|
"""What it costs to register a name under this TLD, in US cents, or None
|
|
when no controller or price oracle is configured."""
|
|
controller = CONTROLLERS.get(tld)
|
|
if not controller:
|
|
return None
|
|
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:
|
|
return read_oracle_prices(controller, oracle)
|
|
except RuntimeError:
|
|
# An oracle that does not expose its curve cannot be quoted from. The
|
|
# name is still registrable; the price is simply not ours to state.
|
|
return None
|
|
|
|
|
|
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
|
|
|
|
|
|
def read_oracle_prices(controller: str, oracle: str):
|
|
"""SimplexPriceOracle keeps the curve in US cents per year, the unit the SMP
|
|
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(*prices_call(oracle)))
|
|
premium_unknown = False
|
|
except RuntimeError:
|
|
base, tiers = decode_letter_prices(oracle)
|
|
premium_unknown = True
|
|
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},
|
|
"basePrice": base,
|
|
"minLabelLength": min_len,
|
|
"_premiumUnknown": premium_unknown,
|
|
}
|
|
|
|
|
|
def decode_letter_prices(oracle: str):
|
|
"""`price1Letter()`..`price6Letter()`, in attoUSD per second. Quotes round
|
|
up, so one is never below what the registry charges. An oracle built before
|
|
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 LETTER_TIERS:
|
|
try:
|
|
rate = decode_uint(eth_call(*letter_price_call(oracle, n)))
|
|
except RuntimeError:
|
|
if n <= 5:
|
|
raise
|
|
break
|
|
tiers[n] = ceil_div(rate * SECONDS_PER_YEAR, ATTO_PER_CENT)
|
|
return tiers.pop(max(tiers)), tiers
|
|
|
|
|
|
def ceil_div(a: int, b: int) -> int:
|
|
return -(-a // b)
|
|
|
|
|
|
def decode_prices(hex_data: str):
|
|
"""`prices()` returns the base price and the lengths priced differently."""
|
|
raw = bytes.fromhex(hex_data[2:] if hex_data.startswith("0x") else hex_data)
|
|
# a short answer is not a curve: decoding it would quote every name as free
|
|
if len(raw) < 96:
|
|
raise RuntimeError("prices(): short response")
|
|
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
|
|
length = int.from_bytes(raw[item:item + 32], "big")
|
|
tiers[length] = int.from_bytes(raw[item + 32:item + 64], "big")
|
|
return base, tiers
|
|
|
|
|
|
def name_status(name: str):
|
|
labels = name.split(".")
|
|
tld = labels[-1]
|
|
registrar = REGISTRARS.get(tld)
|
|
if not registrar or len(labels) < 2:
|
|
return {
|
|
"status": "unknown",
|
|
# nothing was read, so there is no block to report
|
|
"lastBlockTs": None,
|
|
"expires": None,
|
|
"graceEnds": None,
|
|
"reasonCode": None,
|
|
"reason": None,
|
|
}
|
|
|
|
# nameExpires and reservedNames are keyed on uint256(keccak(label)).
|
|
# 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(*expires_call(registrar, token)))
|
|
grace = grace_period(registrar) if expires else 0
|
|
now = chain_now()
|
|
status = expiry_status(expires, grace, now)
|
|
|
|
# A reservation is orthogonal to the registration: a registered name can be
|
|
# held back too.
|
|
code = reservation_reason(tld, token)
|
|
reason = RESERVED_REASONS.get(code, UNKNOWN_REASON) if code else None
|
|
|
|
out = {
|
|
"status": status,
|
|
# the block this was read at
|
|
"lastBlockTs": now,
|
|
"expires": expires or None,
|
|
"graceEnds": (expires + grace) if expires else None,
|
|
"reasonCode": reason[0] if reason else None,
|
|
"reason": reason[1] if reason else None,
|
|
}
|
|
if status in ("unregistered", "expired"):
|
|
pricing = pricing_params(tld)
|
|
# a lapsed name may carry a premium this resolver cannot read, and a
|
|
# quote without it would be below what the registry charges
|
|
if pricing and not (status == "expired" and pricing["_premiumUnknown"]):
|
|
out.update({k: v for k, v in pricing.items() if not k.startswith("_")})
|
|
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_read(to, data))
|
|
if result == "0x":
|
|
raise RuntimeError(f"empty return from {to}: no contract at that address?")
|
|
return result
|
|
|
|
|
|
def decode_address(hex_data: str) -> str:
|
|
return "0x" + hex_data[-40:]
|
|
|
|
|
|
def decode_bytes(hex_data: str) -> bytes:
|
|
raw = bytes.fromhex(hex_data[2:] if hex_data.startswith("0x") else hex_data)
|
|
if len(raw) < 64:
|
|
return b""
|
|
length = int.from_bytes(raw[32:64], "big")
|
|
return raw[64:64 + length]
|
|
|
|
|
|
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(*label_call(registrar, token)))
|
|
return raw.decode("utf-8", errors="replace") if raw else None
|
|
|
|
|
|
def canonical_name(name: str):
|
|
"""The name to answer with: a hashed query does not carry one, so the
|
|
registrar's record of the label fills it in. None when it recorded none."""
|
|
labels = name.split(".")
|
|
registrar = REGISTRARS.get(labels[-1])
|
|
if not registrar or len(labels) != 2 or not is_encoded_labelhash(labels[0]):
|
|
return name
|
|
label = registered_label(registrar, label_token(labels[0]))
|
|
return label + "." + labels[1] if label else None
|
|
|
|
|
|
def label_token(label: str) -> int:
|
|
"""The registry key for a second-level label, whether it arrived as text or
|
|
already hashed."""
|
|
if is_encoded_labelhash(label):
|
|
return int(label[1:-1], 16)
|
|
return int.from_bytes(keccak(label.encode()), "big")
|
|
|
|
|
|
def decode_uint(hex_data: str) -> int:
|
|
raw = hex_data[2:] if hex_data.startswith("0x") else hex_data
|
|
return int(raw[-64:], 16) if raw else 0
|
|
|
|
|
|
def encode_uint(value: int) -> str:
|
|
return value.to_bytes(32, "big").hex()
|
|
|
|
|
|
def encode_text_call(node: bytes, key: str) -> str:
|
|
sel = selector("text(bytes32,string)")
|
|
head = node.hex() + (0x40).to_bytes(32, "big").hex()
|
|
key_bytes = key.encode()
|
|
body = len(key_bytes).to_bytes(32, "big").hex() + key_bytes.hex()
|
|
body += "00" * ((-len(key_bytes)) % 32)
|
|
return sel + head + body
|
|
|
|
|
|
def text(resolver: str, node: bytes, key: str) -> str:
|
|
raw = decode_bytes(eth_call(*text_call(resolver, node, key)))
|
|
return raw.decode("utf-8", errors="replace") if raw else ""
|
|
|
|
|
|
def encode_addr_multicoin_call(node: bytes, coin_type: int) -> str:
|
|
"""ENSIP-9 addr(bytes32 node, uint256 coinType) — both static, no offsets."""
|
|
return (
|
|
selector("addr(bytes32,uint256)")
|
|
+ node.hex()
|
|
+ coin_type.to_bytes(32, "big").hex()
|
|
)
|
|
|
|
|
|
def addr_multicoin(resolver: str, node: bytes, coin_type: int):
|
|
"""Read ENSIP-9 raw bytes for `coinType`, then encode to that chain's
|
|
canonical presentation form. Falls back to `0x`-prefixed hex if the
|
|
payload doesn't match any recognised on-chain shape. Returns None when
|
|
the record is unset."""
|
|
try:
|
|
raw = decode_bytes(eth_call(*addr_call(resolver, node, coin_type)))
|
|
except RuntimeError:
|
|
return None
|
|
if not raw:
|
|
return None
|
|
# An all-zero payload is the ENS convention for "unset" — many tools
|
|
# write 20 zero bytes for coinType=60 instead of clearing the slot.
|
|
# Treat it as null so the response doesn't surface a zero address.
|
|
if raw == b"\x00" * len(raw):
|
|
return None
|
|
encoder = COIN_ENCODERS.get(coin_type)
|
|
if encoder is None:
|
|
return "0x" + raw.hex()
|
|
try:
|
|
return encoder(raw) or ("0x" + raw.hex())
|
|
except Exception:
|
|
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
|
|
# for BTC, SS58 for DOT, Monero-base58 for XMR). All stdlib + eth_hash.
|
|
|
|
|
|
B58_ALPHA = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
|
|
|
|
def _b58_encode(b: bytes) -> str:
|
|
n = int.from_bytes(b, "big")
|
|
out = ""
|
|
while n:
|
|
n, r = divmod(n, 58)
|
|
out = B58_ALPHA[r] + out
|
|
# leading zero bytes → leading '1's
|
|
pad = len(b) - len(b.lstrip(b"\x00"))
|
|
return "1" * pad + out
|
|
|
|
|
|
def _b58check_encode(payload: bytes) -> str:
|
|
"""Base58Check used by BTC legacy/P2SH: payload + dSHA256(payload)[:4]."""
|
|
chk = hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4]
|
|
return _b58_encode(payload + chk)
|
|
|
|
|
|
# ---- Bech32 / Bech32m (BIP-173 / BIP-350) ----
|
|
|
|
_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
_BECH32_GEN = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
|
|
|
|
|
|
def _bech32_polymod(values):
|
|
chk = 1
|
|
for v in values:
|
|
b = chk >> 25
|
|
chk = ((chk & 0x1FFFFFF) << 5) ^ v
|
|
for i in range(5):
|
|
if (b >> i) & 1:
|
|
chk ^= _BECH32_GEN[i]
|
|
return chk
|
|
|
|
|
|
def _bech32_hrp_expand(hrp):
|
|
return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
|
|
|
|
|
|
def _bech32_create_checksum(hrp, data, spec):
|
|
const = 1 if spec == "bech32" else 0x2BC830A3 # bech32m
|
|
values = _bech32_hrp_expand(hrp) + data + [0] * 6
|
|
polymod = _bech32_polymod(values) ^ const
|
|
return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
|
|
|
|
|
|
def _bech32_encode(hrp, data, spec):
|
|
combined = data + _bech32_create_checksum(hrp, data, spec)
|
|
return hrp + "1" + "".join(_BECH32_CHARSET[d] for d in combined)
|
|
|
|
|
|
def _convertbits(data, frombits, tobits, pad=True):
|
|
acc = 0
|
|
bits = 0
|
|
ret = []
|
|
maxv = (1 << tobits) - 1
|
|
max_acc = (1 << (frombits + tobits - 1)) - 1
|
|
for value in data:
|
|
if value < 0 or (value >> frombits):
|
|
return None
|
|
acc = ((acc << frombits) | value) & max_acc
|
|
bits += frombits
|
|
while bits >= tobits:
|
|
bits -= tobits
|
|
ret.append((acc >> bits) & maxv)
|
|
if pad and bits:
|
|
ret.append((acc << (tobits - bits)) & maxv)
|
|
elif not pad and (bits >= frombits or ((acc << (tobits - bits)) & maxv)):
|
|
return None
|
|
return ret
|
|
|
|
|
|
def _segwit_encode(hrp: str, witver: int, witprog: bytes) -> str:
|
|
spec = "bech32" if witver == 0 else "bech32m"
|
|
data = [witver] + _convertbits(list(witprog), 8, 5)
|
|
return _bech32_encode(hrp, data, spec)
|
|
|
|
|
|
# ---- BTC scriptPubKey → address ----
|
|
# ENSIP-9 stores the raw output script. Dispatch by length + opcode prefix.
|
|
|
|
def _btc_encode(raw: bytes) -> str | None:
|
|
hrp = "bc" # mainnet
|
|
if len(raw) == 25 and raw[:3] == b"\x76\xa9\x14" and raw[23:25] == b"\x88\xac":
|
|
return _b58check_encode(b"\x00" + raw[3:23]) # P2PKH
|
|
if len(raw) == 23 and raw[:2] == b"\xa9\x14" and raw[22:23] == b"\x87":
|
|
return _b58check_encode(b"\x05" + raw[2:22]) # P2SH
|
|
if len(raw) == 22 and raw[:2] == b"\x00\x14":
|
|
return _segwit_encode(hrp, 0, raw[2:22]) # P2WPKH
|
|
if len(raw) == 34 and raw[:2] == b"\x00\x20":
|
|
return _segwit_encode(hrp, 0, raw[2:34]) # P2WSH
|
|
if len(raw) == 34 and raw[:2] == b"\x51\x20":
|
|
return _segwit_encode(hrp, 1, raw[2:34]) # P2TR
|
|
return None
|
|
|
|
|
|
# ---- Polkadot SS58 ----
|
|
# Per SS58 spec: base58( prefix_byte + pubkey + blake2b-512("SS58PRE" + body)[:2] )
|
|
# Polkadot mainnet uses network prefix 0 (single byte); Kusama uses 2.
|
|
|
|
_SS58_PRE = b"SS58PRE"
|
|
|
|
|
|
def _ss58_encode(pubkey: bytes, network_prefix: int = 0) -> str:
|
|
if len(pubkey) != 32:
|
|
return None
|
|
body = bytes([network_prefix]) + pubkey
|
|
checksum = hashlib.blake2b(_SS58_PRE + body, digest_size=64).digest()[:2]
|
|
return _b58_encode(body + checksum)
|
|
|
|
|
|
def _dot_encode(raw: bytes) -> str | None:
|
|
return _ss58_encode(raw, network_prefix=0)
|
|
|
|
|
|
# ---- Monero base58 ----
|
|
# Monero base58 encodes in 8-byte blocks; each full block → 11 chars, partial
|
|
# block sizes per fixed table. Alphabet is identical to Bitcoin's.
|
|
|
|
_XMR_BLOCK_SIZES = [0, 2, 3, 5, 6, 7, 9, 10, 11]
|
|
|
|
|
|
def _xmr_encode(raw: bytes) -> str:
|
|
out = []
|
|
for i in range(0, len(raw), 8):
|
|
chunk = raw[i:i + 8]
|
|
n = int.from_bytes(chunk, "big")
|
|
width = 11 if len(chunk) == 8 else _XMR_BLOCK_SIZES[len(chunk)]
|
|
block = []
|
|
for _ in range(width):
|
|
n, r = divmod(n, 58)
|
|
block.append(B58_ALPHA[r])
|
|
out.append("".join(reversed(block)))
|
|
return "".join(out)
|
|
|
|
|
|
# ---- ETH EIP-55 mixed-case checksum ----
|
|
|
|
def _eth_encode(raw: bytes) -> str | None:
|
|
if len(raw) != 20:
|
|
return None
|
|
hex_addr = raw.hex()
|
|
hash_hex = keccak(hex_addr.encode()).hex()
|
|
return "0x" + "".join(
|
|
c.upper() if c.isalpha() and int(hash_hex[i], 16) >= 8 else c
|
|
for i, c in enumerate(hex_addr)
|
|
)
|
|
|
|
|
|
COIN_ENCODERS = {
|
|
COIN_ETH: _eth_encode,
|
|
COIN_BTC: _btc_encode,
|
|
COIN_XMR: _xmr_encode,
|
|
COIN_DOT: _dot_encode,
|
|
}
|
|
|
|
|
|
# ---------- Resolution logic ----------
|
|
|
|
# Text-record keys we read from the resolver. Surfaced under the response
|
|
# field names listed in the docstring above. `name` and `description` are
|
|
# common ENS fallbacks for a human-readable nickname.
|
|
TEXT_KEYS = [
|
|
"name",
|
|
"nickname",
|
|
"description",
|
|
"url",
|
|
"location",
|
|
"simplex.contact",
|
|
"simplex.channel",
|
|
]
|
|
|
|
|
|
# Separator that joins the SMP-server URL list inside a simplex.contact /
|
|
# simplex.channel text record. MUST match SIMPLEX_LINK_SEPARATOR in the dApp
|
|
# (ens-app-v3 src/constants/simplex.ts) — the two sides decode the same record.
|
|
LINK_SEPARATOR = ";"
|
|
|
|
|
|
def split_links(value: str) -> list:
|
|
"""Split a separator-joined text record into an ordered list of entries.
|
|
|
|
Trims whitespace around each element and drops empties so trailing
|
|
separators, doubled separators, and all-whitespace inputs all yield clean
|
|
output. Single-value records yield a 1-element list; empty inputs
|
|
yield `[]`. Used for `simplex.contact` / `simplex.channel`, which
|
|
store one-or-more SMP-server URLs as a single `LINK_SEPARATOR`-joined string.
|
|
"""
|
|
return [item.strip() for item in value.split(LINK_SEPARATOR) if item.strip()]
|
|
|
|
|
|
def upstream_error(subject: dict, e: Exception) -> dict:
|
|
"""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",
|
|
"message": f"upstream RPC failed ({type(e).__name__})",
|
|
}
|
|
|
|
|
|
def name_record(name: str):
|
|
"""The NameRecord for a registered name. A name with no resolver set still
|
|
has one, with every field unset."""
|
|
registry = REGISTRIES[name.rsplit(".", 1)[-1]]
|
|
node = node_of(name)
|
|
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": "",
|
|
"website": "",
|
|
"location": "",
|
|
"simplexContact": [],
|
|
"simplexChannel": [],
|
|
"eth": None,
|
|
"btc": None,
|
|
"xmr": None,
|
|
"dot": None,
|
|
"owner": owner,
|
|
"resolver": resolver_addr,
|
|
}
|
|
if resolver_addr == ZERO_ADDR:
|
|
return rec
|
|
prefetch(record_reads(resolver_addr, node))
|
|
texts = {}
|
|
for k in TEXT_KEYS:
|
|
try:
|
|
v = text(resolver_addr, node, k)
|
|
except RuntimeError:
|
|
v = ""
|
|
if v:
|
|
texts[k] = v
|
|
rec.update(
|
|
{
|
|
"nickname": texts.get("nickname") or texts.get("name") or texts.get("description") or "",
|
|
"website": texts.get("url", ""),
|
|
"location": texts.get("location", ""),
|
|
"simplexContact": split_links(texts.get("simplex.contact", "")),
|
|
"simplexChannel": split_links(texts.get("simplex.channel", "")),
|
|
"eth": addr_multicoin(resolver_addr, node, COIN_ETH),
|
|
"btc": addr_multicoin(resolver_addr, node, COIN_BTC),
|
|
"xmr": addr_multicoin(resolver_addr, node, COIN_XMR),
|
|
"dot": addr_multicoin(resolver_addr, node, COIN_DOT),
|
|
}
|
|
)
|
|
return rec
|
|
|
|
|
|
def name_response(reg, registration_body):
|
|
"""The SMP protocol's NameResponse: the registration and the block read at."""
|
|
return 200, {"lastBlockTs": reg["lastBlockTs"], "registration": registration_body}
|
|
|
|
|
|
def registration(name: str):
|
|
"""The SMP protocol's NameRegistration, which the relay decodes as is.
|
|
Translating the contract's model to it is this resolver's job."""
|
|
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"):
|
|
rec = name_record(name)
|
|
# the client checks that the record names what it asked about, so a
|
|
# hashed query the registrar cannot name is refused rather than answered
|
|
if rec["name"] is None:
|
|
return 502, {"name": name, "error": "labelNotRecorded"}
|
|
# a subname inherits the 2LD's status, so only its node's owner says
|
|
# whether anyone created it
|
|
if len(name.split(".")) > 2 and rec["owner"] == ZERO_ADDR:
|
|
# name_status reads pricing only when the name was already
|
|
# unregistered, so read it here
|
|
status = "unregistered"
|
|
pricing = pricing_params(tld)
|
|
if pricing:
|
|
reg.update({k: v for k, v in pricing.items() if not k.startswith("_")})
|
|
else:
|
|
return name_response(reg, {
|
|
"type": "registered",
|
|
"expires": reg["expires"],
|
|
"graceUntil": reg["graceEnds"],
|
|
"reservedReason_": reg["reasonCode"],
|
|
"nameRecord": rec,
|
|
})
|
|
if reg["reasonCode"]:
|
|
return name_response(reg, {"type": "reserved", "reservedReason": reg["reasonCode"]})
|
|
if status in ("unregistered", "expired"):
|
|
if "basePrice" not in reg:
|
|
return 502, {"name": name, "error": "noPriceOracle"}
|
|
return name_response(reg, {
|
|
"type": "available",
|
|
"pricing": {
|
|
"registrationPrices": reg["registrationPrices"],
|
|
"basePrice": reg["basePrice"],
|
|
"minLabelLength": reg["minLabelLength"],
|
|
},
|
|
})
|
|
return 502, {"name": name, "error": status}
|
|
|
|
|
|
def resolve(name: str):
|
|
tld = name.rsplit(".", 1)[-1]
|
|
registry = REGISTRIES.get(tld)
|
|
if not registry:
|
|
configured = [k for k, v in REGISTRIES.items() if v]
|
|
return 400, {
|
|
"name": name,
|
|
"error": "tldNotConfigured",
|
|
"message": f"TLD '{tld}' is not configured on this resolver",
|
|
"configuredTlds": configured,
|
|
}
|
|
|
|
node = node_of(name)
|
|
prefetch(lookup_reads(name))
|
|
|
|
# Before the resolver lookup, so a lapsed name is not reported as noResolver.
|
|
reg = name_status(name)
|
|
if reg["status"] in ("unregistered", "expired"):
|
|
# A name in grace is not here: its record still resolves.
|
|
body = {
|
|
"name": name,
|
|
**reg,
|
|
"error": reg["status"],
|
|
"message": (
|
|
"this name has never been registered"
|
|
if reg["status"] == "unregistered"
|
|
else "this registration expired and is open to anyone"
|
|
),
|
|
}
|
|
return (404 if reg["status"] == "unregistered" else 410), body
|
|
|
|
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 <date>" stays
|
|
# answerable. For a subname, no owner means nobody created it.
|
|
owner = decode_address(eth_call(*owner_call(registry, node)))
|
|
if len(name.split(".")) > 2 and owner == ZERO_ADDR:
|
|
return 404, {
|
|
"name": name,
|
|
**reg,
|
|
"status": "unregistered",
|
|
"error": "unregistered",
|
|
"message": "this subname has never been created",
|
|
}
|
|
return 200, {
|
|
"name": canonical_name(name) or name,
|
|
"nickname": "",
|
|
"website": "",
|
|
"location": "",
|
|
"simplexContact": [],
|
|
"simplexChannel": [],
|
|
"eth": None,
|
|
"btc": None,
|
|
"xmr": None,
|
|
"dot": None,
|
|
"owner": owner,
|
|
"resolver": ZERO_ADDR,
|
|
**reg,
|
|
}
|
|
|
|
owner = decode_address(eth_call(*owner_call(registry, node)))
|
|
|
|
prefetch(record_reads(resolver_addr, node))
|
|
texts = {}
|
|
for k in TEXT_KEYS:
|
|
try:
|
|
v = text(resolver_addr, node, k)
|
|
except RuntimeError:
|
|
v = ""
|
|
if v:
|
|
texts[k] = v
|
|
|
|
# The user-facing "nickname" prefers an explicit `nickname` record,
|
|
# falls back to `name`, then `description` (ENSIP-5 convention).
|
|
nickname = texts.get("nickname") or texts.get("name") or texts.get("description") or ""
|
|
|
|
# Keys chosen to be valid Haskell record-field identifiers (lowercase
|
|
# initial, no dots) so consumers can derive aeson FromJSON instances
|
|
# without a key-rewriting layer. On-chain text-record names still
|
|
# use the ENSIP-5 dot convention (e.g. "simplex.contact") — only the
|
|
# resolver's JSON surface camelCases them.
|
|
return 200, {
|
|
"name": canonical_name(name) or name,
|
|
"nickname": nickname,
|
|
"website": texts.get("url", ""),
|
|
"location": texts.get("location", ""),
|
|
"simplexContact": split_links(texts.get("simplex.contact", "")),
|
|
"simplexChannel": split_links(texts.get("simplex.channel", "")),
|
|
"eth": addr_multicoin(resolver_addr, node, COIN_ETH),
|
|
"btc": addr_multicoin(resolver_addr, node, COIN_BTC),
|
|
"xmr": addr_multicoin(resolver_addr, node, COIN_XMR),
|
|
"dot": addr_multicoin(resolver_addr, node, COIN_DOT),
|
|
"owner": owner,
|
|
"resolver": resolver_addr,
|
|
**reg,
|
|
}
|
|
|
|
|
|
# ---------- 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": public_endpoint(RPC), "registries": REGISTRIES, **head_block()})
|
|
return
|
|
|
|
if len(parts) == 3 and parts[0] == "v2" and parts[1] == "resolve":
|
|
name = parts[2].strip().lower()
|
|
if not name or "." not in name:
|
|
self._respond(400, {"name": name, "error": "notFullyQualified"})
|
|
return
|
|
try:
|
|
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)
|
|
return
|
|
|
|
# /v1/resolve is an alias: relays before SMP v22 call /resolve
|
|
if parts[:2] == ["v1", "resolve"] and len(parts) == 3:
|
|
parts = ["resolve", parts[2]]
|
|
|
|
if len(parts) == 2 and parts[0] == "resolve":
|
|
name = parts[1].strip().lower()
|
|
if not name or "." not in name:
|
|
self._respond(
|
|
400,
|
|
{
|
|
"name": name,
|
|
"error": "notFullyQualified",
|
|
"message": "expected a fully-qualified name, e.g. alice.testing",
|
|
},
|
|
)
|
|
return
|
|
try:
|
|
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)
|
|
return
|
|
|
|
self._respond(
|
|
404,
|
|
{
|
|
"error": "noSuchRoute",
|
|
"message": "not found",
|
|
"routes": ["/health", "/v2/resolve/<query>", "/v1/resolve/<name>", "/resolve/<name>"],
|
|
},
|
|
)
|
|
|
|
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):
|
|
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():
|
|
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,
|
|
)
|
|
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__":
|
|
main()
|