Overview
The ENS v2 registry system uses a specialized datastore pattern built on ERC-1155Singleton - a custom ERC-1155 implementation where each token ID can only have a balance of 0 or 1. This design enables efficient name ownership tracking while maintaining full ERC-1155 compatibility.
ERC-1155 Singleton Pattern
Design Philosophy
Traditional ERC-1155 implementations allow arbitrary balances for each token ID. ENS v2 modifies this to ensure:
Unique ownership : Each token ID has exactly one owner
No fractional ownership : Balances are always 0 or 1
Direct owner queries : Efficient ownerOf(tokenId) lookups
Standard compatibility : Full ERC-1155 interface support
// Traditional ERC-1155: Multiple owners, arbitrary balances
balanceOf (alice, tokenId) => 5
balanceOf (bob, tokenId) => 3
// ERC-1155 Singleton: Single owner, balance of 1
ownerOf (tokenId) => alice
balanceOf (alice, tokenId) => 1
balanceOf (bob, tokenId) => 0
Storage Structure
The singleton pattern requires only a simple mapping:
abstract contract ERC1155Singleton {
// Single owner per token ID
mapping ( uint256 id => address account) private _owners;
// Operator approvals remain standard
mapping ( address account => mapping ( address operator => bool )) private _operatorApprovals;
}
This is significantly more gas-efficient than standard ERC-1155, which requires mapping(uint256 => mapping(address => uint256)) for balances.
Registry Storage Integration
Entry Mapping
Each registry maintains a mapping from label IDs to entry data:
mapping ( uint256 storageId => Entry entry) internal _entries;
Key Points :
storageId is the label ID with version bits cleared (version 0)
All versions of a label share the same storage entry
Version IDs increment without creating new entries
Entry Structure
The Entry struct packs all name data into a single storage slot:
struct Entry {
uint32 eacVersionId; // 32 bits: Access control version
uint32 tokenVersionId; // 32 bits: Token version
IRegistry subregistry; // 160 bits: Subregistry address
uint64 expiry; // 64 bits: Expiration timestamp
address resolver; // 160 bits: Resolver address (separate slot)
}
Storage Layout
Gas Efficiency
Slot 1 (256 bits):[0-31] eacVersionId
[32-63] tokenVersionId
[64-223] subregistry
[224-255] expiry
Slot 2 (160 bits):
Reading full entry: 2 SLOADs (~2,200 gas)
Updating expiry only: 1 SSTORE (~5,000 gas)
Updating both versions: 1 SSTORE (~5,000 gas)
Full entry rewrite: 2 SSTOREs (~10,000 gas)
Version Management
Version IDs
Each entry maintains two independent version counters:
eacVersionId - Access Control Version
Increments when:
Name is unregistered
Name expires and is re-registered
Purpose:
Invalidates old resource IDs
Prevents use of expired permissions
// After unregister, old resource IDs don't work
uint256 oldResource = labelId | ( uint256 (oldVersion) << 248 );
uint256 newResource = labelId | ( uint256 (newVersion) << 248 );
hasRoles (oldResource, role, account) => false
hasRoles (newResource, role, account) => true ( if granted)
tokenVersionId - Token Version
Increments when:
Name is unregistered
Roles are granted or revoked
Name is re-registered
Purpose:
Creates unique token IDs for different permission sets
Maintains ERC-1155 compliance (immutable token IDs)
// Token regenerated after role grant
uint256 oldTokenId = labelId | ( uint256 (oldVersion) << 248 );
uint256 newTokenId = labelId | ( uint256 (newVersion) << 248 );
ownerOf (oldTokenId) => address ( 0 ) // Burned
ownerOf (newTokenId) => owner // Minted
Version Construction
The registry provides helper functions to construct versioned identifiers:
// Extract storage ID (label ID with version 0)
function _entry ( uint256 anyId ) internal view returns ( Entry storage ) {
return _entries[LibLabel. withVersion (anyId, 0 )];
}
// Construct current token ID
function _constructTokenId (
uint256 anyId ,
Entry storage entry
) internal view returns ( uint256 ) {
return LibLabel. withVersion (anyId, entry.tokenVersionId);
}
// Construct current resource ID
function _constructResource (
uint256 anyId ,
Entry storage entry
) internal view returns ( uint256 ) {
// Use next version if expired
return LibLabel. withVersion (
anyId,
_isExpired (entry.expiry) ? entry.eacVersionId + 1 : entry.eacVersionId
);
}
Token Lifecycle
Registration Flow
// 1. New registration (AVAILABLE -> REGISTERED)
function register ( string label , address owner , ...) {
Entry storage entry = _entry (labelId);
// First time: versions are 0
uint256 tokenId = _constructTokenId (labelId, entry); // version 0
entry.expiry = expiry;
entry.subregistry = registry;
entry.resolver = resolver;
emit NameRegistered (tokenId, labelHash, label, owner, expiry, sender);
_mint (owner, tokenId, 1 , "" ); // Mint version 0
uint256 resource = _constructResource (tokenId, entry); // version 0
emit TokenResource (tokenId, resource);
_grantRoles (resource, roleBitmap, owner, false );
}
Token Regeneration
When roles change, the token is regenerated with a new version:
function _regenerateToken ( uint256 anyId ) internal {
Entry storage entry = _entry (anyId);
if ( ! _isExpired (entry.expiry)) {
uint256 oldTokenId = _constructTokenId (anyId, entry);
address owner = super . ownerOf (oldTokenId);
if (owner != address ( 0 )) {
// Burn old version
_burn (owner, oldTokenId, 1 );
// Increment version
++ entry.tokenVersionId;
// Mint new version
uint256 newTokenId = _constructTokenId (tokenId, entry);
_mint (owner, newTokenId, 1 , "" );
emit TokenRegenerated (oldTokenId, newTokenId);
}
}
}
Triggers :
_onRolesGranted() → _regenerateToken()
_onRolesRevoked() → _regenerateToken()
Unregistration Flow
function unregister ( uint256 anyId ) public {
Entry storage entry = _entry (anyId);
uint256 tokenId = _constructTokenId (anyId, entry);
address owner = super . ownerOf (tokenId);
if (owner != address ( 0 )) {
_burn (owner, tokenId, 1 );
// Increment both versions
++ entry.eacVersionId;
++ entry.tokenVersionId;
}
// Expire immediately
entry.expiry = uint64 ( block .timestamp);
emit NameUnregistered (tokenId, sender);
}
Result :
Old token ID no longer valid
Old resource ID no longer has roles
Name becomes AVAILABLE for re-registration
Query Operations
Owner Lookup
The registry provides two owner query methods:
// Returns owner only if token is current and not expired
function ownerOf ( uint256 tokenId ) public view returns ( address ) {
Entry storage entry = _entry (tokenId);
// Verify token version is current
if (tokenId != _constructTokenId (tokenId, entry)) {
return address ( 0 );
}
// Verify not expired
if ( _isExpired (entry.expiry)) {
return address ( 0 );
}
return super . ownerOf (tokenId);
}
// Returns owner even if expired (internal use)
function latestOwnerOf ( uint256 tokenId ) public view returns ( address ) {
return super . ownerOf (tokenId); // No expiry check
}
Balance Queries
ERC-1155 balance queries work as expected:
// Returns 1 if owner, 0 otherwise
function balanceOf ( address account , uint256 id ) public view returns ( uint256 ) {
return ownerOf (id) == account ? 1 : 0 ;
}
// Batch balance query
function balanceOfBatch (
address [] memory accounts ,
uint256 [] memory ids
) public view returns ( uint256 [] memory ) {
uint256 [] memory balances = new uint256 []( accounts . length );
for ( uint256 i = 0 ; i < accounts.length; ++ i) {
balances[i] = balanceOf (accounts[i], ids[i]);
}
return balances;
}
Transfer Operations
Transfer Validation
All transfers go through the _update override:
function _update (
address from ,
address to ,
uint256 [] memory tokenIds ,
uint256 [] memory values
) internal virtual override {
bool externalTransfer = to != address ( 0 ) && from != address ( 0 );
if (externalTransfer) {
// Check transfer permission for each token
for ( uint256 i; i < tokenIds.length; ++ i) {
if ( ! hasRoles (
tokenIds[i],
RegistryRolesLib.ROLE_CAN_TRANSFER_ADMIN,
from
)) {
revert TransferDisallowed (tokenIds[i], from);
}
}
}
// Perform the transfer
super . _update (from, to, tokenIds, values);
if (externalTransfer) {
// Transfer all roles to new owner
for ( uint256 i; i < tokenIds.length; ++ i) {
_transferRoles ( getResource (tokenIds[i]), from, to, false );
}
}
}
Role Transfer
When a token is transferred, all associated roles move with it:
// Before transfer
hasRoles (resource, ROLE_SET_RESOLVER, alice) => true
// Transfer token from alice to bob
registry. safeTransferFrom (alice, bob, tokenId, 1 , "" );
// After transfer
hasRoles (resource, ROLE_SET_RESOLVER, alice) => false
hasRoles (resource, ROLE_SET_RESOLVER, bob) => true
Storage Efficiency Analysis
Compared to Standard ERC-1155
Standard ERC-1155
ERC-1155 Singleton
// Double nested mapping
mapping ( uint256 => mapping ( address => uint256 )) balances;
// Cost per token:
// - Initial mint: 20,000 gas (new mapping entry)
// - Transfer: 20,000 gas (update 2 mappings)
// - Balance query: 2 SLOADs
// Single mapping
mapping ( uint256 => address ) owners;
// Cost per token:
// - Initial mint: 20,000 gas (new mapping entry)
// - Transfer: 5,000 gas (update 1 mapping)
// - Owner query: 1 SLOAD
// - Balance query: 1 SLOAD + comparison
Savings :
15,000 gas saved per transfer (75% reduction)
Simpler storage layout
Direct owner lookups
Registry Entry Storage
Per-name overhead :
Entry struct: 2 storage slots
- Versions/data: 1 slot (256 bits)
- Resolver: 1 slot (160 bits)
Owner mapping: 1 storage slot
- tokenId -> owner
Access control: ~3 storage slots per role assignment
- Role bitmaps
- Role counts
Total minimum: 6 storage slots (~120,000 gas for new registration)
Implementation Details
The LibLabel library handles version manipulation:
library LibLabel {
// Extract label ID (clear version bits)
function withVersion ( uint256 id , uint32 version )
internal pure returns ( uint256 )
{
return (id & ~( uint256 ( type ( uint32 ).max) << 248 )) |
( uint256 (version) << 248 );
}
// Generate label ID from string
function id ( string memory label ) internal pure returns ( uint256 ) {
return uint256 ( keccak256 ( bytes (label)));
}
}
Expiry Checking
function _isExpired ( uint64 expiry ) internal view returns ( bool ) {
return block .timestamp >= expiry;
}
Expiry uses >= comparison, so a name expires at the exact timestamp, not after.
Usage Patterns
Efficient State Queries
// Get complete state with single function call
State memory state = registry. getState (anyId);
// More efficient than individual queries:
// - 1 function call vs 5
// - Single entry lookup
// - All derived values computed together
Batch Operations
ERC-1155 batch transfers work efficiently:
uint256 [] memory tokenIds = new uint256 []( 3 );
tokenIds[ 0 ] = tokenId1;
tokenIds[ 1 ] = tokenId2;
tokenIds[ 2 ] = tokenId3;
uint256 [] memory values = new uint256 []( 3 );
values[ 0 ] = 1 ;
values[ 1 ] = 1 ;
values[ 2 ] = 1 ;
registry. safeBatchTransferFrom (
msg.sender ,
recipient,
tokenIds,
values,
""
);
Version-Aware Indexing
Indexers should track version changes:
// Listen for regeneration events
event TokenRegenerated (
uint256 indexed oldTokenId ,
uint256 indexed newTokenId
);
// Update token ID in database
function handleTokenRegenerated (
uint256 oldTokenId ,
uint256 newTokenId
) {
// Extract label ID (same for both versions)
uint256 labelId = oldTokenId & ~( uint256 ( type ( uint32 ).max) << 248 );
// Update current token ID
db. updateTokenId (labelId, newTokenId);
}
Security Considerations
Version Validation : Always use current token IDs for operations. Old token IDs from before regeneration will fail.
Expiry Checks : The registry returns address(0) for expired names. Client code must handle this gracefully.
Storage Collisions : Label IDs are 256-bit hashes. While collisions are astronomically unlikely, registrars should verify availability before registration.
PermissionedRegistry Core registry implementation using the datastore
ERC-1155 Singleton ERC-1155 singleton implementation details
Canonical ID System Understanding versioned identifiers
Access Control How resources and roles integrate with storage