The contracts
Six deployments, and the one address you hardcode
A deployment is six contracts, and exactly one of their addresses is meant to be written down
anywhere: ProtocolAddressRegistry. Everything else is resolved through it. This tier is the
on-chain surface — functions, parameters, events, custom errors and the addresses. What each
mechanism is for, and which half of it is guaranteed by code rather than operated by us, are
defined in Protocol.
- Registries —
ProtocolAddressRegistry,MerchantRegistry,ProductRegistry,ReviewRegistry - Escrow and router —
CommissionEscrow,SplitRouter, and the six addresses
The six, and the one they do not deploy
| Contract | What it holds | Access model |
|---|---|---|
ProtocolAddressRegistry | the address of every other contract, plus ORACLE, KEEPER and TREASURY | Ownable |
MerchantRegistry | each merchant's active and suspended flags, and its per-merchant roles | AccessControl |
ProductRegistry | the product record: commission rate, active flag, product type, metadata URI | none of its own |
ReviewRegistry | one record per published review, and the used-nonce and used-escrow sets | AccessControl + EIP712 |
CommissionEscrow | one record per purchase, and the USDC held against it | AccessControl + Pausable + ReentrancyGuard |
SplitRouter | the split arithmetic and the two payment entry points | AccessControl + ReentrancyGuard + EIP712 |
Every ProductRegistry write calls MerchantRegistry.hasRole(merchantId, role, msg.sender)
instead, so authority over a product is the merchant's and no protocol admin can edit a catalogue.
The identity token itself is not one of the six. Agents and merchants are ERC-8004 identities
in an IdentityRegistry the deploy script takes as an input and never deploys. Three of the six
cache its address as an immutable, so a registry change does not move them — see the resolution
table below. Identity and commitments owns that record and the keys that may
act for it.
The deployment order
Seven steps, in this order, from script/Deploy.s.sol. The Base broadcast
artifact records the same six contract creations in the same sequence.
The same steps, in words:
ProtocolAddressRegistryis deployed withAGENT_REGISTRYalready in it. Its constructor takes an owner, a timelock delay and a list of key/value pairs, and writes those pairs directly. This is the only path that bypasses the timelock, becausesetAddressqueues even a first-time set.MerchantRegistry, which resolves the identity registry at construction — so step 1's seeding is what makes it deployable.ProductRegistry, which caches nothing but the address registry.ReviewRegistry, which also caches the identity registry as an immutable.CommissionEscrow, which caches the identity registry and the USDC address.SplitRouter, which caches the address registry and USDC, and takes the initial platform fee in basis points.- The remaining registry keys are set, then the roles are granted.
MERCHANT_REGISTRY,PRODUCT_REGISTRY,REVIEW_REGISTRY,ESCROW,SPLIT_ROUTER,ORACLEandKEEPERare queued withsetAddress; if the delay is zero the script executes them immediately, and otherwise an operator callsexecuteUpdatefor each once the delay matures. ThenORACLE_ROLEis granted onReviewRegistryandCommissionEscrow, andFACILITATOR_ROLEonSplitRouter.
The deployer keeps DEFAULT_ADMIN_ROLE on all four AccessControl contracts and ownership of the
registry. Moving that to a multisig is a grant followed by a revoke, not part of the deployment.
TREASURY is the other key that has to exist before the contracts do.
CommissionEscrow.settleHeldBack and triggerSettleFallback resolve it on every call;
SplitRouter.route() resolves it only when the platform fee leg is non-zero. A missing key reverts
AddressNotSet wherever it is read, so an unset treasury stops both settlement paths outright and
stops any payment that carries a platform fee — a zero-fee deployment would keep paying. The
current script seeds it through the constructor alongside AGENT_REGISTRY for that reason. The
recorded Base broadcast predates that change and set it with setAddress
instead — one of several ways that artifact differs from today's script.
How each contract finds the others
Two resolution styles, and which one a contract uses decides what a registry change does to it.
- Immutable at construction. The address is read once in the constructor and burned into the bytecode. Repointing that key in the registry does not move it; only a redeployment does.
- Live at call time. The address is read from the registry inside every call that needs it, so a matured registry change takes effect on the next transaction.
| Contract | Immutable | Resolved live, per call |
|---|---|---|
MerchantRegistry | the address registry, AGENT_REGISTRY | — |
ProductRegistry | the address registry | MERCHANT_REGISTRY |
ReviewRegistry | the address registry, AGENT_REGISTRY | ESCROW, PRODUCT_REGISTRY |
CommissionEscrow | the address registry, AGENT_REGISTRY, the USDC token | SPLIT_ROUTER, TREASURY |
SplitRouter | the address registry, the USDC token | AGENT_REGISTRY, MERCHANT_REGISTRY, PRODUCT_REGISTRY, REVIEW_REGISTRY, ESCROW, TREASURY, ORACLE |
That table is the map of what a timelocked address change can reach. AGENT_REGISTRY is cached by
three contracts, so repointing it moves the router's view of identities and leaves the other three
reading the old one — the identity registry is effectively immutable in practice. ESCROW and
TREASURY are live everywhere they are read, so changing the treasury is a registry operation
rather than a redeployment.
The registry keys
Nine bytes32 constants, each keccak256 of its own name, exposed as public getters on
ProtocolAddressRegistry.
| Key | Points at | Read by |
|---|---|---|
AGENT_REGISTRY | the ERC-8004 IdentityRegistry | three contracts, at construction; the router, live |
MERCHANT_REGISTRY | MerchantRegistry | ProductRegistry, SplitRouter |
PRODUCT_REGISTRY | ProductRegistry | ReviewRegistry, SplitRouter |
REVIEW_REGISTRY | ReviewRegistry | SplitRouter |
ESCROW | CommissionEscrow | ReviewRegistry, SplitRouter |
SPLIT_ROUTER | SplitRouter | CommissionEscrow, to gate deposit |
ORACLE | the backend hot wallet | SplitRouter, to check a charge attestation |
KEEPER | a scheduled-automation address | nothing |
TREASURY | the protocol fee recipient | CommissionEscrow, SplitRouter |
No deployed contract reads KEEPER and no function is gated on it. The time-based settlement path
is permissionless instead — Payment flow has it.
Note the asymmetry between ORACLE and ORACLE_ROLE. The registry key is what SplitRouter
compares a charge attestation's recovered signer against; the AccessControl role of the same name
on ReviewRegistry and CommissionEscrow is a separate grant. Rotating the oracle means changing
both, and they can disagree.
The roles
| Role | On | Gates |
|---|---|---|
| owner | ProtocolAddressRegistry | setAddress, cancelUpdate — not executeUpdate, which anyone may call |
DEFAULT_ADMIN_ROLE | MerchantRegistry | suspend, unsuspend, and moving the admin role itself — no other role |
DEFAULT_ADMIN_ROLE | ReviewRegistry | role administration only — it has no parameters to set |
DEFAULT_ADMIN_ROLE | CommissionEscrow | setSettlementWindow, pause, unpause, role administration |
DEFAULT_ADMIN_ROLE | SplitRouter | recoverUSDC, setPlatformFeeBps, setMaxCashbackBps, role administration |
ORACLE_ROLE | ReviewRegistry | publishReview |
ORACLE_ROLE | CommissionEscrow | settleHeldBack, releaseCashback, cashbackToReviewer |
FACILITATOR_ROLE | SplitRouter | route — and nothing else |
per-merchant PRODUCT_MANAGER_ROLE | MerchantRegistry, consumed by ProductRegistry | addProduct, setProductCardURI, setProductActive |
per-merchant COMMISSION_MANAGER_ROLE | MerchantRegistry, consumed by ProductRegistry | setCommission |
SplitRouter.splitCharge appears on no row because it has no role gate: it is authorised by an
oracle-signed attestation instead. x402 and MPP has why, and
Escrow and router has the attestation.
Reading state yourself
Every address on the addresses page can be read back out of the registry, which is the check worth doing before trusting any of them. Set the registry address once and derive the rest:
export RPC=https://mainnet.base.org
export PAR=<ProtocolAddressRegistry, from the addresses page>
# The delay in force on this deployment. It may be zero — read it, do not assume.
cast call "$PAR" 'timelockDelay()(uint256)' --rpc-url "$RPC"
# Every other contract, derived rather than pasted.
for KEY in MERCHANT_REGISTRY PRODUCT_REGISTRY REVIEW_REGISTRY ESCROW SPLIT_ROUTER TREASURY; do
printf '%-18s %s\n' "$KEY" \
"$(cast call "$PAR" 'getAddress(bytes32)(address)' "$(cast keccak "$KEY")" --rpc-url "$RPC")"
donegetAddress reverts AddressNotSet for a key that has never been set, rather than returning the
zero address, so an empty key is loud. A key with a change already queued still returns its
current value — getPending is the one that shows what is coming.
Enum values, and why this tier never prints an ordinal
Three enums appear in these ABIs: ProductType, ProofType and EscrowState. Solidity enum
member names never reach the ABI. Every one of them encodes as a uint8, so a decoded log or a
cast call gives you a number and nothing else, and the mapping from number to meaning lives only
in the source you compiled against — where the positions have been renumbered once already. These
pages therefore name members and never rely on an ordinal in prose; where a runnable example has
to pass a uint8, the fence names the member the number currently means, in a comment beside it.
Pin the ABI you decode with, and treat any number you see as meaningful only against that ABI.
Next steps
- Registries — the address registry and the three record-keepers
- Escrow and router — the money path, and the addresses
- Protocol — which of these powers is guaranteed by code, and which we operate