Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 21 additions & 53 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -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.
#
Expand All @@ -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.
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:<network>` in the
Expand All @@ -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" ] || {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand All @@ -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 }}
Expand Down Expand Up @@ -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 }}
Expand All @@ -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
42 changes: 42 additions & 0 deletions rust/contracts/src/bindings/ens.rs
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions rust/contracts/src/bindings/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
52 changes: 52 additions & 0 deletions rust/contracts/tests/anvil.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
17 changes: 17 additions & 0 deletions solidity/contracts/ens/HandleResolver.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions solidity/contracts/ens/IExtendedResolver.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 19 additions & 5 deletions solidity/contracts/ens/test/HandleResolver.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}

Expand Down
Loading