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.
-
Never add or strip a suffix. A name is
alice, orblog.alicefor a subpage. A name with a dot is always a subpage:alice.ethis the subpagealiceof a page calledeth, which anyone may register. Pass labels exactly as the user means them, and refuse anything that fails the label rule. -
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 broadcastcommit, or a crash leaves you with a commitment you can never reveal. -
The commitment binds the owner, not the sender.
reveal(label, owner, referrerId, secret)mints toowner, and anyone may send it and pay. Passrevealthe sameowner,referrerIdandsecretyou passed tomakeCommitment, or it revertsCommitmentNotFound. A trial has a different commitment (see Trials), so a paid commitment can never be revealed as a trial, or the other way round. -
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, checkownerOf: someone may already have sent your reveal for you, and then the page is already yours. -
Pages freeze the second they expire. Once
block.timestamppasses the expiry, the page stops answering (resolvereturns the zero address,addr,textandcontenthashreturn empty) and transfers and record writes revertExpired, through the whole 90-day grace period. Onlyrenewworks. A trial has no grace period at all. Check that a page is usable withstatus(tokenId)(0Active,4Trial), not withisExpiredorinGracePeriod, which are always false for subpages. -
Token ids are reused. When a page lapses and is registered again, the same
tokenIdis burned and minted again for the new holder, itsepochandrecordVersiongo up, and every address, text and website is gone. If you cache anything keyed on a token id, storerecords(tokenId).epochwith it and treat a changed epoch as a different page held by a stranger. -
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.
AtoZare lowercased;atoz,0to9and-are kept; every other byte, including every byte from0x80up, is refused (InvalidLabel; a wrong length isInvalidLength). No hyphen first or last. With 4 or more characters, the third and fourth may not both be hyphens.0xfollowed by exactly 40 characters from0–9anda–fis refused. There is no Unicode and no ENSIP-15: do not normalize labels with ENS tools. Callnormalize(label)to get the stored form. - Ids.
id = uint256(keccak256(abi.encodePacked(ROOT, keccak256(label)))), and a subpage id isuint256(keccak256(abi.encodePacked(bytes32(parentId), keccak256(label)))).computeId("alice")andcomputeId("bob.alice")compute them, but only lowercaseAtoZ: check labels withnormalize. - The commitment.
makeCommitmentreturnskeccak256(abi.encode(bytes(normalizedLabel), owner, referrerId, secret)). It reverts on an invalid label, and withSelfReferralwhenreferrerIdis the label’s own id. - Timing. The reveal window is
commit + 20 stocommit + 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 revertsCommitmentTooNew, which is harmless: retry. - Value and refunds. Send
quote(label).total. Anything above the amount due when the reveal runs is sent back tomsg.senderin 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, readquoteas 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.
referrerIdis another top-level page’s token id, or0for none. It is part of the commitment, somakeCommitmentand 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
AlreadyRegisteredand 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.valueand costs only gas. OnCommitmentTooOld(the window closed) orCommitmentNotFound(already used, or values that do not match), stop retrying: checkownerOf, then start over with a new secret.
Sponsored registration
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, checkownerOfbefore 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: callmakeCommitmentfirst 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 callsrelease(id)after it ends, or when the page is registered again. Collect it withwithdraw(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.revealTrialrevertsTrialNotAllowedwhile 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 revertsAlreadyRegistered.
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’spayee(id)when the page ends: throughrelease(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)untilwithdraw(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,resolvereturns the holder. It is also where the contract pays the deposit. - Other chains.
setAddrForCoin(id, coinType, bytes)sets an address per chain:60for Ethereum,0x80000000 | chainIdfor any other EVM chain, and0x80000000for 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.
setContenthashcan point at an IPFS CID holding the agent’s card, for example an ERC-8004 registration file (ERC-8004, still a draft, allowsipfs://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)mintsworker.fleetstraight to any wallet. The parent must be a live, paid top-level page: a trial parent revertsUnpaid. 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). PassmaxPriceandexpectedFeeTokentoregisterSubpageorregisterSubpageFor, 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. WhencommitRevealis on, usecommitSubpage(makeSubpageCommitment(parentId, label, to, secret)), wait 20 seconds, thenrevealSubpageorrevealSubpageFor. - 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.