Skip to content

Instantly share code, notes, and snippets.

@jefflau
Last active May 11, 2026 20:29
Show Gist options
  • Select an option

  • Save jefflau/906d92c2183c9d90c4fd612e7c85e92b to your computer and use it in GitHub Desktop.

Select an option

Save jefflau/906d92c2183c9d90c4fd612e7c85e92b to your computer and use it in GitHub Desktop.
ENS contract versioning
ensip XX
title On-chain Contract Version Registry
status Draft
type Standards Track
author Jeff Lau <jeff@ens.domains>
created 2026-05-12

Abstract

This ENSIP defines a standard for publishing versioned smart contract addresses on-chain using ENS subdomains. It specifies naming conventions for both proxy contracts (v{N}.{contract}.{namespace}) and their implementations (v{N}.impl.{contract}.{namespace}), a required record schema, a status lifecycle, and a mechanism for "latest version" aliases using ENSv2's PermissionedResolver.setAlias. Any protocol may adopt this standard to give their contracts stable, human-readable, multi-chain addressable identifiers with a clear upgrade history.


Motivation

Smart contracts are upgraded over time. Today, protocols publish current contract addresses through documentation, npm packages, GitHub repos, and ad-hoc registries - each out of date the moment a new version ships, each requiring integrators to find and re-read the source. There is no on-chain, protocol-neutral standard for:

  • resolving the current version of a named contract across chains,
  • resolving a specific historical version by number,
  • discovering the full version history of a contract,
  • or signalling that a version is deprecated.

ENS already serves as a decentralised naming layer. ENSv2 adds a recursive subdomain registry and resolver-level aliasing. These primitives are sufficient to build a complete contract versioning system without any new infrastructure.

The benefits of standardisation:

  • Integrators resolve v2.registrar.ens.eth and know the address is immutable. It will never change.
  • Tooling (block explorers, auditors, dapps) can enumerate all versions and surface deprecation warnings without polling off-chain sources.
  • Protocols update one alias when they ship a new version; all downstream tooling automatically follows.
  • Any protocol (not just ENS Labs) can adopt the same convention under their own namespace (protocol.uniswap.eth, core.aave.eth, etc.).

Specification

The key words "MUST", "MUST NOT", "SHOULD", "SHOULD NOT", and "MAY" in this document are to be interpreted as described in RFC 2119.

1. Name types

This standard defines five name types. All are subdomains within a namespace controlled by the publishing protocol.

Name type Pattern Purpose
Proxy name v{N}.{contract}.{namespace} Canonical, immutable record for a specific proxy deployment
Latest proxy name {contract}.{namespace} Alias always pointing to the current proxy name
Implementation name v{N}.impl.{contract}.{namespace} Canonical, immutable record for a specific implementation deployment
Latest implementation name impl.{contract}.{namespace} Alias always pointing to the current implementation name
Namespace {namespace} Root under which a protocol's contracts live

Examples using ens.eth as namespace:

registrar.ens.eth                   ← latest proxy name (alias)
v1.registrar.ens.eth                ← proxy name
v2.registrar.ens.eth                ← proxy name (current)

impl.registrar.ens.eth              ← latest implementation name (alias)
v1.impl.registrar.ens.eth           ← implementation name
v2.impl.registrar.ens.eth           ← implementation name
v3.impl.registrar.ens.eth           ← implementation name (current)

For non-upgradeable contracts, the implementation name types are optional.

2. Versioning scheme

Version labels use a simple incrementing integer prefixed with v: v1, v2, v3, … Labels MUST be lowercase and match the regex ^v[1-9][0-9]*$. The label v0 is reserved and MUST NOT be used.

Proxy versions increment when a new contract is deployed at a new address. Upgrading the implementation behind an existing proxy does not create a new proxy name.

Implementation versions increment independently of proxy versions. The sequence is global across all proxy versions of a contract — it does not reset when a new proxy is deployed. To determine which proxy an implementation belongs to, check the text("proxy") record on the implementation name.

Full semantic version strings are captured in the text("version") record on each name for tooling that needs them.

3. Records on proxy names

Each proxy name v{N}.{contract}.{namespace} MUST carry the following records:

Record Type Content
addr(coinType) per-chain address Proxy contract address on each chain where this version is deployed. One entry per chain. See §5 for coin type encoding.
text("version") string Full semantic version string, e.g. "3.0.0" or "2.1.4".
text("status") string One of: "current", "supported", "deprecated". See §4 for lifecycle rules.
text("implementation") ENS name The current implementation name, e.g. v4.impl.registrar.ens.eth. Updated each time the proxy's implementation is upgraded.

The following records are RECOMMENDED:

Record Type Content
text("audit") URI Link to audit report(s) for this version.
text("source") URI Link to source code (GitHub commit, IPFS CID, etc.).
text("changelog") URI or string What changed in this version relative to the previous.
ABI record (ENSIP-4) ABI Contract ABI.

The addr(coinType) and text("version") records MUST NOT be changed after initial registration. All other records may be updated. text("implementation") is explicitly mutable and SHOULD be kept current.

3a. Records on implementation names

Each implementation name v{N}.impl.{contract}.{namespace} MUST carry the following records:

Record Type Content
addr(coinType) per-chain address Implementation contract address on each chain. One entry per chain.
text("version") string Full semantic version string for this implementation.
text("proxy") ENS name The proxy name this implementation was deployed for, e.g. v2.registrar.ens.eth.

The following records are RECOMMENDED:

Record Type Content
text("audit") URI Link to audit report(s) for this implementation.
text("source") URI Link to source code at the specific commit for this implementation.
text("changelog") URI or string What changed in this implementation relative to the previous.
ABI record (ENSIP-4) ABI Implementation ABI.

All records on implementation names SHOULD be treated as immutable once set.

4. Status lifecycle

Exactly one versioned name per contract MUST have text("status") = "current" at any point in time.

         deploy          upgrade         sunset
  ──────────────────────────────────────────────────────▶
                │                │               │
                ▼                ▼               ▼
           "current"  ──▶  "supported"  ──▶  "deprecated"
Status Meaning
"current" The canonical version. The latest name aliases to this versioned name.
"supported" An older version that remains safe to use and is still maintained by the protocol.
"deprecated" Should not be used in new integrations. May have known issues, security advisories, or be unmaintained.

The status field is self-reported and advisory. Clients SHOULD surface deprecation warnings but MUST NOT enforce status as a blocker.

5. Multi-chain addresses

For contracts deployed on multiple chains, each addr(coinType) record MUST be set for every chain where that contract version is deployed.

Coin types follow ENSIP-11:

  • Chains in SLIP-44: use the SLIP-44 coin type directly.
  • EVM chains not in SLIP-44: use 0x80000000 | chainId (e.g. OP Mainnet / chainId=10 → 0x8000000a).

Example for a contract deployed on Ethereum mainnet, OP Mainnet, and Base:

addr(60)         = 0x...   # Ethereum mainnet  (SLIP-44 coin type 60)
addr(0x8000000a) = 0x...   # OP Mainnet        (0x80000000 | 10)
addr(0x80002105) = 0x...   # Base              (0x80000000 | 8453)

A versioned name that has no deployment on a given chain MUST NOT set addr(coinType) for that chain. Absence of a coin type record is the authoritative signal that the version is not deployed on that chain.

6. Latest names and the alias mechanism

The latest proxy name {contract}.{namespace} and the latest implementation name impl.{contract}.{namespace} MUST each be configured as pure aliases: they carry no records of their own. All resolution routes through the alias to the current target name.

6.1 ENSv2 alias (normative)

With ENSv2's PermissionedResolver, the alias is set by calling:

resolver.setAlias(
    NameCoder.encode("{contract}.{namespace}"),       // fromName (DNS-encoded)
    NameCoder.encode("v{N}.{contract}.{namespace}")  // toName   (DNS-encoded)
);

This stores the alias keyed by the namehash of fromName. Resolution of {contract}.{namespace} automatically returns all records from v{N}.{contract}.{namespace} without any further action.

setAlias requires ROLE_SET_ALIAS (1 << 28) at the root resource of the resolver. It is strongly recommended that protocols assign this role to a multisig or governance-controlled address rather than an EOA.

Suffix matching behaviour: _resolveAlias matches from the full name upward, label by label. Setting an alias from registrar.ens.eth to v3.registrar.ens.eth will also rewrite subdomains: a query for sub.registrar.ens.eth resolves as sub.v3.registrar.ens.eth. Protocols SHOULD be aware of this and SHOULD NOT create subdomains of a latest name that conflict with the versioned subdomain space.

6.2 ENSv2 requirement

This standard requires ENSv2's PermissionedResolver. The alias mechanism is not available on ENSv1 resolvers and no fallback is defined. Protocols adopting this standard should wait for ENSv2 to be deployed on their target chain before registering versioned names.

7. Upgrade procedures

7.1 New proxy deployment

When a new proxy contract is deployed at a new address:

  1. Register v{N+1}.{contract}.{namespace} and set all required records with text("status") = "current" and text("implementation") pointing to its initial implementation name.
  2. Update the latest proxy alias: call setAlias with toName = v{N+1}.{contract}.{namespace}.
  3. Update text("status") on the previous proxy name from "current" to "supported" (or "deprecated" if it is being immediately retired).

7.2 Implementation upgrade

When the implementation behind an existing proxy is upgraded (same proxy address, new implementation address):

  1. Register v{M+1}.impl.{contract}.{namespace} and set all required records, including text("proxy") pointing to the proxy name this implementation belongs to.
  2. Update the latest implementation alias: call setAlias with toName = v{M+1}.impl.{contract}.{namespace}.
  3. Update text("implementation") on the current proxy name to v{M+1}.impl.{contract}.{namespace}.

Steps within each procedure SHOULD be executed atomically where the target chain supports batched calls (e.g. via multicall on the resolver, or a single transaction via a contract).

8. Version discovery

To discover all versions of a contract, clients SHOULD use one of the following methods:

  1. Sequential enumeration: Resolve v1.{contract}.{namespace}, v2.{contract}.{namespace}, etc. incrementally until a version returns no addr records. The absence of records is the signal that no further versions exist.
  2. Subgraph / indexer: Query an ENS indexer for all subdomains of {contract}.{namespace} matching the pattern v[0-9]+.
  3. Alias walk: Resolve the latest name to find the current version label, then enumerate downward.

Clients MUST NOT assume contiguous version numbers. A protocol MAY skip version numbers (e.g. for internal reasons).


Rationale

Version label position: v{N}.{contract} vs {contract}.v{N}

Placing the version as a subdomain of the contract name (v{N}.{contract}.{namespace}) is preferred because:

  • In ENSv2's tree model, the owner of {contract}.{namespace} controls the v{N}.{contract}.{namespace} child namespace. Ownership and control are colocated.
  • It reads naturally: "v3 of the registrar" is v3.registrar.ens.eth.
  • The latest name (registrar.ens.eth) and all versioned names share a common parent, enabling subdomain enumeration via that parent.

Placing version after contract ({contract}.v{N}.{namespace}) would scatter different contracts' versions across different parts of the tree and require the protocol to control v{N}.{namespace} for every N, which is operationally unwieldy.

Immutability of versioned names

Versioned names are permanent records of what was deployed and when. Mutability of address records would undermine the core invariant that v2.registrar.ens.eth always means the same contract. The immutability expectation is normative for addr and text("version"); metadata fields (text("audit"), etc.) may be updated.


Backwards Compatibility

This ENSIP introduces a new naming convention and does not modify any existing ENS contract or resolver interface. It requires ENSv2 and has no backwards compatibility with ENSv1.

Existing names under protocols' namespaces are unaffected. A protocol may adopt this standard for new contracts without retroactively renaming existing ones.


Security Considerations

Alias update key. The account holding ROLE_SET_ALIAS on the resolver controls which versioned name the latest name points to. A compromised key could silently redirect registrar.ens.eth to a malicious contract. Protocols are strongly recommended to gate ROLE_SET_ALIAS behind a multisig or on-chain governance mechanism with a timelock.

Immutability is by convention, not enforcement. This standard cannot prevent a resolver owner from updating addr(coinType) on a versioned name. Tooling and integrators that rely on immutability SHOULD monitor AddrChanged events on versioned names and alert on unexpected updates.

Status is advisory. The text("status") = "deprecated" field does not prevent a contract from being called. Clients that enforce deprecation as a hard block create new UX surfaces that can be exploited. Treat status as a signal, not a gate.

Suffix rewriting scope. Because _resolveAlias uses suffix matching, setting an alias on {contract}.{namespace} rewrites all subdomains of that name. Protocols SHOULD NOT create subdomains of a latest name that are not versioned names, to avoid unexpected resolution behaviour.

Sequential enumeration is not authoritative. A gap in version numbers (e.g. no v3 but a v4 exists) will cause sequential enumeration to stop early and miss later versions. Clients relying on complete version history SHOULD supplement sequential enumeration with indexer queries.

ENSv2 resolver trust. The alias mechanism depends on the correctness of PermissionedResolver. Protocols adopting this standard inherit the security assumptions of that contract, including its upgrade path (UUPS) and access control model.


Reference Implementation

The following is a non-normative example of the deployment procedure for ENS Labs' contracts under ens.eth.

Name structure

ens.eth
├── registrar.ens.eth                    ← latest proxy; alias → v2.registrar.ens.eth
│   ├── v1.registrar.ens.eth             ← proxy v1, status="deprecated", version="1.0.0"
│   │                                       implementation="v2.impl.registrar.ens.eth"
│   └── v2.registrar.ens.eth             ← proxy v2, status="current",    version="2.0.0"
│                                           implementation="v5.impl.registrar.ens.eth"
│
│   impl.registrar.ens.eth               ← latest impl; alias → v5.impl.registrar.ens.eth
│   ├── v1.impl.registrar.ens.eth        ← proxy="v1.registrar.ens.eth", version="1.0.0"
│   ├── v2.impl.registrar.ens.eth        ← proxy="v1.registrar.ens.eth", version="1.1.0"
│   ├── v3.impl.registrar.ens.eth        ← proxy="v2.registrar.ens.eth", version="2.0.0"
│   ├── v4.impl.registrar.ens.eth        ← proxy="v2.registrar.ens.eth", version="2.1.0"
│   └── v5.impl.registrar.ens.eth        ← proxy="v2.registrar.ens.eth", version="2.2.0"
│
└── registry.ens.eth                     ← latest proxy; alias → v1.registry.ens.eth
    └── v1.registry.ens.eth              ← proxy v1, status="current", version="1.0.0"
                                            (non-upgradeable; no impl namespace)

Deploying a new proxy

import { createWalletClient, createPublicClient, http, namehash } from 'viem'
import { mainnet } from 'viem/chains'
import { encodeName } from '@ensdomains/ensjs/utils'

const walletClient = createWalletClient({ chain: mainnet, transport: http() })

// 1. Set records on the new proxy name
await walletClient.writeContract({
  address: RESOLVER_ADDRESS,
  abi: resolverAbi,
  functionName: 'setAddr',
  args: [namehash('v2.registrar.ens.eth'), COIN_TYPE_ETH, PROXY_ADDRESS],
})
await walletClient.writeContract({
  address: RESOLVER_ADDRESS,
  abi: resolverAbi,
  functionName: 'setText',
  args: [namehash('v2.registrar.ens.eth'), 'version', '2.0.0'],
})
await walletClient.writeContract({
  address: RESOLVER_ADDRESS,
  abi: resolverAbi,
  functionName: 'setText',
  args: [namehash('v2.registrar.ens.eth'), 'status', 'current'],
})
await walletClient.writeContract({
  address: RESOLVER_ADDRESS,
  abi: resolverAbi,
  functionName: 'setText',
  args: [namehash('v2.registrar.ens.eth'), 'implementation', 'v3.impl.registrar.ens.eth'],
})

// 2. Update the latest proxy alias
await walletClient.writeContract({
  address: RESOLVER_ADDRESS,
  abi: resolverAbi,
  functionName: 'setAlias',
  args: [encodeName('registrar.ens.eth'), encodeName('v2.registrar.ens.eth')],
})

// 3. Mark previous proxy as supported
await walletClient.writeContract({
  address: RESOLVER_ADDRESS,
  abi: resolverAbi,
  functionName: 'setText',
  args: [namehash('v1.registrar.ens.eth'), 'status', 'supported'],
})

Upgrading the implementation

// 1. Set records on the new implementation name
await walletClient.writeContract({
  address: RESOLVER_ADDRESS,
  abi: resolverAbi,
  functionName: 'setAddr',
  args: [namehash('v4.impl.registrar.ens.eth'), COIN_TYPE_ETH, IMPL_ADDRESS],
})
await walletClient.writeContract({
  address: RESOLVER_ADDRESS,
  abi: resolverAbi,
  functionName: 'setText',
  args: [namehash('v4.impl.registrar.ens.eth'), 'version', '2.1.0'],
})
await walletClient.writeContract({
  address: RESOLVER_ADDRESS,
  abi: resolverAbi,
  functionName: 'setText',
  args: [namehash('v4.impl.registrar.ens.eth'), 'proxy', 'v2.registrar.ens.eth'],
})

// 2. Update the latest implementation alias
await walletClient.writeContract({
  address: RESOLVER_ADDRESS,
  abi: resolverAbi,
  functionName: 'setAlias',
  args: [encodeName('impl.registrar.ens.eth'), encodeName('v4.impl.registrar.ens.eth')],
})

// 3. Update the proxy name's implementation pointer
await walletClient.writeContract({
  address: RESOLVER_ADDRESS,
  abi: resolverAbi,
  functionName: 'setText',
  args: [namehash('v2.registrar.ens.eth'), 'implementation', 'v4.impl.registrar.ens.eth'],
})

Resolving addresses

import { createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'
import { getEnsAddress } from 'viem/actions'

const publicClient = createPublicClient({ chain: mainnet, transport: http() })

// Current proxy address (resolves through alias)
const proxyAddress = await getEnsAddress(publicClient, {
  name: 'registrar.ens.eth',
  coinType: COIN_TYPE_ETH,
})

// Current implementation address (resolves through alias)
const implAddress = await getEnsAddress(publicClient, {
  name: 'impl.registrar.ens.eth',
  coinType: COIN_TYPE_ETH,
})

// Pinned proxy version
const v1proxy = await getEnsAddress(publicClient, {
  name: 'v1.registrar.ens.eth',
  coinType: COIN_TYPE_ETH,
})

Copyright

Copyright and related rights waived via CC0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment