| ensip | XX |
|---|---|
| title | On-chain Contract Version Registry |
| status | Draft |
| type | Standards Track |
| author | Jeff Lau <jeff@ens.domains> |
| created | 2026-05-12 |
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.
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.ethand 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.).
The key words "MUST", "MUST NOT", "SHOULD", "SHOULD NOT", and "MAY" in this document are to be interpreted as described in RFC 2119.
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.
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.
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.
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.
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.
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.
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.
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.
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.
When a new proxy contract is deployed at a new address:
- Register
v{N+1}.{contract}.{namespace}and set all required records withtext("status") = "current"andtext("implementation")pointing to its initial implementation name. - Update the latest proxy alias: call
setAliaswithtoName = v{N+1}.{contract}.{namespace}. - Update
text("status")on the previous proxy name from"current"to"supported"(or"deprecated"if it is being immediately retired).
When the implementation behind an existing proxy is upgraded (same proxy address, new implementation address):
- Register
v{M+1}.impl.{contract}.{namespace}and set all required records, includingtext("proxy")pointing to the proxy name this implementation belongs to. - Update the latest implementation alias: call
setAliaswithtoName = v{M+1}.impl.{contract}.{namespace}. - Update
text("implementation")on the current proxy name tov{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).
To discover all versions of a contract, clients SHOULD use one of the following methods:
- Sequential enumeration: Resolve
v1.{contract}.{namespace},v2.{contract}.{namespace}, etc. incrementally until a version returns noaddrrecords. The absence of records is the signal that no further versions exist. - Subgraph / indexer: Query an ENS indexer for all subdomains of
{contract}.{namespace}matching the patternv[0-9]+. - 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).
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 thev{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.
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.
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.
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.
The following is a non-normative example of the deployment procedure for ENS Labs' contracts under ens.eth.
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)
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'],
})// 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'],
})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 and related rights waived via CC0.