Integrate identity
A page is a portable onchain identity your app can read without asking anyone. No API keys, no accounts, no rate limits, no company in the middle: a page resolves to addresses, carries public records (avatar, links, bio), and can hand out subpages that act as memberships. Everything on this page is a view call on an immutable contract, so reading an identity costs nothing and can never be revoked or repriced under you.
This is the read side: using identities other people hold. To register pages from code, see AI agents & integrators; for the full ABI and events, see the contracts reference.
The model in one minute
- Every page is an ERC-721 token of the PageNFT contract, which is also the resolver: one contract holds ownership, addresses and records together.
- A name is
aliceor, for a subpage,blog.alice. There is no suffix. - A page’s
tokenIdis a namehash under the rootkeccak256("etherpage.root.v1"), soalice, its records and its subpages all hang off one fixed id. - Given that id, everything is a view call:
resolvefor the main address,addrfor other chains,textfor records,ownerOffor the holder,statusfor whether it is live. - The contracts run only on OP Mainnet (chain id 10). The same address on another chain is not these contracts. The avatar NFT collections live on Ethereum, so avatar checks read Ethereum too.
Setup
The examples use viem. The same calls work with ethers, wagmi, or a raw eth_call.
import { concat, createPublicClient, erc721Abi, getAddress, http, keccak256, parseAbi, stringToBytes } from 'viem'
import { mainnet, optimism } from 'viem/chains'
// PageNFT on OP Mainnet: registry, resolver and ERC-721 in one. Address on the Contracts page.
const PAGE_NFT = '0x…' as const
const ROOT = '0x62460c06d95b73747abf6ea4f28aefd78b4ad2abee112e7cf15fe6a924cca3c6' as const
const abi = parseAbi([
'function resolve(uint256 tokenId) view returns (address)',
'function addr(uint256 tokenId, uint256 coinType) view returns (bytes)',
'function reverseResolve(address addr) view returns (string)',
'function text(uint256 tokenId, string key) view returns (string)',
'function ownerOf(uint256 tokenId) view returns (address)',
'function status(uint256 tokenId) view returns (uint8)',
'function records(uint256 tokenId) view returns (string label, uint256 parent, uint64 expiresAt, uint64 epoch, uint64 parentEpoch)',
'function setText(uint256 tokenId, string key, string value)',
])
// Every page read goes to OP Mainnet.
const client = createPublicClient({ chain: optimism, transport: http() })
// The values status() returns.
const Status = { Active: 0, Grace: 1, Expired: 2, Invalid: 3, Trial: 4 } as const
// The label rule of PageNFT.normalize: 1 to 63 bytes of a-z, 0-9 and '-', with A-Z lowercased,
// no hyphen first or last, not '--' in the third and fourth place, and not an address.
function normalizeLabel(label: string): string {
const bytes = new TextEncoder().encode(label).length
if (bytes < 1 || bytes > 63) throw new Error('InvalidLength')
const l = label.replace(/[A-Z]/g, (c) => c.toLowerCase())
if (!/^[a-z0-9-]+$/.test(l) || l.startsWith('-') || l.endsWith('-') ||
l.slice(2, 4) === '--' || /^0x[0-9a-f]{40}$/.test(l))
throw new Error('InvalidLabel')
return l
}
// PageNFT.computeId: 'alice' hangs from ROOT, and 'bob.alice' from the id of 'alice'.
function computeId(name: string): bigint {
const labels = name.split('.')
if (labels.length > 2) throw new Error('A name has at most one dot')
let node: `0x${string}` = ROOT
for (const label of labels.reverse())
node = keccak256(concat([node, keccak256(stringToBytes(normalizeLabel(label)))]))
return BigInt(node)
}
// computeId('alice') === 0xbd0888be4fc21a23d82616269103328d0cc8ab2697e8da7ad4c2d0d47a6cfa22n
// computeId('bob.alice') === 0x4f9429a51c1d7234fc16ba2c3d8542fcdbaecbb5bf1ff1b46073c3c139650da1n
computeIdhere refuses any label the contract would refuse, so an id you compute is the id of a name that can exist. The contract’s owncomputeId(name)only lowercasesAtoZand checks nothing else; pair it withnormalize(label). Never add or strip a suffix:alice.ethis the subpagealiceof a page calledeth, notalice.
Resolve a name to an address
The everyday lookup: turn alice into the address it points at on OP Mainnet.
const id = computeId('alice')
const main = await client.readContract({ address: PAGE_NFT, abi, functionName: 'resolve', args: [id] })
// 0x0000…0000 when the page is not active.
resolve returns the main address, the page’s address on OP Mainnet. It is the address the holder
saved, or the holder itself when it saved none, so a new page resolves to its holder with no setup.
Treat the zero address as “no resolution” and handle it.
For another chain, ask by coin type. The answer is empty bytes when there is none:
const COIN_ETHEREUM = 60n
const COIN_BASE = 0x80000000n | 8453n // 0x80000000 | chain id, for any EVM chain
const raw = await client.readContract({ address: PAGE_NFT, abi, functionName: 'addr', args: [id, COIN_ETHEREUM] })
const onEthereum = raw === '0x' ? null : getAddress(raw)
Coin type 60 does not fall back to the main address. An EVM chain other than OP answers the address
saved for it, else the holder’s default for every EVM chain (coin type 0x80000000), else nothing. Do
not send funds on Ethereum to the result of resolve: it is an OP Mainnet answer.
Resolve an address to a name
The reverse: show alice instead of 0xd8dA…6045.
const name = await client.readContract({ address: PAGE_NFT, abi, functionName: 'reverseResolve', args: [user] })
// '' when the address has no display page
This returns the address’s display page. The contract shows it only while that page is active, is still held by the account that held it when it was chosen, and still resolves to the same address, so a stale or sold page can never be used to impersonate someone. Even so, treat it as a display convenience: for authorization, check ownership (below) rather than trusting a label.
Build a profile from records
Pages carry free-form text records under keys most wallets already understand. Read several in one round trip with multicall:
const keys = ['avatar', 'url', 'description', 'com.twitter', 'com.github', 'org.telegram']
const values = await client.multicall({
contracts: keys.map((key) => ({ address: PAGE_NFT, abi, functionName: 'text', args: [id, key] }) as const),
allowFailure: false,
})
const profile = Object.fromEntries(keys.map((key, i) => [key, values[i]]))
| Key | Meaning |
|---|---|
avatar |
The page’s picture, as a reference to an NFT (see below) |
url |
Website |
description |
Short bio |
com.twitter / com.github / org.telegram |
Social handles |
A profile counts only while the account that wrote it holds the page. Right after a transfer every record reads empty, until the new holder keeps the profile it found or writes its own. Show an empty profile as empty, not as an error.
You can also read any custom key your own app writes, covered under Store your app’s data.
The avatar record is a claim, not proof
This is the one record you should not read naively, so it is worth spelling out.
An avatar names a token, not a picture, in the same form ENS uses. The supported collections live on Ethereum, chain id 1:
eip155:1/erc721:0xb47e3cd837ddf8e4c57f05d70ab865de6e193bbb/5822
Storing the token rather than an image URL is what makes the claim checkable at all. A URL would let anyone point at a CryptoPunk they do not own, for good and with no way to tell. A reference can be checked by reading who holds the token.
Nothing enforces it for you. The record is free-form text, and the holder can write any token id they like, including one they have never held. So before you render it as somebody’s picture, read the token’s holder on Ethereum and the page’s holder on OP Mainnet:
// A second client, used only for the avatar collections on Ethereum.
const l1 = createPublicClient({ chain: mainnet, transport: http() })
// 1. the token the record names
const m = /^eip155:1\/erc721:(0x[0-9a-fA-F]{40})\/(\d+)$/.exec(record)
if (!m) throw new Error('not an Ethereum ERC-721 reference')
const contract = getAddress(m[1]!)
const tokenId = BigInt(m[2]!)
// 2. who holds that token on Ethereum, and who holds the page on OP Mainnet
const [tokenHolder, pageHolder] = await Promise.all([
l1.readContract({ address: contract, abi: erc721Abi, functionName: 'ownerOf', args: [tokenId] }),
client.readContract({ address: PAGE_NFT, abi, functionName: 'ownerOf', args: [computeId('alice')] }),
])
// 3. they must be the same address
const verified = tokenHolder.toLowerCase() === pageHolder.toLowerCase()
CryptoPunks predate ERC-721: for them, read punkIndexToAddress(index) on the CryptoPunks contract
instead of ownerOf.
Four rules that are easy to get wrong:
- Check against the page’s holder, never its address record.
setAddris self-asserted, so a lookalike page can point its address at a whale. OnlyownerOfis enforced by the contract. - The same address must hold both, on two chains. A smart account deployed at different addresses on OP Mainnet and Ethereum cannot pass this check, and should not.
- Do not cache the verdict. A token can be borrowed and the record written inside a short window. A fresh read makes that worthless soon after; a cached one does not.
- Holding a token is not the same as holding a known token. A reference proves someone holds token 5822 of some contract, never that the contract is the real CryptoPunks. Anyone can deploy their own and honestly hold token 5822 of it. Match the contract address against a list you curate.
When the check fails, show your own placeholder. Treat a failure as unverified, not as sold: it reads the same for a token held in a second wallet, staked, or in a vault.
One more thing worth knowing when you fetch the picture: do not treat a single gateway failure as proof
the image is gone. Testing one collection’s art from a browser, several well-known public gateways
failed to serve it while others returned it fine, and the same URLs succeeded from curl on the same
machine. We could not pin down why, and it may not happen everywhere, which is rather the point: try
more than one source before you fall back.
Gate access by ownership
Gating on a specific page, or on holding any page at all:
async function holdsPage(name: string, user: `0x${string}`) {
const id = computeId(name)
try {
const [owner, status] = await Promise.all([
client.readContract({ address: PAGE_NFT, abi, functionName: 'ownerOf', args: [id] }),
client.readContract({ address: PAGE_NFT, abi, functionName: 'status', args: [id] }),
])
// Add Status.Trial to also admit 14-day trials.
return owner.toLowerCase() === user.toLowerCase() && status === Status.Active
} catch {
return false // ownerOf and status revert for a page that was never registered
}
}
Holding alone is not enough: a page can expire and lapse to someone else, and the old holder still
holds the token during grace. Always pair ownerOf with status, so you never grant access on a page
that is not live, and check again at the moment of access rather than caching the result forever.
A trial costs a tenth of the yearly fee, and that money comes back to whoever paid it. If your gate
should cost something to pass, admit only Active.
Gate a community by subpage
The membership pattern: a community holds dabot and hands out subpages (alice.dabot) as passes. An
event, a forum or a chat then admits only holders of a live subpage under that parent.
Checking a member’s claimed name takes three checks: they hold it, it really is a subpage of your page, and it is live in the parent’s current registration.
const PARENT = computeId('dabot')
async function isMember(claimed: string, user: `0x${string}`) {
const id = computeId(claimed) // e.g. 'alice.dabot'
try {
const [owner, status, [, parent]] = await Promise.all([
client.readContract({ address: PAGE_NFT, abi, functionName: 'ownerOf', args: [id] }),
client.readContract({ address: PAGE_NFT, abi, functionName: 'status', args: [id] }),
client.readContract({ address: PAGE_NFT, abi, functionName: 'records', args: [id] }),
])
return (
owner.toLowerCase() === user.toLowerCase() && // holds the subpage
parent === PARENT && // it is a subpage of dabot
status === Status.Active // live, current registration, not deleted
)
} catch {
return false
}
}
status does the important part. A subpage has no expiry of its own: it follows its parent. It is
Active only while the parent is live, and Invalid when the parent was registered again since the
subpage was minted, or when the subpage was deleted. The contract tells the registrations apart by
epoch: each subpage stores its parent’s epoch when it is minted (records(id).parentEpoch), and the
parent’s epoch (records(PARENT).epoch) goes up at every new registration. Skip this check and you keep
admitting members whose passes the new holder of the parent has already wiped.
To list every current member instead of checking one, index the
SubpageRegistered(tokenId, parentId, label) event for your parentId, and keep each id whose
status is Active. If you store memberships, store the parent’s epoch with them and drop them all
when it changes.
Attributes and verification
Text records are self-asserted: the holder can write anything. That is perfect for a bio or a link, and not enough on its own for a claim like age or KYC. Never gate access on one.
It helps to keep three tiers straight, because the difference is invisible from the outside:
| Enforced by the contract | Safe to gate on | |
|---|---|---|
ownerOf, status, resolve, subpage parentage |
yes | yes |
avatar, once you have checked the holders yourself |
no, but checkable | for display |
| every other text record | no | never |
For trustless attributes, store an attestation rather than a bare value: have a trusted attester sign a statement (for example, that a given address is over 18, with an expiry), publish it in a record under your namespace, and have your app check the attester’s signature before it trusts the claim. The record is only transport; the signature is what you check. Apply the same expiry and epoch discipline as everywhere else.
Store your app’s data
Your app can also write to a page whose holder has connected, keeping its own namespace on every page:
// Holder only, sent on OP Mainnet from the holder's wallet client. Reverse-DNS keys stay collision-free.
await walletClient.writeContract({
address: PAGE_NFT, abi, functionName: 'setText',
args: [computeId('alice'), 'com.myapp.profile', value],
})
Use a reverse-DNS key you control (com.myapp.*) and no other app can clash with you. Your key is part
of the holder’s profile: it reads empty after a transfer until the new holder keeps the profile, and a
new registration of the page empties it, so a new holder never inherits a stranger’s data. For anything
bigger than a string, deploy your own contract keyed on the tokenId and store the page’s epoch
alongside your data, treating a changed epoch as a new identity. Both patterns are covered in
Building on Etherpage.
Rules that keep integrations correct
- OP Mainnet only. Every page read is on OP Mainnet, chain id 10. Check the chain and the contract address before trusting any read. Use a separate Ethereum client only for the avatar collections.
- No suffix. Names are
aliceandblog.alice. Never add or strip.ethor anything else. - Zero means no resolution.
resolvereturns0x000…0for a page that is not active or does not exist, andaddrreturns empty bytes. Handle both. - Ethereum is its own answer. Read
addr(id, 60)for Ethereum; it does not fall back to the main address. - Expiry and grace are real. A page can lapse and change hands. Pair ownership with
status, and check again rather than caching forever. - Token ids are reused. A page that lapsed and was registered again is a different identity with
the same id. For anything you store, keep
records(id).epochand treat a change as a new holder. - Beware look-alikes. Labels are ASCII, but
0andoorland1still look alike. Never rely on a label alone for anything that matters; check ownership onchain. - The chain is the source of truth. Never trust a frontend’s claim of ownership or resolution; confirm it with a view call.
Next steps
- Contracts reference: every function, event and constant.
- Records & resolution: the record types in depth.
- AI agents & integrators: registering and managing pages from code.