Contracts reference
Etherpage is three contracts on OP Mainnet (chain id 10). None has an owner, an admin key, a proxy or an upgrade path: once deployed, nobody can change, pause or reverse them.
| Contract | What it does |
|---|---|
| PageNFT | The registry, the resolver and the ERC-721 in one: registration, trials, renewal, deposits, subpages, records, display pages and the token metadata. |
| SubpageRegistrar | Sells subpages of a page on terms its holder sets. |
| PageCard | Draws the SVG card of each page. It holds no state, and PageNFT fixes its address at deployment. |
The source, the tests, the proofs and the review record (contracts/gates/review-record.md) are in the
contracts folder of the repository, under the Apache License 2.0.
Addresses
| Contract | Chain | Address |
|---|---|---|
| PageNFT | OP Mainnet (chain id 10) | to be published on deploy day |
| PageCard | OP Mainnet (chain id 10) | to be published on deploy day |
| SubpageRegistrar | OP Mainnet (chain id 10) | to be published on deploy day |
Check each address on the OP Mainnet explorer before you rely on it:
its source must be verified there. PageNFT.ROOT() must return
0x62460c06d95b73747abf6ea4f28aefd78b4ad2abee112e7cf15fe6a924cca3c6, PageNFT.card() must return the
PageCard address, and SubpageRegistrar.registry() must return the PageNFT address. The same address on
another chain is not these contracts.
Names and ids
- A page’s name is one label, such as
alice. A subpage adds one label in front, such asbob.alice. There is no suffix and no third level. ROOTiskeccak256("etherpage.root.v1")=0x62460c06d95b73747abf6ea4f28aefd78b4ad2abee112e7cf15fe6a924cca3c6.- The id of a page is
uint256(keccak256(abi.encodePacked(ROOT, keccak256(bytes(label))))). - The id of a subpage is
uint256(keccak256(abi.encodePacked(bytes32(parentId), keccak256(bytes(label))))). - The resolver
nodeof a page isbytes32(tokenId). computeId("alice")=0xbd0888be4fc21a23d82616269103328d0cc8ab2697e8da7ad4c2d0d47a6cfa22andcomputeId("bob.alice")=0x4f9429a51c1d7234fc16ba2c3d8542fcdbaecbb5bf1ff1b46073c3c139650da1.
Label rule (normalize). A label has 1 to 63 bytes, else InvalidLength. A to Z are
lowercased; a to z, 0 to 9 and - are kept; any other byte, including every byte from 0x80
up, gives InvalidLabel. A hyphen may not come first or last. When the label has 4 or more characters,
the third and fourth may not both be hyphens. A label of exactly 42 characters that is 0x followed by
40 characters from 0–9 and a–f is refused, since it reads as an address.
computeNamehash(fullName) returns ROOT for the empty string. It reverts EmptyLabel on a leading,
trailing or double dot and InvalidLabel on more than one dot. It lowercases A to Z but checks
nothing else, so check each label with normalize first. Never add or strip a suffix: alice.eth
is the subpage alice of a page called eth.
Constants
| Constant | Value |
|---|---|
| Commitment window | 20 s to 86400 s (24 h) after the commit, both ends included |
| Registration period | 365 days |
| Trial | 14 days, at a tenth of the yearly fee |
| No new trial | until 14 days after the last registration ended |
| Grace period | 90 days, only for a page with a deposit |
| Premium | 10 ETH falling to 0 over 21 days after the grace period |
| Premium split | 75% to the last payee, 25% to the payee of the last referrer |
| Furthest expiry | 3650 days ahead |
| Fees (length 1 / 2 / 3 / 4 / 5+) | 0.01 / 0.01 / 0.001 / 0.001 / 0.0001 ETH per year |
| Label length | 1 to 63 characters |
| Subpage depth | 1 |
| Main address coin type | 0x8000000a (OP Mainnet) |
| Default EVM coin type | 0x80000000 |
| Dead address | 0x000000000000000000000000000000000000dEaD |
name() / symbol() |
Etherpage / ETHERPAGE |
PageNFT
Registration
function makeCommitment(string label, address owner, uint256 referrerId, bytes32 secret) pure returns (bytes32)
function commit(bytes32 commitment)
function reveal(string label, address owner, uint256 referrerId, bytes32 secret) payable returns (uint256 tokenId)
function revealTrial(string label, address owner, uint256 referrerId, bytes32 secret) payable returns (uint256 tokenId)
function commitments(bytes32 commitment) view returns (uint256 committedAt)
- The commitment for
revealiskeccak256(abi.encode(bytes(normalizedLabel), owner, referrerId, secret)), whichmakeCommitmentreturns. It reverts on an invalid label, and withSelfReferralwhenreferrerIdis the id of the label. - The commitment for
revealTrialiskeccak256(abi.encode(bytes(normalizedLabel), owner, referrerId, secret, true)). There is no view for it. A commitment made forrevealcan never start a trial. commitrevertsAlreadyCommittedwhen the same commitment was made less than a day ago and not used.- The reveal must come 20 to 86400 seconds after the commit, both ends included. It deletes the commitment.
- Anyone may send the reveal and pay. The page is minted to
owner. Anything paid above the price goes back to the caller, so a contract caller must accept ETH or pay the exact price. revealcostsquote(label).total.revealTrialcostsgetFee(length) / 10.revealrevertsAlreadyRegisteredwhile the page is live or in grace.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 registration of a page that existed burns the old token, clears the old holder’s display page,
raises
epochandrecordVersion, and so empties every address, text and website.
Prices and availability
function quote(string label) view returns (uint256 fee, uint256 carried, uint256 premium, uint256 total)
function getFee(uint256 length) pure returns (uint256)
function getPremium(uint256 tokenId) view returns (uint256)
function isAvailable(string label, uint256 parentId) view returns (bool)
quoteis all zeros for an invalid label.carriedis the deposit a registration takes over during the premium period.total = fee + carried + premium. It does not check that the page is free, and it does not give the trial price.getPremiumis 0 unless the page is top level, has a deposit and its grace period has ended. Then it is10 ETH × (21 days − elapsed) / 21 dayswhileelapsed, the time since the grace period ended, is under 21 days.isAvailable(label, 0)is true when the page was never registered or its grace period has ended (its expiry, for a page with no deposit).revealTrialhas the further limits above.isAvailable(label, parentId)is true when the parent is a live, paid top-level page and the subpage is new, from an earlier registration of the parent, or held by the dead address.
Renewal, deposits and money
function renew(uint256 tokenId) payable
function release(uint256 tokenId)
function isReleasable(uint256 tokenId) view returns (bool)
function withdraw(address account) returns (uint256 amount)
function depositOf(uint256 tokenId) view returns (uint256)
function trialPaymentOf(uint256 tokenId) view returns (address payer, uint96 amount)
function owed(address account) view returns (uint256)
function payee(uint256 tokenId) view returns (address)
function referrerOf(uint256 tokenId) view returns (uint256)
function expiresAt(uint256 tokenId) view returns (uint256)
function isExpired(uint256 tokenId) view returns (bool)
function inGracePeriod(uint256 tokenId) view returns (bool)
- renew: anyone may call it for a top-level page until its grace period ends. It adds 365 days to the
current expiry and adds the yearly fee to the deposit. Reverts:
TokenDoesNotExist,NotTopLevel,Expired(after the grace period, or after a trial ended),InsufficientFee, andRenewalTooFarwhen the new expiry would be more than 3650 days ahead. Renewing a trial makes it a paid page and credits the trial money back to its payer. - Deposit: every yearly fee is added to
depositOf. Trial money is held apart intrialPaymentOf. - release: anyone may call it. After a trial ends it credits the trial money back to its payer.
Otherwise, after the grace and premium periods (more than 111 days after expiry), it credits the whole
deposit to the payee. A registration after those periods does this first by itself. Reverts:
NotReleasable(too early),NothingToRelease. - Premium period: a registration during it pays
carriedto the last payee (DepositBoughtOut); the deposit stays in the page and the new fee is added on top. Of the premium, 25% goes to the payee ofreferrerOf, or to the dead address when the referrer is 0, unknown or a subpage (ReferrerPremiumCredited), and the rest to the last payee (OwnerPremiumCredited). - payee: the main address the current holder saved, else the holder, with zero and PageNFT replaced by the dead address. It still counts after expiry.
- owed / withdraw: money is credited to
owed. Anyone may callwithdraw(account); it sends that account everything credited to it, or revertsNothingToWithdraw. Money credited to the dead address can only be burned. - referrerOf: the referrer named at the last registration. It earns only the 25% share of a premium.
isExpiredis true once a top-level page is past its grace period, or a trial has ended.inGracePeriodis true between expiry and the end of grace. Both are always false for a subpage.
Subpages
function registerSubpage(string label, uint256 parentId) returns (uint256 tokenId)
function registerSubpageFor(string label, uint256 parentId, address to) returns (uint256 tokenId)
function setSubpageMinter(uint256 parentId, address minter)
function subpageMinterOf(uint256 parentId) view returns (address)
function parentOf(uint256 tokenId) view returns (uint256)
function epochOf(uint256 tokenId) view returns (uint64)
- The parent must be a top-level page (
NotTopLevel), live (Expired) and paid (Unpaidfor a trial). - The caller must be the parent’s holder or its subpage minter (
NotParentOwner). The holder may also replace a subpage someone holds; the minter only creates free ones (AlreadyRegistered). setSubpageMinter: only the holder may pick a minter, not an approved operator, and a non-zero minter needs a live page. After the page moves, the account that picked the minter may only clear it.subpageMinterOfreturns the minter that counts now: zero unless the account that picked it holds the parent in the same registration. It never reverts and ignores expiry.- A subpage follows its parent’s expiry and grace. It is Invalid when its
parentEpochdiffers from the parent’sepoch, or when the dead address holds it. - Delete: the subpage’s holder sends it to
0x000000000000000000000000000000000000dEaD. It must be active. Afterwards it answers nothing and its label is free.
Records and resolution
function setAddr(uint256 tokenId, address a)
function setAddrForCoin(uint256 tokenId, uint256 coinType, bytes a)
function setText(uint256 tokenId, string key, string value)
function setContenthash(uint256 tokenId, bytes hash)
function keepProfile(uint256 tokenId)
function clearRecords(uint256 tokenId)
function multicall(bytes[] data) returns (bytes[] results)
function resolve(uint256 tokenId) view returns (address)
function resolveAs(uint256 tokenId, address account) view returns (address)
function addr(uint256 tokenId, uint256 coinType) view returns (bytes)
function addr(bytes32 node) view returns (address)
function addr(bytes32 node, uint256 coinType) view returns (bytes)
function text(uint256 tokenId, string key) view returns (string)
function text(bytes32 node, string key) view returns (string)
function contenthash(uint256 tokenId) view returns (bytes)
function contenthash(bytes32 node) view returns (bytes)
function profileWriter(uint256 tokenId) view returns (address)
function recordVersion(uint256 tokenId) view returns (uint256)
- Every write needs an active page (
Expired) and the holder as the caller (Unauthorized). Approved operators cannot write records. - Main address:
setAddrsets the address on OP Mainnet, coin type0x8000000a. Zero points the page back at its holder. It cannot be PageNFT (InvalidRecipient). It is also the payee. resolveandaddr(bytes32)return zero when the page is not active, else the main address while the account that saved it holds the page, else the holder.resolveAsanswers as ifaccountheld it.- Other chains: EVM coin types are
60and0x80000000to0xffffffff. Their value must be 20 bytes or empty (InvalidLength); empty or zero clears it. Coin type0x8000000ais the same assetAddr.0x80000000is the default for every EVM chain but OP. A chain that is not EVM takes raw bytes. addr(tokenId, coinType)is empty when the page is not active. OP answers the main address. Another EVM chain answers the holder’s entry for it, else the holder’s default, else empty. Coin type 60 does not fall back to the main address. A chain that is not EVM answers only what the current holder saved.- An address counts only while the account that saved it holds the page.
- Profile: text records and the website.
textandcontenthashare empty unless the page is active andprofileWriterholds it. A write over a profile left by another holder starts an empty profile.keepProfileadopts the profile the holder found (AlreadyKeptwhen the holder already is its writer). - clearRecords raises
recordVersion: every address, text and website stops counting, and the page points at its holder.keepProfilecannot bring them back. - multicall runs each call on PageNFT in order, as the same caller. It is not payable, and the first failure reverts them all with its revert data.
- Some answers change with no event of their own, for example at expiry. Read
addrlive rather than rebuilding it from events.
Display page
function setPrimaryPage(uint256 tokenId) // 0 clears
function reverseResolve(address a) view returns (string)
function primaryPage(address a) view returns (uint256 tokenId) // raw choice, may be stale
setPrimaryPageneeds an active page, and the caller must be its holder or its main address.reverseResolvereturns the name only while the page is active, is held by the account that held it when it was chosen, andresolve(tokenId)is the address. Otherwise it returns an empty string.- A transfer clears the sender’s display page when it is the page that moves. A new registration or subpage mint clears the old holder’s.
Names, status and metadata
function ROOT() view returns (bytes32)
function computeNamehash(string fullName) pure returns (bytes32)
function computeId(string fullName) pure returns (uint256)
function normalize(string label) pure returns (string)
function getPageName(uint256 tokenId) view returns (string)
function records(uint256 tokenId) view returns (string label, uint256 parent, uint64 expiresAt, uint64 epoch, uint64 parentEpoch)
function status(uint256 tokenId) view returns (uint8)
function tokenURI(uint256 tokenId) view returns (string)
function card() view returns (address)
getPageNamereturnsaliceorbob.alice, with no suffix. It is empty for an unknown id and for a subpage of an earlier registration of its parent. A deleted subpage still returns its name; usestatus.records(tokenId).expiresAtis 0 for a subpage.epochcounts the registrations of the id;parentEpochis the parent’s epoch when the subpage was minted.statusreturns0Active,1Grace,2Expired,3Invalid or4Trial, and revertsTokenDoesNotExistfor an unknown id. A live top-level page with no deposit is a Trial. A subpage is never a Trial.tokenURIis a base64 JSON withname,description(Etherpage: <name> (<short payee>)), the card asimage, and the attributesType(PageorSubpage),StatusandLength, plusExpiresandDeposit(ETH, up to four decimals) for a top-level page.supportsInterfaceis true for ERC-165, ERC-721 and its metadata,addr(0x3b3b57de),addrwith a coin type (0xf1cb7e06),text(0x59d1d43c),contenthash(0xbc1c58d1) and ERC-4906 (0x49064906).
Transfers
- PageNFT is a standard ERC-721:
balanceOf,ownerOf,approve,getApproved,setApprovalForAll,isApprovedForAll,transferFromand bothsafeTransferFrom. - A page moves only while it is active: a page in grace or an invalid subpage reverts
Expired. - A trial can move only to the dead address (
Unpaid). - No page can go to PageNFT itself (
InvalidRecipient).approveand transfers refuse ETH (UnexpectedValue) and refuse the zero address as the caller. - Saved addresses and the profile do not move with the page: the page points at its new holder, and the new holder sees no other address and an empty profile.
- Mint rule: a new 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.
Events
event PageRegistered(uint256 indexed tokenId, string label, address indexed owner, uint256 expiresAt) // top level only
event SubpageRegistered(uint256 indexed tokenId, uint256 indexed parentId, string label)
event PageRenewed(uint256 indexed tokenId, uint256 newExpiresAt)
event PrimaryPageSet(address indexed addr, uint256 indexed tokenId)
event Committed(bytes32 indexed commitment, address indexed committer)
event ReferrerSet(uint256 indexed tokenId, uint256 indexed referrerId)
event SubpageMinterSet(uint256 indexed parentId, address indexed account, address indexed minter, uint64 epoch)
event DepositAdded(uint256 indexed tokenId, address indexed payer, uint256 amount, uint256 newTotal)
event DepositBoughtOut(uint256 indexed tokenId, address indexed to, uint256 amount)
event DepositReleased(uint256 indexed tokenId, address indexed to, uint256 amount)
event ReferrerPremiumCredited(uint256 indexed tokenId, uint256 indexed referrerId, address indexed to, uint256 amount)
event OwnerPremiumCredited(uint256 indexed tokenId, address indexed to, uint256 amount)
event Withdrawn(address indexed account, address indexed caller, uint256 amount)
event TrialStarted(uint256 indexed tokenId, address indexed payer, uint256 price)
event TrialRefunded(uint256 indexed tokenId, address indexed payer, uint256 amount)
event AddrChanged(bytes32 indexed node, address addr) // the main address on OP, not coin type 60
event AddressChanged(bytes32 indexed node, uint256 coinType, bytes addr)
event TextChanged(bytes32 indexed node, string indexed indexedKey, string key, string value)
event ContenthashChanged(bytes32 indexed node, bytes contenthash)
event VersionChanged(bytes32 indexed node, uint64 newVersion)
event ProfileWriterChanged(bytes32 indexed node, address writer, bool fresh)
event MetadataUpdate(uint256 _tokenId)
event BatchMetadataUpdate(uint256 _fromTokenId, uint256 _toTokenId) // declared for ERC-4906, never emitted
Plus the ERC-721 Transfer, Approval and ApprovalForAll.
Errors
| Error | When |
|---|---|
Expired |
The page is not active: expired, in grace, after a trial, or an invalid or deleted subpage. |
EmptyLabel |
A name with a leading, trailing or double dot. |
InvalidLabel |
A label breaks the label rule, or a name has more than one dot. |
InvalidLength |
A label outside 1 to 63 bytes, or an EVM address that is not 20 bytes. |
Unauthorized |
The caller is not the holder, or for setPrimaryPage neither the holder nor the main address. |
InvalidRecipient |
PageNFT itself as a receiver or main address. |
UnexpectedValue |
ETH sent with approve or a transfer. |
NotParentOwner |
A subpage mint by neither the parent’s holder nor its minter. |
NotTopLevel |
A subpage where a top-level page is needed. |
InsufficientFee |
Less ETH than the price. |
AlreadyCommitted |
The same commitment again within a day. |
CommitmentNotFound |
No such commitment, or it was used. |
CommitmentTooNew / CommitmentTooOld |
Outside the 20 s to 24 h window. |
AlreadyRegistered |
The page is live or in grace, or a minter tried a subpage that is not free. |
SelfReferral |
The referrer is the page itself. |
TrialNotAllowed |
A trial while a paid page is live, in grace or in its premium period, or within 14 days of the last end. |
Unpaid |
A trial moved anywhere but the dead address, or a subpage of a trial. |
RenewalTooFar |
The new expiry would be more than 3650 days ahead. |
NotReleasable / NothingToRelease |
release too early, or nothing left to release. |
NothingToWithdraw |
withdraw with nothing owed. |
AlreadyKept |
keepProfile when the holder already is the writer of the profile. |
InvalidRenderer |
Constructor only. |
From the ERC-721 and guard libraries: TokenDoesNotExist, NotOwnerNorApproved,
TransferFromIncorrectOwner, TransferToNonERC721ReceiverImplementer, TransferToZeroAddress,
BalanceQueryForZeroAddress, TokenAlreadyExists, AccountBalanceOverflow and Reentrancy.
SubpageRegistrar
function configure(uint256 parentId, address payout, address feeToken, uint256 price,
bool enabled, address gateToken, uint256 minGateBalance, bool commitReveal)
function disable(uint256 parentId)
function config(uint256 parentId) view returns (address controller, bool enabled, bool commitReveal,
address feeToken, uint96 price, address gateToken, uint96 minGateBalance,
address payout, uint64 parentEpoch)
function registerSubpage(uint256 parentId, string label, uint256 maxPrice, address expectedFeeToken) payable returns (uint256 subId)
function registerSubpageFor(uint256 parentId, string label, address to, uint256 maxPrice, address expectedFeeToken) payable returns (uint256 subId)
function makeSubpageCommitment(uint256 parentId, string label, address to, bytes32 secret) view returns (bytes32)
function commitSubpage(bytes32 commitment)
function revealSubpage(uint256 parentId, string label, bytes32 secret, uint256 maxPrice, address expectedFeeToken) payable returns (uint256 subId)
function revealSubpageFor(uint256 parentId, string label, address to, bytes32 secret, uint256 maxPrice, address expectedFeeToken) payable returns (uint256 subId)
function subCommitments(bytes32 commitment) view returns (uint256 committedAt)
function proceedsOf(address account) view returns (uint256)
function claimProceeds(address to) returns (uint256 amount) // to 0 means the caller
function claimProceedsFor(address account) returns (uint256 amount) // anyone, paid to account
function registry() view returns (address) // PageNFT
- Setup: the holder calls
configure, thenPageNFT.setSubpageMinter(parentId, registrar). Only the holder may callconfigureanddisable, and the page must be top level.configurerecords the holder ascontrollerand the page’s epoch asparentEpoch. feeTokenzero means ETH.payoutzero means the caller, and it may be neither the registrar nor PageNFT.priceandminGateBalancemust fit in 96 bits.gateTokenandminGateBalanceare both set or both zero.- Sale checks, in order:
tois not the registrar; enabled; the fee token equalsexpectedFeeToken; the price is at mostmaxPrice; the commitment, orCommitRequiredfor a direct buy whencommitRevealis on; the parent is top level; its epoch equalsparentEpoch(StaleConfig); its holder iscontroller(StaleController); the registrar is its subpage minter (NotMinter); the label is free (NotAvailable); the caller holds at leastminGateBalanceofgateToken(GateFailed). - Payment: for ETH, send at least the price; the excess is sent back and the price is credited to
proceedsOf(payout). For a token, send no ETH (UnexpectedETH); the registrar moves the price from the caller to the payout withtransferFrom, so approve it first. - Commit and reveal: the commitment is
keccak256(abi.encode(parentId, normalizedLabel, to, secret)). It binds the receiver, not who reveals. The reveal must come 20 to 86400 seconds aftercommitSubpage.revealSubpagebuys for the caller. A reveal works for any sale, also one that does not ask for it. - PageNFT mints each subpage with the registrar as the caller, so a contract receiver must accept ERC-721 tokens, even when it is the buyer, unless it is an account with no code or an EIP-7702 delegation.
- The registrar never holds or moves a page, and a mint or safe transfer to it reverts. Never send it a
page with a plain
transferFrom: nothing can send it back.
event ParentConfigured(uint256 indexed parentId, address indexed controller, address indexed payout,
address feeToken, uint256 price, address gateToken, uint256 minGateBalance,
bool enabled, bool commitReveal, uint64 parentEpoch)
event Committed(bytes32 indexed commitment, address indexed committer)
event SubpageSold(uint256 indexed parentId, uint256 indexed subpageId, address indexed buyer, address to,
address payout, address feeToken, uint256 price, string label) // label as the buyer sent it
event ProceedsClaimed(address indexed account, address indexed to, uint256 amount)
Errors: NotAuthorized, NotEnabled, NotAvailable, GateFailed, BadGateConfig, BadFeeToken,
InsufficientFee, UnexpectedETH, UnexpectedFeeToken, PriceTooHigh, ValueTooLarge,
StaleController, NotMinter, StaleConfig, ParentNotTopLevel, CommitRequired, AlreadyCommitted,
CommitmentNotFound, CommitmentTooNew, CommitmentTooOld, NothingToClaim, ETHTransferFailed,
TransferFromFailed, InvalidAddress (constructor only) and Reentrancy. A PageNFT error from the mint
reaches the buyer unchanged.
PageCard
function render(string label, string parentLabel, bytes32 id, uint8 status, address payee, uint256 expiry) pure returns (string)
function shortAddress(address a) pure returns (string)
PageNFT.tokenURI calls render with the label, the parent’s label (empty for a top-level page), the
token id, the status, the payee and the expiry (the parent’s for a subpage), and embeds the SVG as the
token image. The card shows the name, then the expiry date for an active top-level page, the parent’s
label for an active subpage, or GRACE, TRIAL, EXPIRED or INVALID, and the payee’s short
address. Its pattern and fingerprint come from the id. PageCard holds no state and no money, and
PageNFT fixes its address in its constructor.
Building on Etherpage
The contracts are made to be built on without permission. Three patterns cover most needs.
Store your app’s data in text records. setText(tokenId, key, value) takes any key. Use a
reverse-DNS key you control, such as com.myapp.profile, and your app has its own namespace on every
page. Only the holder can write it. It is part of the holder’s profile, so it reads empty after a
transfer until the new holder keeps the profile, and a new registration empties it.
Key your own contract’s storage on the token id. For anything bigger than a text record, deploy
your own contract with mapping(uint256 tokenId => YourData). The id is fixed by the name, so any
contract can attach state to a page with its own rules.
Snapshot the epoch when you do. Ids are reused: when a page lapses and is registered again, the same
id is burned and minted again for the new holder. Store records(tokenId).epoch next to your data, or
check ownerOf when you read it, and treat a changed epoch as a new page, or your data carries over to
a stranger. For a subpage, also compare its parentEpoch with the parent’s epoch, or read status.