From 5cdb3bf98f168477f2d283a79a8322c78b7ea02c Mon Sep 17 00:00:00 2001 From: Ramesh Nair Date: Mon, 16 Mar 2026 17:17:14 +0800 Subject: [PATCH 1/8] docs: add Enhanced Access Control documentation Add access-control.mdx covering EAC's resource-scoped role system, admin roles, bitmap layout, and how it replaces the fuse system. Link it from the ENSv2 overview and add sidebar entry. --- src/pages/contracts/ensv2/access-control.mdx | 93 ++++++++++++++++++++ src/pages/contracts/ensv2/overview.mdx | 2 +- vocs.config.tsx | 4 + 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 src/pages/contracts/ensv2/access-control.mdx diff --git a/src/pages/contracts/ensv2/access-control.mdx b/src/pages/contracts/ensv2/access-control.mdx new file mode 100644 index 00000000..b04c3c39 --- /dev/null +++ b/src/pages/contracts/ensv2/access-control.mdx @@ -0,0 +1,93 @@ +--- +description: Enhanced Access Control +--- + +# 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. + +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. + +:::note +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. + +For example, the ENS registry treats each name as a separate resource. Permissions you grant on `alice.eth` don't affect `bob.eth` - they're independent. + +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: + +- `grantRoles` / `revokeRoles` - manage roles on a specific resource (name) +- `grantRootRoles` / `revokeRootRoles` - manage roles on `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. + +Contracts can hook into role changes by overriding `_onRolesGranted` and `_onRolesRevoked` to run custom logic whenever permissions change. + +## 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: + +``` + 255 128 127 0 + ┌──────────────┬───────────────┐ + │ Admin Roles │ Regular Roles │ + └──────────────┴───────────────┘ +``` + +A regular role at nybble index `N` occupies bits `N*4` to `N*4+3`. Its admin counterpart sits at the same position in the upper half (`N*4+128` to `N*4+131`). The same nybble-per-role layout is used for assignee counting - each nybble tracks how many accounts hold that role within a resource (which is why the maximum is 15, the largest value a 4-bit nybble can store). + +## Replacing Fuses + +EAC replaces the one-way [fuse system](/wrapper/fuses) from the Name Wrapper. Key advantages: + +- **Reversible**: roles can be granted and revoked, not just permanently burned +- **Multi-account**: up to 15 accounts can hold the same role per resource, enabling delegation and shared management +- **Granular scoping**: permissions can be set contract-wide or per individual resource, with the two scopes composing automatically +- **Extensible**: each contract defines its own roles - no fixed set of permissions baked into the protocol diff --git a/src/pages/contracts/ensv2/overview.mdx b/src/pages/contracts/ensv2/overview.mdx index d5c58a90..44ecf094 100644 --- a/src/pages/contracts/ensv2/overview.mdx +++ b/src/pages/contracts/ensv2/overview.mdx @@ -23,7 +23,7 @@ The information on this page is a work-in-progress. Expect updates as the design the ownership and transfer rules for names and subnames. - **Permissions as Standard** - All of the functionality enabled by the [Name Wrapper in ENSv1](/wrapper/overview) has been integrated into the core of ENSv2, using a new, highly flexible, - role-based permissions model. + [role-based permissions model](/contracts/ensv2/access-control.mdx). - **No Grace Period** - The grace period is no longer a part of the ENS protocol, meaning names expire when they say they expire. Upon expiration, names immediately enter into the [temporary premium](/registry/eth#premium--auctions) period. The owner of a recently-expired name doesn't have to pay the premium fee during this period. - **Contract Factories** - Rather than relying on large multi-user contracts such as the default public resolver, every name gets its own resolver, and every name diff --git a/vocs.config.tsx b/vocs.config.tsx index 25a64a0d..a3605ec3 100644 --- a/vocs.config.tsx +++ b/vocs.config.tsx @@ -359,6 +359,10 @@ export default defineConfig({ text: 'Overview', link: '/contracts/ensv2/overview', }, + { + text: 'Access control', + link: '/contracts/ensv2/access-control', + }, // { // text: 'Registries', // link: '/contracts/ensv2/registries', From 42f70e53d2a5d52b4e1bf6cfb27135ec9277b0ee Mon Sep 17 00:00:00 2001 From: Ramesh Nair Date: Mon, 16 Mar 2026 17:41:21 +0800 Subject: [PATCH 2/8] docs: add Permissioned Registry page and rename Access Control - Add new Permissioned Registry documentation page - Rename access-control.mdx to enhanced-access-control.mdx - Update sidebar config and internal links to match --- ...ontrol.mdx => enhanced-access-control.mdx} | 0 src/pages/contracts/ensv2/overview.mdx | 2 +- .../contracts/ensv2/permissioned-registry.mdx | 115 ++++++++++++++++++ vocs.config.tsx | 8 +- 4 files changed, 122 insertions(+), 3 deletions(-) rename src/pages/contracts/ensv2/{access-control.mdx => enhanced-access-control.mdx} (100%) create mode 100644 src/pages/contracts/ensv2/permissioned-registry.mdx diff --git a/src/pages/contracts/ensv2/access-control.mdx b/src/pages/contracts/ensv2/enhanced-access-control.mdx similarity index 100% rename from src/pages/contracts/ensv2/access-control.mdx rename to src/pages/contracts/ensv2/enhanced-access-control.mdx diff --git a/src/pages/contracts/ensv2/overview.mdx b/src/pages/contracts/ensv2/overview.mdx index 44ecf094..ac7ce096 100644 --- a/src/pages/contracts/ensv2/overview.mdx +++ b/src/pages/contracts/ensv2/overview.mdx @@ -23,7 +23,7 @@ The information on this page is a work-in-progress. Expect updates as the design the ownership and transfer rules for names and subnames. - **Permissions as Standard** - All of the functionality enabled by the [Name Wrapper in ENSv1](/wrapper/overview) has been integrated into the core of ENSv2, using a new, highly flexible, - [role-based permissions model](/contracts/ensv2/access-control.mdx). + [role-based permissions model](/contracts/ensv2/enhanced-access-control.mdx). - **No Grace Period** - The grace period is no longer a part of the ENS protocol, meaning names expire when they say they expire. Upon expiration, names immediately enter into the [temporary premium](/registry/eth#premium--auctions) period. The owner of a recently-expired name doesn't have to pay the premium fee during this period. - **Contract Factories** - Rather than relying on large multi-user contracts such as the default public resolver, every name gets its own resolver, and every name diff --git a/src/pages/contracts/ensv2/permissioned-registry.mdx b/src/pages/contracts/ensv2/permissioned-registry.mdx new file mode 100644 index 00000000..380c832a --- /dev/null +++ b/src/pages/contracts/ensv2/permissioned-registry.mdx @@ -0,0 +1,115 @@ +--- +description: Permissioned Registry +--- + +# Permissioned Registry + +The Permissioned Registry is the tokenized registry at the heart of ENSv2 name management. Each registered name becomes an ERC1155 token with exactly one owner, and all permissions are managed through [Enhanced Access Control](/contracts/ensv2/enhanced-access-control). + +Think of it as a ledger where each entry is both a record (name data) and a deed (ownership token). The ledger tracks who owns each name, what they're allowed to do with it, and when it expires. + +:::note +The contracts and interfaces described here are **not yet final** and may change prior to mainnet deployment. +::: + +## Names + +Each name in the registry is identified by its **labelhash** (the keccak256 hash of the label string) and stores: + +- **Subregistry**: pointer to a child registry (for managing subnames) +- **Resolver**: address of the resolver contract +- **Expiry**: timestamp after which the name is considered expired (`block.timestamp >= expiry`) +- **Version counters**: two internal counters used for permission isolation and token identity (see [Versioning](#versioning)) + +## Name Lifecycle + +Names exist in one of three states: + +```mermaid +stateDiagram-v2 + [*] --> AVAILABLE + AVAILABLE --> REGISTERED: register()\nROLE_REGISTRAR + AVAILABLE --> RESERVED: register() with owner=0\nROLE_REGISTRAR + RESERVED --> REGISTERED: register()\nROLE_REGISTER_RESERVED + REGISTERED --> AVAILABLE: unregister()\nor expiry + RESERVED --> AVAILABLE: unregister() +``` + +- **AVAILABLE**: never registered or expired. Open for registration. +- **RESERVED**: placeholder with no owner and no token. Useful for pre-allocating names before assigning them. +- **REGISTERED**: has an owner, a token, and active permissions. + +**State transitions:** + +| From | To | Required role | Scope | +|------|----|---------------|-------| +| AVAILABLE | REGISTERED | `ROLE_REGISTRAR` | root | +| AVAILABLE | RESERVED | `ROLE_REGISTRAR` | root | +| RESERVED | REGISTERED | `ROLE_REGISTER_RESERVED` | root | +| REGISTERED / RESERVED | AVAILABLE | `ROLE_UNREGISTER` | root or name | + +Re-registering an expired name that had a previous owner burns the old token and resets both version counters, ensuring stale permissions and token approvals don't carry over. + +**Renewal:** `renew()` extends a name's expiry but cannot reduce it. Expired names cannot be renewed — they must be re-registered. + +## Ownership + +Each registered name is an ERC1155 token with exactly one owner (singleton, not fungible). The token ID encodes both the labelhash and a version counter, so it changes when the name is re-registered or roles change (see [Versioning](#versioning)). + +`ownerOf()` returns `address(0)` for: +- Expired names — ownership is time-bounded +- Stale token IDs — after a version bump, old token IDs are no longer valid + +## Roles + +All roles use [Enhanced Access Control](/contracts/ensv2/enhanced-access-control) mechanics — see that page for how granting, revoking, admin roles, and resource scoping work. + +| Role | Scope | Purpose | +|------|-------|---------| +| `ROLE_REGISTRAR` | root | Register or reserve names | +| `ROLE_REGISTER_RESERVED` | root | Promote reserved names to registered | +| `ROLE_SET_PARENT` | root | Set parent registry | +| `ROLE_UNREGISTER` | root or name | Unregister names | +| `ROLE_RENEW` | root or name | Extend expiry | +| `ROLE_SET_SUBREGISTRY` | root or name | Set child registry | +| `ROLE_SET_RESOLVER` | root or name | Set resolver | +| `ROLE_CAN_TRANSFER` | name (admin only) | Authorize token transfers | +| `ROLE_UPGRADE` | root | Authorize proxy upgrades | + +"Root" scope means the role only works on `ROOT_RESOURCE`. "Root or name" means it can be granted on either scope, and the two compose — a root grant applies to all names. + +**Admin role restriction on names:** admin roles on individual names can only be granted at registration time. They can be revoked afterward but not re-granted. On `ROOT_RESOURCE`, admin roles work normally. This prevents a name owner from escalating their own permissions after registration. + +## Transfers + +Transferring a name's token requires `ROLE_CAN_TRANSFER` as an **admin role on the token owner** (not the operator — operator approval via ERC1155 is a separate check). + +When a token transfers, all roles automatically move from the old owner to the new owner. Without `ROLE_CAN_TRANSFER`, the name is effectively non-transferable — similar to the `CANNOT_TRANSFER` fuse in the Name Wrapper. + +## Versioning + +Each name maintains two independent version counters: + +**`eacVersionId`** — bumped on re-registration. Creates a fresh permission scope so that roles from a previous registration don't carry over to a new owner. + +**`tokenVersionId`** — bumped on re-registration **and** whenever roles change. Creates a new token ID each time. + +Why does `tokenVersionId` change on role changes? It prevents an attack where someone approves a token transfer, then has their roles revoked — the old token ID becomes invalid, so the pending transfer fails. Without this, a revoked operator could race to transfer the token before the revocation settles. + +Expired names automatically receive a fresh resource scope (computed as `eacVersionId + 1`), so stale permissions from a lapsed registration can't be used even before the name is re-registered. + +## Registry Hierarchy + +Each name can point to a child registry via its subregistry field. These parent-child relationships form a tree that mirrors the DNS hierarchy: + +``` +.eth registry → nick.eth → sub.nick.eth +(parent) (child) (grandchild) +``` + +`getSubregistry()` and `getResolver()` return `address(0)` for expired names, preventing resolution through lapsed names. + +Three registry variants share this design: +- **PermissionedRegistry**: the base implementation +- **UserRegistry**: upgradeable variant for user subdomains +- **WrapperRegistry**: upgradeable variant for ENSv1 migration diff --git a/vocs.config.tsx b/vocs.config.tsx index a3605ec3..4a518ffb 100644 --- a/vocs.config.tsx +++ b/vocs.config.tsx @@ -360,8 +360,12 @@ export default defineConfig({ link: '/contracts/ensv2/overview', }, { - text: 'Access control', - link: '/contracts/ensv2/access-control', + text: 'Enhanced Access Control', + link: '/contracts/ensv2/enhanced-access-control', + }, + { + text: 'Permissioned Registry', + link: '/contracts/ensv2/permissioned-registry', }, // { // text: 'Registries', From 9dda1c3bd09de44f3ea2cce6fabc57bb677e1046 Mon Sep 17 00:00:00 2001 From: Ramesh Nair Date: Mon, 16 Mar 2026 23:53:53 +0800 Subject: [PATCH 3/8] docs: fix inaccuracies and add missing features to Permissioned Registry page Fix role name, version counter behavior, state diagram, and renewal semantics to match the Solidity source. Add documentation for anyId polymorphism, registration parameters, unregistration, view functions, and token regeneration. --- .../contracts/ensv2/permissioned-registry.mdx | 58 ++++++++++++++----- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/src/pages/contracts/ensv2/permissioned-registry.mdx b/src/pages/contracts/ensv2/permissioned-registry.mdx index 380c832a..ff302411 100644 --- a/src/pages/contracts/ensv2/permissioned-registry.mdx +++ b/src/pages/contracts/ensv2/permissioned-registry.mdx @@ -6,15 +6,13 @@ description: Permissioned Registry The Permissioned Registry is the tokenized registry at the heart of ENSv2 name management. Each registered name becomes an ERC1155 token with exactly one owner, and all permissions are managed through [Enhanced Access Control](/contracts/ensv2/enhanced-access-control). -Think of it as a ledger where each entry is both a record (name data) and a deed (ownership token). The ledger tracks who owns each name, what they're allowed to do with it, and when it expires. - :::note The contracts and interfaces described here are **not yet final** and may change prior to mainnet deployment. ::: ## Names -Each name in the registry is identified by its **labelhash** (the keccak256 hash of the label string) and stores: +Each name in the registry is identified by its **labelhash** (the `keccak256` hash of the label string) and stores: - **Subregistry**: pointer to a child registry (for managing subnames) - **Resolver**: address of the resolver contract @@ -32,7 +30,7 @@ stateDiagram-v2 AVAILABLE --> RESERVED: register() with owner=0\nROLE_REGISTRAR RESERVED --> REGISTERED: register()\nROLE_REGISTER_RESERVED REGISTERED --> AVAILABLE: unregister()\nor expiry - RESERVED --> AVAILABLE: unregister() + RESERVED --> AVAILABLE: unregister()\nor expiry ``` - **AVAILABLE**: never registered or expired. Open for registration. @@ -48,9 +46,27 @@ stateDiagram-v2 | RESERVED | REGISTERED | `ROLE_REGISTER_RESERVED` | root | | REGISTERED / RESERVED | AVAILABLE | `ROLE_UNREGISTER` | root or name | -Re-registering an expired name that had a previous owner burns the old token and resets both version counters, ensuring stale permissions and token approvals don't carry over. +### Registration + +`register()` accepts a `label` (string), `owner`, `registry` (subregistry), `resolver`, `roleBitmap` (initial roles granted to the owner), and `expiry`. If `owner` is `address(0)`, the name is reserved instead of registered, and `roleBitmap` must be `0`. + +When promoting a RESERVED name to REGISTERED, if `expiry` is `0` the current expiry is preserved. + +Re-registering an expired name that had a previous owner burns the old token and increments both version counters, ensuring stale permissions and token approvals don't carry over. + +### Unregistration + +`unregister()` sets the name's expiry to `block.timestamp`, making it immediately available. If the name was REGISTERED (has an owner), the token is burned and both version counters are incremented. + +### Renewal -**Renewal:** `renew()` extends a name's expiry but cannot reduce it. Expired names cannot be renewed — they must be re-registered. +`renew()` extends a name's expiry but cannot reduce it. Both REGISTERED and RESERVED names can be renewed. Expired names cannot be renewed — they must be re-registered. + +## anyId Polymorphism + +Most functions accept an `anyId` parameter that can be a **labelhash**, **tokenId**, or **resource** interchangeably. Internally, `_entry()` zeroes the version bits to find the canonical storage slot for the name. This means you can pass whichever identifier you have on hand — the registry resolves it to the same underlying entry. + +This applies to `setSubregistry()`, `setResolver()`, `renew()`, `unregister()`, `getExpiry()`, `getStatus()`, `getState()`, `getTokenId()`, `getResource()`, and all EAC role functions (`grantRoles()`, `revokeRoles()`, `roles()`, etc.). ## Ownership @@ -60,9 +76,11 @@ Each registered name is an ERC1155 token with exactly one owner (singleton, not - Expired names — ownership is time-bounded - Stale token IDs — after a version bump, old token IDs are no longer valid +`latestOwnerOf()` returns the owner regardless of expiry or version staleness — useful for historical queries or determining who last held a name. + ## Roles -All roles use [Enhanced Access Control](/contracts/ensv2/enhanced-access-control) mechanics — see that page for how granting, revoking, admin roles, and resource scoping work. +All roles use [Enhanced Access Control](/contracts/ensv2/enhanced-access-control) mechanics for role-based access control. | Role | Scope | Purpose | |------|-------|---------| @@ -73,7 +91,7 @@ All roles use [Enhanced Access Control](/contracts/ensv2/enhanced-access-control | `ROLE_RENEW` | root or name | Extend expiry | | `ROLE_SET_SUBREGISTRY` | root or name | Set child registry | | `ROLE_SET_RESOLVER` | root or name | Set resolver | -| `ROLE_CAN_TRANSFER` | name (admin only) | Authorize token transfers | +| `ROLE_CAN_TRANSFER_ADMIN` | root or name (admin only) | Authorize token transfers | | `ROLE_UPGRADE` | root | Authorize proxy upgrades | "Root" scope means the role only works on `ROOT_RESOURCE`. "Root or name" means it can be granted on either scope, and the two compose — a root grant applies to all names. @@ -82,17 +100,17 @@ All roles use [Enhanced Access Control](/contracts/ensv2/enhanced-access-control ## Transfers -Transferring a name's token requires `ROLE_CAN_TRANSFER` as an **admin role on the token owner** (not the operator — operator approval via ERC1155 is a separate check). +Transferring a name's token requires `ROLE_CAN_TRANSFER_ADMIN` as an **admin role on the token owner** (not the operator — operator approval via ERC1155 is a separate check). -When a token transfers, all roles automatically move from the old owner to the new owner. Without `ROLE_CAN_TRANSFER`, the name is effectively non-transferable — similar to the `CANNOT_TRANSFER` fuse in the Name Wrapper. +When a token transfers, all roles automatically move from the old owner to the new owner. Without `ROLE_CAN_TRANSFER_ADMIN`, the name is effectively non-transferable — similar to the `CANNOT_TRANSFER` fuse in the Name Wrapper. ## Versioning Each name maintains two independent version counters: -**`eacVersionId`** — bumped on re-registration. Creates a fresh permission scope so that roles from a previous registration don't carry over to a new owner. +**`eacVersionId`** — incremented on unregistration and re-registration. Creates a fresh permission scope so that roles from a previous registration don't carry over to a new owner. -**`tokenVersionId`** — bumped on re-registration **and** whenever roles change. Creates a new token ID each time. +**`tokenVersionId`** — incremented on unregistration, re-registration, **and** whenever roles change. Creates a new token ID each time. Role changes trigger a burn+mint cycle (`_regenerateToken`), emitting `TokenRegenerated(oldTokenId, newTokenId)` — the owner stays the same but gets a new token ID. Why does `tokenVersionId` change on role changes? It prevents an attack where someone approves a token transfer, then has their roles revoked — the old token ID becomes invalid, so the pending transfer fails. Without this, a revoked operator could race to transfer the token before the revocation settles. @@ -107,9 +125,17 @@ Each name can point to a child registry via its subregistry field. These parent- (parent) (child) (grandchild) ``` +The registry also stores its own parent via `setParent()` / `getParent()`, which records both the parent registry address and the child label. + `getSubregistry()` and `getResolver()` return `address(0)` for expired names, preventing resolution through lapsed names. -Three registry variants share this design: -- **PermissionedRegistry**: the base implementation -- **UserRegistry**: upgradeable variant for user subdomains -- **WrapperRegistry**: upgradeable variant for ENSv1 migration +## View Functions + +| Function | Returns | +|----------|---------| +| `getState(anyId)` | Complete state: status, expiry, latest owner, tokenId, resource | +| `getStatus(anyId)` | `AVAILABLE`, `RESERVED`, or `REGISTERED` | +| `getExpiry(anyId)` | Expiry timestamp | +| `getTokenId(anyId)` | Current token ID for the name | +| `getResource(anyId)` | Current EAC resource ID for the name | +| `latestOwnerOf(tokenId)` | Owner regardless of expiry/version (useful for historical queries) | From 7d55c079b8c0c6a110fdc815fa41159629761fad Mon Sep 17 00:00:00 2001 From: Ramesh Nair Date: Wed, 18 Mar 2026 17:33:51 +0800 Subject: [PATCH 4/8] docs: fix .mdx link extension in ENSv2 overview Co-Authored-By: Claude Opus 4.6 (1M context) --- src/pages/contracts/ensv2/overview.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/contracts/ensv2/overview.mdx b/src/pages/contracts/ensv2/overview.mdx index ac7ce096..68ecf294 100644 --- a/src/pages/contracts/ensv2/overview.mdx +++ b/src/pages/contracts/ensv2/overview.mdx @@ -23,7 +23,7 @@ The information on this page is a work-in-progress. Expect updates as the design the ownership and transfer rules for names and subnames. - **Permissions as Standard** - All of the functionality enabled by the [Name Wrapper in ENSv1](/wrapper/overview) has been integrated into the core of ENSv2, using a new, highly flexible, - [role-based permissions model](/contracts/ensv2/enhanced-access-control.mdx). + [role-based permissions model](/contracts/ensv2/enhanced-access-control). - **No Grace Period** - The grace period is no longer a part of the ENS protocol, meaning names expire when they say they expire. Upon expiration, names immediately enter into the [temporary premium](/registry/eth#premium--auctions) period. The owner of a recently-expired name doesn't have to pay the premium fee during this period. - **Contract Factories** - Rather than relying on large multi-user contracts such as the default public resolver, every name gets its own resolver, and every name From 4d5d8964178ddeddcc3cdc910c84df239a7e7078 Mon Sep 17 00:00:00 2001 From: Ramesh Nair Date: Thu, 19 Mar 2026 11:27:01 +0800 Subject: [PATCH 5/8] docs: improve formatting and accuracy of ENSv2 registry docs Inline-code ERC1155 references, simplify state diagram labels, clarify versioning semantics, and fix minor wording across the overview and permissioned registry pages. --- src/pages/contracts/ensv2/overview.mdx | 2 +- .../contracts/ensv2/permissioned-registry.mdx | 50 +++++++++---------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/pages/contracts/ensv2/overview.mdx b/src/pages/contracts/ensv2/overview.mdx index 68ecf294..e11c6f08 100644 --- a/src/pages/contracts/ensv2/overview.mdx +++ b/src/pages/contracts/ensv2/overview.mdx @@ -161,7 +161,7 @@ at all! ### Token Representation and permissions Although it's not required by the ENSv2 registry interface specification, typically registries are -implemented as ERC1155 token contracts, and standardized tooling is likely to expect as much. Each registry +implemented as `ERC1155` token contracts, and standardized tooling is likely to expect as much. Each registry has an overall owner, who is responsible for the registry itself, as well as owners for individual subnames, which are represented as tokens. Because of the hierarchical nature of the registry structure as demonstrated above, merely owning a subname token on a registry does not guarantee anything in isolation: to be meaningful, diff --git a/src/pages/contracts/ensv2/permissioned-registry.mdx b/src/pages/contracts/ensv2/permissioned-registry.mdx index ff302411..fbe21f91 100644 --- a/src/pages/contracts/ensv2/permissioned-registry.mdx +++ b/src/pages/contracts/ensv2/permissioned-registry.mdx @@ -4,7 +4,7 @@ description: Permissioned Registry # Permissioned Registry -The Permissioned Registry is the tokenized registry at the heart of ENSv2 name management. Each registered name becomes an ERC1155 token with exactly one owner, and all permissions are managed through [Enhanced Access Control](/contracts/ensv2/enhanced-access-control). +The Permissioned Registry is the tokenized registry at the heart of ENSv2 name management. Each registered name becomes an `ERC1155` token with exactly one owner, and all permissions are managed through [Enhanced Access Control](/contracts/ensv2/enhanced-access-control). :::note The contracts and interfaces described here are **not yet final** and may change prior to mainnet deployment. @@ -17,7 +17,7 @@ Each name in the registry is identified by its **labelhash** (the `keccak256` ha - **Subregistry**: pointer to a child registry (for managing subnames) - **Resolver**: address of the resolver contract - **Expiry**: timestamp after which the name is considered expired (`block.timestamp >= expiry`) -- **Version counters**: two internal counters used for permission isolation and token identity (see [Versioning](#versioning)) +- **Versioning**: internal counters that isolate permissions between registrations and prevent stale token approvals (see [Versioning](#versioning)) ## Name Lifecycle @@ -26,20 +26,20 @@ Names exist in one of three states: ```mermaid stateDiagram-v2 [*] --> AVAILABLE - AVAILABLE --> REGISTERED: register()\nROLE_REGISTRAR - AVAILABLE --> RESERVED: register() with owner=0\nROLE_REGISTRAR - RESERVED --> REGISTERED: register()\nROLE_REGISTER_RESERVED - REGISTERED --> AVAILABLE: unregister()\nor expiry - RESERVED --> AVAILABLE: unregister()\nor expiry + AVAILABLE --> REGISTERED: register() + AVAILABLE --> RESERVED: register() with owner=0 + RESERVED --> REGISTERED: register() + REGISTERED --> AVAILABLE: unregister() or expiry + RESERVED --> AVAILABLE: unregister() or expiry ``` -- **AVAILABLE**: never registered or expired. Open for registration. -- **RESERVED**: placeholder with no owner and no token. Useful for pre-allocating names before assigning them. -- **REGISTERED**: has an owner, a token, and active permissions. +- `AVAILABLE`: never registered or expired. Open for registration. +- `RESERVED`: placeholder with no owner and no token. Useful for pre-allocating names before assigning them. +- `REGISTERED`: has an owner, a token, and active permissions. **State transitions:** -| From | To | Required role | Scope | +| From | To | Required role | [Scope](/contracts/ensv2/enhanced-access-control#resources) | |------|----|---------------|-------| | AVAILABLE | REGISTERED | `ROLE_REGISTRAR` | root | | AVAILABLE | RESERVED | `ROLE_REGISTRAR` | root | @@ -48,33 +48,35 @@ stateDiagram-v2 ### Registration -`register()` accepts a `label` (string), `owner`, `registry` (subregistry), `resolver`, `roleBitmap` (initial roles granted to the owner), and `expiry`. If `owner` is `address(0)`, the name is reserved instead of registered, and `roleBitmap` must be `0`. +`register()` accepts a `label` (string), `owner`, `registry` (subregistry), `resolver`, `roleBitmap` (initial roles granted to the owner), and `expiry`. Labels are validated for size before registration. If `owner` is `address(0)`, the name is reserved instead of registered, and `roleBitmap` must be `0`. -When promoting a RESERVED name to REGISTERED, if `expiry` is `0` the current expiry is preserved. +A non-expired registered name cannot be re-registered directly — it must be unregistered first. Similarly, a reserved name cannot be re-reserved; it can only be promoted to registered. + +When promoting a `RESERVED` name to `REGISTERED`, if `expiry` is `0` the current expiry is preserved. Re-registering an expired name that had a previous owner burns the old token and increments both version counters, ensuring stale permissions and token approvals don't carry over. ### Unregistration -`unregister()` sets the name's expiry to `block.timestamp`, making it immediately available. If the name was REGISTERED (has an owner), the token is burned and both version counters are incremented. +`unregister()` sets the name's expiry to `block.timestamp`, making it immediately available. If the name was `REGISTERED` (has an owner), the token is burned and both version counters are incremented. ### Renewal -`renew()` extends a name's expiry but cannot reduce it. Both REGISTERED and RESERVED names can be renewed. Expired names cannot be renewed — they must be re-registered. +`renew()` extends a name's expiry but cannot reduce it. Both `REGISTERED` and `RESERVED` names can be renewed. Expired names cannot be renewed — they must be re-registered. ## anyId Polymorphism -Most functions accept an `anyId` parameter that can be a **labelhash**, **tokenId**, or **resource** interchangeably. Internally, `_entry()` zeroes the version bits to find the canonical storage slot for the name. This means you can pass whichever identifier you have on hand — the registry resolves it to the same underlying entry. +Most functions accept an `anyId` parameter that can be a `labelhash`, `tokenId`, or `resource` interchangeably. Internally, `_entry()` zeroes the version bits to find the canonical storage slot for the name. This means you can pass whichever identifier you have on hand — the registry resolves it to the same underlying entry. This applies to `setSubregistry()`, `setResolver()`, `renew()`, `unregister()`, `getExpiry()`, `getStatus()`, `getState()`, `getTokenId()`, `getResource()`, and all EAC role functions (`grantRoles()`, `revokeRoles()`, `roles()`, etc.). ## Ownership -Each registered name is an ERC1155 token with exactly one owner (singleton, not fungible). The token ID encodes both the labelhash and a version counter, so it changes when the name is re-registered or roles change (see [Versioning](#versioning)). +Each registered name is an `ERC1155` token with exactly one owner (singleton, not fungible). The token ID changes when the name is re-registered or when roles change (see [Versioning](#versioning)). `ownerOf()` returns `address(0)` for: - Expired names — ownership is time-bounded -- Stale token IDs — after a version bump, old token IDs are no longer valid +- Stale token IDs — after versioning changes, old token IDs are no longer valid `latestOwnerOf()` returns the owner regardless of expiry or version staleness — useful for historical queries or determining who last held a name. @@ -100,21 +102,19 @@ All roles use [Enhanced Access Control](/contracts/ensv2/enhanced-access-control ## Transfers -Transferring a name's token requires `ROLE_CAN_TRANSFER_ADMIN` as an **admin role on the token owner** (not the operator — operator approval via ERC1155 is a separate check). +Transferring a name's token requires `ROLE_CAN_TRANSFER_ADMIN` as an **admin role on the token owner** (not the operator — operator approval via `ERC1155` is a separate check). When a token transfers, all roles automatically move from the old owner to the new owner. Without `ROLE_CAN_TRANSFER_ADMIN`, the name is effectively non-transferable — similar to the `CANNOT_TRANSFER` fuse in the Name Wrapper. ## Versioning -Each name maintains two independent version counters: - -**`eacVersionId`** — incremented on unregistration and re-registration. Creates a fresh permission scope so that roles from a previous registration don't carry over to a new owner. +The registry uses internal versioning to provide two security guarantees: -**`tokenVersionId`** — incremented on unregistration, re-registration, **and** whenever roles change. Creates a new token ID each time. Role changes trigger a burn+mint cycle (`_regenerateToken`), emitting `TokenRegenerated(oldTokenId, newTokenId)` — the owner stays the same but gets a new token ID. +**Permission isolation across registrations** — when a name is unregistered and later re-registered, all roles from the previous registration are invalidated. The new owner starts with a clean permission scope, preventing any stale grants from carrying over. -Why does `tokenVersionId` change on role changes? It prevents an attack where someone approves a token transfer, then has their roles revoked — the old token ID becomes invalid, so the pending transfer fails. Without this, a revoked operator could race to transfer the token before the revocation settles. +**Preventing transfer griefing** — when roles are granted or revoked via `grantRoles()` / `revokeRoles()`, the token ID changes (the owner stays the same but receives a new token via a burn+mint cycle). This invalidates any pending `ERC1155` transfer approvals tied to the old token ID, preventing an attack where someone approves a transfer and then has their roles revoked — without this, the approved party could race to transfer the token before the revocation takes effect. During normal transfers, roles move to the new owner without changing the token ID. -Expired names automatically receive a fresh resource scope (computed as `eacVersionId + 1`), so stale permissions from a lapsed registration can't be used even before the name is re-registered. +**Expiry enforcement** — expired names retain their permissions, but all mutating operations check expiry first and revert if the name has lapsed, preventing stale permissions from being exercised. On re-registration, version counters increment to create a fresh scope that invalidates any prior grants. ## Registry Hierarchy From df1db2d109ea9cdf80d3526267e45f7f18bd7f09 Mon Sep 17 00:00:00 2001 From: Ramesh Nair Date: Thu, 9 Apr 2026 13:33:55 +0800 Subject: [PATCH 6/8] docs: add PermissionedRegistry and PermissionedResolver roles to EAC page --- .../ensv2/enhanced-access-control.mdx | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/pages/contracts/ensv2/enhanced-access-control.mdx b/src/pages/contracts/ensv2/enhanced-access-control.mdx index b04c3c39..6957c674 100644 --- a/src/pages/contracts/ensv2/enhanced-access-control.mdx +++ b/src/pages/contracts/ensv2/enhanced-access-control.mdx @@ -91,3 +91,66 @@ EAC replaces the one-way [fuse system](/wrapper/fuses) from the Name Wrapper. Ke - **Multi-account**: up to 15 accounts can hold the same role per resource, enabling delegation and shared management - **Granular scoping**: permissions can be set contract-wide or per individual resource, with the two scopes composing automatically - **Extensible**: each contract defines its own roles - no fixed set of permissions baked into the protocol + +## Permissioned Registry Roles + +The [Permissioned Registry](/contracts/ensv2/permissioned-registry) defines the following roles for managing name ownership and lifecycle: + +| Role | Scope | Purpose | +|------|-------|---------| +| `ROLE_REGISTRAR` | root | Register or reserve names | +| `ROLE_REGISTER_RESERVED` | root | Promote reserved names to registered | +| `ROLE_SET_PARENT` | root | Set parent registry | +| `ROLE_UNREGISTER` | root or name | Unregister names | +| `ROLE_RENEW` | root or name | Extend expiry | +| `ROLE_SET_SUBREGISTRY` | root or name | Set child registry | +| `ROLE_SET_RESOLVER` | root or name | Set resolver | +| `ROLE_CAN_TRANSFER_ADMIN` | root or name (admin only) | Authorize token transfers | +| `ROLE_UPGRADE` | root | Authorize proxy upgrades | + +"Root" scope means the role operates on `ROOT_RESOURCE` only. "Root or name" means it can be granted on either scope, and the two compose — a root grant applies to all names. + +Admin roles on individual names can only be granted at registration time. They can be revoked afterward but not re-granted. On `ROOT_RESOURCE`, admin roles work normally. This prevents a name owner from escalating their own permissions after registration. + +## Permissioned Resolver Roles + +The Permissioned Resolver is the per-name resolver used in ENSv2. Each name gets its own resolver instance deployed via a factory, and all permissions are managed through EAC. + +| Role | Scope | Purpose | +|------|-------|---------| +| `ROLE_SET_ADDR` | root or name | Set address records (e.g. ETH, BTC addresses) | +| `ROLE_SET_TEXT` | root or name | Set text records (e.g. avatar, email) | +| `ROLE_SET_CONTENTHASH` | root or name | Set contenthash record | +| `ROLE_SET_PUBKEY` | root or name | Set SECP256k1 public key record | +| `ROLE_SET_ABI` | root or name | Set ABI data | +| `ROLE_SET_INTERFACE` | root or name | Set interface implementer record | +| `ROLE_SET_NAME` | root or name | Set reverse name record | +| `ROLE_SET_ALIAS` | root | Set alias targets for name rewriting | +| `ROLE_CLEAR` | root or name | Clear all records for a node (version bump) | +| `ROLE_UPGRADE` | root | Authorize proxy upgrades | + +### Fine-Grained Permissions + +`ROLE_SET_ADDR` and `ROLE_SET_TEXT` support an additional level of scoping beyond name-level. Permissions can be restricted to a specific coin type or text key using a **part** identifier, creating a two-dimensional permission grid: + +| | Any record | Specific record | +|--|------------|-----------------| +| **Any name** | `resource(0, 0)` | `resource(0, part)` | +| **Specific name** | `resource(namehash, 0)` | `resource(namehash, part)` | + +When checking permissions, the resolver looks across all four resource combinations and allows the action if any of them grant the required role. + +Because of this part-based scoping, the standard `grantRoles()` function is disabled on the Permissioned Resolver. Instead, three specialized functions are provided: + +- **`grantNameRoles(name, roleBitmap, account)`** — grant roles scoped to a name (no part restriction) +- **`grantTextRoles(name, key, account)`** — grant `ROLE_SET_TEXT` for a specific text key on a name +- **`grantAddrRoles(name, coinType, account)`** — grant `ROLE_SET_ADDR` for a specific coin type on a name + +For example, you could grant someone permission to set only the `avatar` text record on `alice.eth`, without giving them access to any other records. + +### Aliasing + +The Permissioned Resolver supports internal name aliasing via `setAlias()` (requires `ROLE_SET_ALIAS`). An alias maps one name to another using longest-match suffix rewriting: + +- `setAlias("a.eth", "b.eth")` causes `sub.a.eth` to resolve using records from `sub.b.eth` +- Aliases are resolved recursively (cycles of length 1 apply once; longer cycles result in out-of-gas) From 640b304fa4776d310341fb65a7bde535f7a3f9c6 Mon Sep 17 00:00:00 2001 From: Ramesh Nair Date: Thu, 9 Apr 2026 13:50:19 +0800 Subject: [PATCH 7/8] docs: add EAC callback hooks and registry hook overrides documentation --- .../ensv2/enhanced-access-control.mdx | 28 ++++++++++++++++--- .../contracts/ensv2/permissioned-registry.mdx | 12 +++++++- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/pages/contracts/ensv2/enhanced-access-control.mdx b/src/pages/contracts/ensv2/enhanced-access-control.mdx index 6957c674..110f5a8e 100644 --- a/src/pages/contracts/ensv2/enhanced-access-control.mdx +++ b/src/pages/contracts/ensv2/enhanced-access-control.mdx @@ -63,12 +63,32 @@ For example, the admin role for "set resolver" controls who is allowed to grant Roles are managed through four functions: -- `grantRoles` / `revokeRoles` - manage roles on a specific resource (name) -- `grantRootRoles` / `revokeRootRoles` - manage roles on `ROOT_RESOURCE` (contract-wide) +- `grantRoles` / `revokeRoles` — manage roles on a specific resource (name) +- `grantRootRoles` / `revokeRootRoles` — manage roles on `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. +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. -Contracts can hook into role changes by overriding `_onRolesGranted` and `_onRolesRevoked` to run custom logic whenever permissions change. +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 four internal hooks that contracts can override to customize permission behavior: + +### 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 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 both to prevent admin role escalation on individual names after registration and to block all role changes on unregistered or reserved names. ## Bitmap Layout diff --git a/src/pages/contracts/ensv2/permissioned-registry.mdx b/src/pages/contracts/ensv2/permissioned-registry.mdx index fbe21f91..2ac50070 100644 --- a/src/pages/contracts/ensv2/permissioned-registry.mdx +++ b/src/pages/contracts/ensv2/permissioned-registry.mdx @@ -104,7 +104,7 @@ All roles use [Enhanced Access Control](/contracts/ensv2/enhanced-access-control Transferring a name's token requires `ROLE_CAN_TRANSFER_ADMIN` as an **admin role on the token owner** (not the operator — operator approval via `ERC1155` is a separate check). -When a token transfers, all roles automatically move from the old owner to the new owner. Without `ROLE_CAN_TRANSFER_ADMIN`, the name is effectively non-transferable — similar to the `CANNOT_TRANSFER` fuse in the Name Wrapper. +When a token transfers, all roles are atomically moved from the old owner to the new owner — the old owner's roles are revoked first (freeing assignee slots), then granted to the new owner. Roles granted to other accounts on the same name are unaffected. Without `ROLE_CAN_TRANSFER_ADMIN`, the name is effectively non-transferable — similar to the `CANNOT_TRANSFER` fuse in the Name Wrapper. ## Versioning @@ -116,6 +116,16 @@ The registry uses internal versioning to provide two security guarantees: **Expiry enforcement** — expired names retain their permissions, but all mutating operations check expiry first and revert if the name has lapsed, preventing stale permissions from being exercised. On re-registration, version counters increment to create a fresh scope that invalidates any prior grants. +## EAC Hook Overrides + +The Permissioned Registry overrides several [EAC callback hooks](/contracts/ensv2/enhanced-access-control#callback-hooks) to enforce registry-specific invariants: + +**Token regeneration on role changes** — when roles are granted or revoked via `grantRoles()` / `revokeRoles()`, the `_onRolesGranted` and `_onRolesRevoked` hooks trigger a token regeneration (burn + mint with a new token ID). This invalidates any pending ERC1155 transfer approvals tied to the old token ID, preventing an attacker from racing to transfer a token after their roles have been revoked. + +**Admin role restriction on names** — `_getSettableRoles` is overridden so that admin roles on individual names can only be assigned at registration time. After registration, only regular (non-admin) roles can be granted on a name. On `ROOT_RESOURCE`, admin roles work normally. This prevents a name owner from escalating their own permissions after registration. + +**No role changes on unregistered names** — both `_getSettableRoles` and `_getRevokableRoles` return `0` for names that are `AVAILABLE` or `RESERVED`, blocking all role operations until the name is fully registered. + ## Registry Hierarchy Each name can point to a child registry via its subregistry field. These parent-child relationships form a tree that mirrors the DNS hierarchy: From f7e03a87fd704ccf598349fa33298a000dcad7d9 Mon Sep 17 00:00:00 2001 From: Ramesh Nair Date: Wed, 22 Apr 2026 15:41:26 +0800 Subject: [PATCH 8/8] docs: add HCA, Registry Metadata, and Verifiable Factory pages for ENSv2 --- .../ensv2/enhanced-access-control.mdx | 54 ++++---- src/pages/contracts/ensv2/hca.mdx | 83 +++++++++++ src/pages/contracts/ensv2/overview.mdx | 2 +- .../contracts/ensv2/permissioned-registry.mdx | 55 ++++---- .../contracts/ensv2/registry-metadata.mdx | 85 ++++++++++++ .../contracts/ensv2/verifiable-factory.mdx | 130 ++++++++++++++++++ vocs.config.tsx | 12 ++ 7 files changed, 370 insertions(+), 51 deletions(-) create mode 100644 src/pages/contracts/ensv2/hca.mdx create mode 100644 src/pages/contracts/ensv2/registry-metadata.mdx create mode 100644 src/pages/contracts/ensv2/verifiable-factory.mdx diff --git a/src/pages/contracts/ensv2/enhanced-access-control.mdx b/src/pages/contracts/ensv2/enhanced-access-control.mdx index 110f5a8e..399a0f48 100644 --- a/src/pages/contracts/ensv2/enhanced-access-control.mdx +++ b/src/pages/contracts/ensv2/enhanced-access-control.mdx @@ -41,6 +41,8 @@ Up to 15 accounts can hold the same role on the same resource, enabling shared m **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. +The account being checked is the sender as resolved by [Hidden Contract Accounts](/contracts/ensv2/hca), not raw `msg.sender` — smart-account proxies are transparently mapped to their owner before the role lookup runs. + ## Admin Roles Every role has a corresponding **admin role** that controls who can manage it. If you hold the admin role, you can: @@ -116,17 +118,17 @@ EAC replaces the one-way [fuse system](/wrapper/fuses) from the Name Wrapper. Ke The [Permissioned Registry](/contracts/ensv2/permissioned-registry) defines the following roles for managing name ownership and lifecycle: -| Role | Scope | Purpose | -|------|-------|---------| -| `ROLE_REGISTRAR` | root | Register or reserve names | -| `ROLE_REGISTER_RESERVED` | root | Promote reserved names to registered | -| `ROLE_SET_PARENT` | root | Set parent registry | -| `ROLE_UNREGISTER` | root or name | Unregister names | -| `ROLE_RENEW` | root or name | Extend expiry | -| `ROLE_SET_SUBREGISTRY` | root or name | Set child registry | -| `ROLE_SET_RESOLVER` | root or name | Set resolver | -| `ROLE_CAN_TRANSFER_ADMIN` | root or name (admin only) | Authorize token transfers | -| `ROLE_UPGRADE` | root | Authorize proxy upgrades | +| Role | Scope | Purpose | +| ------------------------- | ------------------------- | ------------------------------------ | +| `ROLE_REGISTRAR` | root | Register or reserve names | +| `ROLE_REGISTER_RESERVED` | root | Promote reserved names to registered | +| `ROLE_SET_PARENT` | root | Set parent registry | +| `ROLE_UNREGISTER` | root or name | Unregister names | +| `ROLE_RENEW` | root or name | Extend expiry | +| `ROLE_SET_SUBREGISTRY` | root or name | Set child registry | +| `ROLE_SET_RESOLVER` | root or name | Set resolver | +| `ROLE_CAN_TRANSFER_ADMIN` | root or name (admin only) | Authorize token transfers | +| `ROLE_UPGRADE` | root | Authorize proxy upgrades | "Root" scope means the role operates on `ROOT_RESOURCE` only. "Root or name" means it can be granted on either scope, and the two compose — a root grant applies to all names. @@ -136,26 +138,26 @@ Admin roles on individual names can only be granted at registration time. They c The Permissioned Resolver is the per-name resolver used in ENSv2. Each name gets its own resolver instance deployed via a factory, and all permissions are managed through EAC. -| Role | Scope | Purpose | -|------|-------|---------| -| `ROLE_SET_ADDR` | root or name | Set address records (e.g. ETH, BTC addresses) | -| `ROLE_SET_TEXT` | root or name | Set text records (e.g. avatar, email) | -| `ROLE_SET_CONTENTHASH` | root or name | Set contenthash record | -| `ROLE_SET_PUBKEY` | root or name | Set SECP256k1 public key record | -| `ROLE_SET_ABI` | root or name | Set ABI data | -| `ROLE_SET_INTERFACE` | root or name | Set interface implementer record | -| `ROLE_SET_NAME` | root or name | Set reverse name record | -| `ROLE_SET_ALIAS` | root | Set alias targets for name rewriting | -| `ROLE_CLEAR` | root or name | Clear all records for a node (version bump) | -| `ROLE_UPGRADE` | root | Authorize proxy upgrades | +| Role | Scope | Purpose | +| ---------------------- | ------------ | --------------------------------------------- | +| `ROLE_SET_ADDR` | root or name | Set address records (e.g. ETH, BTC addresses) | +| `ROLE_SET_TEXT` | root or name | Set text records (e.g. avatar, email) | +| `ROLE_SET_CONTENTHASH` | root or name | Set contenthash record | +| `ROLE_SET_PUBKEY` | root or name | Set SECP256k1 public key record | +| `ROLE_SET_ABI` | root or name | Set ABI data | +| `ROLE_SET_INTERFACE` | root or name | Set interface implementer record | +| `ROLE_SET_NAME` | root or name | Set reverse name record | +| `ROLE_SET_ALIAS` | root | Set alias targets for name rewriting | +| `ROLE_CLEAR` | root or name | Clear all records for a node (version bump) | +| `ROLE_UPGRADE` | root | Authorize proxy upgrades | ### Fine-Grained Permissions `ROLE_SET_ADDR` and `ROLE_SET_TEXT` support an additional level of scoping beyond name-level. Permissions can be restricted to a specific coin type or text key using a **part** identifier, creating a two-dimensional permission grid: -| | Any record | Specific record | -|--|------------|-----------------| -| **Any name** | `resource(0, 0)` | `resource(0, part)` | +| | Any record | Specific record | +| ----------------- | ----------------------- | -------------------------- | +| **Any name** | `resource(0, 0)` | `resource(0, part)` | | **Specific name** | `resource(namehash, 0)` | `resource(namehash, part)` | When checking permissions, the resolver looks across all four resource combinations and allows the action if any of them grant the required role. diff --git a/src/pages/contracts/ensv2/hca.mdx b/src/pages/contracts/ensv2/hca.mdx new file mode 100644 index 00000000..813820de --- /dev/null +++ b/src/pages/contracts/ensv2/hca.mdx @@ -0,0 +1,83 @@ +--- +description: Hidden Contract Accounts +--- + +# 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. + +:::note +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` | +| `PermissionedResolver` | `HCAContextUpgradeable` | +| `ETHRegistrar` | `HCAEquivalence` directly | +| `SimpleRegistryMetadata` | `HCAEquivalence` directly | +| `BaseUriRegistryMetadata` | `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/overview.mdx b/src/pages/contracts/ensv2/overview.mdx index e11c6f08..284237d0 100644 --- a/src/pages/contracts/ensv2/overview.mdx +++ b/src/pages/contracts/ensv2/overview.mdx @@ -29,7 +29,7 @@ The information on this page is a work-in-progress. Expect updates as the design the default public resolver, every name gets its own resolver, and every name with subnames gets its own registry contract. This simplifies the contracts significantly, while offering users new functionality such as the possibility of 'aliasing' multiple domains to - the same records. + the same records. See [Verifiable Factory](/contracts/ensv2/verifiable-factory) for the deployment mechanism. ## What hasn't changed? diff --git a/src/pages/contracts/ensv2/permissioned-registry.mdx b/src/pages/contracts/ensv2/permissioned-registry.mdx index 2ac50070..e6f7adf0 100644 --- a/src/pages/contracts/ensv2/permissioned-registry.mdx +++ b/src/pages/contracts/ensv2/permissioned-registry.mdx @@ -39,12 +39,12 @@ stateDiagram-v2 **State transitions:** -| From | To | Required role | [Scope](/contracts/ensv2/enhanced-access-control#resources) | -|------|----|---------------|-------| -| AVAILABLE | REGISTERED | `ROLE_REGISTRAR` | root | -| AVAILABLE | RESERVED | `ROLE_REGISTRAR` | root | -| RESERVED | REGISTERED | `ROLE_REGISTER_RESERVED` | root | -| REGISTERED / RESERVED | AVAILABLE | `ROLE_UNREGISTER` | root or name | +| From | To | Required role | [Scope](/contracts/ensv2/enhanced-access-control#resources) | +| --------------------- | ---------- | ------------------------ | ----------------------------------------------------------- | +| AVAILABLE | REGISTERED | `ROLE_REGISTRAR` | root | +| AVAILABLE | RESERVED | `ROLE_REGISTRAR` | root | +| RESERVED | REGISTERED | `ROLE_REGISTER_RESERVED` | root | +| REGISTERED / RESERVED | AVAILABLE | `ROLE_UNREGISTER` | root or name | ### Registration @@ -75,26 +75,33 @@ This applies to `setSubregistry()`, `setResolver()`, `renew()`, `unregister()`, Each registered name is an `ERC1155` token with exactly one owner (singleton, not fungible). The token ID changes when the name is re-registered or when roles change (see [Versioning](#versioning)). `ownerOf()` returns `address(0)` for: + - Expired names — ownership is time-bounded - Stale token IDs — after versioning changes, old token IDs are no longer valid `latestOwnerOf()` returns the owner regardless of expiry or version staleness — useful for historical queries or determining who last held a name. +Operator approvals and transfer checks resolve the caller through [Hidden Contract Accounts](/contracts/ensv2/hca), so smart-account proxies are attributed to their owner. + +## Token Metadata + +The registry exposes ERC-1155 `uri(tokenId)` by delegating to a swappable [Registry Metadata](/contracts/ensv2/registry-metadata) provider set at construction. The provider decides the URI format — per-token URIs, a single shared base URI, or any custom strategy. + ## Roles All roles use [Enhanced Access Control](/contracts/ensv2/enhanced-access-control) mechanics for role-based access control. -| Role | Scope | Purpose | -|------|-------|---------| -| `ROLE_REGISTRAR` | root | Register or reserve names | -| `ROLE_REGISTER_RESERVED` | root | Promote reserved names to registered | -| `ROLE_SET_PARENT` | root | Set parent registry | -| `ROLE_UNREGISTER` | root or name | Unregister names | -| `ROLE_RENEW` | root or name | Extend expiry | -| `ROLE_SET_SUBREGISTRY` | root or name | Set child registry | -| `ROLE_SET_RESOLVER` | root or name | Set resolver | -| `ROLE_CAN_TRANSFER_ADMIN` | root or name (admin only) | Authorize token transfers | -| `ROLE_UPGRADE` | root | Authorize proxy upgrades | +| Role | Scope | Purpose | +| ------------------------- | ------------------------- | ------------------------------------ | +| `ROLE_REGISTRAR` | root | Register or reserve names | +| `ROLE_REGISTER_RESERVED` | root | Promote reserved names to registered | +| `ROLE_SET_PARENT` | root | Set parent registry | +| `ROLE_UNREGISTER` | root or name | Unregister names | +| `ROLE_RENEW` | root or name | Extend expiry | +| `ROLE_SET_SUBREGISTRY` | root or name | Set child registry | +| `ROLE_SET_RESOLVER` | root or name | Set resolver | +| `ROLE_CAN_TRANSFER_ADMIN` | root or name (admin only) | Authorize token transfers | +| `ROLE_UPGRADE` | root | Authorize proxy upgrades | "Root" scope means the role only works on `ROOT_RESOURCE`. "Root or name" means it can be granted on either scope, and the two compose — a root grant applies to all names. @@ -141,11 +148,11 @@ The registry also stores its own parent via `setParent()` / `getParent()`, which ## View Functions -| Function | Returns | -|----------|---------| -| `getState(anyId)` | Complete state: status, expiry, latest owner, tokenId, resource | -| `getStatus(anyId)` | `AVAILABLE`, `RESERVED`, or `REGISTERED` | -| `getExpiry(anyId)` | Expiry timestamp | -| `getTokenId(anyId)` | Current token ID for the name | -| `getResource(anyId)` | Current EAC resource ID for the name | +| Function | Returns | +| ------------------------ | ------------------------------------------------------------------ | +| `getState(anyId)` | Complete state: status, expiry, latest owner, tokenId, resource | +| `getStatus(anyId)` | `AVAILABLE`, `RESERVED`, or `REGISTERED` | +| `getExpiry(anyId)` | Expiry timestamp | +| `getTokenId(anyId)` | Current token ID for the name | +| `getResource(anyId)` | Current EAC resource ID for the name | | `latestOwnerOf(tokenId)` | Owner regardless of expiry/version (useful for historical queries) | diff --git a/src/pages/contracts/ensv2/registry-metadata.mdx b/src/pages/contracts/ensv2/registry-metadata.mdx new file mode 100644 index 00000000..6d4a5676 --- /dev/null +++ b/src/pages/contracts/ensv2/registry-metadata.mdx @@ -0,0 +1,85 @@ +--- +description: Registry Metadata +--- + +# Registry Metadata + +Registries in ENSv2 delegate ERC-1155 metadata to a swappable provider. The registry holds ownership state; the provider decides what `uri()` returns. This separates presentation concerns (image, name, description for marketplaces and indexers) from ownership logic, and lets a single registry serve many metadata strategies. + +:::note +The contracts and interfaces described here are **not yet final** and may change prior to mainnet deployment. +::: + +## How the Pieces Fit + +Three contracts cooperate to produce a token URI: + +- **`IRegistryMetadata`** — the provider interface, with a single `tokenUri(uint256) → string` method. Interface selector `0x1675f455`. +- **`MetadataMixin`** — base mixin holding an immutable `METADATA_PROVIDER` address. Its internal `_tokenURI(tokenId)` forwards to the provider, or returns an empty string if the provider is `address(0)`. +- **Registry contract** — inherits `MetadataMixin` and overrides ERC-1155 `uri(tokenId)` to return `_tokenURI(tokenId)`. + +```mermaid +flowchart LR + Caller["uri(tokenId)"] --> Registry["Registry
(MetadataMixin)"] + Registry --> Provider["IRegistryMetadata
provider"] + Provider --> URI["string URI"] +``` + +The provider is set once at registry construction and is immutable thereafter. To change the URI strategy you upgrade or replace the registry, or build a provider that itself looks up data from another contract. + +## Stock Implementations + +Two providers ship with the protocol. Both inherit [Enhanced Access Control](/contracts/ensv2/enhanced-access-control) and gate updates with a single role. + +### `SimpleRegistryMetadata` + +Stores a distinct URI per token in a `mapping(uint256 => string)`. + +```solidity +function setTokenUri(uint256 tokenId, string calldata uri) external; +function tokenUri(uint256 tokenId) external view returns (string memory); +``` + +`setTokenUri` is gated by `_ROLE_UPDATE_METADATA` (bit 0) on the `ROOT_RESOURCE`. Use this when each name needs its own metadata payload — for example, per-name SVG art or off-chain JSON URIs. + +### `BaseUriRegistryMetadata` + +Returns a single shared base URI for every token; the `tokenId` argument is ignored. + +```solidity +function setTokenBaseUri(string calldata uri) external; +function tokenUri(uint256 /* tokenId */) external view returns (string memory); +``` + +Same role and scope as above. Use this when a single template URI (e.g. `https://example.com/metadata/{id}.json` resolved by the indexer) is enough. + +## The Metadata Update Role + +Each metadata contract runs its own EAC instance, separate from the registry's. The role bit is local — granting `_ROLE_UPDATE_METADATA` on a metadata contract does not give the holder any role on the registry, and vice versa. At deployment, all roles on the metadata contract are granted to the deployer. + +| Role | Bit | Scope | Purpose | +| ----------------------------- | --- | --------- | ------------------------------- | +| `_ROLE_UPDATE_METADATA` | 0 | root only | Update token URI(s) | +| `_ROLE_UPDATE_METADATA_ADMIN` | 128 | root only | Grant or revoke the update role | + +## Building a Custom Provider + +Implement `IRegistryMetadata` and pass the address to the registry constructor: + +```solidity +contract MyMetadata is IRegistryMetadata { + function tokenUri(uint256 tokenId) external view returns (string memory) { + // compose SVG, fetch from another contract, derive from on-chain state, etc. + } +} +``` + +Common patterns: + +- Deterministic SVG composition from on-chain state (no off-chain dependency). +- URL templates parameterised by token ID and resolved by an indexer. +- A registry of registries: a single provider that routes by token ID range. + +## Where the URI Ends Up + +The provider's output is returned via the registry's standard ERC-1155 `uri()`. Indexers, marketplaces, and the ENS metadata service pick it up from there. Wallets that ignore subname tokens may not query it at all — that is a client concern, not a registry concern. diff --git a/src/pages/contracts/ensv2/verifiable-factory.mdx b/src/pages/contracts/ensv2/verifiable-factory.mdx new file mode 100644 index 00000000..e33307c8 --- /dev/null +++ b/src/pages/contracts/ensv2/verifiable-factory.mdx @@ -0,0 +1,130 @@ +--- +description: Verifiable Factory +--- + +# Verifiable Factory + +ENSv2 deploys per-name resolvers and per-name subname registries through a single shared factory. Each instance is a UUPS proxy with a deterministic CREATE2 address and an on-chain proof of provenance. This page covers the factory itself and how the protocol uses it. + +:::note +The contracts and interfaces described here are **not yet final** and may change prior to mainnet deployment. +::: + +## Why a Factory + +ENSv2 chooses per-name instances over a single shared resolver or registry contract (see the "Contract Factories" point in the [Overview](/contracts/ensv2/overview#whats-new-in-ensv2)). Each instance is a UUPS proxy, so deployment is cheap and any single instance can be upgraded without touching the rest of the system. The factory makes every such deployment: + +- **Deterministic** — the address is known before deployment. +- **Verifiable** — anyone can prove on-chain that an arbitrary address really came from this factory with a known proxy bytecode. +- **Cheap** — only the proxy is deployed; the implementation is shared. + +## Deterministic Deployment + +```solidity +function deployProxy( + address implementation, + uint256 salt, + bytes memory data +) external returns (address proxy); +``` + +`deployProxy` uses CREATE2 to deploy a `UUPSProxy` with `outerSalt = keccak256(abi.encode(msg.sender, salt))`. After construction, the factory calls `proxy.initialize(implementation, data)` to point the proxy at the implementation and forward `data` as initialisation calldata. + +Because `outerSalt` mixes in `msg.sender`, the same `salt` value submitted by two different deployers produces two different proxy addresses — there is no contention between callers and no need for global salt coordination. + +```mermaid +sequenceDiagram + participant Caller + participant Factory as VerifiableFactory + participant Proxy as UUPSProxy + participant Impl as Implementation + + Caller->>Factory: deployProxy(impl, salt, data) + Factory->>Factory: outerSalt = keccak256(sender, salt) + Factory->>Proxy: CREATE2 with outerSalt + Proxy->>Proxy: store factory & salt + Factory->>Proxy: initialize(impl, data) + Proxy->>Impl: delegatecall(data) + Factory-->>Caller: proxy address +``` + +## The UUPSProxy + +Each proxy stores two pieces of provenance: + +- An immutable `verifiableProxyFactory` address baked into the proxy bytecode at construction. +- A salt stored at the ERC-7201 namespaced slot `eth.ens.proxy.verifiable.salt`, exposed via `getVerifiableProxySalt()`. + +`initialize(implementation, data)` runs once. It calls OpenZeppelin's `ERC1967Utils.upgradeToAndCall`, which sets the implementation slot and delegate-calls the implementation with `data` so it can run its own initializer. + +## On-chain Verification + +```solidity +function verifyContract(address proxy) external view returns (bool); +``` + +`verifyContract` asks the proxy for its salt, reconstructs the expected CREATE2 address from `(UUPSProxy creation code, factory, salt)`, and returns `true` if it matches `proxy`. This is the property that makes the factory **verifiable**: a smart contract can prove that an arbitrary address really is a UUPS proxy deployed by this factory, without trusting metadata or off-chain data. + +## How ENSv2 Uses It + +Three implementation contracts are deployed once and then proxied per-name through the factory: + +| Implementation | Salt scheme | Where it's deployed | +| -------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------- | +| `PermissionedResolverImpl` | `keccak256("OwnedResolver", owner, version)` — one resolver per owner | On demand by users | +| `UserRegistryImpl` | `keccak256("UserRegistry", namehash, version)` — one subname registry per name | On demand by name owners | +| `WrapperRegistryImpl` | per-name | Inside migration controllers when a locked v1 name is wrapped | + +Salt schemes come from `contracts/script/setup.ts` in the namechain repo; the same schemes let any client predict a deployment address before it exists. + +## Upgrade Authorization + +Each implementation overrides `_authorizeUpgrade`. In ENSv2 the check is uniform — only an account holding `ROLE_UPGRADE` on the proxy's `ROOT_RESOURCE` can upgrade the implementation: + +```solidity +function _authorizeUpgrade(address newImplementation) + internal + override + onlyRootRoles(ROLE_UPGRADE) +{} +``` + +Upgrades target a single proxy, not the shared implementation; an upgrade to one user's resolver or subname registry does not affect anyone else's. + +## Computing Addresses Off-chain + +Because the proxy address is fully determined by `(factory, deployer, salt, UUPSProxy bytecode)`, clients can compute it before any transaction is sent: + +```ts +import { encodeAbiParameters, getCreate2Address, keccak256 } from 'viem' + +function predictProxyAddress({ + factoryAddress, + proxyBytecode, + deployer, + salt, +}: { + factoryAddress: `0x${string}` + proxyBytecode: `0x${string}` + deployer: `0x${string}` + salt: bigint +}) { + const outerSalt = keccak256( + encodeAbiParameters( + [{ type: 'address' }, { type: 'uint256' }], + [deployer, salt] + ) + ) + const initCode = `${proxyBytecode}${encodeAbiParameters( + [{ type: 'address' }, { type: 'bytes32' }], + [factoryAddress, outerSalt] + ).slice(2)}` as `0x${string}` + return getCreate2Address({ + from: factoryAddress, + salt: outerSalt, + bytecodeHash: keccak256(initCode), + }) +} +``` + +This is the pattern used inside the ENSv2 deployment scripts (`computeVerifiableProxyAddress` in `setup.ts`); reuse it whenever you need a name's resolver or subname-registry address before the user actually creates it. diff --git a/vocs.config.tsx b/vocs.config.tsx index 4a518ffb..8ae93eb7 100644 --- a/vocs.config.tsx +++ b/vocs.config.tsx @@ -367,6 +367,18 @@ export default defineConfig({ text: 'Permissioned Registry', link: '/contracts/ensv2/permissioned-registry', }, + { + text: 'Registry Metadata', + link: '/contracts/ensv2/registry-metadata', + }, + { + text: 'Verifiable Factory', + link: '/contracts/ensv2/verifiable-factory', + }, + { + text: 'Hidden Contract Accounts', + link: '/contracts/ensv2/hca', + }, // { // text: 'Registries', // link: '/contracts/ensv2/registries',