Permissioned Registry
The Permissioned Registry is the tokenized registry at the heart of ENSv2 name management. Each registered name becomes an ERC1155Singleton token with exactly one owner, and all permissions are managed through Enhanced Access Control.
What Changed from ENSv1
In ENSv1, name management was split across three separate contracts: the ENS Registry (flat mapping of all names), the BaseRegistrar (ERC721 tokens for .eth), and the Name Wrapper (ERC1155 wrapping + fuses). ENSv2 replaces all three with a single unified contract. The table below summarizes the key differences; each concept is explained in the sections that follow.
| Feature | ENSv1 | ENSv2 Permissioned Registry |
|---|---|---|
| Architecture | Single flat registry for all names | Hierarchical: each name can have its own registry |
| Token standard | ERC721 (BaseRegistrar) or ERC1155 (Name Wrapper) | ERC1155Singleton with single ownership |
| Permissions | One-way fuse burning (Name Wrapper) | Reversible role-based EAC |
| Token IDs | Fixed (derived from namehash/labelhash) | Mutable: change on role updates and re-registration |
| Subname control | Requires Name Wrapper + fuse configuration | Built-in via subregistry pointer + per-name roles |
| Upgradeability | Not upgradeable | UUPS proxy pattern (for UserRegistry) |
| Name states | Registered or not | Three-state lifecycle (see Name Lifecycle) |
Names
Each name in the registry is identified by its labelhash (the keccak256 hash of the label string). The on-chain data for a name is stored in an Entry struct:
| Field | Type | Purpose |
|---|---|---|
subregistry | IRegistry | Pointer to a subregistry for managing subnames |
resolver | address | Resolver contract that holds this name's records |
expiry | uint64 | Timestamp after which the name is considered expired (block.timestamp >= expiry) |
eacVersionId | uint32 | Isolates permissions across registrations (see Mutable Token IDs) |
tokenVersionId | uint32 | Invalidates marketplace approvals on role changes (see Mutable Token IDs) |
Entries are stored in a mapping keyed by the canonical ID, the labelhash with its lower 32 bits zeroed.
Name Lifecycle
Names exist in one of three states:
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: each transition requires a specific EAC role with the indicated scope.
| 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 |
Registration
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.
- A non-expired registered name cannot be re-registered; it must be unregistered first.
- A reserved name cannot be re-reserved; it can only be promoted to registered.
- When promoting a
RESERVEDname toREGISTERED, ifexpiryis0the current expiry is preserved. - Re-registering an expired name that had a previous owner burns the old token and increments both version counters. This ensures 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
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, token ID, 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, and the registry resolves it to the same underlying entry.
See Mutable Token IDs for the full explanation and diagram.
Ownership
The token ID for a name changes when it is re-registered or when roles change (see Mutable Token IDs).
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. This is useful for historical queries or determining who last held a name.
EAC Integration
All permissions are managed through Enhanced Access Control.
Roles
| Role | Value | Scope | Purpose |
|---|---|---|---|
ROLE_REGISTRAR | 1 << 0 | root | Register or reserve names |
ROLE_REGISTER_RESERVED | 1 << 4 | root | Promote reserved names to registered |
ROLE_SET_PARENT | 1 << 8 | root | Set parent registry |
ROLE_UNREGISTER | 1 << 12 | root or name | Unregister names |
ROLE_RENEW | 1 << 16 | root or name | Extend expiry |
ROLE_SET_SUBREGISTRY | 1 << 20 | root or name | Set subregistry |
ROLE_SET_RESOLVER | 1 << 24 | root or name | Set resolver |
ROLE_CAN_TRANSFER_ADMIN | (1 << 28) << 128 | root or name | Authorize ERC1155 token transfers |
ROLE_SET_URI | 1 << 36 | root | Set metadata URI and renderer |
ROLE_UPGRADE | 1 << 124 | root | Authorize proxy upgrades |
Each role has a corresponding admin role at role << 128 (e.g., ROLE_SET_RESOLVER_ADMIN = (1 << 24) << 128), except ROLE_CAN_TRANSFER_ADMIN which exists only as an admin role. In TypeScript, use 1n << 24n for the bigint equivalent.
ROLE_CAN_TRANSFER_ADMIN has no regular (non-admin) variant and is checked against the token owner, not the operator. See Transfers for details.
"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 roles on individual names are restricted to registration time (see EAC Hook Overrides).
Role Bitmap Composer
Granting and Revoking Roles
The registry uses the standard EAC grantRoles and revokeRoles functions with anyId polymorphism. See Code Examples for usage.
EAC Hook Overrides
The Permissioned Registry overrides several EAC 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, but admin roles can still be revoked (including by the holder revoking their own). On ROOT_RESOURCE, admin roles work normally. This prevents a name owner from escalating their own permissions after registration.
Resource Scheme
The registry derives each EAC resource from the name's labelhash and its current eacVersionId, so permissions are scoped per-name and automatically invalidated on re-registration:
255 32 31 0 ┌──────────────────────────────────────┬─────────────────┐ │ labelhash upper bits │ eacVersionId │ │ (224 bits) │ (32 bits) │ └──────────────────────────────────────┴─────────────────┘
Registry resources participate in anyId polymorphism. See Mutable Token IDs for how resources, token IDs, and canonical IDs relate.
Transfers
Transferring a name's token requires ROLE_CAN_TRANSFER_ADMIN as an admin role on the current owner of the token. It does not matter who initiates the transfer; the role is always checked against the owner.
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.
Operator Approval
The owner of a name can call setApprovalForAll(operator, true) to approve an operator. An approved operator inherits all of the owner's EAC roles on every name the owner holds (via the _getRoles override), allowing them to perform any role-gated action (set resolver, set subregistry, transfer, etc.) on the owner's behalf. This is an all-or-nothing delegation: an approved operator can act on every name the owner holds, with the owner's full set of roles. There is no way to scope operator approval to specific names or roles.
Token Metadata
The registry's uri(tokenId) function returns token metadata following the EIP-1155 standard. It supports two modes:
- Static URI: a single string returned for all tokens. Clients substitute
{id}with the hex token ID per the EIP-1155 metadata spec. Suitable when an off-chain service resolves per-token data from the URL template. - Dynamic renderer: an
IRegistryURIRenderercontract that generates metadata per token. The registry callsrenderURI(registry, tokenId), passing itself so the renderer can query on-chain state (owner, expiry, records) to compose SVGs or JSON.
The renderer takes precedence: if set, the static URI is ignored. If neither is set, uri() returns an empty string.
setURI(uri_, renderer) sets both atomically and requires ROLE_SET_URI on ROOT_RESOURCE.
Building a Custom Renderer
Implement IRegistryURIRenderer:
interface IRegistryURIRenderer {
function renderURI(IRegistry registry, uint256 tokenId)
external view returns (string memory);
}The registry passes itself as the first argument, so the renderer can call getExpiry(), latestOwnerOf(), etc. to build metadata from on-chain state.
Parent Pointer
Each name's subregistry field creates a forward pointer from parent to child. The registry also stores a backward pointer to its own parent via setParent() / getParent(), creating a two-way link at every level:
The backward pointer records both the parent registry's address and the label this registry is known by in the parent. The Universal Resolver's findCanonicalName() uses these backward pointers to walk up the tree, verifying at each step that parent.getSubregistry(label) points back to the current registry, and reconstructing the full name along the way.
Once this pointer is set, the registry has committed to its position in the hierarchy:
However, as long as ROLE_SET_PARENT is still held on ROOT_RESOURCE, the pointer can be changed, effectively remounting the registry at a different position (e.g. moving from nick.eth to nick.xyz). To prevent this, the owner revokes ROLE_SET_PARENT and ROLE_SET_PARENT_ADMIN on ROOT_RESOURCE:
import { createWalletClient, http } from 'viem'
import { mainnet } from 'viem/chains'
const wallet = createWalletClient({ chain: mainnet, transport: http() })
const ROLE_SET_PARENT = 1n << 8n
const ROLE_SET_PARENT_ADMIN = ROLE_SET_PARENT << 128n
// Set the canonical parent: this registry is "sub" under the nick.eth registry
await wallet.writeContract({
address: subRegistryAddress,
abi: permissionedRegistryAbi,
functionName: 'setParent',
args: [parentRegistryAddress, 'sub'],
})
// Lock the parent pointer permanently by revoking both the role and its admin
await wallet.writeContract({
address: subRegistryAddress,
abi: permissionedRegistryAbi,
functionName: 'revokeRootRoles',
args: [ROLE_SET_PARENT | ROLE_SET_PARENT_ADMIN, ownerAddress],
})Emancipation
Emancipation is a property of a registry, but its purpose is protecting names. This section covers how a registry is emancipated, how to verify it on-chain, and how emancipation composes across the hierarchy to fully protect individual names.
Certain roles on ROOT_RESOURCE can override individual name owners. For example, ROLE_SET_RESOLVER on ROOT can change the resolver on every name, ROLE_UNREGISTER can delete any name, and ROLE_UPGRADE can replace the registry implementation entirely. These are examples of dangerous roles: roles that can override individual name owners' permissions. A registry is emancipated when none of them have any assignees on ROOT_RESOURCE.
Non-dangerous roles like ROLE_REGISTRAR and ROLE_RENEW can safely remain on ROOT_RESOURCE (the registrar needs them to function). This is how the .eth registry works: the ETH Registrar holds ROLE_REGISTRAR and ROLE_RENEW on ROOT_RESOURCE, while each name owner holds the roles defined by REGISTRATION_ROLE_BITMAP. Once all dangerous roles are revoked, the separation is irreversible (you need the admin role to grant the admin role).
Verifying Emancipation
Call roleCount(ROOT_RESOURCE) on the registry to inspect the assignee counts. For emancipation, two categories of roles must have zero assignees on ROOT_RESOURCE:
Roles that directly endanger name owners (any account holding these on ROOT_RESOURCE can act on any name):
| Role | Danger |
|---|---|
ROLE_SET_RESOLVER | Holder can change any name's resolver |
ROLE_SET_SUBREGISTRY | Holder can change any name's subregistry |
ROLE_UNREGISTER | Holder can delete any name |
| Role | Danger |
|---|---|
ROLE_SET_RESOLVER_ADMIN | Can grant ROLE_SET_RESOLVER |
ROLE_SET_SUBREGISTRY_ADMIN | Can grant ROLE_SET_SUBREGISTRY |
ROLE_UNREGISTER_ADMIN | Can grant ROLE_UNREGISTER |
ROLE_CAN_TRANSFER_ADMIN | Can grant or revoke transfer rights on any name |
ROLE_UPGRADE | Can replace the registry implementation via UUPS proxy |
ROLE_UPGRADE_ADMIN | Can grant ROLE_UPGRADE |
ROLE_REGISTRAR and ROLE_RENEW on ROOT_RESOURCE are expected (the registrar needs them to function). They don't endanger existing registered names: ROLE_REGISTRAR can only register available names, and ROLE_RENEW only extends expiry (CannotReduceExpiry). Their admin counterparts (ROLE_REGISTRAR_ADMIN, ROLE_RENEW_ADMIN) are also excluded: granting someone the ability to register or renew does not endanger existing name owners.
Emancipation Across the Hierarchy
Emancipation at one level is not sufficient if a higher level can still interfere. If the .eth registry is emancipated but the root registry is not, an account with ROLE_SET_SUBREGISTRY on the root registry's ROOT_RESOURCE could swap the .eth subregistry pointer. True emancipation requires the entire chain from root down to be secured:
- Root registry: governed by ENS governance (trusted by design)
- .eth registry: emancipated (only
ROLE_REGISTRAR+ROLE_RENEWgranted onROOT_RESOURCE) - 2LD registries (e.g., alice.eth): depends on the 2LD owner's configuration
For a subname like sub.alice.eth to be fully emancipated, the alice.eth owner must emancipate their registry (revoke the dangerous roles on ROOT_RESOURCE).
Code Examples
Subname registries are deployed as UserRegistry proxies through the Verifiable Factory. See Deploying a Registry Proxy for a code example. The examples below use the ETHRegistry, but the same functions are available on any PermissionedRegistry instance.
Querying Name State
getState returns the full state of a name in a single call: its registration status, expiry, current owner, token ID, and EAC resource. Both getState and getStatus accept any of a name's three identifiers (labelhash, tokenId, or resource) thanks to anyId polymorphism:
import { createPublicClient, http, keccak256, toHex } from 'viem'
import { mainnet } from 'viem/chains'
const client = createPublicClient({ chain: mainnet, transport: http() })
const labelhash = BigInt(keccak256(toHex('alice')))
// Query the full state of a name
const state = await client.readContract({
address: registryAddress,
abi: permissionedRegistryAbi,
functionName: 'getState',
args: [labelhash],
})
// state: { status, expiry, latestOwner, tokenId, resource }
const STATUS = ['AVAILABLE', 'RESERVED', 'REGISTERED'] as const
console.log(STATUS[state.status]) // "REGISTERED"
console.log(state.latestOwner) // "0x..."
console.log(state.expiry) // expiry as unix timestamp
// anyId polymorphism: labelhash, tokenId, and resource all resolve
// to the same name, so this returns the same result
const sameState = await client.readContract({
address: registryAddress,
abi: permissionedRegistryAbi,
functionName: 'getState',
args: [state.tokenId],
})If you only need to check whether a name is available, getStatus is a lighter alternative that returns just the status enum:
const status = await client.readContract({
address: registryAddress,
abi: permissionedRegistryAbi,
functionName: 'getStatus',
args: [labelhash],
})
// 0 = AVAILABLE, 1 = RESERVED, 2 = REGISTEREDGranting and Revoking Roles
A name owner can delegate specific capabilities to other accounts by granting roles on the name's labelhash. Multiple roles can be combined into a single call using bitwise OR:
import { createWalletClient, http, keccak256, toHex } from 'viem'
import { mainnet } from 'viem/chains'
const wallet = createWalletClient({ chain: mainnet, transport: http() })
const ROLE_SET_RESOLVER = 1n << 24n
const ROLE_SET_SUBREGISTRY = 1n << 20n
const labelhash = BigInt(keccak256(toHex('alice')))
// Grant a single role
await wallet.writeContract({
address: registryAddress,
abi: permissionedRegistryAbi,
functionName: 'grantRoles',
args: [labelhash, ROLE_SET_RESOLVER, operatorAddress],
})
// Grant multiple roles at once using bitwise OR
await wallet.writeContract({
address: registryAddress,
abi: permissionedRegistryAbi,
functionName: 'grantRoles',
args: [labelhash, ROLE_SET_RESOLVER | ROLE_SET_SUBREGISTRY, operatorAddress],
})To revoke a role, call revokeRoles with the same arguments. Only the specified role is removed; other grants on the same name remain active:
await wallet.writeContract({
address: registryAddress,
abi: permissionedRegistryAbi,
functionName: 'revokeRoles',
args: [labelhash, ROLE_SET_RESOLVER, operatorAddress],
})
// ROLE_SET_SUBREGISTRY remains activeReference
Write Functions
register(label, owner, registry, resolver, roleBitmap, expiry) | Register or reserve a name. Pass owner as address(0) to reserve without an owner. |
unregister(anyId) | Unregister a name, making it available. Requires ROLE_UNREGISTER. |
renew(anyId, newExpiry) | Extend a name's expiry. Cannot reduce the current expiry. Requires ROLE_RENEW. |
setSubregistry(anyId, registry) | Set the subregistry for a name. Pointing two names to the same registry creates a namespace alias. |
setResolver(anyId, resolver) | Set the resolver for a name. Requires ROLE_SET_RESOLVER. |
setParent(parent, label) | Set this registry's canonical parent. Requires ROLE_SET_PARENT on ROOT_RESOURCE. |
setURI(uri_, renderer) | Set the metadata URI and optional renderer. Requires ROLE_SET_URI on ROOT_RESOURCE. |
grantRoles(anyId, roleBitmap, account) | Grant roles on a name. Triggers token regeneration. |
revokeRoles(anyId, roleBitmap, account) | Revoke roles on a name. Triggers token regeneration. |
grantRootRoles(roleBitmap, account) | Grant contract-wide roles on ROOT_RESOURCE. |
revokeRootRoles(roleBitmap, account) | Revoke contract-wide roles on ROOT_RESOURCE. |
View Functions
getState(anyId) | Complete state for a name: status, expiry, latestOwner, tokenId, resource. |
getStatus(anyId) | Get the registration status of a name. |
getExpiry(anyId) | Get the expiry timestamp of a name. |
getTokenId(anyId) | Get the current token ID of a name. |
getResource(anyId) | Get the current EAC resource ID of a name. |
latestOwnerOf(tokenId) | Get the owner of a token regardless of expiry or version validity. |
ownerOf(tokenId) | Get the owner of a token, or address(0) if expired or stale token ID. |
getSubregistry(label) | Get the subregistry for a label. Returns address(0) if expired. |
getResolver(label) | Get the resolver for a label. Returns address(0) if expired. |
getParent | Get this registry's canonical parent registry and label. |
hasRoles(anyId, roleBitmap, account) | Check whether an account holds the specified roles on a name. |
roles(anyId, account) | Get the full role bitmap for an account on a name. |
roleCount(anyId) | Get the packed assignee counts for all 64 roles on a name. Each nybble (4 bits) holds the count of accounts holding that role. |
hasAssignees(anyId, roleBitmap) | Check whether any accounts hold the specified roles on a name. |
getAssigneeCount(anyId, roleBitmap) | Get per-role assignee counts for the specified roles on a name. |
hasRootRoles(roleBitmap, account) | Check whether an account holds all specified roles on ROOT_RESOURCE only (does not check token-level roles). |
uri(tokenId) | ERC1155 metadata URI. Delegates to the renderer if set, otherwise returns the static URI. |
findOwner(label) | Get the current owner of a name by label. Returns address(0) if expired. |
findExpiry(label) | Get the expiry timestamp of a name by label. |
findTokenId(label) | Get the current token ID of a name by label. |
Events
RegistryCreated() | Emitted once in the constructor when the registry is deployed. |
LabelRegistered(tokenId, labelHash, label, owner, expiry, sender) | Name registered with an owner. |
TokenResource(tokenId, resource) | Associates a token ID with its EAC resource ID. Emitted during registration. |
LabelReserved(tokenId, labelHash, label, expiry, sender) | Name reserved without an owner. |
LabelUnregistered(tokenId, sender) | Name unregistered. |
ExpiryUpdated(tokenId, newExpiry, sender) | Expiry extended via renew(). |
SubregistryUpdated(tokenId, subregistry, sender) | Subregistry changed. |
ResolverUpdated(tokenId, resolver, sender) | Resolver address changed. |
TokenRegenerated(oldTokenId, newTokenId) | Token ID changed due to role update. |
ParentUpdated(parent, label, sender) | Canonical parent reference changed. |
URIUpdated(uri, renderer, sender) | Emitted when metadata URI or renderer is changed. |