diff --git a/.gitignore b/.gitignore index f70348ed1..02651a39e 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,6 @@ src/data/generated/* !src/data/generated/.keep .wrangler -.claude \ No newline at end of file +.claude +# Local Netlify folder +.netlify diff --git a/design-patterns.md b/design-patterns.md new file mode 100644 index 000000000..817aa9171 --- /dev/null +++ b/design-patterns.md @@ -0,0 +1,45 @@ +# Design Patterns + +This document captures high-signal UI conventions for the docs site so brand and implementation choices stay consistent. + +## Core Tokens + +- Use CSS custom properties in `src/styles.css` for global tokens. +- Prefer semantic token names (`--ens-*`) over hardcoded values in component styles. + +## Buttons + +- All button controls use a 4px corner radius. +- Source of truth: `--ens-radius-button` in `src/styles.css`. +- Applied to: `button`, `input[type='button']`, `input[type='submit']`, and `input[type='reset']`. + +## Tables + +- All documentation tables use `rounded-md` corners (6px). +- Source of truth: `--ens-radius-table` in `src/styles.css`. +- Applied to Vocs table wrappers/content to keep corners visibly clipped. + +## Typography + +- Primary sans serif: `ABCMonumentGrotesk`. +- Serif: `ABCMarist`. +- Monospace: `ABCMonumentGroteskMono` and `ABCMonumentGroteskSemiMono`. +- Keep `font-display: swap` for self-hosted font loading behavior. + +## Color System + +- Use `--ens-*` palette tokens and semantic aliases (`--ens-background`, `--ens-text`, etc.). +- Define both light (`:root`) and dark (`.dark`) token values in `src/styles.css`. +- Figma source of truth: [`ENS Core` color tokens](https://www.figma.com/design/2sH9SoYCbfma5clol8dFP4/%F0%9F%93%93--ENS-Core?node-id=514-12&p=f&t=TTwQnH0qKnppCExL-0) (node `514:12`). + +### Figma Palette Extract (current) + +- **Lapis**: `100 #DBF0F8`, `300 #80C4E0`, `400 #39B4EA`, `500 #0082BB`, `900 #02293B` +- **Quartz**: `100 #EEEDED`, `300 #C7C6C4`, `400 #737373`, `500 #595755`, `900 #191919` +- **Peridot**: `100 #D1EEDF`, `300 #65D388`, `400 #1CBF46`, `500 #007C20`, `900 #033010` +- **Garnet**: `100 #FEEAF0`, `300 #FFB0D0`, `400 #F569AB`, `500 #E72A96`, `900 #5A0024` +- **Citrine**: `100 #F8F7E2`, `300 #E1B77E`, `400 #E7A259`, `500 #984D1B`, `900 #441B03` + +### Current Mapping Rule + +- `--ens-blue-primary` maps to **Lapis 500** (`#0082BB`) and is the default accent blue used by Vocs theme variables. diff --git a/scripts/ensips.ts b/scripts/ensips.ts index 18f0f0601..9002dadfb 100644 --- a/scripts/ensips.ts +++ b/scripts/ensips.ts @@ -36,7 +36,12 @@ export async function ensips() { 'https://api.github.com/repos/ensdomains/ensips/contents/ensips', { headers: githubHeaders } ) - if (!ensipsRepoRes.ok) throw new Error('Failed to fetch ENSIPs') + if (!ensipsRepoRes.ok) { + const body = await ensipsRepoRes.text() + throw new Error( + `Failed to fetch ENSIPs: ${ensipsRepoRes.status} ${ensipsRepoRes.statusText} — ${body}` + ) + } const files = ((await ensipsRepoRes.json()) as DirectoryContents).filter( (f) => f.name.endsWith('.md') ) @@ -173,15 +178,12 @@ async function inlineSubdirectoryFiles( markdown: string, ensipNumber: number ): Promise { - // Replace lines containing links to subdirectory .md files - // (e.g. "- [text](./19/supported.md)") with the actual file content. - // Matches the entire line to avoid leftover list markers. - const subfileLink = - /^[^\S\n]*(?:[-*]|\d+\.)\s*\[([^\]]*)\]\(\.\/\d+\/([^)]+\.md)\)\s*$/gm + // Matches [](./NUMBER/file.md) — content inclusion directives + const subfileLink = /\[\]\(\.\/\d+\/([^)]+\.md)\)/gm let result = markdown for (const match of markdown.matchAll(subfileLink)) { - const [fullMatch, , filename] = match + const [fullMatch, filename] = match const url = `https://raw.githubusercontent.com/ensdomains/ensips/master/ensips/${ensipNumber}/${filename}` const res = await fetchWithRetry(url) diff --git a/scripts/ensv2-deployments.ts b/scripts/ensv2-deployments.ts new file mode 100644 index 000000000..7c1e29f47 --- /dev/null +++ b/scripts/ensv2-deployments.ts @@ -0,0 +1,233 @@ +import fs from 'fs/promises' +import path from 'path' +import { Hex } from 'viem' + +// ENSv2 deployment addresses, sourced from what ENSjs v2 tracks. +// +// Pinned to a commit on the `feature/fet-1885-ensjs-refactor` branch so the +// build cannot break if the branch is force-pushed or deleted. To pick up new +// deployments, bump the SHA: +// git ls-remote https://github.com/ensdomains/ensjs refs/heads/feature/fet-1885-ensjs-refactor +// and delete src/data/generated/ensv2-deployments.json to re-fetch. +// +// TODO: switch to ingesting a proper deployments file once ensjs/ens-contracts +// publish one for ENSv2 (only the fetch + parse below should need to change). +const ENSJS_COMMIT = 'd946c1fb520fe1989d9e50687b545f341d366e62' +const ENSJS_L1_URL = `https://raw.githubusercontent.com/ensdomains/ensjs/${ENSJS_COMMIT}/packages/ensjs/src/clients/l1.ts` + +const CONTRACTS_V2_BLOB = + 'https://github.com/ensdomains/contracts-v2/blob/main/contracts' + +// Deployment artifact snapshot in contracts-v2 that corresponds to the +// deployment set ensjs tracks (verified address-by-address; the generate step +// below re-checks this and fails on any mismatch). The newer `deployments/sepolia` +// set on main is NOT the one ensjs uses yet. +const ABI_SNAPSHOT = 'sepolia-official-v1-20260525-r2' +const ABI_BLOB = `https://github.com/ensdomains/contracts-v2/blob/main/contracts/deployments/${ABI_SNAPSHOT}` +const ABI_RAW = `https://raw.githubusercontent.com/ensdomains/contracts-v2/main/contracts/deployments/${ABI_SNAPSHOT}` + +type ENSv2Deployment = { + name: string + ensjsKey: string + abiFilename?: string // artifact name in the contracts-v2 snapshot + address?: Hex + sourceUrl?: string + abiUrl?: string +} + +export type ENSv2DeploymentsByChain = { + name: string // Sepolia, etc. + slug: string // sepolia, etc. + contracts: ENSv2Deployment[] +} + +// Exactly the `// v2` section of `supportedL1Contracts` in ensjs l1.ts, +// mapped to display names and contracts-v2 source files. +const ENSV2_CONTRACTS: ENSv2Deployment[] = [ + { + name: 'ETHRegistry', + ensjsKey: 'ensRegistry', + abiFilename: 'ETHRegistry', + sourceUrl: `${CONTRACTS_V2_BLOB}/src/registry/PermissionedRegistry.sol`, + }, + { + name: 'ETHRegistrar', + ensjsKey: 'ensEthRegistrar', + abiFilename: 'ETHRegistrar', + sourceUrl: `${CONTRACTS_V2_BLOB}/src/registrar/ETHRegistrar.sol`, + }, + { + name: 'StandardRentPriceOracle', + ensjsKey: 'ensStandardRentPriceOracle', + abiFilename: 'StandardRentPriceOracle', + sourceUrl: `${CONTRACTS_V2_BLOB}/src/registrar/StandardRentPriceOracle.sol`, + }, + { + name: 'VerifiableFactory', + ensjsKey: 'ensVerifiableFactory', + abiFilename: 'VerifiableFactory', + sourceUrl: + 'https://github.com/ensdomains/verifiable-factory/blob/main/src/VerifiableFactory.sol', + }, + { + name: 'UserRegistryImpl', + ensjsKey: 'ensUserRegistryImpl', + abiFilename: 'UserRegistryImpl', + sourceUrl: `${CONTRACTS_V2_BLOB}/src/registry/UserRegistry.sol`, + }, + { + name: 'PermissionedResolverImpl', + ensjsKey: 'ensPermissionedResolverImpl', + abiFilename: 'PermissionedResolverImpl', + sourceUrl: `${CONTRACTS_V2_BLOB}/src/resolver/PermissionedResolver.sol`, + }, + { + name: 'HCAFactory', + ensjsKey: 'ensHcaFactory', + abiFilename: 'HCAFactory', + }, + { + name: 'LockedMigrationController', + ensjsKey: 'ensLockedMigrationController', + abiFilename: 'LockedMigrationController', + sourceUrl: `${CONTRACTS_V2_BLOB}/src/migration/LockedMigrationController.sol`, + }, + { + name: 'UnlockedMigrationController', + ensjsKey: 'ensUnlockedMigrationController', + abiFilename: 'UnlockedMigrationController', + sourceUrl: `${CONTRACTS_V2_BLOB}/src/migration/UnlockedMigrationController.sol`, + }, + { + name: 'MigrationHelper', + ensjsKey: 'ensMigrationHelper', + abiFilename: 'MigrationHelper', + sourceUrl: `${CONTRACTS_V2_BLOB}/src/migration/MigrationHelper.sol`, + }, + { + name: 'USDC (payment token)', + ensjsKey: 'usdc', + abiFilename: 'MockUSDC', + }, + { + name: 'DAI (payment token)', + ensjsKey: 'dai', + abiFilename: 'MockDAI', + }, +] + +const CHAINS = [{ name: 'Sepolia', slug: 'sepolia', ensjsChainKey: 'sepolia' }] + +// Extract `key: { address: '0x...' }` pairs from one chain block of the +// `ensL1Contracts` object in ensjs l1.ts. `zeroAddress` means not deployed. +function parseChainBlock( + source: string, + chainKey: string +): Map { + const exportStart = source.indexOf('export const ensL1Contracts') + if (exportStart === -1) + throw new Error('ensv2-deployments: ensL1Contracts not found in l1.ts') + const exportEnd = source.indexOf('export ', exportStart + 1) + const block = source.slice( + exportStart, + exportEnd === -1 ? undefined : exportEnd + ) + + const chainStart = block.indexOf(`[supportedL1Chains.${chainKey}]`) + if (chainStart === -1) + throw new Error( + `ensv2-deployments: chain block ${chainKey} not found in ensL1Contracts` + ) + const nextChain = block.indexOf('[supportedL1Chains.', chainStart + 1) + const chainBlock = block.slice( + chainStart, + nextChain === -1 ? undefined : nextChain + ) + + const addresses = new Map() + const entry = + /(\w+):\s*\{\s*address:\s*(?:'(0x[0-9a-fA-F]{40})'|zeroAddress)\s*,?\s*\}/g + for (const match of chainBlock.matchAll(entry)) { + addresses.set(match[1], match[2] as Hex | undefined) + } + if (addresses.size === 0) + throw new Error( + `ensv2-deployments: no addresses parsed for ${chainKey}, format changed?` + ) + return addresses +} + +// Generate a JSON file with ENSv2 contract deployment info from ensjs. +// Only runs once; delete the generated file to re-fetch. +export async function ensv2Deployments() { + const outFile = path.join( + __dirname, + '..', + 'src/data/generated/ensv2-deployments.json' + ) + + const alreadyExists = await fs + .access(outFile) + .then(() => true) + .catch(() => false) + if (alreadyExists) { + return + } + + console.log('Fetching ENSv2 deployments (ensjs)') + + const source = await fetch(ENSJS_L1_URL).then((res) => { + if (!res.ok) + throw new Error(`ensv2-deployments: fetch failed (${res.status})`) + return res.text() + }) + + const result: ENSv2DeploymentsByChain[] = CHAINS.map((chain) => { + const addresses = parseChainBlock(source, chain.ensjsChainKey) + const contracts = ENSV2_CONTRACTS.map((contract) => { + if (!addresses.has(contract.ensjsKey)) + throw new Error( + `ensv2-deployments: key ${contract.ensjsKey} missing for ${chain.slug}` + ) + const address = addresses.get(contract.ensjsKey) + if (!address) { + console.warn( + `ensv2-deployments: ${contract.ensjsKey} is zeroAddress on ${chain.slug}, skipping` + ) + return undefined + } + return { ...contract, address } + }).filter((c): c is ENSv2Deployment => c !== undefined) + return { name: chain.name, slug: chain.slug, contracts } + }) + + // Guard: the ABI snapshot in contracts-v2 must describe the same deployment + // set as ensjs. If a future ENSJS_COMMIT bump moves to a newer deployment, + // this fails the build until ABI_SNAPSHOT is updated to match. + await Promise.all( + result.flatMap((chain) => + chain.contracts + .filter((c) => c.abiFilename) + .map(async (contract) => { + const artifact = (await fetch( + `${ABI_RAW}/${contract.abiFilename}.json` + ).then((res) => { + if (!res.ok) + throw new Error( + `ensv2-deployments: artifact fetch failed for ${contract.abiFilename} (${res.status})` + ) + return res.json() + })) as { address: string } + if ( + artifact.address.toLowerCase() !== contract.address!.toLowerCase() + ) + throw new Error( + `ensv2-deployments: address mismatch for ${contract.name}: ensjs ${contract.address} vs snapshot ${artifact.address}. Update ABI_SNAPSHOT to the deployment set ensjs tracks.` + ) + contract.abiUrl = `${ABI_BLOB}/${contract.abiFilename}.json` + }) + ) + ) + + await fs.writeFile(outFile, JSON.stringify(result, null, 2)) +} diff --git a/scripts/run-plugins.ts b/scripts/run-plugins.ts index 3eed1b597..338ef3c9b 100644 --- a/scripts/run-plugins.ts +++ b/scripts/run-plugins.ts @@ -1,8 +1,9 @@ import { daoProposalsSidebar } from './dao-proposals' import { deployments } from './deployments' import { ensips } from './ensips' +import { ensv2Deployments } from './ensv2-deployments' -const plugins = [ensips, deployments, daoProposalsSidebar] +const plugins = [ensips, deployments, ensv2Deployments, daoProposalsSidebar] for (const plugin of plugins) { await plugin() diff --git a/src/components/AddressRecords.tsx b/src/components/AddressRecords.tsx index ef18e6e8c..9c9c5176c 100644 --- a/src/components/AddressRecords.tsx +++ b/src/components/AddressRecords.tsx @@ -14,7 +14,7 @@ export const AddressRecords = ({ name, coinTypes }: UseEnsAddressesProps) => { src={avatar || '/img/fallback-avatar.svg'} className="h-8 w-8 rounded" /> - {name} + {name} +
- {name || 'No name'} + {name || 'No name'} {!hideAddress && ( diff --git a/src/components/TextRecords.tsx b/src/components/TextRecords.tsx index 9741a4d00..324a0d488 100644 --- a/src/components/TextRecords.tsx +++ b/src/components/TextRecords.tsx @@ -14,7 +14,7 @@ export const TextRecords = ({ name, keys }: UseEnsTextsProps) => { src={avatar || '/img/fallback-avatar.svg'} className="h-8 w-8 rounded" /> - {name} + {name}
+ + + + + + {functions.map((fn) => ( + + ))} + +
+ ) +} + +function FunctionRow({ fn }: { fn: FunctionDef }) { + const hasDetails = + (fn.params && fn.params.length > 0) || + (fn.returns && fn.returns.length > 0) + const [open, setOpen] = useState(false) + + const shortName = fn.params + ? `${fn.name}(${fn.params.map((p) => p.name).join(', ')})` + : `${fn.name}` + + return ( + <> + setOpen(!open) : undefined} + style={{ + cursor: hasDetails ? 'pointer' : 'default', + borderTop: '1px solid var(--ens-border)', + }} + > + + + {hasDetails && ( + + + + )} + {!hasDetails && } + + {shortName} + + + + + {fn.description} + + + {open && hasDetails && ( + + + + + + + + + + {fn.params && fn.params.length > 0 && ( + <> + + + + {fn.params.map((p) => ( + + ))} + + )} + {fn.returns && fn.returns.length > 0 && ( + <> + + + + {fn.returns.map((p) => ( + + ))} + + )} + +
+ Parameters +
0 + ? '10px 0 4px 0' + : '0 0 4px 0', + }} + > + Returns +
+ + + )} + + ) +} + +function ParamRow({ param }: { param: Param }) { + return ( + + + + {param.name} + + + + + {param.type} + + {param.indexed && ( + + indexed + + )} + + + {param.description} + + + ) +} diff --git a/src/components/ensv2/ENSv2Deployments.tsx b/src/components/ensv2/ENSv2Deployments.tsx new file mode 100644 index 000000000..167485d2f --- /dev/null +++ b/src/components/ensv2/ENSv2Deployments.tsx @@ -0,0 +1,68 @@ +import { AnchorHTMLAttributes } from 'react' + +import { ENSv2DeploymentsByChain } from '../../../scripts/ensv2-deployments' +import deploymentsJson from '../../data/generated/ensv2-deployments.json' with { type: 'any' } + +const deploymentsByChain = deploymentsJson as ENSv2DeploymentsByChain[] + +export function ENSv2Deployments({ + chain, +}: { + chain: ENSv2DeploymentsByChain['slug'] +}) { + return deploymentsByChain + ?.filter((c) => c.slug === chain) + .map((chain) => ( + + + + + + + + + {chain.contracts.map((contract) => ( + + + + + ))} + +
ContractAddress
+ {contract.name} + {(contract.abiUrl || contract.sourceUrl) && ( +
+ {contract.abiUrl && ( + + ABI + + )} + {contract.sourceUrl && ( + + Source + + )} +
+ )} +
{contract.address}
+ )) +} + +function VocsExternalLink({ + href, + children, +}: AnchorHTMLAttributes) { + return ( + + {children} + + ) +} diff --git a/src/components/ensv2/FrenCallout.tsx b/src/components/ensv2/FrenCallout.tsx new file mode 100644 index 000000000..038a83672 --- /dev/null +++ b/src/components/ensv2/FrenCallout.tsx @@ -0,0 +1,78 @@ +import type { PropsWithChildren } from 'react' + +type Fren = 'earl' | 'bittu' | 'kuzco' | 'lili' | 'peanut' +type Variant = 'note' | 'tip' | 'warning' + +const FREN_IMAGES: Record = { + earl: '/img/frens/Earl_padding.png', + bittu: '/img/frens/Bittu_padding.png', + kuzco: '/img/frens/Kuzco_padding.png', + lili: '/img/frens/Lili_padding.png', + peanut: '/img/frens/Peanut_padding.png', +} + +const VARIANT_STYLES: Record = { + note: { + bg: 'var(--vocs-color_noteBackground, #f0f4f8)', + border: 'var(--vocs-color_noteBorder, #d0d7de)', + }, + tip: { + bg: 'var(--vocs-color_tipBackground, #f0fdf4)', + border: 'var(--vocs-color_tipBorder, #86efac)', + }, + warning: { + bg: 'var(--vocs-color_warningBackground, #fffbeb)', + border: 'var(--vocs-color_warningBorder, #fcd34d)', + }, +} + +interface FrenCalloutProps { + fren: Fren + variant?: Variant + title?: string +} + +export function FrenCallout({ + fren, + variant = 'note', + title, + children, +}: PropsWithChildren) { + const style = VARIANT_STYLES[variant] + const image = FREN_IMAGES[fren] + + return ( + + ) +} diff --git a/src/components/ensv2/IdExplorer.tsx b/src/components/ensv2/IdExplorer.tsx new file mode 100644 index 000000000..d835495c0 --- /dev/null +++ b/src/components/ensv2/IdExplorer.tsx @@ -0,0 +1,158 @@ +import { useMemo, useState } from 'react' +import { keccak256, toHex } from 'viem' + +import { Input } from '../ui/Input' + +function SplitId({ + label, + upper, + lower, + lowerLabel, +}: { + label: string + upper: string + lower: string + lowerLabel: string +}) { + const full = `0x${upper}${lower}` + return ( +
+ +
+
+ +
+
+ +
+ +
+
+ upper 224 bits (labelhash) + {lowerLabel} + +
+
+ ) +} + +export function IdExplorer() { + const [label, setLabel] = useState('alice') + const [eacVersionId, setEacVersionId] = useState(0) + const [tokenVersionId, setTokenVersionId] = useState(0) + + const computed = useMemo(() => { + if (!label) return null + try { + const labelhashHex = keccak256(toHex(label)) + // Split into upper (56 hex = 224 bits) and lower (8 hex = 32 bits) + const fullLabelhash = labelhashHex.slice(2).padStart(64, '0') + const upperHex = fullLabelhash.slice(0, 56) + + const canonicalLower = '00000000' + const tokenLower = BigInt(tokenVersionId).toString(16).padStart(8, '0') + const resourceLower = BigInt(eacVersionId).toString(16).padStart(8, '0') + + return { + labelhash: labelhashHex, + labelhashLower: fullLabelhash.slice(56), + upperHex, + canonicalLower, + tokenLower, + resourceLower, + } + } catch { + return null + } + }, [label, eacVersionId, tokenVersionId]) + + return ( +
+ setLabel(e.target.value)} + placeholder="alice" + /> + +
+
+ + setTokenVersionId(Number(e.target.value))} + className="w-full mt-2" + /> +
+
+ + setEacVersionId(Number(e.target.value))} + className="w-full mt-2" + /> +
+
+ + {computed && ( + <> + + + + + + )} +
+ ) +} diff --git a/src/components/ensv2/MonoDiagram.tsx b/src/components/ensv2/MonoDiagram.tsx new file mode 100644 index 000000000..e881a96ed --- /dev/null +++ b/src/components/ensv2/MonoDiagram.tsx @@ -0,0 +1,25 @@ +export function MonoDiagram({ content }: { content: string }) { + return ( +
+
+        {content}
+      
+
+ ) +} diff --git a/src/components/ensv2/ResourceCalculator.tsx b/src/components/ensv2/ResourceCalculator.tsx new file mode 100644 index 000000000..eb8a4b7e5 --- /dev/null +++ b/src/components/ensv2/ResourceCalculator.tsx @@ -0,0 +1,141 @@ +import { useMemo, useState } from 'react' +import { keccak256, namehash, encodeAbiParameters, toHex } from 'viem' +import { packetToBytes } from 'viem/ens' + +import { Input } from '../ui/Input' + +type RecordType = 'name' | 'text' | 'addr' + +export function ResourceCalculator() { + const [name, setName] = useState('alice.eth') + const [recordType, setRecordType] = useState('text') + const [textKey, setTextKey] = useState('avatar') + const [coinType, setCoinType] = useState('60') + + const computed = useMemo(() => { + if (!name) return null + try { + const node = namehash(name) + + let part: `0x${string}` + let partLabel: string + let authorizeFunction: string + let authorizeArgs: string + + const dnsName = toHex(packetToBytes(name)) + + if (recordType === 'name') { + part = '0x0000000000000000000000000000000000000000000000000000000000000000' + partLabel = 'bytes32(0) (name-level)' + authorizeFunction = 'authorizeNameRoles' + authorizeArgs = `(${dnsName}, roleBitmap, account, true)` + } else if (recordType === 'text') { + // partHash(key) = keccak256(bytes(key)) + part = keccak256(toHex(textKey)) + partLabel = `partHash("${textKey}") = keccak256(bytes("${textKey}"))` + authorizeFunction = 'authorizeTextRoles' + authorizeArgs = `(${dnsName}, "${textKey}", account, true)` + } else { + // partHash(coinType) = keccak256(abi.encode(coinType)) + const coinTypeNum = BigInt(coinType || '0') + part = keccak256( + encodeAbiParameters([{ type: 'uint256' }], [coinTypeNum]) + ) + partLabel = `partHash(${coinType}) = keccak256(abi.encode(${coinType}))` + authorizeFunction = 'authorizeAddrRoles' + authorizeArgs = `(${dnsName}, ${coinType}, account, true)` + } + + // resource = keccak256(node, part) + // Special case: resource(0, 0) = ROOT_RESOURCE = 0 + let resource: string + if (node === '0x0000000000000000000000000000000000000000000000000000000000000000' && + part === '0x0000000000000000000000000000000000000000000000000000000000000000') { + resource = '0x0000000000000000000000000000000000000000000000000000000000000000' + } else { + resource = keccak256( + encodeAbiParameters( + [{ type: 'bytes32' }, { type: 'bytes32' }], + [node, part] + ) + ) + } + + return { + node, + part, + partLabel, + resource, + authorizeFunction, + authorizeArgs, + dnsName, + } + } catch { + return null + } + }, [name, recordType, textKey, coinType]) + + return ( +
+ setName(e.target.value)} + placeholder="alice.eth" + /> + +
+ +
+ {(['name', 'text', 'addr'] as const).map((type) => ( + + ))} +
+
+ + {recordType === 'text' && ( + setTextKey(e.target.value)} + placeholder="avatar" + /> + )} + + {recordType === 'addr' && ( + setCoinType(e.target.value)} + placeholder="60" + /> + )} + + {computed && ( + <> + + + + + + )} +
+ ) +} diff --git a/src/components/ensv2/RoleBitmapComposer.tsx b/src/components/ensv2/RoleBitmapComposer.tsx new file mode 100644 index 000000000..40c7bf65f --- /dev/null +++ b/src/components/ensv2/RoleBitmapComposer.tsx @@ -0,0 +1,156 @@ +import { useMemo, useState } from 'react' + +import { Input } from '../ui/Input' + +// Role definitions copied verbatim from contracts-v2 source code. +// Each value is the exact bigint equivalent of the Solidity constant. + +// From: contracts/src/registry/libraries/RegistryRolesLib.sol +const REGISTRY_ROLES = [ + // Regular roles (lower 128 bits) + { name: 'ROLE_REGISTRAR', value: 1n << 0n }, // Nybble 0 + { name: 'ROLE_REGISTER_RESERVED', value: 1n << 4n }, // Nybble 1 + { name: 'ROLE_SET_PARENT', value: 1n << 8n }, // Nybble 2 + { name: 'ROLE_UNREGISTER', value: 1n << 12n }, // Nybble 3 + { name: 'ROLE_RENEW', value: 1n << 16n }, // Nybble 4 + { name: 'ROLE_SET_SUBREGISTRY', value: 1n << 20n }, // Nybble 5 + { name: 'ROLE_SET_RESOLVER', value: 1n << 24n }, // Nybble 6 + { name: 'ROLE_SET_URI', value: 1n << 36n }, // Nybble 9 + { name: 'ROLE_UPGRADE', value: 1n << 124n }, // Nybble 31 + // Admin roles (upper 128 bits) + { name: 'ROLE_REGISTRAR_ADMIN', value: (1n << 0n) << 128n }, // Nybble 32 + { name: 'ROLE_REGISTER_RESERVED_ADMIN', value: (1n << 4n) << 128n }, // Nybble 33 + { name: 'ROLE_SET_PARENT_ADMIN', value: (1n << 8n) << 128n }, // Nybble 34 + { name: 'ROLE_UNREGISTER_ADMIN', value: (1n << 12n) << 128n }, // Nybble 35 + { name: 'ROLE_RENEW_ADMIN', value: (1n << 16n) << 128n }, // Nybble 36 + { name: 'ROLE_SET_SUBREGISTRY_ADMIN', value: (1n << 20n) << 128n }, // Nybble 37 + { name: 'ROLE_SET_RESOLVER_ADMIN', value: (1n << 24n) << 128n }, // Nybble 38 + { name: 'ROLE_CAN_TRANSFER_ADMIN', value: (1n << 28n) << 128n }, // Nybble 39 (admin-only, no regular variant) + { name: 'ROLE_SET_URI_ADMIN', value: (1n << 36n) << 128n }, // Nybble 41 + { name: 'ROLE_UPGRADE_ADMIN', value: (1n << 124n) << 128n }, // Nybble 63 +] as const + +// From: contracts/src/resolver/libraries/PermissionedResolverLib.sol +const RESOLVER_ROLES = [ + // Regular roles (lower 128 bits) + { name: 'ROLE_SET_ADDR', value: 1n << 0n }, // Nybble 0 + { name: 'ROLE_SET_TEXT', value: 1n << 4n }, // Nybble 1 + { name: 'ROLE_SET_CONTENTHASH', value: 1n << 8n }, // Nybble 2 + { name: 'ROLE_SET_PUBKEY', value: 1n << 12n }, // Nybble 3 + { name: 'ROLE_SET_ABI', value: 1n << 16n }, // Nybble 4 + { name: 'ROLE_SET_INTERFACE', value: 1n << 20n }, // Nybble 5 + { name: 'ROLE_SET_NAME', value: 1n << 24n }, // Nybble 6 + { name: 'ROLE_SET_ALIAS', value: 1n << 28n }, // Nybble 7 + { name: 'ROLE_CLEAR', value: 1n << 32n }, // Nybble 8 + { name: 'ROLE_SET_DATA', value: 1n << 36n }, // Nybble 9 + { name: 'ROLE_UPGRADE', value: 1n << 124n }, // Nybble 31 + // Admin roles (upper 128 bits) + { name: 'ROLE_SET_ADDR_ADMIN', value: (1n << 0n) << 128n }, // Nybble 32 + { name: 'ROLE_SET_TEXT_ADMIN', value: (1n << 4n) << 128n }, // Nybble 33 + { name: 'ROLE_SET_CONTENTHASH_ADMIN', value: (1n << 8n) << 128n }, // Nybble 34 + { name: 'ROLE_SET_PUBKEY_ADMIN', value: (1n << 12n) << 128n }, // Nybble 35 + { name: 'ROLE_SET_ABI_ADMIN', value: (1n << 16n) << 128n }, // Nybble 36 + { name: 'ROLE_SET_INTERFACE_ADMIN', value: (1n << 20n) << 128n }, // Nybble 37 + { name: 'ROLE_SET_NAME_ADMIN', value: (1n << 24n) << 128n }, // Nybble 38 + { name: 'ROLE_SET_ALIAS_ADMIN', value: (1n << 28n) << 128n }, // Nybble 39 + { name: 'ROLE_CLEAR_ADMIN', value: (1n << 32n) << 128n }, // Nybble 40 + { name: 'ROLE_SET_DATA_ADMIN', value: (1n << 36n) << 128n }, // Nybble 41 + { name: 'ROLE_UPGRADE_ADMIN', value: (1n << 124n) << 128n }, // Nybble 63 +] as const + +interface RoleBitmapComposerProps { + contract: 'registry' | 'resolver' +} + +export function RoleBitmapComposer({ contract }: RoleBitmapComposerProps) { + const allRoles = contract === 'registry' ? REGISTRY_ROLES : RESOLVER_ROLES + const [selected, setSelected] = useState>(new Set()) + + // Split into regular and admin for display + const regularRoles = allRoles.filter(r => !r.name.endsWith('_ADMIN')) + const adminRoles = allRoles.filter(r => r.name.endsWith('_ADMIN')) + + // Computation: just OR together the selected role values + const result = useMemo(() => { + if (selected.size === 0) return null + + let bitmap = 0n + const names: string[] = [] + + for (const role of allRoles) { + if (selected.has(role.name)) { + bitmap = bitmap | role.value + names.push(role.name) + } + } + + return { + hex: '0x' + bitmap.toString(16).padStart(64, '0'), + decimal: bitmap.toString(), + solidity: names.join(' | '), + } + }, [selected, allRoles]) + + const toggle = (name: string) => { + setSelected(prev => { + const next = new Set(prev) + if (next.has(name)) next.delete(name) + else next.add(name) + return next + }) + } + + return ( +
+
+ +
+ {regularRoles.map((role) => ( + + ))} +
+
+ +
+ +
+ {adminRoles.map((role) => ( + + ))} +
+
+ + {result && ( + <> + + + + + )} +
+ ) +} diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index e63afff4a..dda7afacf 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -21,7 +21,7 @@ const variantStyles = { destructive: '!bg-red text-white hover:!bg-red-bright', success: '!bg-green text-white hover:!bg-green-bright', outline: - 'ring-2 ring-inset ring-grey-active text-grey-active hover:ring-grey hover:text-grey', + 'ring-1 ring-inset ring-grey-active text-grey-active hover:ring-grey hover:text-grey', } type HrefProperties = { @@ -46,7 +46,7 @@ export const Button: FC< const disabled = 'disabled' in properties ? properties.disabled : false className = cn( - 'py-3 px-5 rounded-lg inline-flex justify-center gap-0.5 overflow-hidden text-sm font-medium transition', + 'py-3 px-5 rounded-sm inline-flex justify-center gap-0.5 overflow-hidden text-sm font-medium transition', 'hover:-translate-y-[1px]', 'active:translate-y-0', variantStyles[variant], diff --git a/src/pages/contracts/ensv2/dns-resolvers.mdx b/src/pages/contracts/ensv2/dns-resolvers.mdx new file mode 100644 index 000000000..1c7ae5b4e --- /dev/null +++ b/src/pages/contracts/ensv2/dns-resolvers.mdx @@ -0,0 +1,95 @@ +--- +description: How DNS domain names are resolved through the ENSv2 protocol +--- + +import { FrenCallout } from '../../../components/ensv2/FrenCallout' + +# DNS Name Resolution + +ENSv2 supports resolving traditional DNS domain names (like `.com`, `.xyz`) through the ENS protocol, provided the domain has DNSSEC enabled. This builds on the [DNS on ENS](/learn/dns) functionality from ENSv1, replacing the single `OffchainDNSResolver` ([ENSIP-17](/ensip/17)) with a set of three specialized contracts. + +Users don't interact with these contracts directly. The [Universal Resolver V2](/contracts/ensv2/universal-resolver-v2) handles the entire flow transparently. This page explains what happens internally and how to configure DNS records for ENS resolution. + + +The contracts and interfaces described here are **not yet final** and may change prior to mainnet deployment. + + +## How It Works + + +ENSv2's DNS resolution fetches DNSSEC proofs off-chain via [CCIP-Read](/resolvers/ccip-read) and verifies them on-chain. The on-chain DNSSEC proof submission used by the v1 [DNS Registrar](/registry/dns) for claiming DNS names remains on v1 infrastructure at launch. + + +When the Universal Resolver encounters a DNS name (e.g., `example.com`), it finds the **DNSTLDResolver** set as the resolver for that TLD on the root registry. The DNSTLDResolver then follows a multi-step resolution strategy: + +1. **Check ENSv1**: look for an existing resolver in the ENSv1 registry. If one exists (and it's not the v1 DNS TLD resolver or the DNSTLDResolver itself), delegate to it directly. This preserves backward compatibility. +2. **Query DNSSEC**: initiate a [CCIP-Read](/resolvers/ccip-read) request to fetch DNSSEC-signed TXT records for the domain. +3. **Parse the TXT record**: find the first TXT record starting with `ENS1` that yields a valid resolver address, and extract the resolver address and context. +4. **Delegate to the parsed resolver**: forward the request to the resolver contract specified in the TXT record. ENS ships two standard resolver implementations: the **DNSAliasResolver** (for name rewriting) and the **DNSTXTResolver** (for inline record data), but any contract implementing `IExtendedDNSResolver` can be used. + +```mermaid +flowchart LR + UR["Universal Resolver"] --> TLD["DNSTLDResolver"] + TLD --> V1{"Usable v1
resolver?"} + V1 -- yes --> Legacy["Delegate to
v1 resolver"] + V1 -- no --> DNSSEC["Fetch & parse
ENS1 TXT record
(CCIP-Read)"] + DNSSEC -- "name rewriting" --> Alias["DNSAliasResolver"] + DNSSEC -- "inline records" --> TXT["DNSTXTResolver"] +``` + +## TXT Record Formats + +To enable ENS resolution for a DNS domain, add a TXT record with the prefix `ENS1`: + +``` +ENS1 +``` + +The `` can be either a hex address (`0x1234...`) or an ENS name that resolves to the resolver contract. The `` depends on which resolver is used. + +### Inline Records (DNSTXTResolver) + +Set the resolver to the DNSTXTResolver address and encode records directly in the context. Multiple records are separated by spaces: + +``` +ENS1 dnstxt.ens.eth a[60]=0x1234... t[avatar]=https://example.com/pic.png +``` + +Supported record formats: + +| Prefix | Record type | Example | +| ----------------- | ------------------------------------------------ | ----------------------- | +| `a[60]` | Ethereum address ([ENSIP-9](/ensip/9)) | `a[60]=0x1234...` | +| `a[e0]` | Default EVM address, any chain ([ENSIP-19](/ensip/19)) | `a[e0]=0x1234...` | +| `a[e]` | EVM chain address by chain ID ([ENSIP-19](/ensip/19)) | `a[e8453]=0x5678...` | +| `a[]` | Non-EVM address by SLIP-44 coin type ([ENSIP-9](/ensip/9)) | `a[0]=bc1qar0...` | +| `t[key]` | Text record | `t[avatar]=https://...` | +| `c` | Content hash ([ENSIP-7](/ensip/7)) | `c=0xe301...` | +| `d[key]` | Data record | `d[mykey]=0x1234...` | +| `xy` | Public key (x and y concatenated, 64 bytes) | `xy=0x...` | + + +Values are separated by spaces. To include spaces in a value, wrap it in single quotes: `t[description]='Hello World'`. Escape single quotes with a backslash: `t[notice]='it\'s ENS'`. + + +This is the simplest approach: all record data lives in the DNS TXT record itself, with no on-chain registration required. + +### Alias Resolution (DNSAliasResolver) + +Set the resolver to the DNSAliasResolver address and provide a rewrite rule in the context. Two modes are supported: + +**Suffix replacement**: rewrite the DNS suffix to an ENS suffix. For example, setting this TXT record on `example.com`: + +``` +ENS1 dnsalias.ens.eth com base.eth +``` + +The context `com base.eth` tells the resolver to strip the `com` suffix and replace it with `base.eth`. So `sub.example.com` resolves as `sub.example.base.eth`, and `example.com` itself resolves as `example.base.eth`. The resolver then looks up the rewritten name through the ENSv2 registry hierarchy. + +**Full replacement**: map a DNS domain to a single ENS name. When the context contains no space, the resolver ignores the queried name entirely and resolves the context as an ENS name. For example, setting this TXT record on `example.com`: + +``` +ENS1 dnsalias.ens.eth alice.eth +``` + +This makes `example.com` resolve as `alice.eth`. diff --git a/src/pages/contracts/ensv2/enhanced-access-control.mdx b/src/pages/contracts/ensv2/enhanced-access-control.mdx new file mode 100644 index 000000000..52ed7c155 --- /dev/null +++ b/src/pages/contracts/ensv2/enhanced-access-control.mdx @@ -0,0 +1,286 @@ +--- +description: Enhanced Access Control +--- + +import { FrenCallout } from '../../../components/ensv2/FrenCallout' +import { ContractReference } from '../../../components/ensv2/ContractReference' +import { MonoDiagram } from '../../../components/ensv2/MonoDiagram' + +# Enhanced Access Control + +Enhanced Access Control (EAC) is the permission system used throughout ENSv2. It controls who is allowed to do what, and on which names. EAC supports up to 2^256 independent resources (e.g., individual names), 64 roles per resource (32 regular + 32 admin), and up to 15 holders of a role within the context of a single resource. + +Think of it like a building where each room has its own set of locks: you can give someone a key to one room, or a master key that opens every room. EAC works the same way, but with ENS names and on-chain permissions. + + +The contracts and interfaces described here are **not yet final** and may change prior to mainnet deployment. + + +## Resources + +A **resource** is the thing you're controlling access to. In most ENS contracts, a resource is a name - but it can be any `uint256` identifier that makes sense for the contract. + +Each ENSv2 contract defines how its resources are computed. For example, registries derive resources from a name's labelhash, while resolvers derive resources from a namehash and record type. See [How Contracts Use EAC](#how-contracts-use-eac) for the specific schemes. + +There's also a special resource called **`ROOT_RESOURCE`** (`0x0`) that represents the contract itself. Permissions granted on `ROOT_RESOURCE` apply everywhere - like a master key. If you have a role on `ROOT_RESOURCE`, you automatically have that role on every individual resource too. + +```mermaid +graph TD + ROOT["ROOT_RESOURCE (0x0)\nApplies to all names"] + A["alice.eth"] + B["bob.eth"] + C["carol.eth"] + ROOT -.->|"master key"| A + ROOT -.->|"master key"| B + ROOT -.->|"master key"| C +``` + +## Roles + +A **role** represents a specific permission - for example, "can set the resolver" or "can register subnames". Each ENS contract defines the roles that are relevant to it. + +Roles are always tied to a resource. Granting someone the "set resolver" role on `alice.eth` doesn't let them set the resolver on `bob.eth`. To give someone a permission across all names, grant the role on `ROOT_RESOURCE` instead. + +Up to 15 accounts can hold the same role on the same resource, enabling shared management and delegation. + +**How role checks work:** when a contract checks whether an account has a role on a specific name, it looks in two places - the name itself and `ROOT_RESOURCE` - and allows the action if the role is found in either. This is how the master key effect works. + +## Admin Roles + +Every role has a corresponding **admin role** that controls who can manage it. If you hold the admin role, you can: + +- **Grant** the regular role to other accounts +- **Grant** the admin role itself to other accounts +- **Revoke** either role from other accounts + +```mermaid +graph LR + Admin["Admin Role\n(manages access)"] + Regular["Regular Role\n(performs actions)"] + Admin -->|"grant / revoke"| Regular + Admin -->|"grant / revoke"| Admin +``` + +For example, the admin role for "set resolver" controls who is allowed to grant or revoke the "set resolver" permission. Admin roles follow the same resource-scoping - you can be an admin for a specific name or for all names via `ROOT_RESOURCE`. + +## Granting and Revoking + +Roles are managed through four functions: + +| Grant | Revoke | Scope | +| ------------------ | ------------------- | ------------------------------------ | +| `grantRoles` | `revokeRoles` | Specific resource (name) | +| `grantRootRoles` | `revokeRootRoles` | `ROOT_RESOURCE` (contract-wide) | + +The caller must hold the admin role for every role being granted or revoked. Admin role holders can also revoke the admin role itself, including from themselves. + +As a safety guardrail, `grantRoles` and `revokeRoles` reject `ROOT_RESOURCE`. You must use `grantRootRoles` / `revokeRootRoles` explicitly for contract-wide permissions. This prevents accidental global grants. + +All four functions return `true` if the account's roles actually changed, or `false` if the roles were already in the desired state. + +## Callback Hooks + +EAC provides five internal hooks that contracts can override to customize permission behavior: + +### Role Resolution Hook + +- **`_getRoles(resource, account)`**: returns the role bitmap for an account on a resource. By default, returns the stored roles directly. Contracts override this to inject additional role logic at read time. The [Permissioned Registry](/contracts/ensv2/permissioned-registry) overrides `_getRoles` so that ERC1155-approved operators (via `setApprovalForAll`) inherit the token owner's roles on that name. + +### Role Change Callbacks + +- **`_onRolesGranted(resource, account, oldRoles, newRoles, roleBitmap)`**: called after roles are successfully granted. Receives the account's role bitmap before and after the change, plus the specific roles that were newly added. +- **`_onRolesRevoked(resource, account, oldRoles, newRoles, roleBitmap)`**: called after roles are successfully revoked. Same parameters, with `roleBitmap` containing the roles that were actually removed. + +Both are no-ops in the base implementation. The [Permissioned Registry](/contracts/ensv2/permissioned-registry) overrides these to [regenerate](/contracts/ensv2/mutable-token-ids#regeneration) ERC1155 tokens when roles change, invalidating stale transfer approvals. + +### Grant/Revoke Restriction Hooks + +- **`_getSettableRoles(resource, account)`**: returns which roles the account is allowed to grant on a given resource. By default, an account can grant any role for which it holds the corresponding admin role. Contracts override this to impose additional restrictions. +- **`_getRevokableRoles(resource, account)`**: returns which roles the account is allowed to revoke on a given resource. Same default behavior as settable roles. + +The [Permissioned Registry](/contracts/ensv2/permissioned-registry) overrides `_getSettableRoles` to prevent admin role escalation on individual names after registration. Role changes on unregistered or reserved names are blocked implicitly: `_getRoles` returns no effective roles when a name has no owner. + +## Bitmap Layout + +Under the hood, roles are packed into a single `uint256` bitmap split into two halves. Each role occupies one nybble (4 bits), giving space for up to 32 regular roles and 32 corresponding admin roles: + + + +Each nybble is a 4-bit slot. A regular role at nybble index `N` occupies bits `4N` to `4N + 3`, so nybble 0 is bits 0–3, nybble 1 is bits 4–7, and so on. Its admin counterpart sits at the same position in the upper half (`4N + 128` to `4N + 131`). + +EAC tracks role assignments in two mappings: + +- **`_roles[resource][account]`**: stores which roles a given account holds on a given resource. Each nybble is either `0` (no role) or `1` (has role). +- **`_roleCount[resource]`**: stores how many accounts hold each role on a given resource. Each nybble is a count from 0 to 15, using the same nybble-per-role layout. This is why the maximum number of assignees per role is 15: it's the largest value a 4-bit nybble can store. + +## Replacing Fuses + +EAC replaces the one-way [fuse system](/wrapper/fuses) from the Name Wrapper. The key conceptual shift: in ENSv1, you **burned** permissions to restrict what could be done. In ENSv2, you **revoke** roles instead. Both achieve the same end result, but revoking is reversible if you hold the admin role. + +| Feature | ENSv1 Fuses | ENSv2 EAC | +| ----------------- | ------------------------------ | ----------------------------------------------------------- | +| Revocability | One-way burn — permanent | Reversible grant/revoke (while admin role is held) | +| Delegation | Single owner only | Up to 15 accounts per role per resource | +| Scope | Per-name only | Per-name or contract-wide via `ROOT_RESOURCE` | +| Extensibility | Fixed set of 16 fuses | Each contract defines its own roles (up to 32) | +| Transfer control | `CANNOT_TRANSFER` fuse | `ROLE_CAN_TRANSFER_ADMIN` (revoke to make non-transferable) | +| Resolver control | `CANNOT_SET_RESOLVER` fuse | `ROLE_SET_RESOLVER` (revoke to lock resolver) | +| Subdomain control | `CANNOT_CREATE_SUBDOMAIN` fuse | `ROLE_REGISTRAR` (revoke to prevent new subnames) | + +## How Contracts Use EAC + +Each ENSv2 contract defines its own roles and its own resource scheme. See the EAC Permissions section on each contract page for details: + +- [Permissioned Registry: EAC Integration](/contracts/ensv2/permissioned-registry#eac-integration): 10 roles, labelhash-based resources with version isolation, anyId polymorphism +- [Permissioned Resolver: EAC Integration](/contracts/ensv2/permissioned-resolver#eac-integration): 11 roles (8 per-record), fine-grained scoping down to individual keys or coin types + +## Reference + +### Write Functions + + + +### View Functions + + + +### Constants + + + +### Events + + + +For code examples of granting and revoking roles, see the [Permissioned Registry](/contracts/ensv2/permissioned-registry#code-examples) and [Permissioned Resolver](/contracts/ensv2/permissioned-resolver#code-examples) pages. diff --git a/src/pages/contracts/ensv2/erc1155-singleton.mdx b/src/pages/contracts/ensv2/erc1155-singleton.mdx new file mode 100644 index 000000000..02ab0f5ae --- /dev/null +++ b/src/pages/contracts/ensv2/erc1155-singleton.mdx @@ -0,0 +1,98 @@ +--- +description: ERC1155Singleton - a gas-optimized ERC1155 variant with exactly one owner per token +--- + +import { FrenCallout } from '../../../components/ensv2/FrenCallout' + +# ERC1155Singleton + +ENSv2 represents names as tokens using a modified ERC1155 implementation called **ERC1155Singleton**. Unlike standard ERC1155 where tokens can be fungible (multiple owners holding quantities of the same token), each ERC1155Singleton token has exactly one owner - combining the ownership semantics of ERC721 with the batching and interface compatibility of ERC1155. + + +The contracts and interfaces described here are **not yet final** and may change prior to mainnet deployment. + + +## Why Not ERC721? + +ENSv1 used ERC721 for `.eth` names via the BaseRegistrar. ENSv2 switched to a modified ERC1155 for several reasons: + +- **Batch operations**: ERC1155 natively supports batch transfers and balance queries, which is useful when operating on multiple names at once +- **Interface compatibility**: the ERC1155 interface is well-supported by wallets, marketplaces, and indexers +- **Gas efficiency**: ERC1155Singleton removes the balance tracking overhead of standard ERC1155 while using a simpler approval model than the NameWrapper's hybrid per-token and operator approvals + +## Key Differences from Standard ERC1155 + +| Feature | Standard ERC1155 | ERC1155Singleton | +| ------------------------ | ------------------------------- | --------------------------------------- | +| Tokens per ID | 0 to N (fungible) | 0 or 1 (singleton) | +| `ownerOf(id)` | Not available | Returns the single owner | +| `balanceOf(account, id)` | Returns quantity held | Returns `1` (owner) or `0` | +| Balance tracking | Mapping of balances per account | Flat `id -> address` mapping | +| Transfer value | Any amount | Must be exactly `1` (reverts otherwise) | + +## Ownership Model + +ERC1155Singleton provides a simple ownership model: + +```solidity +// Returns the owner of a token, or address(0) if it doesn't exist +function ownerOf(uint256 id) external view returns (address) + +// Returns 1 if `account` owns `id`, 0 otherwise +function balanceOf(address account, uint256 id) external view returns (uint256) +``` + +Under the hood, ownership is stored in a flat mapping from token ID to owner address. There's no separate balance counter - `balanceOf` simply checks whether the queried account matches the stored owner. + +## Gas Optimization + +The key gas saving comes from eliminating balance accounting. In a standard ERC1155, every transfer requires two storage writes: decrement the sender's balance and increment the receiver's balance. + +In ERC1155Singleton, a transfer is just a single storage write: updating the `id -> owner` mapping. This makes transfers noticeably cheaper. + +## HCA Support + +ERC1155Singleton integrates with the [Hidden Contract Account (HCA)](/contracts/ensv2/hca) system. When resolving `msg.sender`, the contract checks whether the caller is a registered HCA (a smart contract wallet) and, if so, uses the HCA's owner address instead. + +This means that if you interact with an ENSv2 registry through a smart contract wallet that is registered as an HCA, the registry recognizes you as the owner rather than the wallet contract. This is particularly important for: + +- Smart contract wallets (e.g., Safe, ERC-4337 accounts) that hold names +- Proxy contracts that act on behalf of users + +## Integration with the Registry + +The [Permissioned Registry](/contracts/ensv2/permissioned-registry) inherits from ERC1155Singleton. Every registered name becomes a singleton token: + +- **Minting** happens when a name is registered via `register()` +- **Burning** happens when a name is unregistered via `unregister()`, or when it expires and is re-registered +- **Token regeneration** happens when roles change on a name - the old token is burned and a new one is minted to the same owner with a new ID (see [Mutable Token IDs](/contracts/ensv2/mutable-token-ids)) + +The singleton constraint means that standard ERC1155 operations like `safeTransferFrom` work as expected, but attempting to transfer a value greater than `1` will revert. + +## Marketplace Compatibility + +Because ERC1155Singleton implements the full `IERC1155` interface, names are compatible with standard NFT marketplaces and tools: + +- **Marketplaces** (OpenSea, Blur, etc.) can list and trade names +- **Wallets** display names alongside other ERC1155 tokens +- **Indexers** track ownership changes through standard `TransferSingle` and `TransferBatch` events + +One important caveat: because [token IDs can change](/contracts/ensv2/mutable-token-ids) when roles are updated, marketplace listings tied to an old token ID will become invalid. This is by design - it prevents an attack where a seller changes the name's permissions after listing it for sale. + +## Interface + +`IERC1155Singleton` extends the standard `IERC1155` with a single addition, `ownerOf`, analogous to ERC721's function of the same name: + +```solidity +/// @dev Interface selector: 0x6352211e +interface IERC1155Singleton is IERC1155 { + /// Returns the owner of a token, or address(0) if it doesn't exist. + function ownerOf(uint256 id) external view returns (address owner); +} +``` + +Internally, the contract replaces the standard ERC1155 nested balance mapping (`id → address → balance`) with a flat `id → address` ownership mapping. The [Permissioned Registry](/contracts/ensv2/permissioned-registry) overrides `ownerOf` to add expiry and version validation on top of raw ownership, returning `address(0)` for expired names or stale token IDs. + +Unlike the standard ERC1155 which has a `_uri` storage variable, ERC1155Singleton makes `uri()` abstract with no built-in storage. The [Permissioned Registry](/contracts/ensv2/permissioned-registry) overrides it with a two-mode implementation: a static URI string, or delegation to an [`IRegistryURIRenderer`](/contracts/ensv2/permissioned-registry#token-metadata) contract for dynamic per-token metadata. + +The contract emits all standard ERC1155 events (`TransferSingle`, `TransferBatch`, `ApprovalForAll`, `URI`). diff --git a/src/pages/contracts/ensv2/eth-registrar.mdx b/src/pages/contracts/ensv2/eth-registrar.mdx new file mode 100644 index 000000000..1e5c6e606 --- /dev/null +++ b/src/pages/contracts/ensv2/eth-registrar.mdx @@ -0,0 +1,553 @@ +--- +description: The ENSv2 ETH Registrar - commit-reveal registration, flexible pricing, and multi-token payments for .eth names +--- + +import { FrenCallout } from '../../../components/ensv2/FrenCallout' +import { ContractReference } from '../../../components/ensv2/ContractReference' +import { Card } from '../../../components/ui/Card' + +# ETH Registrar + +The ETH Registrar manages the registration and renewal of `.eth` names in ENSv2. It retains the proven [commit-reveal pattern from ENSv1](/registry/eth#registering-a-name) while adding multi-year registration discounts, ERC20 payment support, and integration with the [Permissioned Registry](/contracts/ensv2/permissioned-registry). + + +The contracts and interfaces described here are **not yet final** and may change prior to mainnet deployment. + + +## What Changed from ENSv1 + +| Feature | ENSv1 | ENSv2 | +| -------------------- | ----------------------------------- | ------------------------------------------------------------------------------------- | +| Grace period | 90-day grace period after expiry | **28-day grace period** | +| Payment | ETH only | ERC20 tokens via `safeTransferFrom` | +| Pricing | USD-denominated, no duration discounts | USD-denominated, multi-year duration discounts | +| Name ownership | ERC721 on BaseRegistrar (optionally wrapped to ERC1155 via [NameWrapper](/wrapper/overview)) | [ERC1155Singleton](/contracts/ensv2/erc1155-singleton) token on Permissioned Registry | +| Permissions | Owner has full control; no granular roles | Registrant receives a fixed set of [EAC](/contracts/ensv2/enhanced-access-control) roles | + +## Pricing + +### Base Rate + +Names are priced annually by character count: + +| Name Length | Annual Price | +|-------------|-------------| +| 3 characters | $640/yr | +| 4 characters | $160/yr | +| 5+ characters | $8/yr | + +### Multi-Year Discounts + +Longer registrations (and renewals) receive a discount applied to the entire duration. The discount is determined by the total term in a single transaction: + +| Term | Discount | 5+ char /yr | 5+ char total | 4-char /yr | 4-char total | 3-char /yr | 3-char total | +|------|----------|------------|--------------|-----------|-------------|-----------|-------------| +| 1 yr | 0% | $8.00 | $8.00 | $160 | $160 | $640 | $640 | +| 2 yr | 12.5% | $7.00 | $14.00 | $140 | $280 | $560 | $1,120 | +| 3 yr | ~31% | $5.50 | $16.50 | $110 | $330 | $440 | $1,320 | +| 4 yr | ~31% | $5.50 | $22.00 | $110 | $440 | $440 | $1,760 | +| 5 yr | ~31% | $5.50 | $27.50 | $110 | $550 | $440 | $2,200 | +| 6 yr | ~44% | $4.50 | $27.00 | $90 | $540 | $360 | $2,160 | + +Because the discount rate increases at six years, the total cost for six years is lower than for five. For example, renewing a 5+ character name for one year always costs $8 regardless of the existing expiration; renewing the same name for six years costs $27 ($4.50/yr). + +Pricing parameters (base rates and discount tiers) are immutable per oracle deployment. Changing them requires deploying a new oracle and calling `setRentPriceOracle()`. + +### Expiry Premium + +After the 28-day grace period, recently-expired names enter a 21-day temporary premium period to prevent sniping. The premium starts at ~$100M and decays exponentially (halving every day), reaching zero at the end of the window. + + +
+ + Premium (USD) + Days after grace period ends + $100M + $0 + 0 + 21 + + + + +
+
+ +### Querying Prices + +Both the registrar and the oracle expose `getRegisterPrice` and `getRenewPrice`, but they serve different purposes: + +- **Registrar** (stateful): `getRegisterPrice(label, duration, paymentToken)` returns the actual cost for a specific name right now. It reads registry state to determine how long the name has been available, then forwards to the oracle. Reverts if the name is not available or the duration is below the minimum. +- **Oracle** (stateless): `getRegisterPrice(label, available, duration, paymentToken)` takes a fully parameterized `available` duration and returns what the price would be for any hypothetical scenario. It has no knowledge of registry state or minimums. + + + Use the registrar's version when you need the true price for a specific name (e.g., before calling `register()`). Use the oracle directly when you need hypothetical pricing (e.g., a pricing calculator or "what would this cost?" UI). + + +## Multi-Token Payments + +Unlike ENSv1 which only accepted ETH, the ENSv2 registrar supports payment in multiple ERC20 tokens. The `StandardRentPriceOracle` maintains exchange rate ratios for each accepted token. Check accepted tokens via `isPaymentToken(token)` on the `StandardRentPriceOracle`. + +Payment is collected via `safeTransferFrom` to an immutable beneficiary address. The caller must approve the registrar for the payment token before calling `register()` or `renew()`. + +## Registering a Name + +Like [ENSv1](/registry/eth#registering-a-name), the ETH Registrar uses a commit-reveal scheme to prevent front-running registrations. You first call `commit` with an opaque commitment hash, wait at least `MIN_COMMITMENT_AGE` (60 seconds), then call `register` to reveal the parameters and complete registration. + +### Commit-Reveal + +Generate a commitment hash using `makeCommitment`: + +```solidity +ETHRegistrar.makeCommitment( + label string, // "alice" for alice.eth (label only, not full name) + owner address, // The address that will own the name + secret bytes32, // A randomly generated 32-byte secret + subregistry IRegistry, // Child registry to set (or address(0) for none) + resolver address, // Resolver contract to set + duration uint64, // Registration duration in seconds + referrer bytes32 // Referral identifier (or bytes32(0)) +) + +// For example +makeCommitment( + "alice", + 0x1234..., + 0xabcd..., // Random secret, generate off-chain + address(0), // No subregistry + 0x5678..., // Resolver address + 31536000, // 1 year in seconds + bytes32(0) // No referrer +); +``` + +Once you have calculated the commitment hash, submit it on-chain: + +```solidity +ETHRegistrar.commit(commitment bytes32) +``` + +After committing, wait at least `MIN_COMMITMENT_AGE` (60 seconds) before calling `register`. The commitment expires after `MAX_COMMITMENT_AGE` (typically 24 hours). + +### Registering + +Before initiating registration, ensure that: + +- `isAvailable(label)` returns `true` +- `duration` >= `MIN_REGISTER_DURATION` +- The commitment is between `MIN_COMMITMENT_AGE` and `MAX_COMMITMENT_AGE` old +- The payment token is approved for `base + premium` (query via `getRegisterPrice`) + +```solidity +ETHRegistrar.register( + label string, // Same label used in makeCommitment + owner address, // Same owner used in makeCommitment + secret bytes32, // Same secret used in makeCommitment + subregistry IRegistry, // Same subregistry used in makeCommitment + resolver address, // Same resolver used in makeCommitment + duration uint64, // Same duration used in makeCommitment + paymentToken IERC20, // ERC20 token to pay with (must be approved) + referrer bytes32 // Same referrer used in makeCommitment +) returns (uint256 tokenId) + +// For example +register( + "alice", + 0x1234..., + 0xabcd..., // Same secret as in commit step + address(0), + 0x5678..., + 31536000, + 0x9abc..., // USDC token address + bytes32(0) +); +``` + +#### Roles Granted at Registration + +The registration grants the owner a fixed set of roles defined by `REGISTRATION_ROLE_BITMAP`: + +- `ROLE_SET_SUBREGISTRY`: change the name's child registry +- `ROLE_SET_SUBREGISTRY_ADMIN`: delegate `ROLE_SET_SUBREGISTRY` to others +- `ROLE_SET_RESOLVER`: change the name's resolver +- `ROLE_SET_RESOLVER_ADMIN`: delegate `ROLE_SET_RESOLVER` to others +- `ROLE_CAN_TRANSFER_ADMIN`: controls whether the token owner can transfer the name (admin-only; there is no non-admin `ROLE_CAN_TRANSFER`) + +Unlike ENSv1, the role set is not configurable per registration; it is hardcoded in the registrar. See [Enhanced Access Control](/contracts/ensv2/enhanced-access-control) for details on the role system. + +## Renewing a Name + +Any account can renew any name, not just the owner. Renewal extends the expiry without changing ownership or permissions. Only the base rate is charged (no premium). + +```solidity +ETHRegistrar.renew( + label string, // The label to renew + duration uint64, // Duration to extend by (in seconds) + paymentToken IERC20, // ERC20 token to pay with (must be approved) + referrer bytes32 // Referral identifier +) +``` + +## BatchRegistrar + +The **BatchRegistrar** is a companion contract used during migration from ENSv1. It has a single owner-only function, `batchRegister(registry, resolver, labels, expires)`: + +- `AVAILABLE` names: registered as `RESERVED` (no owner, no payment) +- `RESERVED` names where the target expiry exceeds the current expiry: extended via `ETH_REGISTRY.renew()` directly (no payment, absolute timestamp) +- `REGISTERED` names: silently skipped + +This contract is intended for pre-migration use, not for general public registration. + +## EAC Integration + +### Registrar Governance + +The ETH Registrar uses OpenZeppelin `Ownable` for its own governance. The owner can call `setRentPriceOracle()` to replace the pricing oracle. Ownership is transferable via `transferOwnership()`. + +### Roles on the ETH Registry + +To register and renew names, the ETH Registrar needs roles on the `.eth` [Permissioned Registry](/contracts/ensv2/permissioned-registry). At deployment it is granted two [registry roles](/contracts/ensv2/permissioned-registry#roles) on `ROOT_RESOURCE`: + +| Role | Purpose | +|------|---------| +| [`ROLE_REGISTRAR`](/contracts/ensv2/permissioned-registry#roles) | Allows calling `register()` to create new `.eth` names | +| [`ROLE_RENEW`](/contracts/ensv2/permissioned-registry#roles) | Allows calling `renew()` on any `.eth` name | + +Because these are granted at `ROOT_RESOURCE`, they apply to all names (the EAC [checks both root and token-level roles](/contracts/ensv2/enhanced-access-control#roles)). The registrar does not hold admin variants of these roles, so it cannot delegate its own authority to other contracts. + +## Code Examples + +### Full Registration Flow + +```ts [Viem] +import { + createPublicClient, + createWalletClient, + erc20Abi, + http, + keccak256, + toHex, +} from 'viem' +import { mainnet } from 'viem/chains' + +const client = createPublicClient({ chain: mainnet, transport: http() }) +const wallet = createWalletClient({ chain: mainnet, transport: http() }) + +const label = 'alice' +const owner = '0x...' // Your address +const duration = 31536000n // 1 year in seconds +const secret = keccak256(toHex(crypto.randomUUID())) // Random secret +const resolver = '0x...' // Resolver address +const subregistry = '0x0000000000000000000000000000000000000000' +const paymentToken = '0x...' // ERC20 token address +const referrer = '0x0000000000000000000000000000000000000000000000000000000000000000' + +// 1. Check availability +const available = await client.readContract({ + address: ethRegistrarAddress, + abi: ethRegistrarAbi, + functionName: 'isAvailable', + args: [label], +}) + +if (!available) throw new Error('Name not available') + +// 2. Get the price +const [base, premium] = await client.readContract({ + address: ethRegistrarAddress, + abi: ethRegistrarAbi, + functionName: 'getRegisterPrice', + args: [label, duration, paymentToken], +}) + +// 3. Approve the registrar to spend the payment token +const totalCost = base + premium +await wallet.writeContract({ + address: paymentToken, + abi: erc20Abi, + functionName: 'approve', + args: [ethRegistrarAddress, totalCost], +}) + +// 4. Create and submit commitment +const commitment = await client.readContract({ + address: ethRegistrarAddress, + abi: ethRegistrarAbi, + functionName: 'makeCommitment', + args: [label, owner, secret, subregistry, resolver, duration, referrer], +}) + +await wallet.writeContract({ + address: ethRegistrarAddress, + abi: ethRegistrarAbi, + functionName: 'commit', + args: [commitment], +}) + +// 5. Wait at least MIN_COMMITMENT_AGE (e.g., 60 seconds) +// ... wait ... + +// 6. Register +await wallet.writeContract({ + address: ethRegistrarAddress, + abi: ethRegistrarAbi, + functionName: 'register', + args: [ + label, + owner, + secret, + subregistry, + resolver, + duration, + paymentToken, + referrer, + ], +}) +``` + +### Renewing a Name + +```ts [Viem] +const renewPrice = await client.readContract({ + address: ethRegistrarAddress, + abi: ethRegistrarAbi, + functionName: 'getRenewPrice', + args: [label, duration, paymentToken], +}) + +// Approve the registrar to spend the payment token +await wallet.writeContract({ + address: paymentToken, + abi: erc20Abi, + functionName: 'approve', + args: [ethRegistrarAddress, renewPrice], +}) + +await wallet.writeContract({ + address: ethRegistrarAddress, + abi: ethRegistrarAbi, + functionName: 'renew', + args: [label, duration, paymentToken, referrer], +}) +``` + + +## Reference + +### Write Functions + + + +### View Functions + + + +### Constants + + + +### Events + + + diff --git a/src/pages/contracts/ensv2/faq.mdx b/src/pages/contracts/ensv2/faq.mdx new file mode 100644 index 000000000..df4b72484 --- /dev/null +++ b/src/pages/contracts/ensv2/faq.mdx @@ -0,0 +1,31 @@ +--- +description: Frequently asked questions about ENSv2 +--- + +import { FrenCallout } from '../../../components/ensv2/FrenCallout' + +# ENSv2 FAQ + + +This page is under construction. Questions and answers will be added as the ENSv2 launch approaches. + + + +ENSv2 replaces the single flat registry from ENSv1 with a hierarchical model where each name can have its own registry, and each account gets its own resolver. Check out the [Registry Hierarchy](/contracts/ensv2/registry-hierarchy) page to learn more. + + + +You don't need to compute EAC resources manually. The `authorize*` functions on the [Permissioned Resolver](/contracts/ensv2/permissioned-resolver) and `grantRoles`/`revokeRoles` on the [Permissioned Registry](/contracts/ensv2/permissioned-registry) handle it for you. + + + +Token IDs in ENSv2 are mutable. Don't cache them! Use the labelhash instead and call `getTokenId(anyId)` when you need the current token ID. See [Mutable Token IDs](/contracts/ensv2/mutable-token-ids) for details. + + + +Each account in ENSv2 gets its own resolver instance, deployed as a UUPS proxy. All names owned by the same account share one resolver. You can grant fine-grained permissions per record type, and even per individual text key or coin type. + + + +Start with the [Overview](/contracts/ensv2/overview) page for a high-level introduction, then explore the [Registry Hierarchy](/contracts/ensv2/registry-hierarchy) section to understand the core design. + diff --git a/src/pages/contracts/ensv2/hca.mdx b/src/pages/contracts/ensv2/hca.mdx new file mode 100644 index 000000000..ea576392f --- /dev/null +++ b/src/pages/contracts/ensv2/hca.mdx @@ -0,0 +1,84 @@ +--- +description: Hidden Contract Accounts +--- + +import { FrenCallout } from '../../../components/ensv2/FrenCallout' + +# Hidden Contract Accounts + +Hidden Contract Accounts (HCAs) let smart-account proxies act on behalf of their owners while still being attributed to the owner. ENSv2 wires HCA-aware sender resolution into every layer of the stack. When a user controls a name through an HCA proxy, the protocol records the owner as the actor for permission checks, ownership reads, and event indexing. + + +The contracts and interfaces described here are **not yet final** and may change prior to mainnet deployment. + + +## The Problem + +Smart-account flows usually involve a per-user proxy contract that signs and submits transactions on the user's behalf. Without intervention, every contract that inspects `msg.sender` would see the proxy address, not the controlling account. For ENS that means role checks would fail, ownership would land on the proxy, and indexers would track the wrong actor. + +## Resolution Mechanism + +Every HCA-aware contract inherits `HCAEquivalence`, which carries an immutable reference to an `IHCAFactoryBasic`. When `_msgSender()` is called: + +1. If the factory address is `address(0)`, return `msg.sender` unchanged. +2. Otherwise call `HCA_FACTORY.getAccountOwner(msg.sender)`. +3. If the result is `address(0)` (the caller is not a registered HCA), return `msg.sender`. +4. Otherwise return the resolved owner. + +```mermaid +flowchart TD + Call["_msgSender() called"] --> Factory{"HCA_FACTORY
== address(0)?"} + Factory -- yes --> Sender["return msg.sender"] + Factory -- no --> Lookup["HCA_FACTORY.getAccountOwner(msg.sender)"] + Lookup --> Check{"result == 0?"} + Check -- yes --> Sender + Check -- no --> Owner["return resolved owner"] +``` + +The factory itself is treated as opaque. The protocol does not care how the owner-account mapping is implemented, only that it can ask. + +## Two Context Flavours + +OpenZeppelin contracts inherit from one of two `Context` base classes. ENSv2 ships HCA-aware drop-in replacements for both: + +- **`HCAContext`** extends `Context`, used by non-upgradeable contracts. +- **`HCAContextUpgradeable`** extends `ContextUpgradeable`, used by UUPS proxies, e.g. `PermissionedResolver`. + +Inheriting either makes every downstream `_msgSender()` call HCA-aware, including the role-check modifiers in [Enhanced Access Control](/contracts/ensv2/enhanced-access-control), `Ownable`, and standard ERC-1155 operator/approval logic. + +## Where HCA Is Wired + +HCA-aware sender resolution is built into every contract that gates actions or records actors: + +| Contract | Reaches HCA via | +| ------------------------- | ---------------------------------------------- | +| `ERC1155Singleton` | `HCAContext` (registries inherit transitively) | +| `PermissionedRegistry` | via `ERC1155Singleton` | +| `WrapperRegistry` | via `PermissionedRegistry` | +| `UserRegistry` | via `PermissionedRegistry` | +| `PermissionedResolver` | `HCAContextUpgradeable` | +| `ETHRegistrar` | `HCAEquivalence` directly | + +The HCA factory address is supplied to each contract at construction time. + +## The Factory Interface + +```solidity +interface IHCAFactoryBasic { + function getAccountOwner(address hca) external view returns (address); +} +``` + +A single read-only method. It returns the owner of `hca` if the address is a registered Hidden Contract Account, or `address(0)` otherwise. The interface selector is `0x442b172c`. + +## What HCA Does Not Change + +- `tx.origin`: untouched. +- The actual `msg.sender` for low-level calls and any third-party contracts. +- Balances, approvals, or behavior on contracts outside the ENSv2 stack. + +HCA is a contract-side opt-in for attribution only. It changes who the protocol _thinks_ is acting; it does not change who the EVM thinks is acting. + +## Production Factory + +The production HCA factory is deployed externally (currently from Rhinestone's ENS modules) and is audited separately. The protocol is implementation-agnostic: passing `address(0)` for the factory disables HCA entirely, which is what test fixtures and devnet deployments do by default. diff --git a/src/pages/contracts/ensv2/indexing.mdx b/src/pages/contracts/ensv2/indexing.mdx new file mode 100644 index 000000000..c884daf8e --- /dev/null +++ b/src/pages/contracts/ensv2/indexing.mdx @@ -0,0 +1,542 @@ +--- +description: Guide to indexing ENSv2 contracts, functions, and events for building indexers and subgraphs +--- + +import { FrenCallout } from '../../../components/ensv2/FrenCallout' +import { MonoDiagram } from '../../../components/ensv2/MonoDiagram' + +# Indexing ENSv2 + +This page describes the ENSv2 contract events and functions relevant to building an indexer. It covers the full lifecycle of names - registration, transfer, renewal, subname creation, resolver record changes, role management, and aliasing. + + +The contracts and interfaces described here are **not yet final** and may change prior to mainnet deployment. + + +## Contract Hierarchy + +ENSv2 uses a hierarchical registry model. There is no single registry contract that holds all names. Instead: + + + +Each registry is a [Permissioned Registry](/contracts/ensv2/permissioned-registry) (or UserRegistry for subnames), implementing `IRegistry`, `IStandardRegistry`, `IPermissionedRegistry`, and `IEnhancedAccessControl`. Tokens are [ERC1155Singleton](/contracts/ensv2/erc1155-singleton) (one token per name). + +Resolvers are separate contracts ([Permissioned Resolver](/contracts/ensv2/permissioned-resolver)) deployed per-account as UUPS proxies. All names owned by the same account share one resolver. Names on the same resolver can also share records via [aliases](/contracts/ensv2/permissioned-resolver#aliasing) (`setAlias`), which rewrite the name suffix during resolution (e.g., `sub.alias.eth` → `sub.test.eth`). + +## Registry Events + +These events are emitted by any registry contract (PermissionedRegistry / UserRegistry). + +### RegistryCreated + +```solidity +event RegistryCreated(); +``` + +Emitted once per registry: by `PermissionedRegistry` in the constructor, and by `UserRegistry`/`WrapperRegistry` on `initialize()`. Allows indexers to discover new registry deployments on-chain. + +### LabelRegistered + +```solidity +event LabelRegistered( + uint256 indexed tokenId, + bytes32 indexed labelHash, + string label, + address owner, + uint64 expiry, + address indexed sender +); +``` + +**Emitted by:** `IRegistryEvents` (on PermissionedRegistry, UserRegistry) + +**When:** a new name is registered via `register()`. The full name is constructed by appending the parent name (e.g., label `"test"` under ETHRegistry = `"test.eth"`). The registry contract address that emitted this event identifies which level of the hierarchy this name belongs to. + +The `sender` parameter is the account that called `register()`. For normal `.eth` registrations this is the ETHRegistrar; for names [migrated from ENSv1](/contracts/ensv2/migration) it is a migration controller, since migration bypasses the registrar and calls `register()` on the ETHRegistry directly. Checking `sender` against the known migration controller addresses is how an indexer distinguishes migrated names from fresh registrations. + +### LabelReserved + +```solidity +event LabelReserved( + uint256 indexed tokenId, + bytes32 indexed labelHash, + string label, + uint64 expiry, + address indexed sender +); +``` + +**Emitted by:** `IRegistryEvents` (on PermissionedRegistry) + +**When:** a name is reserved via `register()` with `owner = address(0)` and `roleBitmap = 0`. No token is minted and no owner is set. A reserved name can be promoted to `REGISTERED` by calling `register()` again with a real owner, which requires `ROLE_REGISTER_RESERVED`. + +### LabelUnregistered + +```solidity +event LabelUnregistered(uint256 indexed tokenId, address indexed sender); +``` + +**Emitted by:** `IRegistryEvents` (on PermissionedRegistry, UserRegistry) + +**When:** a name is explicitly deleted via `unregister()`. The ERC1155 token is burned and the expiry is set to `block.timestamp`. + +### ExpiryUpdated + +```solidity +event ExpiryUpdated(uint256 indexed tokenId, uint64 newExpiry, address indexed sender); +``` + +**Emitted by:** `IRegistry` (on PermissionedRegistry, UserRegistry) + +**When:** a name's expiry is extended via `renew()` on the registry. + +### SubregistryUpdated + +```solidity +event SubregistryUpdated( + uint256 indexed tokenId, + IRegistry subregistry, + address indexed sender +); +``` + +**Emitted by:** `IRegistry` (on PermissionedRegistry, UserRegistry) + +**When:** a name's child registry is set or changed via `setSubregistry()`. This also fires during `register()` if a subregistry is provided. New subregistry addresses indicate dynamically deployed UserRegistry contracts for subnames. + +### ResolverUpdated + +```solidity +event ResolverUpdated(uint256 indexed tokenId, address resolver, address indexed sender); +``` + +**Emitted by:** `IRegistry` (on PermissionedRegistry, UserRegistry) + +**When:** a name's resolver is set or changed via `setResolver()`. Also fires during `register()` if a resolver is provided. New resolver addresses indicate dynamically deployed PermissionedResolver contracts. + +### TokenRegenerated + +```solidity +event TokenRegenerated(uint256 indexed oldTokenId, uint256 indexed newTokenId); +``` + +**Emitted by:** `IRegistry` (on PermissionedRegistry, UserRegistry) + +**When:** a name's [EAC roles](/contracts/ensv2/enhanced-access-control) are modified via `grantRoles()` or `revokeRoles()`. The ERC1155 token ID changes (its version counter increments), while the underlying name (canonical ID / resource) remains the same. Always accompanied by ERC1155 `TransferSingle` events (burn old + mint new). See [Mutable Token IDs](/contracts/ensv2/mutable-token-ids) for details. + +### ParentUpdated + +```solidity +event ParentUpdated(IRegistry indexed parent, string label, address indexed sender); +``` + +**Emitted by:** `IRegistry` (on PermissionedRegistry, UserRegistry) + +**When:** a registry's parent reference is set via `setParent()`. This establishes the upward link in the registry hierarchy, complementing `SubregistryUpdated` (which links parent → child) by establishing the child → parent direction. + +`setParent()` is an optional, separately-permissioned call (`ROLE_SET_PARENT`), so this event is **not** emitted for every child registry and `getParent()` may return empty for registries where it was never called. Treat `SubregistryUpdated` (emitted by the parent) as the authoritative parent → child link, and use `ParentUpdated`/`getParent()` only as a supplementary child → parent hint when present. + +### TokenResource + +```solidity +event TokenResource(uint256 indexed tokenId, uint256 indexed resource); +``` + +**Emitted by:** `IPermissionedRegistry` + +**When:** a token is created during registration (`register()`). Maps a `tokenId` to its corresponding `resource`. It is **not** re-emitted on token regeneration: the `resource` is derived from the `labelHash` and `eacVersionId`, so it stays stable across role-change regenerations (which only bump the `tokenVersionId`), and you should carry it onto the new `tokenId` by following [`TokenRegenerated`](#tokenregenerated) rather than expecting a fresh `TokenResource`. The `resource` does change on re-registration (which increments `eacVersionId`), at which point the new `register()` emits a fresh `TokenResource`. See [Mutable Token IDs](/contracts/ensv2/mutable-token-ids#id-types) for details. + +## ERC1155 Transfer Events + +These standard ERC1155 events are emitted by all registries (which extend ERC1155Singleton). + +### TransferSingle + +```solidity +event TransferSingle( + address indexed operator, + address indexed from, + address indexed to, + uint256 id, + uint256 value +); +``` + +**When:** + +- **Registration (mint):** `from = address(0)`, `to = owner` - a new name token is minted +- **Transfer:** `from = previousOwner`, `to = newOwner` - ownership changes via `safeTransferFrom()` +- **Unregistration (burn):** `from = owner`, `to = address(0)` - name token is burned +- **Token regeneration:** two events fire - burn old tokenId + mint new tokenId. Correlate with `TokenRegenerated` to avoid treating it as a separate domain. + +### TransferBatch + +```solidity +event TransferBatch( + address indexed operator, + address indexed from, + address indexed to, + uint256[] ids, + uint256[] values +); +``` + +**When:** batch transfers of multiple name tokens. Same semantics as `TransferSingle` but for multiple tokens at once. + +## Registrar Events + +These events are emitted by the [ETH Registrar](/contracts/ensv2/eth-registrar) contract, which is the user-facing entry point for `.eth` name registration (with commit-reveal and pricing). + +### CommitmentMade + +```solidity +event CommitmentMade(bytes32 commitment); +``` + +**Emitted by:** `IETHRegistrar` + +**When:** step 1 of the commit-reveal registration via `commit()`. The commitment hash can be matched to the subsequent registration. + +### NameRegistered (Registrar) + +```solidity +event NameRegistered( + uint256 indexed tokenId, + string label, + address owner, + IRegistry subregistry, + address resolver, + uint64 duration, + IERC20 paymentToken, + bytes32 referrer, + uint256 base, + uint256 premium +); +``` + +**Emitted by:** `IETHRegistrar` + +**When:** step 2 of the commit-reveal registration via `register()`. This is distinct from the registry's `LabelRegistered` event. The registrar emits its own event with pricing information, and the underlying ETHRegistry also emits `LabelRegistered`. + + +Both events fire for the same registration. The registrar event has pricing data; the registry event has the canonical registration data. + + +### NameRenewed + +```solidity +event NameRenewed( + uint256 indexed tokenId, + string label, + uint64 duration, + uint64 newExpiry, + IERC20 paymentToken, + bytes32 referrer, + uint256 base +); +``` + +**Emitted by:** `IETHRegistrar` + +**When:** a `.eth` name is renewed via `renew()`. The registrar also calls `renew()` on the ETHRegistry, which emits `ExpiryUpdated`. + +## Resolver Events + +These events are emitted by [Permissioned Resolver](/contracts/ensv2/permissioned-resolver) contracts and any contract implementing the standard resolver profile interfaces. Resolvers are keyed by `node` (namehash of the full name). + +### AddressChanged + +```solidity +event AddressChanged(bytes32 indexed node, uint256 coinType, bytes newAddress); +``` + +**When:** an address record is set via `setAddr(node, coinType, address)`. `coinType = 60` is ETH. Other coin types follow [SLIP-44](https://github.com/AdrianSimionov/slip-0044/blob/main/slip-0044.md) (e.g., 0 = BTC, 501 = SOL). The `node` maps to a domain via namehash. + +### TextChanged + +```solidity +event TextChanged( + bytes32 indexed node, + string indexed indexedKey, + string key, + string value +); +``` + +**When:** a text record is set via `setText(node, key, value)`. Common keys: `avatar`, `url`, `description`, `com.twitter`, `com.github`, `email`, etc. + +### ContenthashChanged + +```solidity +event ContenthashChanged(bytes32 indexed node, bytes hash); +``` + +**When:** a content hash is set via `setContenthash(node, hash)`. Supports IPFS, Arweave, Swarm, etc. + +### ABIChanged + +```solidity +event ABIChanged(bytes32 indexed node, uint256 indexed contentType); +``` + +**When:** an ABI record is set. + +### PubkeyChanged + +```solidity +event PubkeyChanged(bytes32 indexed node, bytes32 x, bytes32 y); +``` + +**When:** a public key record is set (secp256k1 x, y coordinates). + +### NameChanged + +```solidity +event NameChanged(bytes32 indexed node, string name); +``` + +**When:** a reverse name record is set. + +### InterfaceChanged + +```solidity +event InterfaceChanged( + bytes32 indexed node, + bytes4 indexed interfaceID, + address implementer +); +``` + +**When:** an EIP-165 interface implementer is set. + +### VersionChanged + +```solidity +event VersionChanged(bytes32 indexed node, uint64 newVersion); +``` + +**When:** all records for a node are cleared via `clearRecords(node)`. The version counter is incremented, invalidating all previously stored records for that node. + +### AliasChanged + +```solidity +event AliasChanged( + bytes indexed indexedFromName, + bytes indexed indexedToName, + bytes fromName, + bytes toName +); +``` + +**When:** an alias is set via `setAlias(fromName, toName)`. Names are DNS-encoded. Setting `toName` to empty bytes removes the alias. The resolver rewrites the suffix during resolution (e.g., if `alias.eth → test.eth`, then `sub.alias.eth` resolves records for `sub.test.eth`). Aliases are resolver-level constructs - the registry does not know about them. + +### NamedResource + +```solidity +event NamedResource(uint256 indexed resource, bytes name); +``` + +**When:** an EAC resource is associated with a name for [fine-grained permission control](/contracts/ensv2/permissioned-resolver#eac-integration) on the resolver. + +### NamedTextResource + +```solidity +event NamedTextResource(uint256 indexed resource, bytes name, bytes32 indexed keyHash, string key); +``` + +**When:** an EAC resource is associated with a specific text record key for a name, allowing fine-grained permission to modify only a specific text record (e.g., only the `avatar` key). + +### NamedAddrResource + +```solidity +event NamedAddrResource(uint256 indexed resource, bytes name, uint256 indexed coinType); +``` + +**When:** an EAC resource is associated with a specific address coin type for a name, allowing fine-grained permission to modify only a specific address record (e.g., only the ETH address). + +## Role Events + +### EACRolesChanged + +```solidity +event EACRolesChanged( + uint256 indexed resource, + address indexed account, + uint256 oldRoleBitmap, + uint256 newRoleBitmap +); +``` + +**Emitted by:** every contract inheriting [Enhanced Access Control](/contracts/ensv2/enhanced-access-control), which includes **both registries and resolvers**. + +**When:** the roles of `account` on `resource` change. On registries this happens via `grantRoles()` / `revokeRoles()` (which also trigger [`TokenRegenerated`](#tokenregenerated)) and once at creation, when the initial root role bitmap is granted to the registry owner (in the `constructor`, or in `initialize()` for proxy-deployed registries). On resolvers it happens via `initialize()` and the `authorize(Name|Text|Addr)Roles()` functions. + + +Registries and resolvers use different resource ID spaces: a registry resource is derived from the labelhash and its EAC version counter, while a resolver resource is `keccak256(node, part)`. The same numeric `resource` value can therefore mean different things on different contracts. Always key role state by the emitting contract address together with the resource. + + +## Key Concepts for Indexers + +### Dynamic Contract Discovery + +An indexer cannot know all contract addresses at startup. The core pattern is: + +1. Start by watching the **RootRegistry** and **ETHRegistry** (known addresses from deployment). +2. When a `SubregistryUpdated` event fires with a new subregistry address, add that address to the watch list. +3. When a `ResolverUpdated` event fires with a new resolver address, add that address to the watch list for resolver events. + +This creates a self-expanding set of monitored contracts. + +Link-time discovery has a blind spot: anything a registry or resolver emits **before** it is linked (its `RegistryCreated`, the initial `EACRolesChanged` root grant from `initialize()`, or records set before linking) predates the discovery point. Since registries and resolvers are deployed as proxies through the [Verifiable Factory](/contracts/ensv2/verifiable-factory), which emits `ProxyDeployed(sender, proxy, salt, implementation)`, watching the factory address discovers contracts at deployment time instead and closes that gap. + +Note the log order inside a deployment transaction: `deployProxy()` calls `initialize()` on the new proxy **before** emitting `ProxyDeployed`. For a UserRegistry deployment the sequence is `Upgraded` → `RegistryCreated` → `EACRolesChanged` → `ProxyDeployed`. An indexer that discovers a contract from `ProxyDeployed` must therefore also process the earlier logs of that same transaction. + +### TokenId vs Resource (Canonical ID) + +- **tokenId**: the ERC1155 token ID. Changes when roles are modified (`TokenRegenerated`), because it embeds a version counter that increments on each change. +- **resource**: the EAC permission key for a name within a registry. Derived from the `labelHash` and `eacVersionId`. Stable across role modifications, but changes on re-registration (when `eacVersionId` increments). + +Use the `TokenResource` event (emitted at registration) to establish the `tokenId → resource` mapping, and follow `TokenRegenerated` to move that mapping onto the new `tokenId` when roles change, since no new `TokenResource` is emitted then. The `resource` should be the primary key for domain lookups, as it stays stable across regenerations. + +### Name Construction + +The indexer must track the registry hierarchy to construct full names: + +1. **ETHRegistry** emits `LabelRegistered` with `label = "test"` → full name is `"test.eth"` +2. A `SubregistryUpdated` on `test.eth` points to a UserRegistry at address `0xABC` +3. That UserRegistry emits `LabelRegistered` with `label = "sub"` → full name is `"sub.test.eth"` + +The indexer must map each registry address to its parent name to build the complete DNS name. + +### Shared Subregistries (Linked Names) + +Multiple parent names can point to the same subregistry via `setSubregistry()`. For example: + +- `sub1.sub2.parent.eth` has subregistry at `0xABC` +- `linked.parent.eth` also has subregistry at `0xABC` + +Children registered in `0xABC` appear under both parents. The token `wallet` in registry `0xABC` is simultaneously `wallet.sub1.sub2.parent.eth` and `wallet.linked.parent.eth` - they share the same tokenId. + +### Alias Resolution + +Aliases are a resolver-level concept, not a registry-level one: + +- `alias.eth` and `test.eth` may share the same resolver +- The resolver stores an `alias.eth → test.eth` alias mapping +- When resolving `sub.alias.eth`, the resolver rewrites it to `sub.test.eth` and returns those records +- The registry hierarchy knows nothing about aliases - they exist only in the resolver's storage + +Two consequences for record indexing: record events (`AddressChanged`, `TextChanged`, ...) always fire under the node they were set on, and `setAlias()` emits no record events. During resolution the alias is checked **first**, so it shadows any records stored directly on the source name: once `alias.eth → test.eth` is set, records previously written under `alias.eth` are no longer returned by `resolve()`, even though they remain in storage. Indexed records therefore reflect stored state, not effective resolution; apply alias rewriting before treating them as what a name resolves to. + +### Registration Status + +Names can be in one of three states (from `IPermissionedRegistry.Status`): + +| Status | Value | Description | +| ------------ | ----- | ------------------------------------------------------------------------------------------------ | +| `AVAILABLE` | 0 | Name can be registered | +| `RESERVED` | 1 | Name is reserved (cannot be registered until expiry, unless caller has `ROLE_REGISTER_RESERVED`) | +| `REGISTERED` | 2 | Name is actively registered with an owner | + +After expiry, names return to `AVAILABLE`. Re-registration creates both a new `tokenId` and a new `resource` (both version counters increment). + +For `.eth` names, the [ETH Registrar](/contracts/ensv2/eth-registrar) adds a grace period on top of the registry status: `GRACE_PERIOD` is an immutable constructor parameter, and while it runs an expired name cannot be re-registered but can still be renewed. No event marks entering or leaving grace (or the premium window that follows), so `.eth` availability must be computed client-side from the expiry, the grace constant, and the current time. A plain "expired means available" check is wrong for `.eth` names in grace. + +### Event Processing Order + +For a single registration via `ETHRegistrar.register()`, events fire in this order. The registrar calls `ETHRegistry.register()` first (which emits all the registry events) and only then emits its own event, so `NameRegistered` comes **last**, not first: + +1. `IRegistryEvents.LabelRegistered` - registry-level event with registration details +2. `TransferSingle` (mint) - ERC1155 token creation +3. `TokenResource` - tokenId-to-resource mapping +4. `SubregistryUpdated` - if a subregistry was provided +5. `ResolverUpdated` - if a resolver was provided +6. `IETHRegistrar.NameRegistered` - registrar-level event with pricing + +For a direct `IStandardRegistry.register()` call (e.g., on a UserRegistry): + +1. `IRegistryEvents.LabelRegistered` +2. `TransferSingle` (mint) +3. `TokenResource` +4. `SubregistryUpdated` - if a subregistry was provided +5. `ResolverUpdated` - if a resolver was provided + +For a name [migrated from ENSv1](/contracts/ensv2/migration), the migration controllers call `register()` on the ETHRegistry directly, so only the registry sequence fires: there is no `CommitmentMade` and no registrar `NameRegistered`, and `LabelRegistered.sender` is the migration controller. + +## Contract Address Summary + +| Contract | Role | Events to Watch | +| ----------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| PermissionedRegistry (ETHRegistry) | Manages `.eth` names | All `IRegistry` events, `TokenResource`, ERC1155 transfers, `EACRolesChanged` | +| PermissionedRegistry (RootRegistry) | Manages TLDs | Same as above | +| UserRegistry | Manages subnames (dynamically deployed) | Same as above | +| ETHRegistrar | User-facing `.eth` registration | `CommitmentMade`, `NameRegistered`, `NameRenewed` | +| PermissionedResolver | Stores resolver records (dynamically deployed) | `AddressChanged`, `TextChanged`, `ContenthashChanged`, `ABIChanged`, `PubkeyChanged`, `NameChanged`, `InterfaceChanged`, `VersionChanged`, `AliasChanged`, `NamedResource`, `NamedTextResource`, `NamedAddrResource`, `EACRolesChanged` | +| VerifiableFactory | Deploys registry/resolver proxies | `ProxyDeployed` | + +## Read Functions for State Verification + +These view functions are useful for verifying indexed state or backfilling data: + + +Several of these views mask state once a name is expired (`block.timestamp >= expiry`): `getOwner()`, `getSubregistry()`, and `getResolver()` return zero, `ownerOf()` returns zero for expired or stale token IDs, and `getResource()` (and `getState().resource`) return the *next* resource, one version ahead of what `TokenResource` announced, without any storage write or event. A mismatch between indexed state and these functions for an expired name is expected behavior, not an indexing bug. `getExpiry()` and `getTokenId()` return raw, unmasked values. + + +### Registry State + +```solidity +// Get full state of a name (status, expiry, owner, tokenId, resource) +function getState(uint256 anyId) external view returns (State memory); + +// Get the subregistry for a label +function getSubregistry(string calldata label) external view returns (IRegistry); + +// Get the resolver for a label +function getResolver(string calldata label) external view returns (address); + +// Get the owner of a token +function ownerOf(uint256 tokenId) external view returns (address); + +// Get the owner of a name by any of its IDs +function getOwner(uint256 anyId) external view returns (address); + +// Get the stable resource ID +function getResource(uint256 anyId) external view returns (uint256); + +// Get the current tokenId (may change after role modifications) +function getTokenId(uint256 anyId) external view returns (uint256); + +// Get the expiry +function getExpiry(uint256 anyId) external view returns (uint64); +``` + +### Registrar State + +```solidity +// Check if a name is available for registration +function isAvailable(string memory label) external view returns (bool); + +// Get rental price +function rentPrice( + string memory label, + address buyer, + uint64 duration, + IERC20 paymentToken +) external view returns (uint256 base, uint256 premium); +``` + +### Resolver State + +```solidity +// Get address record +function addr(bytes32 node, uint256 coinType) external view returns (bytes memory); + +// Get text record +function text(bytes32 node, string calldata key) external view returns (string memory); + +// Get content hash +function contenthash(bytes32 node) external view returns (bytes memory); + +// Get alias +function getAlias(bytes calldata name) external view returns (bytes memory); +``` diff --git a/src/pages/contracts/ensv2/migration.mdx b/src/pages/contracts/ensv2/migration.mdx new file mode 100644 index 000000000..f7f18b28b --- /dev/null +++ b/src/pages/contracts/ensv2/migration.mdx @@ -0,0 +1,288 @@ +--- +description: Migrating ENS names from v1 to v2 - premigration, migration paths, fuse conversion, and ENSv1 continuity +--- + +import { FrenCallout } from '../../../components/ensv2/FrenCallout' + +# Migration + +ENSv2 provides a migration framework for transitioning ENSv1 `.eth` names to the new system. Migration is a two-phase process: **premigration** reserves all existing names in v2 automatically, then **migration** lets owners claim their names by transferring their v1 tokens to a migration controller. + + +The contracts and interfaces described here are **not yet final** and may change prior to mainnet deployment. + + + +Once ENSv2 is live, v1 `.eth` registrations and renewals are disabled. New registrations go through the v2 [ETH Registrar](/contracts/ensv2/eth-registrar). Migrated names are renewed via the ETH Registrar; unmigrated ([RESERVED](#key-definitions)) names are renewed via [`ETHRenewerV1`](#renewals), which syncs the expiry back to v1. Names in v1 will simply expire and can never be re-registered through v1. + + +## What You Need to Do + +### .eth Name Owners + + +If you just own a `.eth` name and aren't a developer, you don't need to understand the technical details below. A frontend migration tool will be available when the time comes that handles everything for you. + + +Premigration happens automatically. To migrate your name to v2: + +1. Transfer your v1 token to the appropriate migration controller: + - **Unwrapped or unlocked**: `UnlockedMigrationController` + - **Locked**: `LockedMigrationController` +2. Specify the name's label plus the new v2 owner, resolver, and subregistry in the transfer data (the label must match the transferred token, otherwise migration reverts with `NameDataMismatch`) +3. The v2 owner can be a different address than the v1 owner (e.g., migrating to a smart account) + +If your name is in the v1 grace period, use `ETHRenewerV1` to renew it first. + + +Migration is **not required** immediately. Unmigrated names continue to resolve through v1 via `ENSV1Resolver`. However, migrating gives you access to v2 features like [per-record permissions](/contracts/ensv2/permissioned-resolver#eac-integration), [aliasing](/contracts/ensv2/permissioned-resolver#aliasing), and the new resolver. + + +### Subname Owners + +If you own an emancipated subname (3LD+), your parent must migrate first. Once the parent has migrated, transfer your token to the parent's `WrapperRegistry`: + +- **Locked subnames** (`CANNOT_UNWRAP` set): fuses are converted to v2 roles, a new WrapperRegistry is deployed as the subregistry +- **Detached subnames** (emancipated without `CANNOT_UNWRAP`): unwrapped to the [Graveyard](#graveyard) and registered with the standard role set (plus renewal roles if `CAN_EXTEND_EXPIRY` was set) + +Non-emancipated 3LD+ subnames cannot be migrated through the migration controllers. They must be registered directly in v2 once their parent has a v2 registry. + +### What Happens to Records + +- **Locked names with `CANNOT_SET_RESOLVER`**: the v1 resolver is preserved. If it's a known `PublicResolver`, it's replaced with `PublicResolverV2` (records may need to be copied separately). +- **All other names**: the v1 resolver is cleared during migration. The v2 resolver is set from the migration transfer data (or can be set up afterwards). + +## Overview + +```mermaid +flowchart TB + subgraph Phase 1: Premigration + batch["BatchRegistrar reserves all\nregistered/in-grace v1 names in v2"] + reserved["RESERVED in v2 ETH Registry\n(expiry = v1 + ~62 days, no owner)"] + batch --> reserved + end + + subgraph Phase 2: Migration + transfer["Owner transfers v1 token\nto migration controller"] + registered["REGISTERED in v2 ETH Registry\n(owner, resolver, subregistry set)"] + graveyard["v1 token sent to Graveyard"] + transfer --> registered + transfer --> graveyard + end + + reserved -.->|"owner initiates"| transfer +``` + +### ENSv1 Token Types + +Migration supports `.eth` 2LDs and emancipated subnames. The migration path depends on the token type: + +| Type | Token Standard | Description | +|------|---------------|-------------| +| **Unwrapped** | BaseRegistrar ERC-721 | 2LD only | +| **Unlocked** | NameWrapper ERC-1155 | Without `CANNOT_UNWRAP` (2LD only) | +| **Locked** | NameWrapper ERC-1155 | Emancipated + `CANNOT_UNWRAP` (2LD and 3LD+) | +| **Detached** | NameWrapper ERC-1155 | Emancipated, without `CANNOT_UNWRAP`, parent is Locked (3LD+ only) | + +Both Locked and Detached are subtypes of **Emancipated** (`PARENT_CANNOT_CONTROL` set). The distinction determines the migration path: Locked names preserve their fuse restrictions as v2 roles, while Detached names are unwrapped and registered with the standard role set (see [Detached Names](#detached-names)). + +### Key Definitions + +- **RESERVED**: a v2 registry slot with an expiry and resolver but no owner. Created during premigration. +- **REGISTERED**: a v2 registry slot with an owner. Created when a user migrates their v1 token. +- **Graveyard**: the contract that receives migrated v1 tokens and can clear stale v1 registry entries so they no longer resolve. + +### Time Constants + +| Constant | Value | Description | +|----------|-------|-------------| +| `GRACE_PERIOD_V1` | 90 days | Post-expiry window in ENSv1 where the owner can still renew | +| `GRACE_PERIOD_V2` | 28 days | Post-expiry window in ENSv2 where the owner can still renew via the [ETH Registrar](/contracts/ensv2/eth-registrar) | +| `BONUS_PERIOD` | ~62 days | `GRACE_PERIOD_V1 - GRACE_PERIOD_V2` (plus 1 second due to boundary handling). Added to v1 expiry during premigration so that the v2 reservation does not expire before v1's grace period ends | + +## Premigration + +Before users can migrate, every registered or in-grace v1 name is batch-reserved in the v2 ETH Registry by the `BatchRegistrar`. This happens automatically and does not require any user action. + +For each name: +- **Status**: set to `RESERVED` +- **Expiry**: `expiryV1 + BONUS_PERIOD` +- **Resolver**: set to `ENSV1Resolver`, which performs wildcard fallback to the v1 ENS registry +- **Owner**: not set (RESERVED names have no owner) + +After premigration, v1 `.eth` registrations and v1 renewals are disabled. Migrated (REGISTERED) names are renewed via the `ETHRegistrar`. Unmigrated (RESERVED) names are renewed via `ETHRenewerV1`, which syncs the expiry back to the v1 BaseRegistrar. Names continue to resolve through v1 during this phase because `ENSV1Resolver` mirrors v1 resolution. + +## Migration Paths + +Migration is initiated by the name owner transferring their v1 token to the appropriate controller. The controller promotes the pre-existing RESERVED slot to REGISTERED, setting the owner, resolver, subregistry, and role bitmap. The v1 token is forwarded to the Graveyard. Promoted names additionally carry the non-revokable `ROLE_WAS_RESERVED` marker role, which fresh registrations do not have. + +The v2 owner specified during migration **does not need to match** the v1 owner. This allows migrating directly to a new wallet or smart account. + +### Unwrapped Names + +Unwrapped 2LD names (BaseRegistrar ERC-721 tokens) are migrated via the `UnlockedMigrationController`. + +1. Transfer the ERC-721 token via `safeTransferFrom` with an encoded data payload specifying: label, new owner, resolver, and subregistry +2. The controller reclaims the token on the BaseRegistrar, transfers v1 registry ownership to the Graveyard, and clears the v1 resolver +3. The ERC-721 token is sent to the Graveyard +4. The name is promoted from RESERVED to REGISTERED in the v2 ETH Registry with the provided parameters +5. The token is granted the same roles as a fresh `ETHRegistrar.register()` ([REGISTRATION_ROLE_BITMAP](/contracts/ensv2/eth-registrar#roles-granted-at-registration)) + +### Unlocked Wrapped Names + +Unlocked 2LD names (NameWrapper ERC-1155 tokens without `CANNOT_UNWRAP`) are also migrated via the `UnlockedMigrationController`. + +1. Transfer the ERC-1155 token via `safeTransferFrom` (or batch via `safeBatchTransferFrom`) with an encoded data payload +2. The controller verifies the name is NOT locked (reverts `NameIsLocked` otherwise) +3. The token is unwrapped to the Graveyard and the v1 resolver is cleared +4. The name is promoted from RESERVED to REGISTERED with the provided parameters +5. Token roles are the same as `ETHRegistrar.register()` + + +Unlocked 3LD+ names cannot be migrated through this path. They must be registered directly in v2 once their parent has a v2 registry. + + +### Locked Names + +Locked names (NameWrapper ERC-1155 tokens with `CANNOT_UNWRAP`) have irrevocable restrictions that must be preserved. The migration path depends on the name level: + +- **2LD**: transfer to `LockedMigrationController` +- **3LD+**: transfer to the parent name's `WrapperRegistry` (the parent must have migrated first) + +The migration flow: + +1. Transfer the ERC-1155 token to the appropriate receiver +2. The receiver branches on the token type: locked tokens continue with the steps below, detached tokens follow their own path (see [Detached Names](#detached-names)), and anything else reverts (`NameNotLocked`) +3. If `CANNOT_APPROVE` is burned and `getApproved()` is non-null after transfer, migration reverts (`FrozenTokenApproval`). If `CANNOT_APPROVE` is not burned, any approval is automatically cleared during the transfer, so this cannot trigger. +4. ENSv1 [fuses](/wrapper/fuses) are converted to ENSv2 roles (see [Fuse-to-Role Conversion](#fuse-to-role-conversion)) +5. A `WrapperRegistry` is always deployed as the subregistry, replicating the v1 fuse-based access control using v2's [EAC](/contracts/ensv2/enhanced-access-control) system +6. The v1 resolver is cleared and the v2 name gets the resolver from the transfer data, **unless** `CANNOT_SET_RESOLVER` is burned, in which case the transfer-data resolver is ignored and the existing v1 resolver is carried over to v2. If the carried-over resolver is a known `PublicResolver`, it is replaced with `PublicResolverV2` +7. The token is **not unwrapped** but is transferred to the Graveyard as an ERC-1155 token +8. The name is promoted from RESERVED to REGISTERED (for 2LD) or registered directly (for 3LD+) with the converted roles + +### Detached Names + +Detached names are emancipated children of locked parents that don't have `CANNOT_UNWRAP` set (3LD+ only). They are migrated via the parent's `WrapperRegistry`. + +1. Transfer the ERC-1155 token to the parent's WrapperRegistry +2. The token is unwrapped to the Graveyard and the v1 resolver is cleared (the v2 name gets the resolver from the transfer data) +3. The name is registered with the same token roles as `ETHRegistrar.register()`, plus `ROLE_RENEW` and `ROLE_RENEW_ADMIN` if the v1 token had `CAN_EXTEND_EXPIRY` set + +## Fuse-to-Role Conversion + +When migrating locked names, ENSv1 fuses are converted to ENSv2 roles. The key principle: a burned fuse that restricts an action means the corresponding role is **not granted**. + +Admin roles (the `<< 128` shifted counterparts) are only granted when `CANNOT_BURN_FUSES` is **not** set. If fuses are frozen, only regular roles are granted, preventing any further permission changes in v2. The one exception is `ROLE_CAN_TRANSFER_ADMIN`: it has no non-admin counterpart and is granted whenever `CANNOT_TRANSFER` is not burned, frozen or not. + +### Token Roles + +| ENSv1 Fuse | ENSv2 Role | Granted when fuse is... | +|------------|-----------|------------------------| +| `CAN_EXTEND_EXPIRY` | `ROLE_RENEW` | Set (enabled) | +| `CANNOT_SET_RESOLVER` | `ROLE_SET_RESOLVER` | Not set (not burned) | +| `CANNOT_TRANSFER` | `ROLE_CAN_TRANSFER_ADMIN` | Not set (not burned) | +| `CANNOT_BURN_FUSES` | Admin counterparts of above | Not set (not frozen) | +| `CANNOT_SET_TTL` | N/A | Ignored (no TTL in v2) | + +### Subregistry Roles + +These roles are granted to the name owner on the WrapperRegistry's [`ROOT_RESOURCE`](/contracts/ensv2/enhanced-access-control#resources), giving them contract-wide authority over the subregistry. + +| ENSv1 Fuse | ENSv2 Role | Granted when fuse is... | +|------------|-----------|------------------------| +| `CANNOT_CREATE_SUBDOMAIN` | `ROLE_REGISTRAR` | Not set (not burned) | +| (always) | `ROLE_RENEW` + `ROLE_UPGRADE` + `ROLE_CAN_NAME` | Always granted | +| `CANNOT_BURN_FUSES` | Admin counterparts of all of the above | Not set (not frozen) | + +## Graveyard + +The `Graveyard` contract receives migrated v1 tokens and provides functionality to clear stale v1 registry entries so they no longer resolve. It has no mechanism to transfer or upgrade the tokens it holds. + +During migration, the resolver of the migrated name is cleared (when possible) and, for unwrapped names, v1 registry ownership is transferred to the Graveyard. However, the unemancipated subname namespace under the migrated name is left unchanged. The `clear()` function handles this cleanup separately: + +```solidity +// Anyone can call this to clean up v1 registry entries +Graveyard.clear(names) +``` + +`clear()` recursively walks the v1 namespace hierarchy for each name, clearing resolvers and transferring subnode ownership to the Graveyard. It is permissionless: callers cannot harm names they don't own, because the function only succeeds for names the Graveyard controls or names that have expired past the v1 grace period. + +For expired 2LD names that were never migrated, `clear()` re-registers them to the Graveyard via the v1 BaseRegistrar with a near-permanent duration, then clears their resolver. This is used by an off-chain service that periodically calls `clear()` on expired names to prevent stale v1 resolution. Locked names that are not owned by the Graveyard cannot be cleared (reverts `NameNotClearable`). + +## ENSv1 Continuity + +Not all names will migrate immediately. ENSv2 provides mechanisms to keep unmigrated names functional. + +### Renewals + +Renewals are handled by two contracts depending on the name's status: + +- **`ETHRegistrar`**: renews REGISTERED names (migrated names). Does not sync v1. +- **`ETHRenewerV1`**: renews RESERVED names (unmigrated names). Syncs the expiry back to the v1 BaseRegistrar so both systems stay in lockstep. + +**Key invariants:** +- The v2 expiry is always `BONUS_PERIOD` ahead of the v1 expiry +- The `ETHRegistrar` cannot register RESERVED names (it lacks `ROLE_REGISTER_RESERVED`), so unmigrated names are protected until they expire +- `ENSV1Resolver` continues resolving unmigrated names until the v2 reservation expires + +### Unmigratable Names + +Some names are structurally unable to migrate (see [Restrictions](#restrictions)). These names remain fully functional on v1: + +- Resolution continues via `ENSV1Resolver` as long as the v2 reservation is active +- Renewals work via `ETHRenewerV1`, keeping the v1 and v2 expiries in sync +- v1 fuses, resolver, and ownership remain unchanged + +The only v2 features unavailable to unmigratable names are per-record permissions, aliasing, and the new resolver. Once the v2 reservation expires and the 28-day v2 grace period has passed, the name becomes available for fresh registration in v2 (during the grace window it can still be renewed via `ETHRenewerV1`). The `Graveyard` can clear the expired v1 namespace. + +### What Happens to Unmigrated Names + +Names that *could* migrate but don't will eventually expire. Since v1 registrations are disabled, expired names never become available in v1 again. The v2 RESERVED slot also expires. Once expired in v2 and past the 28-day v2 grace period (during which `ETHRenewerV1` can still renew it), the name becomes available for fresh registration via the `ETHRegistrar`. An off-chain service re-registers expired names to the Graveyard, which then clears their v1 resolvers so they no longer produce stale results. + +## Scenarios + +### 100 days remaining + +*A name with 100 days left in v1 is premigrated.* + +The RESERVED slot has 162 days left (100 + 62 day bonus). The v1 token expires after 100 days and its grace period ends after 190 days (100 + 90). The v2 reservation expires after 162 days and becomes available for registration after 190 days (162 + 28 day grace). The v2 availability aligns with the end of the v1 grace period. + +### Migrates then renews + +*A name with 50 days remaining is premigrated, migrated, then renewed for 50 days.* + +The RESERVED slot has 112 days (50 + 62). The owner migrates, promoting to REGISTERED. The v1 token goes to the Graveyard with 50 days left. The owner renews via `ETHRegistrar` for 50 days, extending the v2 registration to 162 days (50 + 62 + 50). The v2 grace period begins after 162 days and lasts 28 days. + +### In grace period, renews before migrating + +*A name expired 61 days ago and has 29 days left in the v1 grace period (i.e., -61 days remaining). The owner cannot migrate because the name is expired in v1.* + +The RESERVED slot has 1 day left (-61 + 62 day bonus). The owner renews for 62 days via `ETHRenewerV1` (the minimum needed to make the name active again), which extends both expiries: + +- **v1 expiry**: -61 + 62 = 1 day from now. Grace period ends after 91 days (1 + 90). +- **v2 expiry**: 1 + 62 = 63 days from now. Becomes available after 91 days (63 + 28). + +The name is active again (no longer in grace), so the token can be transferred and migrated to v2. + +## Restrictions + +Migration is not possible in the following cases: + +- The v1 token is not transferable (owner or approval restrictions) +- Locked names with `CANNOT_TRANSFER` burned +- Locked names with `CANNOT_APPROVE` burned and a non-null `getApproved()` (reverts `FrozenTokenApproval`) +- 3LD+ subnames whose parent has not migrated yet + +## Contracts + +| Contract | Purpose | +|----------|---------| +| `BatchRegistrar` | Batch-reserves v1 names in v2 during premigration | +| `UnlockedMigrationController` | Migrates unwrapped and unlocked 2LD names | +| `LockedMigrationController` | Migrates locked 2LD names | +| `MigrationHelper` | Batch migration of mixed unwrapped/unlocked/locked tokens using operator approvals | +| `WrapperRegistry` | Migrates locked/detached 3LD+ names; replicates fuse-based access control in v2 | +| `Graveyard` | Receives migrated v1 tokens; clears stale v1 registry entries and resolvers | +| `ETHRenewerV1` | Renews RESERVED (unmigrated) names with v1 BaseRegistrar sync | +| `ENSV1Resolver` | Wildcard fallback resolver for premigrated names | +| `PublicResolverV2` | Replacement for v1 PublicResolver that respects v2 ownership | diff --git a/src/pages/contracts/ensv2/mutable-token-ids.mdx b/src/pages/contracts/ensv2/mutable-token-ids.mdx new file mode 100644 index 000000000..684e0db27 --- /dev/null +++ b/src/pages/contracts/ensv2/mutable-token-ids.mdx @@ -0,0 +1,191 @@ +--- +description: How ENSv2's mutable token ID system protects against permission leaks and transfer griefing +--- + +import { Card } from '../../../components/ui/Card' +import { FrenCallout } from '../../../components/ensv2/FrenCallout' +import { IdExplorer } from '../../../components/ensv2/IdExplorer' + +# Mutable Token IDs + +Names in the [Permissioned Registry](/contracts/ensv2/permissioned-registry) are represented as [ERC1155Singleton](/contracts/ensv2/erc1155-singleton) tokens, where each registered name is a token with exactly one owner. ENSv2's [Enhanced Access Control](/contracts/ensv2/enhanced-access-control) system allows name owners to delegate fine-grained permissions to multiple accounts. This flexibility requires a mechanism to keep permissions in sync with a name's lifecycle, for example invalidating delegated roles when a name changes hands or expires. ENSv2 solves this with **mutable token IDs** that change in response to security-relevant events, providing automatic protection against two attack vectors: stale permissions and transfer griefing. + + +The contracts and interfaces described here are **not yet final** and may change prior to mainnet deployment. + + +## The Problem + +### Stale Permissions + +Imagine Alice registers `alice.eth` and grants Bob `ROLE_SET_RESOLVER`. Later, Alice's name expires, and Carol registers `alice.eth`. If permissions were tied to a fixed identifier, Bob's role grant from Alice's registration would carry over - Carol wouldn't know that Bob has resolver permissions on her newly-registered name. + +### Transfer Griefing + +Imagine Alice owns `alice.eth` with `ROLE_CAN_TRANSFER_ADMIN` and `ROLE_SET_RESOLVER_ADMIN`, and lists it for sale on a marketplace. The marketplace holds an approval to transfer the token. A buyer submits a purchase transaction, which enters the mempool. Alice sees it and frontruns with a `revokeRoles` call that strips `ROLE_SET_RESOLVER_ADMIN` from the name. The buyer's transaction then executes - they receive the name, but it's been silently degraded: they can no longer change the resolver. Without mutable token IDs, the buyer would have no protection against this. + +## How Token IDs Work + +### Canonical ID + +Each name in the registry is internally tracked by its **canonical ID**, a stable 256-bit identifier derived from the name's **labelhash** (`keccak256` of the label string) with the lower 32 bits zeroed out. The canonical ID never changes for a given label, regardless of how many times the name is registered, transferred, or has its permissions modified. + +The registry uses the canonical ID as the key to look up the name's storage entry, which contains the name's current state: subregistry, resolver, expiry, and two version counters. From this entry, the registry derives the name's current **token ID** (used for ERC1155 ownership) and its current **resource** (used as the key for [EAC](/contracts/ensv2/enhanced-access-control) permission storage). Both are produced by encoding a version counter into the lower 32 bits of the canonical ID. + +### Version Counters + +The [Permissioned Registry](/contracts/ensv2/permissioned-registry) maintains two version counters in each name's storage entry: + +| Counter | Incremented when | Encoded into | Purpose | +| ---------------- | -------------------------------------- | ------------ | ---------------------------------------------------------------------------- | +| `tokenVersionId` | Roles are granted or revoked, name is unregistered or re-registered | Token ID | Invalidates marketplace approvals when the name's permission profile changes | +| `eacVersionId` | Name is unregistered or re-registered | Resource | Isolates permissions across different registrations of the same label | + +When a counter increments, the derived ID changes, producing a new token ID or a new resource. Re-registration increments both counters simultaneously. + +### ID Types + +All three identifier types share the same upper 224 bits (from the labelhash) and differ only in their lower 32 bits: + +| ID | Lower 32 bits | Used for | +| ---------------- | ---------------- | ------------------------------------------- | +| **Canonical ID** | `0x00000000` | `_entries` mapping key (name storage) | +| **Token ID** | `tokenVersionId` | ERC1155 ownership and marketplace approvals | +| **Resource** | `eacVersionId` | EAC `_roles` mapping (permission checks) | + +### ID Explorer + + +Explore how these IDs are derived for any label. Slide the version counters to see how the token ID and resource change while the canonical ID stays fixed. + + + + + + +### Regeneration + +When a version counter increments, the registry performs a **regeneration**: the old token is burned and a new one with the updated token ID is minted to the same owner, atomically in a single transaction. The owner doesn't change - only the ID does. The registry emits a `TokenRegenerated(oldTokenId, newTokenId)` event to signal this. + +### How This Solves the Problems + +**Stale permissions** are solved by `eacVersionId`. When a name expires and is re-registered, both version counters increment. The new registration gets a fresh [resource](/contracts/ensv2/enhanced-access-control#resources) (because `eacVersionId` changed), and all role grants from the previous registration are effectively orphaned - they're stored under the old resource, which no longer corresponds to any active name. + +**Transfer griefing** is solved by `tokenVersionId`. When Alice frontruns with `revokeRoles`, the registry's `_onRolesRevoked` hook fires, triggering regeneration. This increments `tokenVersionId`, burning the old token and minting a new one. The marketplace's `safeTransferFrom` still references the old token ID - but that token no longer exists. The transaction reverts with `ERC1155InsufficientBalance`, protecting the buyer from receiving a degraded name. If the buyer still wants it, they must re-approve using the new token ID, at which point they can inspect the current roles. + +Note that legitimate ERC1155 transfers (`safeTransferFrom`) move roles from the old owner to the new owner *without* triggering regeneration. The internal role transfer bypasses the callbacks. This is intentional: a transfer should not invalidate the token ID that the transfer itself is using. + +### When Token IDs Change + +| Event | eacVersionId | tokenVersionId | Token ID changes? | Effect | +| ------------------------------ | :----------: | :------------: | :--------------------------: | ----------------------------------------------------------- | +| First registration | - | - | New token minted | Both counters start at 0 (storage default) | +| Role grant/revoke | - | +1 | Yes (regeneration) | Invalidates marketplace approvals | +| Transfer | - | - | No | Owner changes, same token ID | +| Re-registration (after expiry) | +1 | +1 | Yes (old burned, new minted) | Old token burned, all prior role grants invalidated | +| Unregistration (registered) | +1 | +1 | Yes (burned) | Token destroyed, versions incremented for next registration | +| Unregistration (reserved) | - | - | No | No token exists to burn; counters unchanged | +| Renewal | - | - | No | Only expiry changes | + + +A token ID changes in exactly three situations: a role is granted or revoked, the name is re-registered after expiry, or the name is unregistered. Transfers and renewals never change the token ID. + + +## `anyId` Polymorphism + +Because token IDs change, you might not always have the current token ID on hand. To make this easier, most [Permissioned Registry](/contracts/ensv2/permissioned-registry) functions accept a `uint256 anyId` parameter that can be any of: + +- A **labelhash** (`keccak256` of the label string) +- A **token ID** (current or even stale) +- A **resource** (EAC resource identifier) +- A **canonical ID** + +Internally, the registry applies `anyId ^ uint32(anyId)` to strip the lower 32 bits, resolving any of these to the same canonical ID and therefore the same storage entry. From that entry, the current token ID and resource are reconstructed as needed. This means you can pass whichever identifier you have - the registry figures out the rest. + +```mermaid +flowchart TD + subgraph input [" "] + direction LR + LH(["labelhash"]) + TK(["tokenId
current or stale"]) + RS(["resource"]) + SI(["canonicalId"]) + end + + LH --> STRIP + TK --> STRIP + RS --> STRIP + SI --> STRIP + + STRIP["_entry(anyId)
anyId ^ uint32(anyId)
→ storageId (= canonicalId)"] + + STRIP --> ENTRY["Entry storage
subregistry · resolver · expiry
eacVersionId · tokenVersionId
"] + + ENTRY --> BUILD_TK["_constructTokenId
withVersion(anyId, tokenVersionId)
lower 32 = tokenVersionId"] + ENTRY --> BUILD_RS["_constructResource
withVersion(anyId, eacVersionId)
lower 32 = eacVersionId"] + + BUILD_TK --> USE_TK["tokenId
ERC1155 ownership
& marketplace approvals
"] + BUILD_RS --> USE_RS["resource
EAC _roles mapping
& permission checks
"] + + style input fill:none,stroke:none + style LH fill:#CEE1E8,stroke:#0080BC,color:#011A25 + style TK fill:#CEE1E8,stroke:#0080BC,color:#011A25 + style RS fill:#CEE1E8,stroke:#0080BC,color:#011A25 + style SI fill:#CEE1E8,stroke:#0080BC,color:#011A25 + style STRIP fill:#0080BC,stroke:#011A25,color:#fff + style ENTRY fill:#011A25,stroke:#0080BC,color:#fff + style BUILD_TK fill:#F5F5F5,stroke:#0080BC,color:#011A25 + style BUILD_RS fill:#F5F5F5,stroke:#0080BC,color:#011A25 + style USE_TK fill:#0080BC,stroke:#011A25,color:#fff + style USE_RS fill:#0080BC,stroke:#011A25,color:#fff +``` + +Functions that accept `anyId` include: `setSubregistry()`, `setResolver()`, `renew()`, `unregister()`, `getExpiry()`, `getStatus()`, `getState()`, `getTokenId()`, `getResource()`, and all EAC role functions. + +:::note +`anyId` polymorphism is specific to the [Permissioned Registry](/contracts/ensv2/permissioned-registry). The [Permissioned Resolver](/contracts/ensv2/permissioned-resolver) uses a [different resource scheme](/contracts/ensv2/enhanced-access-control#resources) based on `keccak256(node, part)` and does not use `anyId`. +::: + +## Implications for Developers + +### Don't Cache Token IDs + +Token IDs are not stable identifiers. If you need to reference a name, store the **labelhash** instead and use `getTokenId(anyId)` to get the current token ID when needed. + +### Use `anyId` Where Possible + +Since most functions accept `anyId`, you can simply pass the labelhash and avoid dealing with token IDs entirely in many cases. + +### Marketplace Integrations + +If you're building a marketplace or trading contract, be aware that: + +1. Approvals tied to a specific token ID will be invalidated when the token ID changes +2. Use `getState(anyId)` to verify the current token ID before executing a trade +3. The `TokenRegenerated(oldTokenId, newTokenId)` event signals when a token ID changes + +### Reading Token ID Changes + +```ts [Viem] +import { createPublicClient, http } from 'viem' +import { mainnet } from 'viem/chains' + +const client = createPublicClient({ + chain: mainnet, + transport: http(), +}) + +// Get the current token ID for a name using its labelhash +const state = await client.readContract({ + address: registryAddress, + abi: permissionedRegistryAbi, + functionName: 'getState', + args: [labelhash], +}) + +// state.tokenId is the current token ID +// state.resource is the current EAC resource +// state.status is AVAILABLE (0), RESERVED (1), or REGISTERED (2) +// state.latestOwner is the current owner +// state.expiry is the expiration timestamp +``` diff --git a/src/pages/contracts/ensv2/overview.mdx b/src/pages/contracts/ensv2/overview.mdx index d5c58a905..91c3a526f 100644 --- a/src/pages/contracts/ensv2/overview.mdx +++ b/src/pages/contracts/ensv2/overview.mdx @@ -1,8 +1,18 @@ --- -description: Overview of the ENSv2 Smart Contracts +description: Overview of ENSv2 --- -# ENSv2 Smart Contracts Overview +import { ENSv2Deployments } from '../../../components/ensv2/ENSv2Deployments' +import { FrenCallout } from '../../../components/ensv2/FrenCallout' + +# ENSv2 Overview + + + ENSv2 is deployed on the Sepolia testnet, where you can already try the new + contracts with the [ENS Explorer](https://explorer.ens.dev). This is an early + preview rather than an official release, so expect the contracts to keep + evolving. + Welcome to the next evolution of the Ethereum Name Service! @@ -10,193 +20,102 @@ ENSv2 introduces a suite of upgraded smart-contracts designed to make the protoc more scalable, modular and future-proof. This section will outline the high-level architecture, guiding principles and migration strategy for ENSv2. -:::note -The information on this page is a work-in-progress. Expect updates as the design is finalised and audits are completed. -::: +