diff --git a/design/ens-integration.md b/design/ens-integration.md index f357319b..17146f77 100644 --- a/design/ens-integration.md +++ b/design/ens-integration.md @@ -39,6 +39,12 @@ gas-heavy transaction, never repeated; it produces an ordinary ENS registry entr that a wildcard resolver then serves. The `.link` TLD is already wired into ENS (`registry.resolver(link)` is set), so nothing exotic is required. +**ENSv2 changes none of this.** Its root registry gives every DNS TLD one +`DNSTLDResolver`, whose first step is to look the name up in the v1 registry and +hand the query to whatever resolver it finds there, full name included. A DNS +name imported as above therefore keeps resolving through the same entry and the +same resolver after v2 ships; only `.eth` names have a migration. + A `.eth` second-level name was the alternative, and it is rejected. It costs an annual renewal whose expiry would kill every name beneath it at once — a recurring dependency taken on for nothing, since the DNS domain already exists and is @@ -265,17 +271,22 @@ the gateway's signing key. **1. The resolver contract** — mainnet, small, stateless. `resolve(name, data)` reverting `OffchainLookup`; the callback verifying the response; -`supportsInterface(0x9061b923)`; owner-managed gateway URLs and signer set. It +`supportsInterface(0x9061b923)` and no other id — ERC-7996 in particular, see +**What the gateway signs for**; owner-managed gateway URLs and signer set. It holds no names. -**2. The gateway** — below. +**2. The gateway** — below. It reads the indexed model `usernames-indexer` +keeps of every chain, and nothing else: no RPC, no per-chain configuration. +The chains it serves are the chains indexers have written; a new indexer is +served the first time it commits. **3. Nothing on the write path.** `IdentityNames` is untouched: no new call, no migration, no per-user transaction. ## The backend -A **stateless, read-only CCIP-Read gateway**. No database, no queue, no write +A **read-only CCIP-Read gateway** with no state of its own: it reads the +Postgres mirror `usernames-indexer` keeps of every chain. No queue, no write path, one signing key. ``` @@ -284,9 +295,10 @@ request → GET /{sender}/{data}.json (ERC-3668) work → 1. decode → (DNS-encoded name, record calldata) 2. parse right to left → handle, platform, optional chain 3. platformId = keccak256(platform domain) - 4. chainId = coinType & 0x7fffffff; refuse on chain-label mismatch - 5. IdentityNames.resolveHandle(platformId, handle) on that chain - 6. sign (sender, expires, keccak(callData), keccak(result)) + 4. match coinType forward against the chains the store holds; + a label naming another chain answers null + 5. look the binding up in that chain's indexed model + 6. sign (resolver, expires, keccak(callData), keccak(result)) response → { "data": "0x…" } → the resolver's callback verifies ``` @@ -299,6 +311,43 @@ response → { "data": "0x…" } → the resolver's callback verifies - **Cacheable** — answers are `IdentityNames` reads and carry an expiry the resolver enforces. +**What the gateway signs for.** The `resolver` in the digest is the address the +gateway is configured to serve, never the `{sender}` in the path. Today the two +agree, but only because of the route: a wallet reaches the resolver through the +ENS `UniversalResolver`, which forwards the `OffchainLookup` to a batch gateway +with the original sender intact (ENSIP-21). A resolver announcing ERC-7996 is +instead called directly, and the universal resolver raises the lookup again +under its own address (ENSIP-22), so `{sender}` names the universal resolver. +The reference `offchain-resolver` gateway signs for the path and would fail on +that route; that, and not taste, is why the resolver announces ENSIP-10 and +nothing else. Signing for the configured address costs one setting and holds on +both routes. `{sender}` never enters the digest; the gateway may refuse a +mismatch with a terminal 400, and logs it either way. The comparison is +case-insensitive — viem lowercases it, so a checksum check refuses every +request — and that refusal is the first thing to relax before any ERC-7996 +deployment. One instance serves one resolver; another network or a +replacement resolver gets its own. + +**CORS, on every response.** The mainnet universal resolver lists its gateways +as `["https://ccip-v3.ens.xyz", "x-batch-gateway:true"]`, and the second entry +tells viem to run the batch gateway inside the page. The browser therefore +fetches this gateway directly from the wallet's origin, and without +`Access-Control-Allow-Origin: *` the script never sees the answer and the name +does not resolve. The header goes on every response, errors included. `GET` is +the only method: the resolver's `urls` carry `{data}`, the deployment workflow +refuses a template without it, and ERC-3668 uses its `POST` form only when +`{data}` is absent — so no `POST` route and no preflight. It belongs in the +binary rather than in a proxy in front of it, so no deployment can lose it. `*` +is right: the answers are public and signed, and nothing is sent with +credentials. + +**Direct invocation, later.** Once the gateway signs for a configured address +and can answer a `multicall(bytes[])` inside `resolve`, a new resolver +deployment may announce ERC-7996 with `eth.ens.resolver.extended.multicall`: +one signed answer then carries a whole profile, and the ENS batch gateway drops +out of the path. Not before the v2 contracts are final, and never by editing +the deployed resolver, which is immutable — replacing it is one `setResolver`. + It belongs in its own repository, following the pattern of `notary` and `identity-backend`: a small Rust binary shipped as a container. It must not live inside `identity-backend`, which is the write path — OAuth, proofs, claiming. @@ -309,7 +358,9 @@ serverless deployment. and a client with CCIP-Read enabled follows the lookup silently, which makes an offchain resolver look like an on-chain one. Telling the two apart means disabling CCIP-Read deliberately — in viem that is a client option, and passing it to the -action instead is silently ignored. +action instead is silently ignored. A test through the mainnet +`UniversalResolver`, with the batch gateway played by the test, covers the route +a wallet actually takes; the resolver's own tests do not. ## What ENSIP-15 allows @@ -500,7 +551,9 @@ where attention usually goes. What a signature covers is `(resolver, expires, keccak(callData), keccak(result))`. An answer is therefore bound to that resolver and that query, and expires — it -cannot be replayed for another name or after its deadline. +cannot be replayed for another name or after its deadline. The `resolver` is the +gateway's own configuration, not the request's `{sender}`, for the reason given +under **The backend**. A storage proof against an L2 state root posted on L1 would replace the signature, and it is not the plan. It also would not generalize: it needs a chain that posts