Skip to content

ENS: modernize resolution — CCIP-Read (ERC-3668), UniversalResolver, records, reverse [tracking] #1661

Description

@pragmaxim

This is only a research umbrella issue to analyze complexity and learn about ENS and erc-3668

Blockbook's ENS support (bchain/coins/eth/ethrpc.go) is a generation behind modern ENS. It does a manual namehash -> registry.resolver(bytes32) -> resolver.addr(bytes32) walk, mainnet-only (ensContracts() gates on MainNetChainID == MainNet), ETH-address only. It cannot resolve any offchain / L2 name (*.base.eth, *.linea.eth, *.cb.id, *.uni.eth, most subnames), has no wildcard (ENSIP-10) support, no reverse resolution, no text/contenthash/multicoin records, and no name normalization (unicode/emoji names mis-hash).

The modern resolution path is ERC-3668 CCIP-Read through the ENS UniversalResolver. Reference infra for the proof-verified variant: unruggable/gateways (@unruggable/gateways, MIT), which also demonstrates the ENSIP-10 wildcard resolve(bytes,bytes) flow, multicoin/text/contenthash dispatch, and reverse resolution.

Related: #1109 (reverse resolution shows a wrong/expired primary name), #1622 (ENS aliases unvalidated / not reverted on reorg).

Note: this epic was revised after a deep adversarial review (13 agents; every on-chain claim reproduced against a live mainnet archive node). The review corrected a factually wrong premise in the original trust framing, surfaced a missing mandatory ENSIP-21 batch-gateway layer, and found that #1109 would not actually be fixed by the original plan. The sections below are the corrected version.

Trust model: trust-minimized where the resolver uses a proof verifier; trusted-signer elsewhere

The mechanism this epic buys is verification by execution: because the ERC-3668 callback is performed as an eth_call to sender, the resolver's own callback runs inside our node's EVM, so whatever that resolver checks is checked by the EVM and never by our own parsing. That claim is correct and load-bearing — it was confirmed against four UniversalResolver generations and against CCIPBatcher.ccipBatchCallback / AbstractUniversalResolver._callResolver.

What it does NOT buy is blanket trustlessness. Two distinct resolver classes exist today and both go through the same ERC-3668 loop:

  • PROOF-VERIFIED*.linea.eth (resolver 0x1507ce94…, verifier() = 0x9a4b070a… LineaSparseProofVerifier), plus the L1 ENSIP-19 Base/Optimism/Linea/Scroll reverse resolvers in ens-contracts/deployments/mainnet.
  • TRUSTED-SIGNER (ENS OffchainResolver + SignatureVerifier; owner-rotatable ECDSA key, no verifier contract) — *.base.eth (Coinbase; the resolver's owner() is a plain EOA that can addSigners), *.cb.id (CoinbaseResolver, upgradeable proxy), *.uni.eth (Uniswap).

So, precisely: for a proof-verifying resolver the gateway cannot fabricate a value — it can only serve values provable against a commit already on L1; it can still withhold, and can choose any commit inside the verifier's staleness window. For a trusted-signer resolver the gateway and the signer are the same party and it can lie outright.

Consequences for implementation:

  1. The callback MUST be an eth_call. Never decode and trust gateway-returned bytes directly. That is the line between preserving the resolver's guarantee and discarding it.
  2. No cross-chain RPC wiring for the mainnet instance. The L2 state root is already committed on L1, so resolution runs entirely against the existing L1 RPC — the ethereum instance never dials an L2. Realistic cost is >=2 eth_calls + >=1 gateway fetch per offchain hop (initial call reverts OffchainLookup -> batch-gateway round trip -> callback eth_call, inside which the resolver's callback runs via staticcall). Size recursion depth, timeouts and budgets from that, not from a single fetch. (Only the L2 instances need an L1 endpoint — ENS: L2-native resolution on L2 coins (Base/OP/Arbitrum/Polygon) #1668.)
  3. Blockbook writes no proof code. Verification is per-chain and already audited: MPT for eth/OP/Arbitrum, Mimc sparse tree for Linea, Poseidon for Scroll, Blake2S SMT for zkSync, Pedersen for Starknet. eth_call supports all of them — including chains added after we ship. A Go reimplementation would cover MPT only and would not improve the trust model (our anchor is our own L1 node either way); see ENS: optional Go-side MPT proof cross-check (defense-in-depth, not the primary mechanism) #1670.

⚠️ OPEN DECISION — how to treat trusted-signer resolvers (owner: #1669)

This must be settled before #1673 is implemented, because it decides that issue's acceptance criteria.

  • Option A — Trustless-only by default. Only proof-verified resolvers resolve; trusted-signer requires an explicit opt-in flag. Strongest guarantee, but *.base.eth / *.cb.id / *.uni.eth — the names users actually type — fail out of the box.
  • Option B — Resolve + label the trust level. Resolve everything, but classify and expose trust=proof-verified vs trust=trusted-signer on the result. Users get the names they expect; honesty via labelling rather than refusal, at the cost of "trustless" being a property of some results rather than the feature.

Either way, unchecked/unknown is never accepted and the trust class is surfaced, not flattened.

Where the actual rug surface is — not the proofs

Approach / sequencing

Land as a series of small PRs (one per child issue), in dependency order. Do the refactor first so the feature work has a clean home:

  1. Refactor — extract all ENS code out of ethrpc.go into bchain/coins/eth/ens.go (no behavior change). ENS refactor: extract ENS from ethrpc.go into bchain/coins/eth/ens.go #1662
  2. Name handling — DNS-encoding + normalization safety. ENS name handling: DNS-encoding + normalization safety (reject non-ASCII) #1663
  3. ERC-3668 transport + ENSIP-21 batch gateway — chain-agnostic CCIP-Read client. ENS: ERC-3668 CCIP-Read transport + ENSIP-21 batch gateway #1664
  4. Trust policy + classifier — settle the open decision above; must land with or before ENS: switch resolution to UniversalResolver (ENSIP-10 wildcard + entry-point predicate) #1673. ENS: verifier/resolver trust-level policy + classifier (OPEN DECISION: trustless-only vs resolve+label) #1669
  5. UniversalResolver switch — ENSIP-10 wildcard + entry-point predicate; the actual unlock for L2/offchain names. ENS: switch resolution to UniversalResolver (ENSIP-10 wildcard + entry-point predicate) #1673
  6. Records — multicoin addr, text, contenthash. ENS records: multicoin addr(bytes32,uint256), text, contenthash #1665
  7. Reverse resolution — address -> verified primary name. ENS reverse resolution: address -> verified primary name #1666
  8. Alias-label precedence — what actually lets EVM: ENS aliases are not correctly resolved #1109 close. ENS: deprecate the unvalidated NameRegistered alias label in favour of verified reverse resolution #1672
  9. Caching — TTL positive/negative cache. ENS resolution caching (TTL positive/negative cache) #1667
  10. L2-coin semantics — needs an L1 endpoint. ENS: L2-native resolution on L2 coins (Base/OP/Arbitrum/Polygon) #1668
  11. (optional) Go-side MPT cross-check — defense-in-depth only, explicitly not the trust mechanism. ENS: optional Go-side MPT proof cross-check (defense-in-depth, not the primary mechanism) #1670
  12. (stretch, unscheduled) ENSIP-15/UTS-46 normalizer — replaces ENS name handling: DNS-encoding + normalization safety (reject non-ASCII) #1663's reject-non-ASCII guard. ENS: ENSIP-15/UTS-46 name normalizer (vendor/port generated tables) #1671

Adjacent, not covered by the children above but required for #1109 to actually close: #1622 (validation + reorg-safety of the NameRegistered alias harvest).

Trust & safety guardrails (apply throughout)

  • Do the ERC-3668 callback as an eth_call to sender — never trust gateway JSON directly.
  • Validate the revert's sender equals the address called (else the callback can be redirected to an attacker-chosen target).
  • Treat gateway URLs as attacker-controlled: https-only for real URLs plus the allowlisted x-batch-gateway: sentinel (handled internally, never fetched); SSRF-block private/link-local/metadata destinations; cap redirects and total fetches per resolution.
  • Cap the gateway response at ~400–500KB — it roughly doubles as hex inside the JSON-RPC request body, and the node front-end's limit binds first (measured: nginx default 1 MiB returns HTTP 413 with an HTML body and no JSON-RPC error object).
  • HTTP client: a package-level *http.Client with an explicit Timeout, mirroring feeHTTPClient (bchain/coins/eth/alternativefeeprovider.go:29); leave Transport nil so ProxyFromEnvironment is honored; bound the body with io.LimitReader. (There is no repo-wide proxy helper; server/timeouts.go is inbound http.Server constants and does not apply.)
  • Reject non-ASCII names until ENS: ENSIP-15/UTS-46 name normalizer (vendor/port generated tables) #1671 lands (mis-hashing can resolve to an attacker's name).
  • Never accept unchecked verifiers; always surface the trust class (ENS: verifier/resolver trust-level policy + classifier (OPEN DECISION: trustless-only vs resolve+label) #1669).
  • Metrics & docs: counters in common/metrics.go for gateway fetch count/latency/failure, CCIP hop depth, verifier-class rejections, and the resolution-outcome taxonomy; plus a docs/ page covering the new outbound HTTPS egress requirement and the trust model. Fine to fold into the implementing PRs.

Estimated size

~2.3k–3.2k LOC across #1662#1670 + #1672#1673 (production + tests + per-coin boilerplate), revised upward from the original ~1.6k–2.3k after the review added the ENSIP-21 batch gateway (~150–250), the entry-point predicate, expiry/precedence work, and acceptance tests. Excludes #1671 (the ENSIP-15 normalizer, ~1.5k–3k, overwhelmingly generated tables — vendor it) and #1622.

Child issues

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions