| ensip | XX (draft, number TBD, next after ENSIP-24) |
|---|---|
| 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 proxy contracts (v{N}.{contract}.{namespace}) and their implementations (v{N}.impl.{contract}.{namespace}), a required record schema, a status lifecycle, and a version routing mechanism implemented by a dedicated ContractVersionResolver. All writes are mediated by a ContractVersionRegistrar that enforces atomic publication, write-once semantics, and namespace ownership checks. 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. Combined with a purpose-built resolver and registrar, these primitives are sufficient to build a complete contract versioning system without protocol-specific 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 publish to the registrar when they ship a new version; the resolver automatically routes latest-name queries to the new version.
- 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} |
Routes 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} |
Routes 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 (routes to current)
v1.registrar.ens.eth ← proxy name
v2.registrar.ens.eth ← proxy name (current)
impl.registrar.ens.eth ← latest implementation name (routes to current)
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 routes 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} carry no records of their own. Resolution of these names is handled by the ContractVersionResolver (see §7.1), which maintains an internal currentVersion mapping from each latest name node to its corresponding versioned name node. When a latest name is resolved, the resolver transparently returns the records stored for the current versioned name.
resolve registrar.ens.eth
│
└── ContractVersionResolver.addr(namehash("registrar.ens.eth"), coinType)
│
└── currentVersion[namehash("registrar.ens.eth")]
= namehash("v2.registrar.ens.eth")
│
└── return stored addr for v2.registrar.ens.eth
The currentVersion mapping is updated by the ContractVersionRegistrar (see §7.2) each time a new version is published.
This standard requires two contracts: ContractVersionResolver and ContractVersionRegistrar. Reference implementations are non-normative; the interfaces below are normative.
ContractVersionResolver is a purpose-built ENS resolver that stores all records for names following this standard and routes resolution of latest names to the current versioned name.
interface IContractVersionResolver {
/// @notice Returns the addr record for `node` on `coinType`.
/// If `node` has a currentVersion entry, returns the addr for
/// that version node instead.
function addr(bytes32 node, uint256 coinType) external view returns (bytes memory);
/// @notice Returns the text record for `node` and `key`.
/// If `node` has a currentVersion entry, returns the text record
/// for that version node instead.
function text(bytes32 node, string calldata key) external view returns (string memory);
/// @notice Returns the current versioned name node for a latest name node.
function currentVersion(bytes32 contractNode) external view returns (bytes32 versionNode);
// Write functions — callable only by ContractVersionRegistrar
/// @notice Sets an addr record. Reverts if addr(node, coinType) is already
/// set and the node is frozen.
function setAddr(bytes32 node, uint256 coinType, bytes calldata a) external;
/// @notice Sets a text record. Reverts if key is "version" and the record
/// is already set and the node is frozen.
function setText(bytes32 node, string calldata key, string calldata value) external;
/// @notice Updates the currentVersion pointer for a latest name node.
function setCurrentVersion(bytes32 contractNode, bytes32 versionNode) external;
/// @notice Freezes a versioned name, preventing further updates to addr
/// and text("version") records.
function freeze(bytes32 node) external;
event CurrentVersionChanged(
bytes32 indexed contractNode,
bytes32 indexed previousVersionNode,
bytes32 indexed newVersionNode
);
}The resolver MUST reject setAddr and setText calls on a frozen node for the addr and version fields. Nodes are frozen by the registrar immediately after all records for a new version are written.
Only the ContractVersionRegistrar address (set at resolver deploy time) may call the write functions. All other callers MUST be reverted.
ContractVersionRegistrar is the single write entry point for this standard. It verifies namespace ownership, atomically registers versioned names, sets records, updates the currentVersion pointer, and transitions the status of the previous current version.
interface IContractVersionRegistrar {
struct ProxyVersionParams {
bytes32 contractNode; // namehash of the latest proxy name
string versionLabel; // e.g. "v2"
uint256[] coinTypes; // one entry per chain
bytes[] addresses; // parallel array of encoded addresses
string semanticVersion; // text("version")
string implName; // text("implementation"); empty string if not yet known
string auditUri; // text("audit"); optional
string sourceUri; // text("source"); optional
string changelog; // text("changelog"); optional
}
struct ImplVersionParams {
bytes32 implContractNode; // namehash of the latest implementation name
bytes32 proxyVersionNode; // proxy version node to update text("implementation") on
string versionLabel; // e.g. "v4"
uint256[] coinTypes;
bytes[] addresses;
string semanticVersion;
string auditUri;
string sourceUri;
string changelog;
}
/// @notice Registers a namespace as participating in this standard.
/// Sets ContractVersionResolver as the resolver for `node`.
/// Caller must own or be authorised for `node` in ENS.
function registerNamespace(bytes32 node) external;
/// @notice Atomically:
/// 1. Registers the versioned proxy subdomain under contractNode.
/// 2. Sets all records on the new version name.
/// 3. Freezes the new version name.
/// 4. Updates currentVersion(contractNode) to the new version.
/// 5. Transitions the previous current version's status to "supported".
function publishVersion(ProxyVersionParams calldata params) external;
/// @notice Atomically:
/// 1. Registers the versioned implementation subdomain under implContractNode.
/// 2. Sets all records on the new implementation name.
/// 3. Freezes the new implementation name.
/// 4. Updates currentVersion(implContractNode) to the new version.
/// 5. Updates text("implementation") on proxyVersionNode.
function publishImplementation(ImplVersionParams calldata params) external;
/// @notice Transitions a versioned name's status to "deprecated".
function deprecateVersion(bytes32 versionNode) external;
event NamespaceRegistered(bytes32 indexed node, address indexed owner);
event VersionPublished(bytes32 indexed contractNode, bytes32 indexed versionNode, string versionLabel);
event ImplementationPublished(bytes32 indexed implContractNode, bytes32 indexed versionNode, string versionLabel);
event VersionDeprecated(bytes32 indexed versionNode);
}The registrar MUST verify that msg.sender owns or is authorised for the relevant ENS node before executing any write. It MUST revert if the version label is already registered under the given contract node.
When a new proxy contract is deployed at a new address:
- Call
registrar.publishVersion(params)withparams.versionLabel = "v{N+1}", the new proxy address(es),semanticVersion, andimplNamepointing to the initial implementation name for this proxy. - The registrar atomically registers the new subdomain, sets all records, freezes the version name, updates
currentVersion, and transitions the previous version's status from"current"to"supported".
When the implementation behind an existing proxy is upgraded (same proxy address, new implementation address):
- Call
registrar.publishImplementation(params)withparams.versionLabel = "v{M+1}", the new implementation address(es),semanticVersion, andproxyVersionNodeset to the proxy version whosetext("implementation")should be updated. - The registrar atomically registers the new implementation subdomain, sets all records, freezes the version name, updates
currentVersionfor the implementation name, and updatestext("implementation")on the specified proxy version.
Both procedures execute atomically within a single transaction.
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]+. - Current version 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.
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 ContractVersionResolver enforces immutability on addr and text("version") records after a version is frozen by the registrar. Metadata fields (text("audit"), text("changelog"), etc.) may be updated.
An earlier version of this standard used PermissionedResolver.setAlias to implement latest-name routing. The dedicated resolver and registrar design was chosen for three reasons:
- Enforced immutability. The
ContractVersionResolverrejects writes to immutable fields at the contract level, not by convention. A general-purpose resolver with open write access cannot make this guarantee. - Single write path. The registrar batches all writes for a version publication into one transaction. With a general resolver, callers must sequence multiple calls correctly and rely on a wrapper contract or client-side multicall for atomicity.
- No suffix-matching side effects.
setAliasresolves subdomains of the aliased name through the alias target, which can produce unexpected results if a latest name has non-version subdomains. ThecurrentVersionmapping applies only to the exact node, with no rewriting of child names.
This ENSIP introduces a new naming convention and two new contracts (ContractVersionResolver and ContractVersionRegistrar). It 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.
Registrar key. The account authorised to call publishVersion and publishImplementation controls which versioned name the latest name routes to. A compromised key could register a new version pointing to a malicious contract and silently redirect registrar.ens.eth to it. Protocols are strongly recommended to gate registrar calls behind a multisig or on-chain governance mechanism with a timelock.
Immutability enforcement. The ContractVersionResolver enforces write-once semantics on addr and text("version") after a node is frozen. However, this is only as strong as the resolver itself: a namespace owner who retains the ability to change the resolver for a versioned name can bypass immutability by pointing to a different resolver. Protocols SHOULD ensure that the ENSv2 fuse preventing resolver changes is burned for each versioned name at publication time. The reference registrar implementation does this atomically as part of publishVersion and publishImplementation.
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.
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.
Registrar trust. The ContractVersionRegistrar is the sole write path to the resolver. Protocols adopting this standard inherit the security assumptions of the deployed registrar, including its upgrade path and access control model. Protocols SHOULD review the registrar implementation and, where possible, use an immutable or governance-controlled deployment.
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; routes to 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; routes to 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; routes to v1.registry.ens.eth
└── v1.registry.ens.eth ← proxy v1, status="current", version="1.0.0"
(non-upgradeable; no impl namespace)
import { createWalletClient, http, namehash } from 'viem'
import { mainnet } from 'viem/chains'
const walletClient = createWalletClient({ chain: mainnet, transport: http() })
await walletClient.writeContract({
address: REGISTRAR_ADDRESS,
abi: contractVersionRegistrarAbi,
functionName: 'publishVersion',
args: [{
contractNode: namehash('registrar.ens.eth'),
versionLabel: 'v2',
coinTypes: [COIN_TYPE_ETH],
addresses: [PROXY_ADDRESS],
semanticVersion: '2.0.0',
implName: 'v3.impl.registrar.ens.eth',
auditUri: '',
sourceUri: '',
changelog: '',
}],
})
// registers v2.registrar.ens.eth, sets and freezes records,
// updates currentVersion pointer, marks v1 as "supported"await walletClient.writeContract({
address: REGISTRAR_ADDRESS,
abi: contractVersionRegistrarAbi,
functionName: 'publishImplementation',
args: [{
implContractNode: namehash('impl.registrar.ens.eth'),
proxyVersionNode: namehash('v2.registrar.ens.eth'),
versionLabel: 'v4',
coinTypes: [COIN_TYPE_ETH],
addresses: [IMPL_ADDRESS],
semanticVersion: '2.1.0',
auditUri: '',
sourceUri: '',
changelog: '',
}],
})
// registers v4.impl.registrar.ens.eth, sets and freezes records,
// updates currentVersion pointer, sets text("implementation") on v2.registrar.ens.ethimport { createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'
import { getEnsAddress } from 'viem/actions'
const publicClient = createPublicClient({ chain: mainnet, transport: http() })
// current proxy address (routed by ContractVersionResolver)
const proxyAddress = await getEnsAddress(publicClient, {
name: 'registrar.ens.eth',
coinType: COIN_TYPE_ETH,
})
// current implementation address (routed by ContractVersionResolver)
const implAddress = await getEnsAddress(publicClient, {
name: 'impl.registrar.ens.eth',
coinType: COIN_TYPE_ETH,
})
// pinned proxy version (returned directly from stored records)
const v1proxy = await getEnsAddress(publicClient, {
name: 'v1.registrar.ens.eth',
coinType: COIN_TYPE_ETH,
})Copyright and related rights waived via CC0.