From 02bc20ed4d364c336aaf9f760e6ffabdb57e7b82 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Mon, 7 Sep 2026 11:16:35 +0100 Subject: [PATCH 1/4] docs(design): what the gateway signs for, CORS, and ENSv2 The gateway signs for the resolver address it is configured to serve, not the {sender} in the path: through the batch gateway the two agree, but a resolver announcing ERC-7996 is called directly and the universal resolver raises the lookup again under its own address. CORS goes on every response because viem runs the batch gateway in the page and fetches the gateway cross-origin. ENSv2's DNS TLD resolver consults the v1 registry first, so the import and the resolver survive v2 unchanged. Assisted-by: Claude Fable 5.1 Signed-off-by: xgreenx --- design/ens-integration.md | 51 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/design/ens-integration.md b/design/ens-integration.md index f357319b..3b689adb 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,7 +271,8 @@ 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. @@ -286,7 +293,7 @@ work → 1. decode → (DNS-encoded name, record calldata) 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)) + 6. sign (resolver, expires, keccak(callData), keccak(result)) response → { "data": "0x…" } → the resolver's callback verifies ``` @@ -299,6 +306,38 @@ 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}` is logged and not parsed strictly — viem lowercases +it, so a checksum check refuses every request. 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, with an +`OPTIONS` handler for the `POST` form ERC-3668 falls back to when a template has +no `{data}`. 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 +348,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 +541,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 From 9059a243f633e423b78243dfad5854177c13bab5 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Mon, 7 Sep 2026 11:43:00 +0100 Subject: [PATCH 2/4] docs(design): the gateway may refuse a foreign sender, and GET is the only method The gateway in usernames-indexer#5 compares {sender} to its configured resolver and answers a terminal 400 on mismatch; the digest never uses it. Say so, instead of "logged and not parsed strictly". The resolver's urls carry {data} and the deployment workflow refuses a template without it, so ERC-3668's POST form never occurs; drop the OPTIONS clause that promised it. Assisted-by: Claude Fable 5.1 Signed-off-by: xgreenx --- design/ens-integration.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/design/ens-integration.md b/design/ens-integration.md index 3b689adb..64195745 100644 --- a/design/ens-integration.md +++ b/design/ens-integration.md @@ -316,20 +316,25 @@ 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}` is logged and not parsed strictly — viem lowercases -it, so a checksum check refuses every request. One instance serves one -resolver; another network or a replacement resolver gets its own. +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, with an -`OPTIONS` handler for the `POST` form ERC-3668 falls back to when a template has -no `{data}`. 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. +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 From 71f75cdaf91ad6d0464e97bf764e07a79895943a Mon Sep 17 00:00:00 2001 From: xgreenx Date: Mon, 7 Sep 2026 16:36:53 +0100 Subject: [PATCH 3/4] docs(design): the gateway reads the indexed model of every chain, and no RPC Aligns the backend section with usernames-indexer#7: answers come from the indexed model, chains are whatever indexers have written into the store, and the gateway takes no per-chain configuration. Assisted-by: Claude Fable 5.1 Signed-off-by: xgreenx --- design/ens-integration.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/design/ens-integration.md b/design/ens-integration.md index 64195745..34344477 100644 --- a/design/ens-integration.md +++ b/design/ens-integration.md @@ -275,7 +275,10 @@ reverting `OffchainLookup`; the callback verifying the response; **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. @@ -292,7 +295,7 @@ 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 + 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 From ba3bd531600ed7c1c72f978fc4fe8e50d9ace9e2 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Mon, 7 Sep 2026 16:50:07 +0100 Subject: [PATCH 4/4] docs(design): the gateway reads the mirror, matches coin types forward, and never decodes them The backend block said "no database" and decoded the coin type, which is not what usernames-indexer#7 does: the gateway reads the Postgres mirror the indexer keeps, matches a coin type forward against the chains the store holds, and answers null for a label that names another chain. Assisted-by: Claude Fable 5.1 Signed-off-by: xgreenx --- design/ens-integration.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/design/ens-integration.md b/design/ens-integration.md index 34344477..17146f77 100644 --- a/design/ens-integration.md +++ b/design/ens-integration.md @@ -285,7 +285,8 @@ 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. ``` @@ -294,7 +295,8 @@ 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 + 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))