skills/

ens

Infrastructureethereum|#ens#naming#resolution#identity
Target:

Install this skill:

$ npx cryptoskills install ens

Install all 95 skills:

$ npx cryptoskills install --all

ENS (Ethereum Name Service)

ENS maps human-readable names (alice.eth) to Ethereum addresses, content hashes, and arbitrary metadata. It is the identity layer for Ethereum — used for wallets, dApps, and onchain profiles. The architecture separates the registry (who owns a name) from resolvers (what data a name points to).

What You Probably Got Wrong

  • ENS uses namehash, not plain strings -- The registry and resolvers never see "alice.eth" as a string. Names are normalized (UTS-46), then hashed with the recursive namehash algorithm (EIP-137). If you pass a raw string to a contract call, it will not work. viem handles this automatically in its ENS actions but you must use namehash() and labelhash() for direct contract calls.
  • Registry vs Resolver vs Registrar -- three different contracts -- The Registry tracks name ownership and which resolver to use. The Resolver stores records (address, text, contenthash). The Registrar handles .eth name registration and renewal. Confusing these is the most common ENS integration bug.
  • .eth registrar uses commit-reveal, not a single transaction -- Registration requires two transactions separated by at least 60 seconds: first commit(secret), wait, then register(name, owner, duration, secret, ...). This prevents frontrunning. Skipping the wait or reusing a secret will revert.
  • Reverse resolution is opt-in -- An address only has a "primary name" if the owner explicitly set it via the Reverse Registrar. Do not assume every address has a reverse record. Always handle null returns from getEnsName().
  • Name Wrapper changes ownership semantics -- Since 2023, ENS names can be "wrapped" as ERC-1155 tokens via the Name Wrapper contract. Wrapped names have fuses that permanently restrict operations (cannot unwrap, cannot set resolver, etc.). Check isWrapped before assuming standard ownership patterns.
  • CCIP-Read (ERC-3668) enables offchain resolution -- Resolvers can return an OffchainLookup error that instructs the client to fetch data from an offchain gateway and verify it onchain. This powers offchain subdomains, L2 resolution, and gasless record updates. viem handles CCIP-Read automatically.
  • Wildcard resolution (ENSIP-10) is real -- Resolvers can implement resolve(bytes name, bytes data) to handle any subdomain dynamically, even ones not explicitly registered. This is how services like cb.id and lens.xyz work.
  • ENS names expire -- .eth names require annual renewal. Expired names enter a 90-day grace period, then a 21-day premium auction, then become available. Do not cache resolution results indefinitely.
  • normalize() before any ENS operation -- Names must be UTS-46 normalized before hashing. "Alice.ETH" and "alice.eth" produce different hashes. viem normalizes automatically, but if you build raw calldata you must normalize first using @adraffy/ens-normalize.

Quick Start

Installation

npm install viem

viem has built-in ENS support -- no additional packages needed for resolution.

Forward Resolution (Name to Address)

import { createPublicClient, http } from "viem";
import { mainnet } from "viem/chains";
 
const client = createPublicClient({
  chain: mainnet,
  transport: http(process.env.RPC_URL),
});
 
const address = await client.getEnsAddress({
  name: "vitalik.eth",
});
// "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"

Reverse Resolution (Address to Name)

const name = await client.getEnsName({
  address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
});
// "vitalik.eth" (or null if no primary name set)

Get Avatar

const avatar = await client.getEnsAvatar({
  name: "vitalik.eth",
});
// HTTPS URL to avatar image, or null

Name Resolution

Forward Resolution with Coin Types

ENS can store addresses for any blockchain, not just Ethereum. Each chain has a SLIP-44 coin type.

const ethAddress = await client.getEnsAddress({
  name: "vitalik.eth",
});
 
// BTC address (coin type 0)
const btcAddress = await client.getEnsAddress({
  name: "vitalik.eth",
  coinType: 0,
});
 
// Solana address (coin type 501)
const solAddress = await client.getEnsAddress({
  name: "vitalik.eth",
  coinType: 501,
});

Text Records

ENS text records store arbitrary key-value metadata. Standard keys are defined in ENSIP-5.

const twitter = await client.getEnsText({
  name: "vitalik.eth",
  key: "com.twitter",
});
 
const github = await client.getEnsText({
  name: "vitalik.eth",
  key: "com.github",
});
 
const email = await client.getEnsText({
  name: "vitalik.eth",
  key: "email",
});
 
const url = await client.getEnsText({
  name: "vitalik.eth",
  key: "url",
});
 
const description = await client.getEnsText({
  name: "vitalik.eth",
  key: "description",
});
 
// Avatar is also a text record (ENSIP-12 supports NFT references)
const avatarRecord = await client.getEnsText({
  name: "vitalik.eth",
  key: "avatar",
});
// Can be HTTPS URL, IPFS URI, or NFT reference like
// "eip155:1/erc721:0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d/1234"

Standard Text Record Keys

Key Description
email Email address
url Website URL
avatar Avatar image (HTTPS, IPFS, or NFT reference)
description Short bio
display Display name (may differ from ENS name)
com.twitter Twitter/X handle
com.github GitHub username
com.discord Discord username
org.telegram Telegram handle
notice Contract notice text
keywords Comma-separated keywords
header Profile header/banner image

Content Hash

import { createPublicClient, http, parseAbi } from "viem";
import { mainnet } from "viem/chains";
import { namehash } from "viem/ens";
 
const client = createPublicClient({
  chain: mainnet,
  transport: http(process.env.RPC_URL),
});
 
const RESOLVER_ABI = parseAbi([
  "function contenthash(bytes32 node) view returns (bytes)",
]);
 
const node = namehash("vitalik.eth");
 
// First get the resolver address
const resolverAddress = await client.getEnsResolver({
  name: "vitalik.eth",
});
 
const contenthash = await client.readContract({
  address: resolverAddress,
  abi: RESOLVER_ABI,
  functionName: "contenthash",
  args: [node],
});
// Encoded content hash (IPFS, Swarm, Arweave, etc.)

Batch Resolution with Multicall

import { createPublicClient, http, parseAbi } from "viem";
import { mainnet } from "viem/chains";
import { namehash } from "viem/ens";
 
const client = createPublicClient({
  chain: mainnet,
  transport: http(process.env.RPC_URL),
});
 
const RESOLVER_ABI = parseAbi([
  "function addr(bytes32 node) view returns (address)",
  "function text(bytes32 node, string key) view returns (string)",
]);
 
const node = namehash("vitalik.eth");
 
const resolverAddress = await client.getEnsResolver({
  name: "vitalik.eth",
});
 
const results = await client.multicall({
  contracts: [
    {
      address: resolverAddress,
      abi: RESOLVER_ABI,
      functionName: "addr",
      args: [node],
    },
    {
      address: resolverAddress,
      abi: RESOLVER_ABI,
      functionName: "text",
      args: [node, "com.twitter"],
    },
    {
      address: resolverAddress,
      abi: RESOLVER_ABI,
      functionName: "text",
      args: [node, "com.github"],
    },
    {
      address: resolverAddress,
      abi: RESOLVER_ABI,
      functionName: "text",
      args: [node, "url"],
    },
  ],
});
 
const [addr, twitter, github, url] = results.map((r) => r.result);

Registration

Commit-Reveal Process

ENS .eth registration uses a two-step commit-reveal to prevent frontrunning. You must wait at least 60 seconds between commit and register.

import {
  createPublicClient,
  createWalletClient,
  http,
  parseAbi,
  encodePacked,
  keccak256,
  parseEther,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { mainnet } from "viem/chains";
 
const ETH_REGISTRAR_CONTROLLER =
  "0x253553366Da8546fC250F225fe3d25d0C782303b" as const;
 
const CONTROLLER_ABI = parseAbi([
  "function rentPrice(string name, uint256 duration) view returns (tuple(uint256 base, uint256 premium))",
  "function available(string name) view returns (bool)",
  "function makeCommitment(string name, address owner, uint256 duration, bytes32 secret, address resolver, bytes[] data, bool reverseRecord, uint16 ownerControlledFuses) pure returns (bytes32)",
  "function commit(bytes32 commitment) external",
  "function register(string name, address owner, uint256 duration, bytes32 secret, address resolver, bytes[] data, bool reverseRecord, uint16 ownerControlledFuses) payable",
]);
 
const PUBLIC_RESOLVER = "0x231b0Ee14048e9dCcD1d247744d114a4EB5E8E63" as const;
 
const account = privateKeyToAccount(
  process.env.PRIVATE_KEY as `0x${string}`
);
 
const client = createPublicClient({
  chain: mainnet,
  transport: http(process.env.RPC_URL),
});
 
const walletClient = createWalletClient({
  account,
  chain: mainnet,
  transport: http(process.env.RPC_URL),
});
 
async function registerName(label: string, durationSeconds: bigint) {
  // 1. Check availability
  const isAvailable = await client.readContract({
    address: ETH_REGISTRAR_CONTROLLER,
    abi: CONTROLLER_ABI,
    functionName: "available",
    args: [label],
  });
  if (!isAvailable) throw new Error(`${label}.eth is not available`);
 
  // 2. Get price
  const rentPrice = await client.readContract({
    address: ETH_REGISTRAR_CONTROLLER,
    abi: CONTROLLER_ABI,
    functionName: "rentPrice",
    args: [label, durationSeconds],
  });
  // Add 10% buffer for price fluctuation during commit-reveal wait
  const totalPrice =
    ((rentPrice.base + rentPrice.premium) * 110n) / 100n;
 
  // 3. Generate secret (random 32 bytes)
  const secret = keccak256(
    encodePacked(["address", "uint256"], [account.address, BigInt(Date.now())])
  );
 
  // 4. Create commitment
  const commitment = await client.readContract({
    address: ETH_REGISTRAR_CONTROLLER,
    abi: CONTROLLER_ABI,
    functionName: "makeCommitment",
    args: [
      label,
      account.address,
      durationSeconds,
      secret,
      PUBLIC_RESOLVER,
      [],              // data (encoded resolver calls to set records at registration)
      true,            // reverseRecord (set as primary name)
      0,               // ownerControlledFuses (0 = no fuses)
    ],
  });
 
  // 5. Submit commitment
  const commitHash = await walletClient.writeContract({
    address: ETH_REGISTRAR_CONTROLLER,
    abi: CONTROLLER_ABI,
    functionName: "commit",
    args: [commitment],
  });
  await client.waitForTransactionReceipt({ hash: commitHash });
  console.log("Commitment submitted. Waiting 60 seconds...");
 
  // 6. Wait at least 60 seconds (minCommitmentAge)
  await new Promise((resolve) => setTimeout(resolve, 65_000));
 
  // 7. Register
  const registerHash = await walletClient.writeContract({
    address: ETH_REGISTRAR_CONTROLLER,
    abi: CONTROLLER_ABI,
    functionName: "register",
    args: [
      label,
      account.address,
      durationSeconds,
      secret,
      PUBLIC_RESOLVER,
      [],
      true,
      0,
    ],
    value: totalPrice,
  });
  const receipt = await client.waitForTransactionReceipt({
    hash: registerHash,
  });
 
  if (receipt.status !== "success") {
    throw new Error("Registration transaction reverted");
  }
 
  console.log(`Registered ${label}.eth for ${durationSeconds / 31536000n} year(s)`);
  return receipt;
}
 
// Register for 1 year (365 days in seconds)
await registerName("myname", 31536000n);

Renewal

const CONTROLLER_ABI_RENEW = parseAbi([
  "function rentPrice(string name, uint256 duration) view returns (tuple(uint256 base, uint256 premium))",
  "function renew(string name, uint256 duration) payable",
]);
 
async function renewName(label: string, durationSeconds: bigint) {
  const rentPrice = await client.readContract({
    address: ETH_REGISTRAR_CONTROLLER,
    abi: CONTROLLER_ABI_RENEW,
    functionName: "rentPrice",
    args: [label, durationSeconds],
  });
  // 5% buffer for price changes
  const totalPrice = ((rentPrice.base + rentPrice.premium) * 105n) / 100n;
 
  const hash = await walletClient.writeContract({
    address: ETH_REGISTRAR_CONTROLLER,
    abi: CONTROLLER_ABI_RENEW,
    functionName: "renew",
    args: [label, durationSeconds],
    value: totalPrice,
  });
  const receipt = await client.waitForTransactionReceipt({ hash });
 
  if (receipt.status !== "success") {
    throw new Error("Renewal transaction reverted");
  }
 
  console.log(`Renewed ${label}.eth for ${durationSeconds / 31536000n} year(s)`);
  return receipt;
}

Check Price Before Registering

async function getRegistrationCost(
  label: string,
  durationSeconds: bigint
): Promise<{ base: bigint; premium: bigint; total: bigint }> {
  const rentPrice = await client.readContract({
    address: ETH_REGISTRAR_CONTROLLER,
    abi: CONTROLLER_ABI,
    functionName: "rentPrice",
    args: [label, durationSeconds],
  });
  return {
    base: rentPrice.base,
    premium: rentPrice.premium,
    total: rentPrice.base + rentPrice.premium,
  };
}

Working with Resolvers

Setting Text Records

import {
  createPublicClient,
  createWalletClient,
  http,
  parseAbi,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { mainnet } from "viem/chains";
import { namehash } from "viem/ens";
 
const PUBLIC_RESOLVER = "0x231b0Ee14048e9dCcD1d247744d114a4EB5E8E63" as const;
 
const RESOLVER_ABI = parseAbi([
  "function setText(bytes32 node, string key, string value) external",
  "function setAddr(bytes32 node, address addr) external",
  "function setAddr(bytes32 node, uint256 coinType, bytes value) external",
  "function setContenthash(bytes32 node, bytes hash) external",
  "function multicall(bytes[] data) external returns (bytes[])",
]);
 
const account = privateKeyToAccount(
  process.env.PRIVATE_KEY as `0x${string}`
);
 
const client = createPublicClient({
  chain: mainnet,
  transport: http(process.env.RPC_URL),
});
 
const walletClient = createWalletClient({
  account,
  chain: mainnet,
  transport: http(process.env.RPC_URL),
});
 
const node = namehash("myname.eth");
 
// Set a single text record
const hash = await walletClient.writeContract({
  address: PUBLIC_RESOLVER,
  abi: RESOLVER_ABI,
  functionName: "setText",
  args: [node, "com.twitter", "myhandle"],
});
await client.waitForTransactionReceipt({ hash });

Batch Update Records with Multicall

Setting multiple records in a single transaction using the resolver's built-in multicall.

import { encodeFunctionData } from "viem";
 
const node = namehash("myname.eth");
 
const calls = [
  encodeFunctionData({
    abi: RESOLVER_ABI,
    functionName: "setText",
    args: [node, "com.twitter", "myhandle"],
  }),
  encodeFunctionData({
    abi: RESOLVER_ABI,
    functionName: "setText",
    args: [node, "com.github", "mygithub"],
  }),
  encodeFunctionData({
    abi: RESOLVER_ABI,
    functionName: "setText",
    args: [node, "url", "https://mysite.com"],
  }),
  encodeFunctionData({
    abi: RESOLVER_ABI,
    functionName: "setText",
    args: [node, "email", "me@mysite.com"],
  }),
  encodeFunctionData({
    abi: RESOLVER_ABI,
    functionName: "setText",
    args: [node, "avatar", "https://mysite.com/avatar.png"],
  }),
];
 
const hash = await walletClient.writeContract({
  address: PUBLIC_RESOLVER,
  abi: RESOLVER_ABI,
  functionName: "multicall",
  args: [calls],
});
const receipt = await client.waitForTransactionReceipt({ hash });
 
if (receipt.status !== "success") {
  throw new Error("Multicall record update reverted");
}

Setting the Primary Name (Reverse Record)

const REVERSE_REGISTRAR = "0xa58E81fe9b61B5c3fE2AFD33CF304c454AbFc7Cb" as const;
 
const REVERSE_ABI = parseAbi([
  "function setName(string name) external returns (bytes32)",
]);
 
const hash = await walletClient.writeContract({
  address: REVERSE_REGISTRAR,
  abi: REVERSE_ABI,
  functionName: "setName",
  args: ["myname.eth"],
});
await client.waitForTransactionReceipt({ hash });

Subdomains

Creating an Onchain Subdomain

import { parseAbi } from "viem";
import { namehash, labelhash } from "viem/ens";
 
const ENS_REGISTRY = "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e" as const;
 
const REGISTRY_ABI = parseAbi([
  "function setSubnodeRecord(bytes32 node, bytes32 label, address owner, address resolver, uint64 ttl) external",
  "function owner(bytes32 node) view returns (address)",
  "function resolver(bytes32 node) view returns (address)",
]);
 
const parentNode = namehash("myname.eth");
const subLabel = labelhash("sub");
 
const hash = await walletClient.writeContract({
  address: ENS_REGISTRY,
  abi: REGISTRY_ABI,
  functionName: "setSubnodeRecord",
  args: [
    parentNode,
    subLabel,
    account.address,       // owner of sub.myname.eth
    PUBLIC_RESOLVER,       // resolver
    0n,                    // TTL
  ],
});
await client.waitForTransactionReceipt({ hash });
// sub.myname.eth now exists and points to PUBLIC_RESOLVER

Offchain Subdomains (CCIP-Read / ERC-3668)

Offchain subdomains let you issue unlimited subdomains without gas costs. The resolver responds with an OffchainLookup error that directs the client to a gateway URL. The gateway returns signed data that is verified onchain.

This is how services like cb.id (Coinbase), uni.eth (Uniswap), and lens.xyz work.

For offchain resolution, viem handles CCIP-Read transparently -- no client-side changes needed:

// Resolving an offchain subdomain works identically to onchain names
const address = await client.getEnsAddress({
  name: "myuser.cb.id",
});
// viem automatically:
// 1. Calls resolver.resolve(...)
// 2. Catches OffchainLookup revert
// 3. Fetches from the gateway URL
// 4. Calls resolver with the gateway proof
// 5. Returns the verified address
 
const avatar = await client.getEnsAvatar({
  name: "myuser.cb.id",
});

To build your own offchain resolver, implement ERC-3668:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
import {IExtendedResolver} from "@ensdomains/ens-contracts/contracts/resolvers/profiles/IExtendedResolver.sol";
 
/// @notice Offchain resolver that delegates lookups to a gateway
/// @dev Implements ERC-3668 (CCIP-Read) and ENSIP-10 (wildcard resolution)
contract OffchainResolver is IExtendedResolver {
    string public url;
    address public signer;
 
    error OffchainLookup(
        address sender,
        string[] urls,
        bytes callData,
        bytes4 callbackFunction,
        bytes extraData
    );
 
    constructor(string memory _url, address _signer) {
        url = _url;
        signer = _signer;
    }
 
    /// @notice ENSIP-10 wildcard resolve entry point
    function resolve(
        bytes calldata name,
        bytes calldata data
    ) external view returns (bytes memory) {
        string[] memory urls = new string[](1);
        urls[0] = url;
 
        revert OffchainLookup(
            address(this),
            urls,
            data,
            this.resolveWithProof.selector,
            abi.encode(name, data)
        );
    }
 
    /// @notice Callback that verifies the gateway signature
    function resolveWithProof(
        bytes calldata response,
        bytes calldata extraData
    ) external view returns (bytes memory) {
        // Verify signature from gateway matches expected signer
        // Return decoded result
        // Implementation depends on your signing scheme
    }
}

Contract Addresses

Ethereum Mainnet. Last verified: 2025-03-01.

Contract Address Purpose
ENS Registry 0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e Core registry -- maps names to owners and resolvers
Public Resolver 0x231b0Ee14048e9dCcD1d247744d114a4EB5E8E63 Default resolver for address, text, contenthash, and ABI records
ETH Registrar Controller 0x253553366Da8546fC250F225fe3d25d0C782303b Handles .eth name registration and renewal (commit-reveal)
Name Wrapper 0xD4416b13d2b3a9aBae7AcD5D6C2BbDBE25686401 Wraps names as ERC-1155 tokens with permission fuses
Reverse Registrar 0xa58E81fe9b61B5c3fE2AFD33CF304c454AbFc7Cb Manages reverse records (address-to-name mapping)
Base Registrar (NFT) 0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85 ERC-721 NFT for .eth second-level names
Universal Resolver 0xce01f8eee7E30F8E3BfC1C22bCBc01faBc8680E4 Batch resolution with CCIP-Read support

Address Constants for TypeScript

const ENS_ADDRESSES = {
  registry: "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e",
  publicResolver: "0x231b0Ee14048e9dCcD1d247744d114a4EB5E8E63",
  ethRegistrarController: "0x253553366Da8546fC250F225fe3d25d0C782303b",
  nameWrapper: "0xD4416b13d2b3a9aBae7AcD5D6C2BbDBE25686401",
  reverseRegistrar: "0xa58E81fe9b61B5c3fE2AFD33CF304c454AbFc7Cb",
  baseRegistrar: "0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85",
  universalResolver: "0xce01f8eee7E30F8E3BfC1C22bCBc01faBc8680E4",
} as const satisfies Record<string, `0x${string}`>;

Sepolia Testnet

Last verified: 2025-03-01.

Contract Address
ENS Registry 0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e
Public Resolver 0x8FADE66B79cC9f707aB26799354482EB93a5B7dD
ETH Registrar Controller 0xFED6a969AaA60E4961FCD3EBF1A2e8913DeBe6c7
Name Wrapper 0x0635513f179D50A207757E05759CbD106d7dFcE8
Reverse Registrar 0xA0a1AbcDAe1a2a4A2EF8e9113Ff0e02DD81DC0C6

Error Handling

Common Resolution Errors

async function safeResolve(name: string) {
  try {
    const address = await client.getEnsAddress({ name });
    if (!address) {
      console.log(`${name} has no address record set`);
      return null;
    }
    return address;
  } catch (error) {
    if (error instanceof Error) {
      // Name does not exist or is malformed
      if (error.message.includes("Could not find resolver")) {
        console.log(`${name} is not registered or has no resolver`);
        return null;
      }
      // CCIP-Read gateway failure
      if (error.message.includes("OffchainLookup")) {
        console.log(`Offchain resolution failed for ${name}`);
        return null;
      }
    }
    throw error;
  }
}

Common Registration Errors

Error Cause Fix
CommitmentTooNew Called register() less than 60s after commit() Wait at least 60 seconds between commit and register
CommitmentTooOld Commitment expired (older than 24 hours) Submit a new commitment
NameNotAvailable Name is registered or in grace period Check available() first
DurationTooShort Duration under minimum (28 days) Use at least 2419200 seconds
InsufficientValue Sent less ETH than rentPrice() requires Add a 5-10% buffer to rentPrice() result
Unauthorised Caller is not the name owner Verify ownership via registry before writing records

Validating ENS Names

import { normalize } from "viem/ens";
 
function isValidEnsName(name: string): boolean {
  try {
    normalize(name);
    return true;
  } catch {
    return false;
  }
}
 
// normalize() throws on invalid names
// Valid: "alice.eth", "sub.alice.eth", "alice.xyz"
// Invalid: names with zero-width characters, confusable Unicode, etc.

Key Constants

Constant Value Notes
Min commitment age 60 seconds Wait between commit and register
Max commitment age 86400 seconds (24h) Commitment expires after this
Min registration duration 2419200 seconds (28 days) Shortest allowed registration
Grace period 90 days After expiry, owner can still renew
Premium auction 21 days After grace period, decaying price auction
Namehash of eth 0x93cdeb708b7545dc668eb9280176169d1c33cfd8ed6f04690a0bcc88a93fc4ae Used as parent node for .eth names

References