This guide walks you through deploying and configuring the ConfigResolver, AddressSubnameRegistrar, and L1ConfigResolver contracts.
| Contract | Purpose |
|---|---|
ConfigResolver |
A general-purpose ENS resolver that allows name owners to set records |
AddressSubnameRegistrar |
Allows users to claim 0x<address>.yourname.eth subnames |
L1ConfigResolver |
Reads L2 ConfigResolver records from L1 via CCIP-Read |
Users claim subnames on L1 (Ethereum), but records are stored on L2 (Base) for lower gas costs. Users can optionally change their resolver.
┌─────────────────────────────────────────────────────────────────────────┐
│ L1 (Ethereum) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────┐ ┌───────────────────────────────┐ │
│ │ AddressSubnameRegistrar │ │ L1ConfigResolver │ │
│ │ ───────────────────────── │ │ ─────────────────────────── │ │
│ │ • Users call claim() │────▶│ • Default resolver for │ │
│ │ • Creates ENS node on L1 │ │ claimed subnames │ │
│ │ • Sets resolver to │ │ • Reads records via │ │
│ │ L1ConfigResolver │ │ CCIP-Read from L2 │ │
│ └─────────────────────────────┘ └───────────────┬───────────────┘ │
│ │ │
│ User owns ENS node → can change resolver if desired │ │
│ │ │
└───────────────────────────────────────────────────────┼──────────────────┘
│ CCIP-Read
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ L2 (Base) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ ConfigResolver │ │
│ │ ───────────────────────────────────────────────────────────── │ │
│ │ • Stores text, address, contenthash records │ │
│ │ • Users set records here (low gas) │ │
│ │ • Authorizes via reverse node (user's address) │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
User Flow:
- User calls
claim()on L1AddressSubnameRegistrar→ creates0x<address>.parent.ethin ENS - Subname is created with
L1ConfigResolveras the resolver - User sets records on L2
ConfigResolver(low gas) - L1 resolution reads from L2 via CCIP-Read
- User can change their L1 resolver anytime (they own the ENS node)
Simpler setup where everything lives on L2, and L1 uses wildcard resolution. Users cannot change their resolver.
L1: Parent name resolver = L1ConfigResolver (wildcard ENSIP-10)
L2: ConfigResolver + AddressSubnameRegistrar
Limitation: Subnames don't exist in L1 ENS registry, so users cannot change their resolver.
- Foundry installed - Install Foundry
- Deployer account - A funded wallet in Foundry's keystore
- ENS name ownership - You must own the parent name (e.g.,
ethconfig.eth)
# Import an existing private key
cast wallet import deployer --interactive
# Or create a new account
cast wallet new deployer
# Verify it exists
cast wallet list| Contract | Address |
|---|---|
| ENS Registry | 0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e |
| Contract | Address |
|---|---|
| NameWrapper | 0xD4416b13d2b3a9aBae7AcD5D6C2BbDBE25686401 |
| Gateway Verifier | 0x0bC6c539e5fc1fb92F31dE34426f433557A9A5A2 |
| Contract | Address |
|---|---|
| NameWrapper | 0x0635513f179D50A207757E05759CbD106d7dFcE8 |
| Gateway Verifier | 0x7F68510F0fD952184ec0b976De429a29A2Ec0FE3 |
# Sepolia
PARENT_NODE=$(cast namehash "yourname.eth") \
forge script script/Deploy.s.sol \
--rpc-url https://eth-sepolia.g.alchemy.com/v2/$ALCHEMY_API_KEY \
--account deployer \
--broadcast \
--verify
# Mainnet
PARENT_NODE=$(cast namehash "yourname.eth") \
forge script script/Deploy.s.sol \
--rpc-url https://eth-mainnet.g.alchemy.com/v2/$ALCHEMY_API_KEY \
--account deployer \
--broadcast \
--verifyforge script script/Deploy.s.sol --sig "deployConfigResolver()" \
--rpc-url $RPC_URL \
--account deployer \
--broadcast \
--verifyFor reading L2 records from L1:
L2_CONFIG_RESOLVER=0x... \
forge script script/Deploy.s.sol --sig "deployL1Resolver()" \
--rpc-url $RPC_URL \
--account deployer \
--broadcast \
--verifyEnvironment Variables:
| Variable | Required | Default | Description |
|---|---|---|---|
L2_CONFIG_RESOLVER |
Yes | - | Address of ConfigResolver on L2 |
VERIFIER |
No | Chain-dependent* | Gateway verifier address |
L2_CHAIN_ID |
No | Chain-dependent* | L2 chain ID |
*Defaults: Mainnet → Base (8453) with 0x0bC6c539e5fc1fb92F31dE34426f433557A9A5A2, Sepolia → Base Sepolia (84532) with 0x7F68510F0fD952184ec0b976De429a29A2Ec0FE3
Custom L2 deployment (non-Base chains):
# Example: Deploy for Arbitrum
VERIFIER=0x... \
L2_CHAIN_ID=42161 \
L2_CONFIG_RESOLVER=0x... \
forge script script/Deploy.s.sol --sig "deployL1Resolver()" \
--rpc-url $RPC_URL \
--account deployer \
--broadcast \
--verifyFor L1 claiming with L2 storage - deploy after L1ConfigResolver:
# Sepolia
PARENT_NODE=$(cast namehash "yourname.eth") \
L1_CONFIG_RESOLVER=0x... \
forge script script/Deploy.s.sol --sig "deployL1Registrar()" \
--rpc-url https://eth-sepolia.g.alchemy.com/v2/$ALCHEMY_API_KEY \
--account deployer \
--broadcast \
--verify
# Mainnet
PARENT_NODE=$(cast namehash "yourname.eth") \
L1_CONFIG_RESOLVER=0x... \
forge script script/Deploy.s.sol --sig "deployL1Registrar()" \
--rpc-url https://eth-mainnet.g.alchemy.com/v2/$ALCHEMY_API_KEY \
--account deployer \
--broadcast \
--verifySave the deployed addresses for the next steps.
The registrar needs permission to create subnames under your wrapped name.
⚠️ Important: The approval must come from the actual owner of the wrapped name, not the deployer (unless they're the same).
export PARENT_NODE=$(cast namehash "yourname.eth")
# Check who owns the wrapped name
cast call $NAME_WRAPPER "ownerOf(uint256)(address)" $PARENT_NODE --rpc-url $RPC_URL# Approve registrar to manage your wrapped names
# Run this from the OWNER's account, not the deployer!
cast send $NAME_WRAPPER "setApprovalForAll(address,bool)" \
$REGISTRAR \
true \
--rpc-url $RPC_URL \
--account <owner-account> # Must be the wrapped name ownerThe ConfigResolver supports wildcard resolution, allowing 0x<address>.yourname.eth to resolve without users needing to claim the subname first.
# Set ConfigResolver as the resolver for your parent name
# Run this from the wrapped name OWNER's account
cast send $NAME_WRAPPER "setResolver(bytes32,address)" \
$PARENT_NODE \
$CONFIG_RESOLVER \
--rpc-url $RPC_URL \
--account <owner-account># Check that ConfigResolver supports IExtendedResolver (0x9061b923)
cast call $CONFIG_RESOLVER "supportsInterface(bytes4)" "0x9061b923" --rpc-url $RPC_URL
# Should return: true (0x01)
# Check that the parent name uses ConfigResolver
cast call $ENS_REGISTRY "resolver(bytes32)(address)" $PARENT_NODE --rpc-url $RPC_URL
# Should return: your CONFIG_RESOLVER address# Verify it supports the resolver interface (ERC-165)
cast call $CONFIG_RESOLVER "supportsInterface(bytes4)" "0x01ffc9a7" --rpc-url $RPC_URL
# Should return: true (0x01)
# Verify it supports wildcard resolution (ENSIP-10)
cast call $CONFIG_RESOLVER "supportsInterface(bytes4)" "0x9061b923" --rpc-url $RPC_URL
# Should return: true (0x01)# Check parent node
cast call $REGISTRAR "parentNode()" --rpc-url $RPC_URL
# Check default resolver
cast call $REGISTRAR "defaultResolver()" --rpc-url $RPC_URL
# Check if an address's subname is available
cast call $REGISTRAR "available(address)" "0x8d25687829D6b85d9e0020B8c89e3Ca24dE20a89" --rpc-url $RPC_URL# Verify it supports IExtendedResolver
cast call $L1_RESOLVER "supportsInterface(bytes4)" "0x9061b923" --rpc-url $RPC_URL
# Should return: true (0x01)
# Check the L2 chain ID
cast call $L1_RESOLVER "l2ChainId()(uint256)" --rpc-url $RPC_URL
# Check the L2 target
cast call $L1_RESOLVER "l2ConfigResolver()(address)" --rpc-url $RPC_URL
# Check the verifier
cast call $L1_RESOLVER "verifier()(address)" --rpc-url $RPC_URLUsers can claim their address-based subname by calling claim():
# User claims their subname
cast send $REGISTRAR "claim()" \
--rpc-url $RPC_URL \
--account user-walletThis creates 0x<address>.yourname.eth for the caller (e.g., 0x8d25687829d6b85d9e0020b8c89e3ca24de20a89.yourname.eth).
After claiming, users can set records on their subname:
# Get the user's node
USER_NODE=$(cast call $REGISTRAR "node(address)" $USER_ADDRESS --rpc-url $RPC_URL)
# Set a text record
cast send $CONFIG_RESOLVER "setText(bytes32,string,string)" \
$USER_NODE \
"url" \
"https://example.com" \
--rpc-url $RPC_URL \
--account user-wallet
# Set an address record
cast send $CONFIG_RESOLVER "setAddr(bytes32,address)" \
$USER_NODE \
$USER_ADDRESS \
--rpc-url $RPC_URL \
--account user-wallet# Get text record
cast call $CONFIG_RESOLVER "text(bytes32,string)" $USER_NODE "url" --rpc-url $RPC_URL
# Get address
cast call $CONFIG_RESOLVER "addr(bytes32)" $USER_NODE --rpc-url $RPC_URLimport { ethers } from "ethers";
const registrar = new ethers.Contract(
REGISTRAR_ADDRESS,
[
"function claim() returns (bytes32)",
"function available(address) view returns (bool)",
"function getLabel(address) view returns (string)",
"function node(address) view returns (bytes32)",
],
signer
);
const resolver = new ethers.Contract(
RESOLVER_ADDRESS,
[
"function setText(bytes32,string,string)",
"function text(bytes32,string) view returns (string)",
"function setAddr(bytes32,address)",
"function addr(bytes32) view returns (address)",
],
signer
);
// Check availability
const isAvailable = await registrar.available(userAddress);
// Claim subname
if (isAvailable) {
const tx = await registrar.claim();
await tx.wait();
}
// Get the node for the user
const node = await registrar.node(userAddress);
// Set records
await resolver.setText(node, "url", "https://example.com");
await resolver.setAddr(node, userAddress);import { createPublicClient, createWalletClient, http } from "viem";
import { mainnet } from "viem/chains";
const publicClient = createPublicClient({
chain: mainnet,
transport: http(),
});
// Check availability
const isAvailable = await publicClient.readContract({
address: REGISTRAR_ADDRESS,
abi: registrarAbi,
functionName: "available",
args: [userAddress],
});
// Claim
const hash = await walletClient.writeContract({
address: REGISTRAR_ADDRESS,
abi: registrarAbi,
functionName: "claim",
});| Network | Contract | Address |
|---|---|---|
| Base Sepolia | ConfigResolver | 0xA66c55a6b76967477af18A03F2f12d52251Dc2C0 |
| Sepolia | L1ConfigResolver | 0x380e926f5D78F21b80a6EfeF2B3CEf9CcC89356B |
| Network | Contract | Address |
|---|---|---|
| Base | ConfigResolver | TBD |
| Ethereum | L1ConfigResolver | TBD |
This is the most common issue. It means the registrar is not approved to create subnames.
Diagnosis:
# 1. Find the wrapped name owner
cast call $NAME_WRAPPER "ownerOf(uint256)(address)" $PARENT_NODE --rpc-url $RPC_URL
# 2. Check if the registrar is approved by that owner
cast call $NAME_WRAPPER "isApprovedForAll(address,address)(bool)" \
<owner-address> \
$REGISTRAR \
--rpc-url $RPC_URLSolution: Run the approval from the wrapped name owner's account (not the deployer):
cast send $NAME_WRAPPER "setApprovalForAll(address,bool)" \
$REGISTRAR \
true \
--rpc-url $RPC_URL \
--account <owner-account>- Ensure the caller is the address they're trying to claim for
- Or ensure they have ENS approval (
isApprovedForAll)
- The subname has already been claimed by someone
- Check with
available(address)
- Ensure the registrar is approved via NameWrapper by the wrapped name owner
- Ensure the caller owns the subname (check
ens.owner(node)) - Ensure the resolver is set correctly (check
ens.resolver(node))
This is the complete workflow for deploying the L1 claiming with L2 storage architecture.
# Base Sepolia
forge script script/Deploy.s.sol --sig "deployConfigResolver()" \
--rpc-url https://base-sepolia.g.alchemy.com/v2/$ALCHEMY_API_KEY \
--account deployer \
--broadcast \
--verify
# Base Mainnet
forge script script/Deploy.s.sol --sig "deployConfigResolver()" \
--rpc-url https://base-mainnet.g.alchemy.com/v2/$ALCHEMY_API_KEY \
--account deployer \
--broadcast \
--verifySave the L2 ConfigResolver address as L2_CONFIG_RESOLVER.
# Sepolia
L2_CONFIG_RESOLVER=0x... \
forge script script/Deploy.s.sol --sig "deployL1Resolver()" \
--rpc-url https://eth-sepolia.g.alchemy.com/v2/$ALCHEMY_API_KEY \
--account deployer \
--broadcast \
--verify
# Mainnet
L2_CONFIG_RESOLVER=0x... \
forge script script/Deploy.s.sol --sig "deployL1Resolver()" \
--rpc-url https://eth-mainnet.g.alchemy.com/v2/$ALCHEMY_API_KEY \
--account deployer \
--broadcast \
--verifySave the L1ConfigResolver address as L1_CONFIG_RESOLVER.
# Sepolia
PARENT_NODE=$(cast namehash "yourname.eth") \
L1_CONFIG_RESOLVER=0x... \
forge script script/Deploy.s.sol --sig "deployL1Registrar()" \
--rpc-url https://eth-sepolia.g.alchemy.com/v2/$ALCHEMY_API_KEY \
--account deployer \
--broadcast \
--verify
# Mainnet
PARENT_NODE=$(cast namehash "yourname.eth") \
L1_CONFIG_RESOLVER=0x... \
forge script script/Deploy.s.sol --sig "deployL1Registrar()" \
--rpc-url https://eth-mainnet.g.alchemy.com/v2/$ALCHEMY_API_KEY \
--account deployer \
--broadcast \
--verifySave the L1 AddressSubnameRegistrar address as L1_REGISTRAR.
The L1 registrar needs permission to create subnames under your wrapped parent name.
# From the wrapped name owner's account
cast send $NAME_WRAPPER "setApprovalForAll(address,bool)" \
$L1_REGISTRAR \
true \
--rpc-url https://eth-sepolia.g.alchemy.com/v2/$ALCHEMY_API_KEY \
--account <owner-account>Users can now claim their subname on L1:
cast send $L1_REGISTRAR "claim()" \
--rpc-url https://eth-sepolia.g.alchemy.com/v2/$ALCHEMY_API_KEY \
--account user-walletUsers set their records on the L2 ConfigResolver:
# Get the user's node hash
USER_NODE=$(cast call $L1_REGISTRAR "node(address)(bytes32)" $USER_ADDRESS \
--rpc-url https://eth-sepolia.g.alchemy.com/v2/$ALCHEMY_API_KEY)
# Set a text record on L2
cast send $L2_CONFIG_RESOLVER "setText(bytes32,string,string)" \
$USER_NODE \
"url" \
"https://example.com" \
--rpc-url https://base-sepolia.g.alchemy.com/v2/$ALCHEMY_API_KEY \
--account user-walletUsers who want a different resolver can change it on L1 (they own the wrapped subname):
# User changes their resolver via NameWrapper
cast send $NAME_WRAPPER "setResolver(bytes32,address)" \
$USER_NODE \
$NEW_RESOLVER \
--rpc-url https://eth-sepolia.g.alchemy.com/v2/$ALCHEMY_API_KEY \
--account user-wallet-
Subname ownership: Users fully own their claimed subnames and can set any records.
-
No fees: This implementation doesn't charge fees. Add payment logic if needed.
-
No expiry: Subnames don't expire unless the parent expires. Consider adding reclaim logic.
-
Wrapped subnames: Since the parent is wrapped, subnames will be wrapped too with inherited fuses.