From ccee614af53c8924f38e9144f0d1c804ad8ef83c Mon Sep 17 00:00:00 2001 From: xgreenx Date: Thu, 3 Sep 2026 14:46:28 +0100 Subject: [PATCH 1/5] test(ens): name the reverts the owner check and the recovery raise 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 --- solidity/contracts/ens/test/HandleResolver.t.sol | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/solidity/contracts/ens/test/HandleResolver.t.sol b/solidity/contracts/ens/test/HandleResolver.t.sol index 2757560..c9d0f1e 100644 --- a/solidity/contracts/ens/test/HandleResolver.t.sol +++ b/solidity/contracts/ens/test/HandleResolver.t.sol @@ -2,6 +2,8 @@ pragma solidity ^0.8.20; import {Test} from "forge-std/Test.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import {HandleResolver, OffchainLookup} from "../HandleResolver.sol"; import {IExtendedResolver} from "../IExtendedResolver.sol"; @@ -189,13 +191,14 @@ contract HandleResolverTest is Test { function test_onlyTheOwnerConfigures() public { string[] memory urls = new string[](1); urls[0] = "https://elsewhere/{sender}/{data}.json"; + address mallory = makeAddr("mallory"); - vm.prank(makeAddr("mallory")); - vm.expectRevert(); + vm.prank(mallory); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, mallory)); resolver.setUrls(urls); - vm.prank(makeAddr("mallory")); - vm.expectRevert(); + vm.prank(mallory); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, mallory)); resolver.setSigner(vm.addr(impostorKey), true); } @@ -276,6 +279,8 @@ contract HandleResolverTest is Test { junk[3] = abi.encode(bytes("x"), uint64(block.timestamp + 300)); // signature missing for (uint256 i = 0; i < junk.length; i++) { + // `abi.decode` reverts with no data, so there is no selector to + // name here: the property is that nothing returns. vm.expectRevert(); resolver.resolveWithProof(junk[i], request); } @@ -304,7 +309,7 @@ contract HandleResolverTest is Test { bytes memory request = abi.encodeWithSelector(IExtendedResolver.resolve.selector, name, data); bytes memory response = abi.encode(abi.encode(address(0xBEEF)), uint64(block.timestamp + 300), new bytes(65)); - vm.expectRevert(); + vm.expectRevert(ECDSA.ECDSAInvalidSignature.selector); resolver.resolveWithProof(response, request); } From 516aba3168c0c5486a07003a3a69471364b928f6 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Thu, 3 Sep 2026 14:47:25 +0100 Subject: [PATCH 2/5] ci(deploy): this workflow deploys the resolver, and says so 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 --- .github/workflows/deploy.yml | 74 ++++++++++-------------------------- 1 file changed, 21 insertions(+), 53 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d94d92b..c0e52f5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,15 +1,12 @@ -name: Deploy contracts +name: Deploy the ENS resolver -# Deploy/upgrade contracts on a network, signing with an AWS KMS key. +# Deploy `HandleResolver` and point an ENS name at it, signing with an AWS KMS +# key. This is the one thing this workflow does: the contracts themselves are +# deployed by chain-configurations (`libid-deploy`), not from here. # -# WHAT IS IMPLEMENTED: the `ens-resolver` component — deploy `HandleResolver` -# and point an ENS name at it. The `stack` component is still the TODO this -# file was created with, and still exits 1 rather than pretending to succeed. -# `component` picks between them. -# -# WHY KMS AND NOT A GITHUB SECRET: the deployer owns every UUPS proxy, so the -# ability to sign as it is the ability to replace the implementation behind the -# contracts and drain them. A KMS key cannot be exported — CI calls kms:Sign and +# WHY KMS AND NOT A GITHUB SECRET: the deployer key owns the ENS name and the +# resolver, so the ability to sign as it is the ability to point every name +# under `handles.link` anywhere. A KMS key cannot be exported — CI calls kms:Sign and # gets a signature back — so a compromised runner can sign while the run lasts # but cannot walk away with the key. # @@ -35,11 +32,10 @@ name: Deploy contracts # # `network` NAMES THE ENVIRONMENT, NOT THE CHAIN. It selects the authorization # and the RPC endpoint, and those are the only things it selects. Which chain is -# reached is decided by the RPC alone — for `stack`, `testnet` means eden, while -# for `ens-resolver` it means Sepolia, because that is where ENS exists. Rather -# than leave that to a comment, the preflight asserts the chain id it reached -# matches the one the component expects, so a wrong endpoint fails before it -# spends anything. +# reached is decided by the RPC alone — `testnet` means Sepolia, because that is +# where ENS exists. Rather than leave that to a comment, the preflight asserts +# the chain id it reached matches the one the environment expects, so a wrong +# endpoint fails before it spends anything. # # Every third-party action is pinned by commit SHA, with the tag in a comment, # so a moved tag cannot change what executes. @@ -54,25 +50,17 @@ on: options: - testnet - mainnet - component: - description: What to deploy - required: true - type: choice - default: ens-resolver - options: - - ens-resolver - - stack ens_name: - description: "ens-resolver: the ENS name to point at the resolver" + description: The ENS name to point at the resolver required: false type: string default: handles.link gateway_url: - description: "ens-resolver: CCIP-Read endpoint, carrying the {sender} and {data} placeholders" + description: "CCIP-Read endpoint, carrying the {sender} and {data} placeholders" required: false type: string gateway_signer: - description: "ens-resolver: address the gateway signs with; the resolver trusts only this" + description: Address the gateway signs with; the resolver trusts only this required: false type: string rpc_url: @@ -95,7 +83,7 @@ on: # against one name would race: the second `setResolver` lands on whichever # contract deployed last, and the first is orphaned. concurrency: - group: deploy-${{ inputs.network }}-${{ inputs.component }}-${{ inputs.ens_name }} + group: deploy-${{ inputs.network }}-${{ inputs.ens_name }} cancel-in-progress: false permissions: @@ -114,7 +102,7 @@ env: jobs: deploy: - name: ${{ inputs.component }} on ${{ inputs.network }} + name: ${{ inputs.ens_name }} on ${{ inputs.network }} runs-on: ubuntu-latest timeout-minutes: 30 # Load-bearing twice over: it is what puts `:environment:` in the @@ -134,7 +122,6 @@ jobs: version: ${{ env.FOUNDRY_VERSION }} - name: The chain endpoint is configured - if: ${{ inputs.component == 'ens-resolver' }} run: | set -euo pipefail [ -n "$RPC_URL" ] || { @@ -169,7 +156,6 @@ jobs: # contract nobody points at. - name: Preflight the name and the chain id: name - if: ${{ inputs.component == 'ens-resolver' }} env: DEPLOYER: ${{ steps.signer.outputs.deployer }} # Through the environment rather than `${{ }}` in the command: these @@ -239,13 +225,10 @@ jobs: echo "| | |" echo "|---|---|" echo "| Network | \`${{ inputs.network }}\` |" - echo "| Component | \`${{ inputs.component }}\` |" echo "| Deployer | \`$DEPLOYER\` |" - if [ "${{ inputs.component }}" = "ens-resolver" ]; then - echo "| Name | \`$ENS_NAME\` |" - echo "| Gateway URL | \`$GATEWAY_URL\` |" - echo "| Gateway signer | \`$GATEWAY_SIGNER\` |" - fi + echo "| Name | \`$ENS_NAME\` |" + echo "| Gateway URL | \`$GATEWAY_URL\` |" + echo "| Gateway signer | \`$GATEWAY_SIGNER\` |" echo "| Commit | \`$GITHUB_SHA\` |" } >> "$GITHUB_STEP_SUMMARY" @@ -257,7 +240,7 @@ jobs: - name: Deploy the resolver id: deploy - if: ${{ !inputs.dry_run && inputs.component == 'ens-resolver' }} + if: ${{ !inputs.dry_run }} working-directory: solidity env: AWS_KMS_KEY_ID: ${{ env.KMS_KEY_ID }} @@ -290,7 +273,7 @@ jobs: echo "address=$addr" >> "$GITHUB_OUTPUT" - name: Point the name at it - if: ${{ !inputs.dry_run && inputs.component == 'ens-resolver' }} + if: ${{ !inputs.dry_run }} env: AWS_KMS_KEY_ID: ${{ env.KMS_KEY_ID }} RESOLVER: ${{ steps.deploy.outputs.address }} @@ -313,18 +296,3 @@ jobs: echo "ENSIP-10: $wildcard" [ "$wildcard" = "true" ] || { echo "::error::the resolver does not announce ENSIP-10"; exit 1; } echo "_Deployed \`$RESOLVER\` and set it on \`$ENS_NAME\`._" >> "$GITHUB_STEP_SUMMARY" - - # --------------------------------------------------------------------- - # TODO: the contract stack. NOT IMPLEMENTED. - # - # Everything needed to sign is already in place when this runs: - # - AWS credentials for the deployer role are in the environment - # - AWS_KMS_KEY_ID selects the key - # - forge/cast can sign with `--aws` (no private key anywhere) - # --------------------------------------------------------------------- - - name: Deploy the stack - if: ${{ !inputs.dry_run && inputs.component == 'stack' }} - run: | - echo "::error::The stack deployment is not implemented yet." - echo "See the TODO in .github/workflows/deploy.yml." - exit 1 From 164444bc2a9f043032914da32e8e0460e8841766 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Thu, 3 Sep 2026 14:47:32 +0100 Subject: [PATCH 3/5] feat(rust): a binding for HandleResolver, and a deploy through the crate 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 --- rust/contracts/src/bindings/ens.rs | 42 ++++++++++++++++++++++++ rust/contracts/src/bindings/mod.rs | 1 + rust/contracts/tests/anvil.rs | 52 ++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 rust/contracts/src/bindings/ens.rs diff --git a/rust/contracts/src/bindings/ens.rs b/rust/contracts/src/bindings/ens.rs new file mode 100644 index 0000000..3aa8071 --- /dev/null +++ b/rust/contracts/src/bindings/ens.rs @@ -0,0 +1,42 @@ +//! Bindings for the ENS resolver (`solidity/contracts/ens/`). + +/// Bindings for `ens/HandleResolver.sol` — the wildcard resolver for +/// `handles.link`. +/// +/// Not a proxy: the owner, the gateway endpoints and the signer set arrive in +/// the constructor, and replacing the contract is `setResolver` on the ENS +/// name. Deploy it with `deploy_with_ctor` and the ABI-encoded constructor +/// tuple; `resolve` is absent because it always reverts `OffchainLookup` — +/// the client-side protocol, not something a Rust consumer calls. +#[allow(clippy::too_many_arguments, unused_attributes)] +mod inner { + use alloy::sol; + + sol! { + #[sol(rpc)] + contract HandleResolver { + constructor(address owner_, string[] memory urls_, address[] memory signers_); + + function urls(uint256 index) external view returns (string memory); + function urlCount() external view returns (uint256); + function signers(address signer) external view returns (bool); + function MAX_LIFETIME() external view returns (uint256); + function makeSignatureHash(address target, uint64 expires, bytes memory request, bytes memory result) external pure returns (bytes32); + function resolveWithProof(bytes calldata response, bytes calldata extraData) external view returns (bytes memory); + function supportsInterface(bytes4 interfaceId) external pure returns (bool); + + function setUrls(string[] calldata urls_) external; + function setSigner(address signer, bool trusted) external; + + function owner() external view returns (address); + function pendingOwner() external view returns (address); + function transferOwnership(address newOwner) external; + function acceptOwnership() external; + + event UrlsChanged(string[] urls); + event SignerChanged(address indexed signer, bool trusted); + } + } +} + +pub use inner::HandleResolver; diff --git a/rust/contracts/src/bindings/mod.rs b/rust/contracts/src/bindings/mod.rs index 0de398f..e6d8cef 100644 --- a/rust/contracts/src/bindings/mod.rs +++ b/rust/contracts/src/bindings/mod.rs @@ -2,6 +2,7 @@ //! sources in `solidity/contracts`. One module per contract directory. pub mod ceremony; +pub mod ens; pub mod factory; pub mod identity; pub mod proxy; diff --git a/rust/contracts/tests/anvil.rs b/rust/contracts/tests/anvil.rs index 5a816a3..a115030 100644 --- a/rust/contracts/tests/anvil.rs +++ b/rust/contracts/tests/anvil.rs @@ -384,3 +384,55 @@ async fn bootstraps_the_deterministic_factory_and_deploys_through_it() { assert_eq!(notary.fee().call().await.unwrap(), U256::from(7)); assert_eq!(notary.owner().call().await.unwrap(), deployer0); } + +/// The ENS resolver: a plain contract with constructor arguments, deployed +/// through `deploy_with_ctor` from the embedded artifact, then read back. +#[tokio::test] +async fn deploys_the_ens_resolver_with_its_constructor_arguments() { + use alloy::{ + primitives::FixedBytes, + sol_types::SolValue, + }; + use libid_contracts::{ + bindings::ens::HandleResolver, + deploy::deploy_with_ctor, + }; + + let provider = test_provider(); + let artifacts = Artifacts::embedded(); + let deployer = default_signer(&provider).await; + let gateway_signer = Address::repeat_byte(0x51); + let urls = vec!["https://gw.handles.link/{sender}/{data}.json".to_string()]; + + let ctor_args = (deployer, urls.clone(), vec![gateway_signer]).abi_encode_params(); + let resolver_addr = deploy_with_ctor( + &provider, + &artifacts.bytecode("HandleResolver").unwrap(), + &ctor_args, + "HandleResolver", + None, + ) + .await + .unwrap(); + + let resolver = HandleResolver::new(resolver_addr, &provider); + assert_eq!(resolver.owner().call().await.unwrap(), deployer); + assert_eq!(resolver.urlCount().call().await.unwrap(), U256::from(1)); + assert_eq!(resolver.urls(U256::ZERO).call().await.unwrap(), urls[0]); + assert!(resolver.signers(gateway_signer).call().await.unwrap()); + // ENSIP-10, or no wildcard name ever reaches it. + let ensip10 = FixedBytes::<4>::from([0x90, 0x61, 0xb9, 0x23]); + assert!(resolver.supportsInterface(ensip10).call().await.unwrap()); + + // The owner rotates the signer set; the read follows. + let next = Address::repeat_byte(0x52); + resolver + .setSigner(next, true) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(resolver.signers(next).call().await.unwrap()); +} From 56549adf549aa1832d1615b65378ee3f54bea64e Mon Sep 17 00:00:00 2001 From: xgreenx Date: Thu, 3 Sep 2026 14:51:06 +0100 Subject: [PATCH 4/5] docs(ens): the interface no longer cites contracts that are gone 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 --- solidity/contracts/ens/IExtendedResolver.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/solidity/contracts/ens/IExtendedResolver.sol b/solidity/contracts/ens/IExtendedResolver.sol index f7ab425..51c2179 100644 --- a/solidity/contracts/ens/IExtendedResolver.sol +++ b/solidity/contracts/ens/IExtendedResolver.sol @@ -9,8 +9,8 @@ pragma solidity ^0.8.20; /// resolver at `handles.link` serve every name beneath it without a /// registry entry per user. /// -/// Declared here rather than vendored, the way `IIdentityVerifier` and -/// `INotary` are: one function does not justify a dependency. +/// Declared here rather than vendored: one function does not justify a +/// dependency on the ENS contracts package. interface IExtendedResolver { /// @param name DNS wire format, e.g. `\x05alice\x01x\x07handles\x04link\x00`. /// @param data The resolution call the client wanted to make, such as From 56c94c92fac55a7d4c5648b79f55aea57a956892 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Mon, 7 Sep 2026 11:15:35 +0100 Subject: [PATCH 5/5] docs(ens): say why the resolver announces ENSIP-10 and nothing else 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 --- solidity/contracts/ens/HandleResolver.sol | 17 +++++++++++++++++ .../contracts/ens/test/HandleResolver.t.sol | 9 +++++++++ 2 files changed, 26 insertions(+) diff --git a/solidity/contracts/ens/HandleResolver.sol b/solidity/contracts/ens/HandleResolver.sol index 228b85c..25c3480 100644 --- a/solidity/contracts/ens/HandleResolver.sol +++ b/solidity/contracts/ens/HandleResolver.sol @@ -193,6 +193,23 @@ contract HandleResolver is IExtendedResolver, IERC165, Ownable2Step { /// @dev A client checks `0x9061b923` before it will hand this contract a /// name it did not find an exact entry for. Answer no, and wildcard /// resolution never reaches here. + /// + /// ERC-7996 (`0x582de3e7`) is NOT announced, and that is load-bearing. + /// The ENS universal resolver reads that id as leave to call a resolver + /// directly instead of through the batch gateway (ENSIP-22). Called + /// directly, it catches the `OffchainLookup` from `resolve` and raises + /// it again under its own address, so the `{sender}` a client fills + /// into the URL is the universal resolver rather than this contract. + /// A gateway that signs for the address in the URL then signs for the + /// wrong target and `resolveWithProof` refuses every answer. Through + /// the batch gateway the original sender survives (ENSIP-21), which is + /// why the reference gateway scheme works today. + /// + /// Announce ERC-7996 only from a deployment whose gateway signs for a + /// configured resolver address whatever the URL says. That deployment + /// may also declare `eth.ens.resolver.extended.multicall`, so one + /// signed answer carries a whole profile. Both mean a new deployment: + /// this contract is immutable, and replacing it is `setResolver`. function supportsInterface(bytes4 interfaceId) external pure returns (bool) { return interfaceId == type(IExtendedResolver).interfaceId || interfaceId == type(IERC165).interfaceId; } diff --git a/solidity/contracts/ens/test/HandleResolver.t.sol b/solidity/contracts/ens/test/HandleResolver.t.sol index c9d0f1e..e34e119 100644 --- a/solidity/contracts/ens/test/HandleResolver.t.sol +++ b/solidity/contracts/ens/test/HandleResolver.t.sol @@ -56,6 +56,15 @@ contract HandleResolverTest is Test { assertFalse(resolver.supportsInterface(0xdeadbeef)); } + /// ERC-7996 is the id the universal resolver reads as "call me directly, + /// skip the batch gateway". A direct call raises the `OffchainLookup` + /// again under the universal resolver's address, so a gateway signing for + /// the URL's `{sender}` signs for the wrong target and every answer is + /// refused. Not announced, and pinned so a future edit has to say why. + function test_itDoesNotInviteDirectInvocation() public view { + assertFalse(resolver.supportsInterface(0x582de3e7), "ERC-7996 announced"); + } + /// `resolve` always reverts, and that IS the protocol: the revert carries /// the endpoints, which is why nothing has to be registered with a wallet. function test_resolveRevertsWithTheEndpointsAndTheQuery() public {