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 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()); +} 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/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 diff --git a/solidity/contracts/ens/test/HandleResolver.t.sol b/solidity/contracts/ens/test/HandleResolver.t.sol index 2757560..e34e119 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"; @@ -54,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 { @@ -189,13 +200,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 +288,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 +318,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); }