feat(ens): a wildcard resolver for handles.link, and the workflow that sets it - #15
Merged
Merged
Conversation
SupremaLex
force-pushed
the
feat/ens-resolver
branch
from
August 28, 2026 16:43
59d97a5 to
379f537
Compare
SupremaLex
force-pushed
the
feat/ens-resolver
branch
from
August 28, 2026 16:48
379f537 to
8882f2b
Compare
SupremaLex
force-pushed
the
feat/ens-resolver
branch
from
September 2, 2026 10:12
8882f2b to
21a43b7
Compare
`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>
SupremaLex
force-pushed
the
feat/ens-resolver
branch
from
September 3, 2026 07:43
21a43b7 to
3a1dbc4
Compare
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
approved these changes
Sep 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Design: libid#7, section 1. The gateway of section 2 is usernames-indexer#5;
IdentityNamesis untouched.The resolver
resolveHandle(platformId, handle)already answers the question an ENS resolver asks, so a name underhandles.linkneeds no registry entry, no NFT and no record of its own — it is a view onto state that already exists.HandleResolveris 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.linkandalice.x.base.handles.linkalike. ERC-3668 carries the endpoints in a revert, so nothing is registered with any wallet. Both on-chain halves areview.Not upgradeable, on purpose. Replacing it is
setResolveron the ENS name — one owner transaction, visible in the registry.What review changed
/code-review xhighfound four things worth the PR description.An answer could claim to be good forever.
expiresis 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_LIFETIMEis 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;
setSignerandsetUrlsare bothonlyOwner, 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 againstUntrustedSigner.The contract shipped in neither published package.
vendor-artifacts.sh,COVEREDand the TS codegen are explicit allowlists, so both--checkgates 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
setResolvermust come from the name owner, which isalias/dyaka-testnet-deployer— the same key that owns every UUPS proxy. So it runs in CI, on @xgreenx skeleton from #14: same OIDC role, samedry_rundefault, samecast wallet address --aws, RPC as an environment secret exactly as that TODO recommends.Kept as its own file rather than folded into
deploy.ymlbecause 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 realhandles.linknode, the impersonated owner,setResolver, andresolve()revertingOffchainLookupwith the gateway URL inside it.Not here
Section 0 of the design — importing
handles.linkthroughDNSRegistrar— is already done: the name is claimed on Sepolia, resolver unset. And four findings from the same review live onmainrather than this branch: the DCO check rejects any commit whose author differs from its committer,rust/identitycarries#![deny(warnings)]into a published crate,Rules::for_platformhardcodes the platform list the registry owns, andTemplateMatching.t.solwas left half-renamed.