How to Name Things on a Blockchain

A DNS name is not really yours. You rent example.com from a registrar — the company you pay to hold the name — and whoever controls the authoritative server for example.com controls every name under it. That is fine for a hierarchy run by one organisation, and completely wrong for a namespace — a set of names — where the participants do not trust each other.

What this post is about. ENS, the Ethereum Name Service, is a set of smart contracts: programs stored on Ethereum and run by its network. This post walks through the ens-contracts repository contract by contract. You will learn what a registry, a resolver and a registrar each do, why they are three separate contracts, and why registering a name takes two steps and a five-minute wait.

What you need to know first. Two words carry most of this post. A contract is code stored at an address on the blockchain; calling it means sending a transaction — a signed instruction that names a function and passes its arguments. Every transaction costs gas, the fee the network charges for computation and storage.

The situation

ENS inverts the DNS arrangement. The whole namespace lives under one on-chain root — data stored in the blockchain itself — and every top-level domain (a TLD, such as .eth) beneath it is delegated to a contract that owns its own subtree.

But “put a name on a chain” is the easy part. Four harder problems shape the design.

  1. A name must be owned, not merely pointed at. In DNS, “ownership” is a legal relationship with a registrar; there is no asset to hold. ENS wants a name to be an asset you can sell, and a sub-name — a name below another, like pay.alice.eth under alice.eth — to be something a parent owner cannot quietly reclaim.
  2. Records must be extensible — able to gain new kinds of data — without migrating the registry. New record types keep appearing: multichain addresses, content hashes, text records, DNSSEC proofs. If the registry stored records itself, every new type would mean redeploying it and moving every name in existence.
  3. Resolution must not require trusting a server.
  4. Name acquisition must be fair. A first-come-first-served auction on a public mempool — the queue of transactions waiting to be included in a block — is a name-sniping contest.

Why this design

The registry stores a resolver address, not records

A registry here means one contract that maps a name to an address; a resolver is a second contract that holds the actual records. The registry’s entire state is:

1
2
3
4
5
6
7
8
struct Record {
address owner;
address resolver;
uint64 ttl;
}

mapping(bytes32 => Record) records;
mapping(address => mapping(address => bool)) operators;

The bytes32 key is a namehash: a 32-byte hash of the name, so every name lives in one flat lookup table. ttl is how long a resolver’s answer may be cached, in seconds. The operators mapping records which addresses an owner has authorised to act for them (contracts/registry/ENSRegistry.sol:9). The registry is the root of trust for ownership and has no opinion about what a name resolves to: the record schema is an implementation detail of a contract you can swap per name.

The price is a second hop: compute the namehash, ask the registry for the resolver address, then call addr(namehash) on it. The payoff is that adding EIP-2304 multichain addresses (an EIP is an Ethereum Improvement Proposal — one of the numbered standards documents), EIP-1577 content hashes, or EIP-634 text records never touches the registry. For a namespace whose root is bytes32(0), “never migrate the registry” is the entire point.

Events are the primary interface

1
2
3
4
5
6
function setResolver(bytes32 node, address resolver)
public virtual override authorised(node)
{
emit NewResolver(node, resolver);
records[node].resolver = resolver;
}

contracts/registry/ENSRegistry.sol:105. An event is a log line the contract writes into the block; it returns nothing to the caller. Note the ordering: the event is emitted before the state write, so a client watching logs can never observe a state change that the log stream missed.

The registry is therefore an append-only log with a key-value view on top. The whole ENS product — the app, the subgraph (an off-chain index of those events), the API — is built from logs rather than from eth_call, a one-off read of a contract at a single block. If state were the only truth, every consumer would have to scan the whole contract.

The registrar is not the registry

The .eth registrar is a separate contract that owns the .eth node in the registry. BaseRegistrarImplementation holds the expiry, mints an ERC-721 per label (ERC-721 is the token standard for one-of-a-kind collectibles; a label is one dot-separated piece of a name — alice in alice.eth), and delegates registration to controllers.

The registration itself is four decisive lines (contracts/ethregistrar/BaseRegistrarImplementation.sol:143): it stamps the expiry as block.timestamp + duration, burns the old token if the name had expired, mints a new one to the owner, and writes the new owner into the registry with setSubnodeOwner. The surrounding function also checks that the name is available and that the caller is a controller.

Controllers can register and renew, and nothing else: they cannot change ownership or shorten an expiry. So the registrar owner can replace the pricing machinery without endangering a single nameholder. That is the “separation of concerns” the README talks about.

Registration takes two steps, because bots are faster than you

1
2
3
4
5
6
function commit(bytes32 commitment) public override {
if (commitments[commitment] + maxCommitmentAge >= block.timestamp) {
revert UnexpiredCommitmentExists(commitment);
}
commitments[commitment] = block.timestamp;
}

contracts/ethregistrar/ETHRegistrarController.sol:141. The first step publishes only a commitment: a hash — a fixed-length fingerprint of some secret data. The contract stores the time it saw that hash, and rejects an identical commitment while the previous one is still inside its window.

The hash is built from everything that matters (contracts/ethregistrar/ETHRegistrarController.sol:121): the label hash of the name, the owner address, the duration, a secret, the resolver, the record data, the reverse-record flag, and the owner-controlled fuse bits — switches that can be burned but never un-burned. The name itself never appears in the mempool between the two steps, so a competing bot cannot see it and snipe it. The owner is inside the hash too, so a third party cannot redeem your commitment and register the name to themselves.

Both ends of the window are enforced at claim time (contracts/ethregistrar/ETHRegistrarController.sol:252). The commitment must be at least minCommitmentAge old, so a commit and a register cannot land in the same block, and no older than maxCommitmentAge, so a stale commitment cannot be used months later. If the name is already taken by then, the registration stops.

Reverse resolution needs its own contract

Forward resolution is name → node → resolver → addr. Reverse is addr → node → resolver → name, and the node is derived rather than registered: the contract computes it as keccak256(abi.encodePacked(ADDR_REVERSE_NODE, sha3HexAddress(addr))) (contracts/registry/ReverseRegistrar.sol:151), where keccak256 is the hash function Ethereum uses everywhere and abi.encodePacked simply glues the two byte strings together.

The addr.reverse subtree is a pure construct, so somebody has to own it and create the addr.<hash> entries — that is the ReverseRegistrar. It also answers a chicken-and-egg question — “who may set name() for my address?” — with a policy inside the registrar rather than a property of the address.

Two answers to “who may write records for this name”

Both models are in this codebase, and the difference between them shows what ENS trades off.

PublicResolver asks the registry who owns the node (contracts/resolvers/PublicResolver.sol:131): the caller may write if it is the owner, if the registry approved it as an operator for that owner, or if it is one of two trusted contracts (the .eth registrar controller and the reverse registrar). If the registry’s owner is the name-wrapper contract, the resolver asks the wrapper instead, so ownership still has one home.

NameWrapper inverts it: the registry’s owner of a name is the wrapper contract, and the real owner is whoever holds the ERC-1155 token (the multi-token standard, where one contract can issue many token types at once). The wrapper is the only thing allowed to write to the registry for that node, and it consults fuses and expiry.

1
2
3
4
5
6
function getData(uint256 tokenId) public view virtual returns (address owner, uint32 fuses, uint64 expiry) {
uint256 t = _tokens[tokenId];
owner = address(uint160(t));
expiry = uint64(t >> 192);
fuses = uint32(t >> 160);
}

contracts/wrapper/ERC1155Fuse.sol:130. The three values share one 256-bit storage slot — one cell of a contract’s storage — so reading a name costs one storage read instead of three. >> is a right shift: it throws away the low bits and moves the rest down. t >> 192 keeps the top 64 bits, the expiry; t >> 160 keeps the next 32 bits, the fuse bits; address(uint160(t)) takes the lowest 160 bits, exactly the size of an address.

A fuse is a one-way switch: once a bit is burned it cannot be un-burned. That is how “the parent can never take this sub-name back” becomes a promise the chain enforces. The wrapper buys four things at once. A parent can no longer silently reclaim a sub-name once PARENT_CANNOT_CONTROL is burned. Names can expire. Names are tradeable tokens. And the owner is a token holder, so an exchange can be a trusted operator without ever being trusted with the name itself.

How it works

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  alice.eth
│
│ namehash("alice.eth") = keccak(labelhash ∘ parentnode)
▼
┌──────────────────┐ resolver address only
│ ENS REGISTRY │ ──────────────────────────────┐
│ owner │ │
│ resolver │ │
│ ttl │ │
└──────────────────┘ ▼
┌──────────────────────┐
│ PublicResolver │
│ addr(node, coin) │
│ contenthash(node) │
│ text(node, "key") │
└──────────┬───────────┘
│ ERC-165
│ supportsInterface
▼
┌──────────────────────┐
│ NameWrapper (owner) │ ← registry owner is the wrapper
│ ERC-1155 balance │
│ fuses + expiry │
└──────────────────────┘

Reading the diagram: each box is a contract and each arrow is a step or a call. The top arrow turns alice.eth into a 32-byte namehash. The registry answers with two things — who owns the name, and the resolver’s address — and the long right-hand arrow is that dynamic hop, into code that answers addr, contenthash or text queries. The ERC-165 step is the standard for asking a contract “do you implement this interface?”. The wrapper box is the special case: for a wrapped name, the registry’s “owner” is the wrapper contract itself.

The batch call that checks its own homework

When you register a name you can set records in the same transaction. The registrar batches them with a multicall — one call that performs several contract calls in a row — and the batch re-derives the namehash of every sub-call, so the caller cannot set records for a different name:

1
2
3
4
5
6
7
8
for (uint256 i = 0; i < data.length; i++) {
if (nodehash != bytes32(0)) {
bytes32 txNamehash = bytes32(data[i][4:36]);
require(txNamehash == nodehash, "multicall: All records must have a matching namehash");
}
(bool success, bytes memory result) = address(this).delegatecall(data[i]);
require(success);
}

contracts/resolvers/Multicallable.sol:12. Each data[i] is calldata — the raw bytes a call carries to name a function and its arguments. The first 4 bytes name the function, so the namehash is the first full 32-byte word after them, at positions 4 to 36. Any sub-call naming a different name rejects the whole batch. The delegatecall runs each sub-call as if it were part of this contract — same storage, same permissions. The full version also collects each sub-call’s return value into a results array and returns it.

Trade-offs

Registration costs three transactions and a mandatory wait. Commit, wait at least minCommitmentAge (five minutes on mainnet), then register. That is roughly double the transaction count of a naive registrar — the price of keeping bots from sniping names out of the mempool.

There is no name normalisation on chain, and that surprises people. The contracts contain no punycode — the encoding that writes 你好吗 as ASCII — and no UTS-46, the rulebook that decides which spelling of a name is canonical. makeCommitment hashes keccak256(bytes(name)) over whatever raw UTF-8 the client sent, so "你好吗" and "xn--6qq79v" produce different labelhashes for the same visual name. Normalisation is a client-side convention; the on-chain rules are only a minimum of three code points and a 255-byte cap.

Resolution is a dynamic call into code you chose. The registry hands you an address; that address decides what addr() returns, and it can lie. InterfaceResolver makes a staticcall — a call that cannot change any state — into whatever addr() returned: user-controlled code. Multicallable delegatecalls into itself with attacker-supplied calldata. UniversalResolver can be redirected into off-chain CCIP-read lookups (where the client fetches data off chain and proves it on chain). Every mitigation is policy, not proof: ERC-165 so a client can ask for a capability, a namehash check so a privileged caller cannot touch another name, and the assumption that you picked the resolver.

“DNS” means two unrelated things in this codebase. DNSResolver stores real DNS RRsets — the set of DNS records for one name — on chain. DNSRegistrar lets the holder of a DNS zone (a part of the DNS tree a party controls) claim the matching ENS name by proving a DNSSEC chain of trust (DNSSEC cryptographically signs DNS records). It refuses to let you claim com by checking that the parent is a public suffix — a suffix anyone can register under. Same word, two features.

Expired is a revert, not a zero. ownerOf reverts — fails and undoes itself — after expiry rather than returning the zero address, and recordExists is simply owner != 0x0. The registry has no separate existence flag, so “registered” and “has an owner” are the same statement, and off-chain code must handle that.

The subnode owner can still take a sub-name back. Unless PARENT_CANNOT_CONTROL has been burned, the parent can overwrite the child’s owner and resolver. Burning that fuse is irreversible until the name expires; the wrapper exists mostly to make this relationship explicit and one-way.

What I would do differently

Normalisation should be pinned down, not assumed. Two clients hashing the same visual name differently is a footgun the chain permits. Either the namehash specification fixes normalisation, or the contract normalises before hashing, or the registry refuses a label whose normalised form differs from its raw form. Today the guarantee lives in the clients alone.

The fuse bit-packing saves gas, and the state machine it encodes does not. Owner, 32 fuse bits and a 64-bit expiry in one uint256 is a real gas win, but the wrapper’s state machine (Unregistered → Unwrapped → Wrapped → Emancipated → Locked) is hard to reason about. I would spend the extra gas on a plain struct and keep the audit surface small.

Expiry should be visible, not a revert. Every off-chain reader has to catch the failing ownerOf call and interpret it. Returning zero with an expired flag would make the awkward case explicit instead of exceptional.

The DNSSEC oracle — the on-chain verifier for those DNS proofs — is a large attack surface for a small feature. It brings RSA, ECDSA and multiple digest algorithms into the trust boundary of a naming system. The “is the parent a public suffix” check is the right instinct, but the feature deserves to be isolated from the core.

And the deployment scripts should not have a mainnet branch that does nothing. The mainnet deploy scripts return early and point at archived artifacts — PublicResolver_mainnet_9412610, ETHRegistrarController_mainnet_9380471 — that live in a deployments/archive directory which is not in this checkout. A reader who runs the deployment gets a confusing failure instead of a clear “not reproducible from this tree”.

This tree is the upstream ENS repository, audited and running on mainnet, but the working tree has drifted into a private-chain experiment (lixb / lixbtest networks, an ERC-721 variant of the wrapper, an ERC-20 payment path — ERC-20 being the standard for interchangeable tokens). The core machinery has survived years on mainnet; the private-chain parts have not, and the criticism above is about the former.

References