Skip to content
OpenSouk

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.

  • RegistriesProtocolAddressRegistry, MerchantRegistry, ProductRegistry, ReviewRegistry
  • Escrow and routerCommissionEscrow, SplitRouter, and the six addresses

The six, and the one they do not deploy

ContractWhat it holdsAccess model
ProtocolAddressRegistrythe address of every other contract, plus ORACLE, KEEPER and TREASURYOwnable
MerchantRegistryeach merchant's active and suspended flags, and its per-merchant rolesAccessControl
ProductRegistrythe product record: commission rate, active flag, product type, metadata URInone of its own
ReviewRegistryone record per published review, and the used-nonce and used-escrow setsAccessControl + EIP712
CommissionEscrowone record per purchase, and the USDC held against itAccessControl + Pausable + ReentrancyGuard
SplitRouterthe split arithmetic and the two payment entry pointsAccessControl + 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.

Loading diagram...

The same steps, in words:

  1. ProtocolAddressRegistry is deployed with AGENT_REGISTRY already 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, because setAddress queues even a first-time set.
  2. MerchantRegistry, which resolves the identity registry at construction — so step 1's seeding is what makes it deployable.
  3. ProductRegistry, which caches nothing but the address registry.
  4. ReviewRegistry, which also caches the identity registry as an immutable.
  5. CommissionEscrow, which caches the identity registry and the USDC address.
  6. SplitRouter, which caches the address registry and USDC, and takes the initial platform fee in basis points.
  7. The remaining registry keys are set, then the roles are granted. MERCHANT_REGISTRY, PRODUCT_REGISTRY, REVIEW_REGISTRY, ESCROW, SPLIT_ROUTER, ORACLE and KEEPER are queued with setAddress; if the delay is zero the script executes them immediately, and otherwise an operator calls executeUpdate for each once the delay matures. Then ORACLE_ROLE is granted on ReviewRegistry and CommissionEscrow, and FACILITATOR_ROLE on SplitRouter.

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.
ContractImmutableResolved live, per call
MerchantRegistrythe address registry, AGENT_REGISTRY
ProductRegistrythe address registryMERCHANT_REGISTRY
ReviewRegistrythe address registry, AGENT_REGISTRYESCROW, PRODUCT_REGISTRY
CommissionEscrowthe address registry, AGENT_REGISTRY, the USDC tokenSPLIT_ROUTER, TREASURY
SplitRouterthe address registry, the USDC tokenAGENT_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.

KeyPoints atRead by
AGENT_REGISTRYthe ERC-8004 IdentityRegistrythree contracts, at construction; the router, live
MERCHANT_REGISTRYMerchantRegistryProductRegistry, SplitRouter
PRODUCT_REGISTRYProductRegistryReviewRegistry, SplitRouter
REVIEW_REGISTRYReviewRegistrySplitRouter
ESCROWCommissionEscrowReviewRegistry, SplitRouter
SPLIT_ROUTERSplitRouterCommissionEscrow, to gate deposit
ORACLEthe backend hot walletSplitRouter, to check a charge attestation
KEEPERa scheduled-automation addressnothing
TREASURYthe protocol fee recipientCommissionEscrow, 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

RoleOnGates
ownerProtocolAddressRegistrysetAddress, cancelUpdate — not executeUpdate, which anyone may call
DEFAULT_ADMIN_ROLEMerchantRegistrysuspend, unsuspend, and moving the admin role itself — no other role
DEFAULT_ADMIN_ROLEReviewRegistryrole administration only — it has no parameters to set
DEFAULT_ADMIN_ROLECommissionEscrowsetSettlementWindow, pause, unpause, role administration
DEFAULT_ADMIN_ROLESplitRouterrecoverUSDC, setPlatformFeeBps, setMaxCashbackBps, role administration
ORACLE_ROLEReviewRegistrypublishReview
ORACLE_ROLECommissionEscrowsettleHeldBack, releaseCashback, cashbackToReviewer
FACILITATOR_ROLESplitRouterroute — and nothing else
per-merchant PRODUCT_MANAGER_ROLEMerchantRegistry, consumed by ProductRegistryaddProduct, setProductCardURI, setProductActive
per-merchant COMMISSION_MANAGER_ROLEMerchantRegistry, consumed by ProductRegistrysetCommission

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")"
done

getAddress 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