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 alice or, for a subpage, blog.alice. There is no suffix.
  • A page’s tokenId is a namehash under the root keccak256("etherpage.root.v1"), so alice, its records and its subpages all hang off one fixed id.
  • Given that id, everything is a view call: resolve for the main address, addr for other chains, text for records, ownerOf for the holder, status for 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

computeId here refuses any label the contract would refuse, so an id you compute is the id of a name that can exist. The contract’s own computeId(name) only lowercases A to Z and checks nothing else; pair it with normalize(label). Never add or strip a suffix: alice.eth is the subpage alice of a page called eth, not alice.

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. setAddr is self-asserted, so a lookalike page can point its address at a whale. Only ownerOf is 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 alice and blog.alice. Never add or strip .eth or anything else.
  • Zero means no resolution. resolve returns 0x000…0 for a page that is not active or does not exist, and addr returns 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).epoch and treat a change as a new holder.
  • Beware look-alikes. Labels are ASCII, but 0 and o or l and 1 still 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