AI agents & integrators

Etherpage is a good home for pages held by machines because nothing is in the way: no owner, no admin keys, no API keys, no accounts, no rate limits, no company that can gate, reprice or revoke a registration. Every function below is a public method on an immutable contract. Authorization is plain msg.sender: there are no tx.origin checks and no EOA-only gates, so a smart account (ERC-4337), a delegated EOA (EIP-7702), an MPC wallet and a plain hot key all work the same.

The contracts run only on OP Mainnet, chain id 10. Their addresses and the full function reference are on the contracts page. Before signing, check the chain id, check the address, and check that PageNFT.ROOT() returns 0x62460c06d95b73747abf6ea4f28aefd78b4ad2abee112e7cf15fe6a924cca3c6. The same address on another chain is not these contracts.

Rules that prevent lost funds

Read these before writing any registration code. Each one is a real failure mode.

  1. Never add or strip a suffix. A name is alice, or blog.alice for a subpage. A name with a dot is always a subpage: alice.eth is the subpage alice of a page called eth, which anyone may register. Pass labels exactly as the user means them, and refuse anything that fails the label rule.

  2. Persist a strong secret before you commit. Registration is commit and reveal: the reveal needs the same (label, owner, referrerId, secret) you committed. The secret must be 32 bytes from a CSPRNG, unique per commitment, never derived from the label, the time or an address; the commitment is a salted hash and its only hiding property is that entropy. A guessable secret lets an observer rebuild your commitment, learn the label and race you. Write the secret, the owner and the referrer to durable storage before you broadcast commit, or a crash leaves you with a commitment you can never reveal.

  3. The commitment binds the owner, not the sender. reveal(label, owner, referrerId, secret) mints to owner, and anyone may send it and pay. Pass reveal the same owner, referrerId and secret you passed to makeCommitment, or it reverts CommitmentNotFound. A trial has a different commitment (see Trials), so a paid commitment can never be revealed as a trial, or the other way round.

  4. A reverted reveal keeps the commitment; only a new commit needs a new secret. If a reveal fails, retry the same reveal with the same secret: the commitment survives, since the revert undoes everything. A commitment can be revealed for 24 hours, and sending the identical commitment again within that day reverts AlreadyCommitted. Before any retry, check ownerOf: someone may already have sent your reveal for you, and then the page is already yours.

  5. Pages freeze the second they expire. Once block.timestamp passes the expiry, the page stops answering (resolve returns the zero address, addr, text and contenthash return empty) and transfers and record writes revert Expired, through the whole 90-day grace period. Only renew works. A trial has no grace period at all. Check that a page is usable with status(tokenId) (0 Active, 4 Trial), not with isExpired or inGracePeriod, which are always false for subpages.

  6. Token ids are reused. When a page lapses and is registered again, the same tokenId is burned and minted again for the new holder, its epoch and recordVersion go up, and every address, text and website is gone. If you cache anything keyed on a token id, store records(tokenId).epoch with it and treat a changed epoch as a different page held by a stranger.

  7. Contracts must accept the token. A page or subpage minted to a contract must be accepted by its onERC721Received, unless the contract is the caller or an account with no code or an EIP-7702 delegation. A registrar sale always counts as minted by someone else, since PageNFT sees the registrar as the caller. A contract that sends the reveal must also accept ETH (receive()), or pay the exact price, or the refund reverts the whole call.

Registering a page, step by step

1.  label  = normalize(name)                      (refuse anything it rejects; never add a suffix)
2.  require isAvailable(label, 0)
3.  id     = computeId(label)
4.  secret = 32 CSPRNG bytes, unique              (persist it now)
5.  refId  = a top-level page's token id, or 0    (persist it too; never id itself)
6.  send commit(makeCommitment(label, owner, refId, secret))
7.  wait until a block timestamp >= the commit block timestamp + 20
    (the commitment stays valid until commit + 86400; both ends inclusive)
8.  (fee, carried, premium, total) = quote(label)  (read it right before the reveal)
9.  send reveal{value: total}(label, owner, refId, secret)
10. confirm ownerOf(id) == owner

Details that matter to a program:

  • The label rule. 1 to 63 bytes. A to Z are lowercased; a to z, 0 to 9 and - are kept; every other byte, including every byte from 0x80 up, is refused (InvalidLabel; a wrong length is InvalidLength). No hyphen first or last. With 4 or more characters, the third and fourth may not both be hyphens. 0x followed by exactly 40 characters from 0–9 and a–f is refused. There is no Unicode and no ENSIP-15: do not normalize labels with ENS tools. Call normalize(label) to get the stored form.
  • Ids. id = uint256(keccak256(abi.encodePacked(ROOT, keccak256(label)))), and a subpage id is uint256(keccak256(abi.encodePacked(bytes32(parentId), keccak256(label)))). computeId("alice") and computeId("bob.alice") compute them, but only lowercase A to Z: check labels with normalize.
  • The commitment. makeCommitment returns keccak256(abi.encode(bytes(normalizedLabel), owner, referrerId, secret)). It reverts on an invalid label, and with SelfReferral when referrerId is the label’s own id.
  • Timing. The reveal window is commit + 20 s to commit + 86400 s, both ends included, measured by block timestamps. OP Mainnet makes a block every 2 seconds, so waiting 30 seconds is plenty. Revealing too early reverts CommitmentTooNew, which is harmless: retry.
  • Value and refunds. Send quote(label).total. Anything above the amount due when the reveal runs is sent back to msg.sender in the same transaction. During the premium period the price only goes down, so a quote taken then stays enough. One trap: a page still in grace quotes only the fee while it cannot be registered, and at the first second after grace the price jumps to the fee plus the old deposit plus almost 10 ETH. If you commit early to catch an expiring page, read quote as of the reveal block, and send only what you mean to pay.
  • Who pays, who holds. The page goes to owner. The caller pays and gets the refund. A trial’s money is credited back to the caller that paid it.
  • Referrer. referrerId is another top-level page’s token id, or 0 for none. It is part of the commitment, so makeCommitment and the reveal must pass the same value. It earns nothing at registration or renewal: it is recorded, and if the page later lapses and is registered again during its premium period, 25% of that premium goes to the referrer’s payee. A referrer of 0, one that does not exist or one that is a subpage sends that share to the dead address instead.
  • Races. If someone registers the label first, your reveal reverts AlreadyRegistered and your ETH never leaves. The commit hash shows nothing about the label, and the commitment binds the owner, so watching your transactions gives nobody a way to take the page from you.
  • Failure is atomic. Every revert returns the full msg.value and costs only gas. On CommitmentTooOld (the window closed) or CommitmentNotFound (already used, or values that do not match), stop retrying: check ownerOf, then start over with a new secret.

The commitment names the owner, so a funder can register a page straight into a wallet that holds no ETH, with no custody hop and no transfer:

sponsor: commit(makeCommitment(label, agentWallet, refId, secret))
sponsor: wait >= 20 s (block timestamps)
sponsor: reveal{value: quote(label).total}(label, agentWallet, refId, secret)
result:  ownerOf(id) == agentWallet; the sponsor paid and got any refund
  • Someone who sees the reveal can copy it, but the page still lands on the committed owner; all they can do is pay for it. If your own transaction then reverts CommitmentNotFound, check ownerOf before retrying: the agent may already hold the page.
  • The owner never receives ETH, and cannot make the payer lose anything beyond a reverted transaction.

Two mistakes are yours to avoid. A contract owner must accept ERC-721 tokens (rule 7). And a mistyped owner cannot be undone: the page goes to a stranger, and there is no admin to appeal to. Validate the checksum of the owner and simulate before you broadcast.

Trials

revealTrial(label, owner, referrerId, secret) registers a page for 14 days for a tenth of the yearly fee (getFee(length) / 10).

  • Its commitment is keccak256(abi.encode(bytes(normalizedLabel), owner, referrerId, secret, true)). There is no view for it: call makeCommitment first to check the label and the referrer, then hash locally. In viem: keccak256(encodeAbiParameters(parseAbiParameters('bytes, address, uint256, bytes32, bool'), [toHex(normalizedLabel), owner, refId, secret, true])).
  • The trial money is held in trialPaymentOf(id) and always credited back to the account that paid it: when the trial is renewed, when anyone calls release(id) after it ends, or when the page is registered again. Collect it with withdraw(payer).
  • During the trial the page has no grace period, cannot have subpages (Unpaid), and can move only to the dead address (Unpaid).
  • renew(id) before the end, with the full yearly fee, makes it a paid page that expires one year after the trial would have ended.
  • revealTrial reverts TrialNotAllowed while a paid page is live, in grace or in its premium period, and until 14 days after the page’s last registration ended. A live trial reverts AlreadyRegistered.

Renewal

renew(tokenId) is open to anyone: anyone can pay to renew any top-level page until its grace period ends, and the page stays with its holder. Each call adds 365 days to the current expiry and pays getFee(length) into the page’s deposit. It reverts RenewalTooFar when the new expiry would be more than 3650 days ahead, so a page can be paid about ten years ahead at most.

renew sends any excess back to msg.sender, so send each call exactly its fee. PageNFT’s own multicall is not payable, so it cannot carry renewals: send one transaction per year, or batch them in a wallet that sends several calls with value (EIP-5792). If you route renewals through another contract, that contract is msg.sender and gets the refund. Past grace the page is gone and must be registered again, with a premium (see Pricing). Watch expiresAt(tokenId) and renew with margin: an agent whose identity stops answering mid-task is a worse failure than a year of extra deposit.

Deposits and money

  • Every yearly fee goes into depositOf(id). It belongs to the page and goes back to the page’s payee(id) when the page ends: through release(id), which anyone may call more than 111 days after expiry, or when someone registers the page again.
  • The payee is the main address the current holder saved, or the holder. It still counts after expiry, when the main address can no longer change.
  • Credits sit in owed(account) until withdraw(account) sends them. Anyone may call it for any account, and the money always goes to that account, so a payee that is a contract must accept ETH.

Identity records for agents

A page gives an agent a resolvable identity its holder controls, with no extra contracts:

  • Main address. setAddr(id, wallet) points the page at the agent’s operating wallet on OP Mainnet. Until it is set, resolve returns the holder. It is also where the contract pays the deposit.
  • Other chains. setAddrForCoin(id, coinType, bytes) sets an address per chain: 60 for Ethereum, 0x80000000 | chainId for any other EVM chain, and 0x80000000 for a default on every EVM chain without its own entry. Coin type 60 never falls back to the main address. Save a default only for a key you control on every chain, never for a smart account that may not exist at the same address everywhere.
  • Display page. setPrimaryPage(id) can be called by the holder or by the page’s main address. That gives a clean split: a cold wallet holds the page and points it at the agent’s hot wallet, and the hot wallet chooses the page as its display page itself, with no key sharing and no operator approvals. reverseResolve(hotWallet) then returns the name.
  • Agent card and metadata. setContenthash can point at an IPFS CID holding the agent’s card, for example an ERC-8004 registration file (ERC-8004, still a draft, allows ipfs:// registration URIs, which a page serves natively). An A2A agent card is found at a well-known HTTPS URL, so treat an IPFS copy as a mirror, not the original. For metadata over HTTP, use text records with standard keys (url, avatar, description).
  • ERC-8004 binding. If you register the agent in an ERC-8004 identity registry, publish the binding with the ENSIP-25 text record key format exactly (agent-registration[<registry>][<agentId>] = any non-empty value). Use the upstream format rather than inventing a variant. ENSIP-25 checkers today resolve through ENS, so treat this as a convention for later, not as working interop.
  • One transaction. multicall(bytes[]) runs several record writes as the same caller and reverts them all if one fails. It is not payable.

Records follow the holder. Addresses and the profile count only while the account that saved them holds the page: after a transfer the new holder starts with no addresses but its own and an empty profile, until it calls keepProfile. A new registration empties everything (rule 6). For identity that is a feature: a lapsed agent’s endpoints can never silently point a stranger’s page at your infrastructure, or the other way round.

Token-bound accounts: the hazard first

ERC-6551 gives every ERC-721 a fixed token-bound account, and it works on pages with no contract support. Understand the failure mode before putting anything of value in one: if the page lapses past grace, a new registration mints the same tokenId to the new registrant, who then controls the old token-bound account and everything in it, for little more than the yearly fee once the premium has run out. Holding the page is holding the account, across registrations. Do not treat a page-bound account as a treasury unless renewal is automated with wide margin; keep assets in the agent’s own wallet and use the page only as identity.

Subpage fleets

For fleets of agents, subpages are simpler than pages: no commit and reveal, one transaction, free under a page you hold.

  • registerSubpageFor(label, parentId, to) mints worker.fleet straight to any wallet. The parent must be a live, paid top-level page: a trial parent reverts Unpaid. One level only.
  • To let a service mint for you, make it the subpage minter with setSubpageMinter(parentId, minter). The minter only creates free subpages, and counts only while you hold the parent in the same registration.
  • To buy subpages from the SubpageRegistrar, read config(parentId). It returns (controller, enabled, commitReveal, feeToken, price, gateToken, minGateBalance, payout, parentEpoch). Pass maxPrice and expectedFeeToken to registerSubpage or registerSubpageFor, so a change of terms reverts your purchase instead of charging you more. For a token price, approve the registrar for exactly the price, never an unlimited allowance. When commitReveal is on, use commitSubpage(makeSubpageCommitment(parentId, label, to, secret)), wait 20 seconds, then revealSubpage or revealSubpageFor.
  • The trust model is explicit: the parent’s holder can always replace a subpage by minting it again. A subpage is trust in the parent, right for an operator’s own fleet, priced accordingly when buying from strangers.
  • To retire a subpage, send it to 0x000000000000000000000000000000000000dEaD. It is deleted, answers nothing, and its label is free again.

Privacy note

Availability and price lookups leak intent. isAvailable, quote and getPremium need no API key, but their arguments contain the label (or its id, which is unsalted and can be reversed with a dictionary), so asking a third-party API or a public RPC “is alice free?” shows the label to that operator before anything binds it to you. Run those calls against your own node or a provider you trust. The commit itself shows nothing, since it is a salted hash, so the label first appears onchain at reveal time, when the commitment already binds it to you.

Quick reference

Fact Value
Chain OP Mainnet (chain id 10)
Names alice, blog.alice; no suffix, one level of subpages
Labels 1 to 63 of a–z, 0–9, - (see the label rule)
ROOT 0x62460c06d95b73747abf6ea4f28aefd78b4ad2abee112e7cf15fe6a924cca3c6
Commitment window commit + 20 s to commit + 86400 s (inclusive)
Reveal reveal(label, owner, referrerId, secret), mints to owner, anyone may pay
Registration / grace 365 days / 90 days (no grace for a trial)
Trial 14 days at a tenth of the fee, money credited back to its payer
Fees (length 1 / 2 / 3 / 4 / 5+) 0.01 / 0.01 / 0.001 / 0.001 / 0.0001 ETH per year, into a refundable deposit
Premium after grace 10 ETH falling to 0 over 21 days; 75% to the last payee, 25% to the referrer’s payee
Furthest expiry 3650 days ahead
Liveness check status(tokenId): 0 Active, 1 Grace, 2 Expired, 3 Invalid, 4 Trial
New registration same tokenId, epoch goes up, records emptied
Money depositOf, payee, release, owed, withdraw

A machine-readable summary of this page lives at /llms.txt. Function signatures, events, errors and addresses are on the contracts page.