Skip to content

feat(ens): a wildcard resolver for handles.link, and the workflow that sets it - #15

Merged
xgreenx merged 12 commits into
mainfrom
feat/ens-resolver
Sep 7, 2026
Merged

xgreenx merged 12 commits into
mainfrom
feat/ens-resolver

Conversation

@SupremaLex

@SupremaLex SupremaLex commented Aug 28, 2026

Copy link
Copy Markdown
Member

Design: libid#7, section 1. The gateway of section 2 is usernames-indexer#5; IdentityNames is untouched.

The resolver

resolveHandle(platformId, handle) already answers the question an ENS resolver asks, so a name under handles.link needs no registry entry, no NFT and no record of its own — it is a view onto state that already exists. HandleResolver is the arrangement around that view and nothing more: where to ask, and whose answer to believe. It stores no name and never learns one.

ENSIP-10 has the client walk up from the full name and hand the resolver it finds the ORIGINAL name, so one contract at the apex covers alice.x.handles.link and alice.x.base.handles.link alike. ERC-3668 carries the endpoints in a revert, so nothing is registered with any wallet. Both on-chain halves are view.

Not upgradeable, on purpose. Replacing it is setResolver on the ENS name — one owner transaction, visible in the registry.

What review changed

/code-review xhigh found four things worth the PR description.

An answer could claim to be good forever. expires is the gateway choice and only the near end was checked, so a signing key could mint blobs valid indefinitely — capture one, replay it after the handle moves, and the chain returns the wallet the name used to hold. MAX_LIFETIME is one hour, and a constant rather than owner state because it bounds what the owner own signer set can do.

The NatSpec claimed a separation the code does not have. It said redirecting resolution needs the owner key AND a signing key; setSigner and setUrls are both onlyOwner, so the owner key alone does it in two transactions. The comment now says so, and says the part that matters here: the owner key, the deployer key and the ENS name owner are ONE identity today.

A test proved nothing. 65 zero bytes give v == 0, which OpenZeppelin rejects before the signer lookup runs — so the assertion passed with the membership check deleted. It is now a real signature from an untrusted key, asserted against UntrustedSigner.

The contract shipped in neither published package. vendor-artifacts.sh, COVERED and the TS codegen are explicit allowlists, so both --check gates passed with it missing.

Also: the expiry boundary was untested, and the signature covers no chain id — matching the ENS reference the suite pins byte for byte — so the deployment discipline that stands in for it is now written down.

The workflow

setResolver must come from the name owner, which is alias/dyaka-testnet-deployer — the same key that owns every UUPS proxy. So it runs in CI, on @xgreenx skeleton from #14: same OIDC role, same dry_run default, same cast wallet address --aws, RPC as an environment secret exactly as that TODO recommends.

Kept as its own file rather than folded into deploy.yml because the operations differ in kind: that one deploys and upgrades the stack repeatedly; this runs when a domain gets its resolver, which is once.

Verification

839 forge tests, forge fmt, vendor-artifacts.sh --check, codegen:check, 23 TS tests, the vendored Rust crate. The deploy path was rehearsed against a Sepolia fork: the real handles.link node, the impersonated owner, setResolver, and resolve() reverting OffchainLookup with the gateway URL inside it.

Not here

Section 0 of the design — importing handles.link through DNSRegistrar — is already done: the name is claimed on Sepolia, resolver unset. And four findings from the same review live on main rather than this branch: the DCO check rejects any commit whose author differs from its committer, rust/identity carries #![deny(warnings)] into a published crate, Rules::for_platform hardcodes the platform list the registry owns, and TemplateMatching.t.sol was left half-renamed.

`IdentityNames.resolveHandle(platformId, handle)` already answers the question
an ENS resolver asks. So a name under `handles.link` needs no registry entry,
no NFT, no mint and no record of its own -- it is a view onto state that
already exists, on whichever chain holds the binding.

`HandleResolver` is the arrangement around that view, and nothing more: where
to ask, and whose answer to believe. It stores no name and never learns one.

  * ENSIP-10 has the client walk up from the full name until something
    answers, and hand that resolver the ORIGINAL name. Set once on
    `handles.link`, this covers `alice.x.handles.link` and
    `alice.x.base.handles.link` alike, with no name created between them.
    `supportsInterface(0x9061b923)` is what makes a wallet willing to try.
  * ERC-3668 carries the endpoints in a revert, so nothing is registered with
    any wallet: `resolve` always reverts `OffchainLookup`, the client fetches
    a signed blob, and `resolveWithProof` returns the record. Both on-chain
    halves are `view`.
  * The gateway is not trusted. It signs; this verifies against a pinned
    signer set. The signature covers the resolver, an expiry, the exact
    request and the exact result, so an answer cannot be replayed against
    another resolver, reused for another query, or served after its deadline.
    `makeSignatureHash` is byte-identical to the reference
    `SignatureVerifier`, so a gateway written against the ENS libraries works
    unmodified.
  * Not upgradeable. Replacing it is `setResolver` on the ENS name -- one
    owner transaction, visible in the registry. A proxy would add a second way
    to change behaviour and buy nothing.

### On ENSv2

Nothing here is v1-specific. CCIP-Read, DNS-imported names and ENSIP-11 coin
types all survive into ENSv2, where resolution runs through the
`UniversalResolver` walking the hierarchical registries -- which is the same
walk-up this depends on. The one concrete v2 instruction, never to hardcode a
resolver address, binds clients rather than this contract: we ARE the
resolver, and both versions find us through the registry.

### What the tests pin

The protocol first: that the wildcard interface is announced, that `resolve`
reverts with the endpoints and the query, and that a signed answer resolves.
Then the four ways an answer must not be accepted -- wrong signer, altered
result, another query, another resolver, past its expiry.

Two more that were missing. The response is bytes from an untrusted HTTP
endpoint, decoded before anything is checked; `abi.decode` reverts on a
malformed word, so the guarantee already held, but nothing recorded it and it
would have stopped holding silently the day someone hand-rolled the slicing.
And deploying with no endpoint is deliberate -- the ENS name and the resolver
are set in one `proveAndClaimWithResolver`, before the gateway need exist --
so a test walks that path rather than leaving `NoUrls` looking like an
oversight.

Design: libid PR #7. The gateway of section 2 and the `handles.link` import of
section 0 are not here; this is section 1 alone, and `IdentityNames` is
untouched.

Assisted-by: Claude Opus 5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The commit that added this resolver claimed a gateway written against the ENS
libraries works here unmodified. That was a sentence, not a test, and it is the
kind of claim that stops being true quietly.

`ensdomains/offchain-resolver` carries `abi.encode(callData, address(this))` in
`extraData` and recovers the target by decoding it; this contract passes
`callData` alone and names `address(this)` directly. Different bytes travel, so
the two look incompatible until you follow what actually reaches the hash: the
gateway never sees `extraData` -- ERC-3668 hands it `sender` and `data` -- and
both sides end up hashing the same `(target, expires, keccak(request),
keccak(result))`.

The test recomputes `SignatureVerifier.makeSignatureHash` inline, so the
comparison holds without taking a dependency for twenty lines. Change either
side and it fails.

Assisted-by: Claude Opus 5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…ry the ABI

`/code-review xhigh` over this branch. Four things, and one of them was a test
that asserted nothing.

**An answer could claim to be good forever.** `expires` is chosen entirely by
the gateway and the contract only checked the near end, so a signing key could
mint blobs valid until the heat death — capture one, replay it after the handle
moves, and the chain returns the wallet the name USED to hold. `MAX_LIFETIME`
is one hour and a constant rather than owner state: it bounds what the owner's
own signer set can do, and a bound the owner can widen is not one.

**The NatSpec claimed a separation the code does not have.** It said
redirecting resolution needs the owner key AND a signing key, "and those must
not be held together". `setSigner` and `setUrls` are both `onlyOwner`, so the
owner key alone does it in two transactions. The comment now says that, and
says the part that actually matters here: the owner key, the deployer key and
the ENS name's owner are ONE identity in the current setup, so a single
compromise reaches all three.

**`test_aWellFormedResponseWithAGarbageSignatureIsRefused` proved nothing.**
Sixty-five zero bytes give `v == 0`, which OZ's `ECDSA.recover` rejects before
the signer lookup runs at all — so a bare `expectRevert` passed with the
membership check deleted entirely. It is now two tests: a real signature from
an untrusted key asserted against this contract's own `UntrustedSigner`, and
the malformed case kept but named for what it does.

**The contract shipped in neither published package.** `vendor-artifacts.sh`,
`artifacts.rs`'s `COVERED` and the TypeScript codegen are all explicit
allowlists, so both `--check` gates passed with the new contract missing: a
`libid-contracts` consumer could not deploy it from embedded bytecode and a
`@libid/contracts` consumer had no `handleResolverAbi` to call `setUrls` with.

Also: the expiry boundary was untested — only `expires + 1` was exercised, so
flipping `<` to `<=` would have made every answer unusable in the block its own
deadline names and no test would have noticed. And the signature covers no
chain id, matching the ENS reference the suite pins byte for byte, so the
deployment discipline that stands in for it is now written down rather than
assumed: never share a signing key between deployments that could land on the
same address.

Four ways it could have reported success while leaving a mess: it took
`deployedTo` unvalidated and would have called `setResolver(node, null)` after
paying for a deploy; it printed the registry value after `setResolver` without
comparing it, and `cast send` exits 0 on a reverted transaction; it probed
`supportsInterface` on the address it deployed rather than the one the registry
names; and it required no `{sender}`/`{data}` placeholders in the gateway URL,
which would have deployed cleanly and then failed every resolution with an
opaque client-side error. `AWS_REGION` was documented as required and not
checked.

Four findings from the same review are NOT fixed here, because this branch adds
three files under `contracts/ens/` and touches nothing else: the DCO check in
`ci.yml` rejects any commit whose author differs from its committer,
`rust/identity` carries `#![deny(warnings)]` into a published crate,
`Rules::for_platform` hardcodes the platform list the registry is supposed to
own, and `TemplateMatching.t.sol` was left half-renamed. They belong in their
own change against `main`.

Rebased onto build-time artifact generation. The ABI is no longer committed:
the resolver is registered in `COVERED`, `vendor-artifacts.sh` and
`codegen.mjs`, and the build produces both its artifact and its TypeScript
ABI. Verified after the rebase — vendoring picks it up among 29 artifacts and
codegen writes `handleResolver.ts` and its export.

Assisted-by: Claude Opus 5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
Fills the TODO the deploy skeleton was created with, rather than adding a
second workflow beside it. `component` picks what to deploy: `ens-resolver`
is implemented here, `stack` keeps the exit-1 TODO so the file does not
claim more than it does.

The steps deploy `HandleResolver` signed by the KMS deployer and point an
ENS name at it. Both directions are checked before anything is spent — the
deployer must own the name, since `setResolver` reverts for anyone else and
a revert after the deploy strands a paid-for contract — and checked again
after, because `cast send` exits 0 on a reverted transaction.

`network` names the ENVIRONMENT, not the chain: it selects the OIDC sub and
the endpoint, and nothing else. For `stack` testnet means eden, for
`ens-resolver` it means Sepolia, because that is where ENS lives. The
preflight asserts the chain id it reached is the one the component expects,
so a wrong endpoint fails before it spends rather than deploying somewhere
nobody will look.

`rpc_url` is accepted so a one-shot deploy is not blocked on creating an
environment secret; it is documented as public-endpoint-only, because
dispatch inputs are recorded with the run.

Assisted-by: Claude Opus 5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
Brings #21 under the resolver: the wallet products are gone, the JWKS
roots are GoogleJwtRoots under ceremony/, and the crate and package lists
name the identity stack. The three conflicts were the artifact lists the
resolver adds itself to; HandleResolver keeps its entry beside
IdentityNames and the removed contracts stay removed.

Assisted-by: Claude Fable 5.1
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
Two bare expectReverts asserted only that something failed: an outsider
calling setUrls or setSigner, and a signature recovery refuses. Each now
names its error and its argument, so a check that stopped firing would be
noticed. The decode of a malformed gateway blob stays bare, and says why.

Assisted-by: Claude Fable 5.1
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
The stack component was the TODO the file was created with, kept beside
the resolver as a step that exits 1. The contracts are deployed by
chain-configurations (libid-deploy), so the component, its input and the
paragraphs written around it go; what is left is the one thing the
workflow does, and the KMS rationale names what that key really holds:
the ENS name and the resolver, not a set of proxies.

Assisted-by: Claude Fable 5.1
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
The crate embeds the resolver's artifact so a consumer can deploy it
without a checkout, but a consumer talking to it had no binding: the
constructor tuple, the owner surface and the views were left to hand
encoding, unlike every other covered contract. The anvil test deploys it
from the embedded artifact with its constructor arguments and reads the
configuration back.

Assisted-by: Claude Fable 5.1
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
IExtendedResolver justified being declared locally by analogy with
IIdentityVerifier and INotary; neither exists on main any more.

Assisted-by: Claude Fable 5.1
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
ERC-7996 is the id the ENS universal resolver reads as leave to call a
resolver directly instead of through the batch gateway. On that route it
raises the OffchainLookup again under its own address, so the {sender} a
client fills into the gateway URL is the universal resolver, and a gateway
that signs for the URL's address signs for the wrong target. The NatSpec on
supportsInterface now records this, and a test pins the absence of the id.

Assisted-by: Claude Fable 5.1
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
docs(ens): say why the resolver announces ENSIP-10 and nothing else
chore(ens): the workflow deploys the resolver only, a crate binding, named reverts
@xgreenx
xgreenx merged commit 4a7f42d into main Sep 7, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants