Skip to main content

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:
  1. Unique ownership: Each token ID has exactly one owner
  2. No fractional ownership: Balances are always 0 or 1
  3. Direct owner queries: Efficient ownerOf(tokenId) lookups
  4. 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)
}
Slot 1 (256 bits):
[0-31]    eacVersionId
[32-63]   tokenVersionId
[64-223]  subregistry
[224-255] expiry
Slot 2 (160 bits):
[0-159]   resolver

Version Management

Version IDs

Each entry maintains two independent version counters:
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)
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

// 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
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

Label ID Extraction

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

Build docs developers (and LLMs) love