Registries
Where addresses, merchants, products and reviews are recorded
Four contracts, one page. ProtocolAddressRegistry is the directory the other three are found
through; MerchantRegistry, ProductRegistry and ReviewRegistry hold the records the money path
reads. Every example below assumes the shell variables set on
The contracts and an address list from
Escrow and router.
ProtocolAddressRegistry
Ownable, not AccessControl, and not upgradeable. Its own address is the one permanently
hardcoded value in the system. Every write is a two-phase queue: setAddress records a pending
value with an effective time, and a second call applies it.
Constructor
constructor(address initialOwner, uint256 _timelockDelay, bytes32[] memory keys, address[] memory values)| Parameter | Type | Notes |
|---|---|---|
initialOwner | address | Becomes owner(). Not checked against zero here — Ownable does that |
_timelockDelay | uint256 | Seconds. Stored as an immutable, so it can never be changed. May be zero |
keys | bytes32[] | Written directly, bypassing the queue |
values | address[] | Must be the same length as keys, and none may be zero |
# Deploying the registry with the two keys that must be readable before any other contract
# exists — AGENT_REGISTRY and TREASURY. A zero delay makes every later setAddress apply
# immediately; anything else queues.
forge create src/ProtocolAddressRegistry.sol:ProtocolAddressRegistry \
--constructor-args "$OWNER" 0 \
"[$(cast keccak AGENT_REGISTRY),$(cast keccak TREASURY)]" \
"[$IDENTITY_REGISTRY,$TREASURY]" \
--rpc-url "$RPC" --private-key "$DEPLOYER_KEY"
# Read one back. It is the same getter a matured update writes to.
cast call "$PAR" 'getAddress(bytes32)(address)' "$(cast keccak TREASURY)" --rpc-url "$RPC"Each seeded pair emits AddressUpdated(key, address(0), value) — the same event a matured update
emits, with a zero oldValue. Reverts LengthMismatch on unequal arrays and ZeroAddress on a
zero value.
setAddress
Queues a change. Does not apply it.
function setAddress(bytes32 key, address newValue) external // onlyOwner| Parameter | Type | Notes |
|---|---|---|
key | bytes32 | Any 32 bytes. The contract does not check it against its nine constants |
newValue | address | Must not be zero, so a key can never be unset once written |
Returns nothing. Emits AddressQueued(key, newValue, effectiveAt) with
effectiveAt = block.timestamp + timelockDelay().
cast send "$PAR" 'setAddress(bytes32,address)' \
"$(cast keccak TREASURY)" "$NEW_TREASURY" \
--rpc-url "$RPC" --private-key "$OWNER_KEY"Errors
| Revert | Cause |
|---|---|
OwnableUnauthorizedAccount(address) | Caller is not owner() |
ZeroAddress() | newValue is the zero address |
UpdateAlreadyPending(bytes32) | A queued change for this key has not been executed or cancelled |
executeUpdate
Applies a matured change. Open to anyone — deliberately, so a change cannot be stranded by an absent owner.
function executeUpdate(bytes32 key) external // no gate| Parameter | Type | Notes |
|---|---|---|
key | bytes32 | The key whose queued change should take effect |
Returns nothing. Emits AddressUpdated(key, oldValue, newValue) and clears the pending entry.
cast send "$PAR" 'executeUpdate(bytes32)' "$(cast keccak TREASURY)" \
--rpc-url "$RPC" --private-key "$ANY_KEY"Errors
| Revert | Cause |
|---|---|
NoPendingUpdate(bytes32) | Nothing queued for that key |
TimelockNotExpired(uint256 effectiveAt, uint256 currentTime) | Called before effectiveAt |
Where the delay is zero, queue and execute can land in the same transaction batch — which is what the deployment script does.
cancelUpdate
Drops a queued change before it matures.
function cancelUpdate(bytes32 key) external // onlyOwner| Parameter | Type | Notes |
|---|---|---|
key | bytes32 | The key whose pending change should be discarded |
Returns nothing. Emits AddressUpdateCancelled(key).
cast send "$PAR" 'cancelUpdate(bytes32)' "$(cast keccak ORACLE)" \
--rpc-url "$RPC" --private-key "$OWNER_KEY"Errors
| Revert | Cause |
|---|---|
OwnableUnauthorizedAccount(address) | Caller is not owner() |
NoPendingUpdate(bytes32) | Nothing queued for that key |
getAddress
function getAddress(bytes32 key) external view returns (address)| Parameter | Type | Notes |
|---|---|---|
key | bytes32 | One of the nine constants, in practice |
Returns the currently active address. Never the zero address — an unset key reverts instead.
cast call "$PAR" 'getAddress(bytes32)(address)' "$(cast keccak ESCROW)" --rpc-url "$RPC"Errors
| Revert | Cause |
|---|---|
AddressNotSet(bytes32) | The key has never been written |
getPending
function getPending(bytes32 key) external view returns (address pendingValue, uint256 effectiveAt)| Parameter | Type | Notes |
|---|---|---|
key | bytes32 | The key to inspect |
Returns the queued address and the timestamp it becomes active. Both are zero when nothing is queued, which is how you test for a pending change.
cast call "$PAR" 'getPending(bytes32)(address,uint256)' "$(cast keccak SPLIT_ROUTER)" --rpc-url "$RPC"Errors — none. Unlike getAddress, an unset key is reported as two zeros rather than a
revert.
timelockDelay
function timelockDelay() external view returns (uint256)Parameters — none.
Returns the delay in seconds, fixed at deployment.
cast call "$PAR" 'timelockDelay()(uint256)' --rpc-url "$RPC"Errors — none. A deployment is free to set the delay to zero, which makes every queued change effective immediately.
The key getters
Nine view functions returning bytes32, one per key, each equal to keccak256 of its own name:
AGENT_REGISTRY, MERCHANT_REGISTRY, PRODUCT_REGISTRY, REVIEW_REGISTRY, ESCROW,
SPLIT_ROUTER, ORACLE, KEEPER, TREASURY.
Parameters — none, on any of them. Returns the key.
cast call "$PAR" 'ESCROW()(bytes32)' --rpc-url "$RPC"
# identical to:
cast keccak ESCROWErrors — none. cast keccak derives them locally, as the loop on
The contracts does.
Events
| Event | Signature |
|---|---|
AddressQueued | (bytes32 indexed key, address indexed newValue, uint256 effectiveAt) |
AddressUpdated | (bytes32 indexed key, address indexed oldValue, address indexed newValue) |
AddressUpdateCancelled | (bytes32 indexed key) |
AddressUpdated indexes all three of its parameters, so a change can be filtered by key, by the
address being replaced, or by the address replacing it.
MerchantRegistry
Holds two booleans per merchant and a per-merchant role table. It mints nothing. A
merchantId is an ERC-8004 identity token id in the identity registry, and every merchant-scoped
function starts by calling ownerOf on it — an id nobody has minted reverts MerchantNotFound,
and transferring that token moves control of the merchant. Identity and
commitments owns that
relationship.
setActive
The merchant's own switch: whether new purchases and ref links are accepted.
function setActive(uint256 merchantId, bool active) external // identity owner only| Parameter | Type | Notes |
|---|---|---|
merchantId | uint256 | The identity token id |
active | bool | true to accept business, false to stop it |
Returns nothing. Emits MerchantActiveSet(merchantId, active), both parameters indexed.
cast send "$MERCHANT_REGISTRY" 'setActive(uint256,bool)' 12 true \
--rpc-url "$RPC" --private-key "$MERCHANT_KEY"Errors
| Revert | Cause |
|---|---|
MerchantNotFound(uint256) | ownerOf(merchantId) reverted — no such identity |
NotMerchantOwner(uint256, address) | Caller is not the identity's current owner |
MerchantIsSuspended(uint256) | Activating while protocol-suspended |
Neither merchant role can call this, and neither can the protocol admin — suspension is the admin's lever instead.
suspend
function suspend(uint256 merchantId, string calldata reason) external // DEFAULT_ADMIN_ROLE| Parameter | Type | Notes |
|---|---|---|
merchantId | uint256 | The identity token id. Must exist |
reason | string | Recorded in the event only; never stored |
Returns nothing. Emits MerchantSuspended(merchantId, reason).
cast send "$MERCHANT_REGISTRY" 'suspend(uint256,string)' 12 'payment endpoint offline' \
--rpc-url "$RPC" --private-key "$ADMIN_KEY"Errors
| Revert | Cause |
|---|---|
AccessControlUnauthorizedAccount(address, bytes32) | Caller lacks DEFAULT_ADMIN_ROLE |
MerchantNotFound(uint256) | No such identity |
Suspension overrides the merchant's own active flag rather than clearing it: isActive returns
false while suspended, and the merchant's stored flag is untouched.
unsuspend
function unsuspend(uint256 merchantId) external // DEFAULT_ADMIN_ROLE| Parameter | Type | Notes |
|---|---|---|
merchantId | uint256 | The identity token id. Must exist |
Returns nothing. Emits MerchantUnsuspended(merchantId).
cast send "$MERCHANT_REGISTRY" 'unsuspend(uint256)' 12 \
--rpc-url "$RPC" --private-key "$ADMIN_KEY"Errors
| Revert | Cause |
|---|---|
AccessControlUnauthorizedAccount(address, bytes32) | Caller lacks DEFAULT_ADMIN_ROLE |
MerchantNotFound(uint256) | No such identity |
Lifting a suspension restores whatever the merchant's own flag said, so a merchant that was inactive before being suspended is still inactive afterwards.
grantRole(uint256,bytes32,address)
The merchant-scoped grant. Note the arity: this is the three-argument overload, not
AccessControl's.
function grantRole(uint256 merchantId, bytes32 role, address account) external // identity owner only| Parameter | Type | Notes |
|---|---|---|
merchantId | uint256 | Scopes the grant. The same role for two merchants is two grants |
role | bytes32 | Only PRODUCT_MANAGER_ROLE or COMMISSION_MANAGER_ROLE are accepted |
account | address | The address being given the role |
Returns nothing. Emits MerchantRoleGranted(merchantId, role, account, msg.sender) — the
granter is on the event, which the flat AccessControl path does not give you.
cast send "$MERCHANT_REGISTRY" 'grantRole(uint256,bytes32,address)' \
12 "$(cast keccak PRODUCT_MANAGER_ROLE)" "$MANAGER" \
--rpc-url "$RPC" --private-key "$MERCHANT_KEY"Errors
| Revert | Cause |
|---|---|
MerchantNotFound(uint256) | No such identity |
NotMerchantOwner(uint256, address) | Caller is not the identity owner |
InvalidRole(bytes32) | role is neither of the two accepted roles |
Internally the stored role id is keccak256(abi.encodePacked(merchantId, role)), so the
underlying AccessControl table holds composite ids and a flat hasRole against a bare role name
will always read false.
revokeRole(uint256,bytes32,address)
function revokeRole(uint256 merchantId, bytes32 role, address account) external // identity owner only| Parameter | Type | Notes |
|---|---|---|
merchantId | uint256 | Scopes the revocation |
role | bytes32 | Must be one of the two accepted roles |
account | address | The address losing the role |
Returns nothing. Emits MerchantRoleRevoked(merchantId, role, account, msg.sender).
cast send "$MERCHANT_REGISTRY" 'revokeRole(uint256,bytes32,address)' \
12 "$(cast keccak COMMISSION_MANAGER_ROLE)" "$MANAGER" \
--rpc-url "$RPC" --private-key "$MERCHANT_KEY"Errors — the same three as the scoped grant: MerchantNotFound, NotMerchantOwner,
InvalidRole.
grantRole(bytes32,address) and revokeRole(bytes32,address)
The inherited flat AccessControl pair, deliberately narrowed.
function grantRole(bytes32 role, address account) public override
function revokeRole(bytes32 role, address account) public override| Parameter | Type | Notes |
|---|---|---|
role | bytes32 | Must be DEFAULT_ADMIN_ROLE. Anything else reverts |
account | address | The address being given or losing the admin role |
Returns nothing. Emits AccessControl's own RoleGranted / RoleRevoked.
# Rotating the protocol admin — the only role the flat path still accepts.
# DEFAULT_ADMIN_ROLE is bytes32(0) — derive it rather than pasting 64 zeros.
DEFAULT_ADMIN_ROLE=$(cast to-bytes32 0)
cast send "$MERCHANT_REGISTRY" 'grantRole(bytes32,address)' \
"$DEFAULT_ADMIN_ROLE" "$NEW_ADMIN" \
--rpc-url "$RPC" --private-key "$ADMIN_KEY"Errors
| Revert | Cause |
|---|---|
UseMerchantScopedRole() | role is anything other than DEFAULT_ADMIN_ROLE |
AccessControlUnauthorizedAccount(address, bytes32) | Caller lacks the role's admin role |
Narrowing this path is what stops the protocol admin handing itself a merchant's
PRODUCT_MANAGER_ROLE directly, which would bypass the owner gate, the role allowlist and the
MerchantRole* events all at once.
hasRole(uint256,bytes32,address)
function hasRole(uint256 merchantId, bytes32 role, address account) external view returns (bool)| Parameter | Type | Notes |
|---|---|---|
merchantId | uint256 | Must exist |
role | bytes32 | Must be one of the two accepted roles |
account | address | The address being tested |
Returns true if account holds the scoped role — or is the identity owner, who is
always treated as holding every merchant role without a grant.
cast call "$MERCHANT_REGISTRY" 'hasRole(uint256,bytes32,address)(bool)' \
12 "$(cast keccak PRODUCT_MANAGER_ROLE)" "$MANAGER" --rpc-url "$RPC"Errors
| Revert | Cause |
|---|---|
MerchantNotFound(uint256) | No such identity |
InvalidRole(bytes32) | role is neither accepted role — and account is not the identity owner |
The owner shortcut runs before the role allowlist, so an owner queried with a role that does
not exist returns true rather than reverting.
This is the function ProductRegistry calls on every write, so its owner shortcut is why a
merchant needs no self-grant to manage its own catalogue.
isActive
function isActive(uint256 merchantId) external view returns (bool)| Parameter | Type | Notes |
|---|---|---|
merchantId | uint256 | Must exist |
Returns the merchant's own flag and not suspended — one boolean covering both conditions.
cast call "$MERCHANT_REGISTRY" 'isActive(uint256)(bool)' 12 --rpc-url "$RPC"Errors
| Revert | Cause |
|---|---|
MerchantNotFound(uint256) | No such identity |
SplitRouter calls this on both rails, so a false here stops payments. An unknown id reverts
rather than reading false, so a successful call of either polarity proves a record exists.
isSuspended
function isSuspended(uint256 merchantId) external view returns (bool)| Parameter | Type | Notes |
|---|---|---|
merchantId | uint256 | Must exist |
Returns only the suspension flag, so it distinguishes "the merchant switched itself off" from
"we switched them off" — which isActive collapses.
cast call "$MERCHANT_REGISTRY" 'isSuspended(uint256)(bool)' 12 --rpc-url "$RPC"Errors
| Revert | Cause |
|---|---|
MerchantNotFound(uint256) | No such identity |
Events and errors
| Event | Signature |
|---|---|
MerchantActiveSet | (uint256 indexed merchantId, bool indexed active) |
MerchantSuspended | (uint256 indexed merchantId, string reason) |
MerchantUnsuspended | (uint256 indexed merchantId) |
MerchantRoleGranted | (uint256 indexed merchantId, bytes32 indexed role, address indexed account, address granter) |
MerchantRoleRevoked | (uint256 indexed merchantId, bytes32 indexed role, address indexed account, address revoker) |
Errors: NotMerchantOwner(uint256, address), MerchantIsSuspended(uint256),
MerchantNotFound(uint256), UseMerchantScopedRole(), InvalidRole(bytes32).
ProductRegistry
The product record, and the only contract here with no access control of its own. Every write
delegates its gate to MerchantRegistry.hasRole, resolved from the address registry on each call.
The record's fields, and what product type changes downstream, are on
Identity and commitments.
Products are keyed by (merchantId, productId). A productId is any bytes32 the merchant
chooses — in practice keccak256 of a slug — and is unique per merchant, so two merchants may use
the same id for different products.
addProduct
function addProduct(
uint256 merchantId,
bytes32 productId,
uint16 commissionBps,
ProductType productType,
string calldata productCardURI
) external // PRODUCT_MANAGER_ROLE, or the identity owner| Parameter | Type | Notes |
|---|---|---|
merchantId | uint256 | Must be active — not merely registered |
productId | bytes32 | Must not already exist for this merchant |
commissionBps | uint16 | 0 to 10000. This is the rate the router reads live at settlement |
productType | ProductType | Repeat or OneOff. Encoded as uint8; out-of-range values fail ABI decoding before the body runs |
productCardURI | string | Off-chain metadata pointer. May be empty, and is not emitted |
Returns nothing. Emits ProductAdded(merchantId, productId, commissionBps), which carries
neither the product type nor the URI — read those with getProduct.
# The uint8 after the rate is productType: 0 is ProductType.Repeat, 1 is OneOff.
cast send "$PRODUCT_REGISTRY" 'addProduct(uint256,bytes32,uint16,uint8,string)' \
12 "$(cast keccak premium-plan)" 500 0 'ipfs://bafy…' \
--rpc-url "$RPC" --private-key "$MERCHANT_KEY"Errors
| Revert | Cause |
|---|---|
Unauthorised(uint256, bytes32, address) | Caller holds neither PRODUCT_MANAGER_ROLE for this merchant nor its identity |
MerchantNotActive(uint256) | The merchant's isActive is false |
ProductAlreadyExists(uint256, bytes32) | That id is taken for this merchant |
InvalidRates(uint16) | commissionBps above 10000 |
MerchantNotFound(uint256) | Bubbles up from MerchantRegistry for an unknown merchant |
createdAt is stamped from block.timestamp and is what existence is tested against, so it is
never zero for a live product.
setProductCardURI
function setProductCardURI(uint256 merchantId, bytes32 productId, string calldata uri) external
// PRODUCT_MANAGER_ROLE, or the identity owner| Parameter | Type | Notes |
|---|---|---|
merchantId | uint256 | The owning merchant |
productId | bytes32 | Must exist |
uri | string | The new metadata pointer. May be empty, which clears it |
Returns nothing. Emits ProductCardURIUpdated(merchantId, productId, uri).
cast send "$PRODUCT_REGISTRY" 'setProductCardURI(uint256,bytes32,string)' \
12 "$(cast keccak premium-plan)" 'https://example.test/card.json' \
--rpc-url "$RPC" --private-key "$MERCHANT_KEY"Errors
| Revert | Cause |
|---|---|
Unauthorised(uint256, bytes32, address) | Missing PRODUCT_MANAGER_ROLE |
ProductNotFound(uint256, bytes32) | No product at that id |
Unlike addProduct, this does not require the merchant to be active — metadata can be corrected
on a merchant that has switched itself off.
setProductActive
function setProductActive(uint256 merchantId, bytes32 productId, bool active) external
// PRODUCT_MANAGER_ROLE, or the identity owner| Parameter | Type | Notes |
|---|---|---|
merchantId | uint256 | The owning merchant |
productId | bytes32 | Must exist |
active | bool | false stops new purchases and new reviews for this product |
Returns nothing. Emits ProductActiveSet(merchantId, productId, active), all three indexed.
cast send "$PRODUCT_REGISTRY" 'setProductActive(uint256,bytes32,bool)' \
12 "$(cast keccak premium-plan)" false \
--rpc-url "$RPC" --private-key "$MERCHANT_KEY"Errors
| Revert | Cause |
|---|---|
Unauthorised(uint256, bytes32, address) | Missing PRODUCT_MANAGER_ROLE |
ProductNotFound(uint256, bytes32) | No product at that id |
Deactivating reaches further than a catalogue listing: ReviewRegistry refuses to publish a review
for an inactive product on both paths, and SplitRouter refuses to settle one on both rails.
Escrows already open are unaffected.
setCommission
function setCommission(uint256 merchantId, bytes32 productId, uint16 commissionBps) external
// COMMISSION_MANAGER_ROLE, or the identity owner| Parameter | Type | Notes |
|---|---|---|
merchantId | uint256 | The owning merchant |
productId | bytes32 | Must exist |
commissionBps | uint16 | 0 to 10000 |
Returns nothing. Emits CommissionUpdated(merchantId, productId, newCommissionBps).
cast send "$PRODUCT_REGISTRY" 'setCommission(uint256,bytes32,uint16)' \
12 "$(cast keccak premium-plan)" 750 \
--rpc-url "$RPC" --private-key "$MERCHANT_KEY"Errors
| Revert | Cause |
|---|---|
Unauthorised(uint256, bytes32, address) | Missing COMMISSION_MANAGER_ROLE |
ProductNotFound(uint256, bytes32) | No product at that id |
InvalidRates(uint16) | Above 10000 |
This takes effect retroactively on anything not yet settled. The router reads the rate from here at settlement, so a change applies to every purchase that settles after it lands — including ones authorised, and ref links issued, under the previous rate. Nothing on-chain locks a rate to a purchase. Payment flow has the split this feeds.
getProduct
function getProduct(uint256 merchantId, bytes32 productId) external view returns (Product memory)| Parameter | Type | Notes |
|---|---|---|
merchantId | uint256 | The owning merchant |
productId | bytes32 | Must exist |
Returns the full record:
| Field | Type | Notes |
|---|---|---|
merchantId | uint256 | Echoes the argument |
productId | bytes32 | Echoes the argument |
commissionBps | uint16 | The live rate |
active | bool | The product's own flag. Says nothing about the merchant's |
productCardURI | string | May be empty |
createdAt | uint40 | Seconds. Non-zero for any existing product |
productType | ProductType | Decodes as uint8 — see the note on enums on The contracts |
cast call "$PRODUCT_REGISTRY" \
'getProduct(uint256,bytes32)((uint256,bytes32,uint16,bool,string,uint40,uint8))' \
12 "$(cast keccak premium-plan)" --rpc-url "$RPC"Errors
| Revert | Cause |
|---|---|
ProductNotFound(uint256, bytes32) | No product at that id |
isProductActive
function isProductActive(uint256 merchantId, bytes32 productId) external view returns (bool)| Parameter | Type | Notes |
|---|---|---|
merchantId | uint256 | Need not exist |
productId | bytes32 | Need not exist |
Returns true only when the product exists, its own flag is set, and the merchant is active.
It never reverts once the product exists and MERCHANT_REGISTRY is set — a missing product, a
missing merchant, or a failed call into MerchantRegistry all return false, because the merchant
read is wrapped in a try/catch. The key lookup itself sits outside that try, so an unset
MERCHANT_REGISTRY reverts AddressNotSet — unreachable while setAddress refuses the zero
address, but it is the one input that is not answered with false.
cast call "$PRODUCT_REGISTRY" 'isProductActive(uint256,bytes32)(bool)' \
12 "$(cast keccak premium-plan)" --rpc-url "$RPC"Errors — none, once MERCHANT_REGISTRY is set. A false here does not
distinguish "deactivated" from "the merchant registry is unreachable". Both the router and
ReviewRegistry gate on this single boolean, so a repointed or broken MERCHANT_REGISTRY key
reads as "every product is inactive" and stops business quietly rather than reverting loudly.
Events and errors
| Event | Signature |
|---|---|
ProductAdded | (uint256 indexed merchantId, bytes32 indexed productId, uint16 commissionBps) |
CommissionUpdated | (uint256 indexed merchantId, bytes32 indexed productId, uint16 newCommissionBps) |
ProductActiveSet | (uint256 indexed merchantId, bytes32 indexed productId, bool indexed active) |
ProductCardURIUpdated | (uint256 indexed merchantId, bytes32 indexed productId, string uri) |
Errors: Unauthorised(uint256, bytes32, address), ProductAlreadyExists(uint256, bytes32),
ProductNotFound(uint256, bytes32), InvalidRates(uint16), MerchantNotActive(uint256).
ReviewRegistry
One record per published review, plus the two replay guards that make the records unforgeable: a
per-agent nonce set for the oracle path, and a used-escrow set for the agent-direct path. It is an
EIP712 domain in its own right — name ReviewRegistry, version 1, verifyingContract itself —
and it is the only contract that recovers a signature produced by an agent. SplitRouter
recovers one too, but the oracle's.
The eight commitments has the ReviewCommitment type string byte
for byte.
Review ids start at 1, so reviewId zero is the "no review" sentinel the router relies on.
publishReview
The oracle path: we submit and pay the gas, the agent's signature authorises it.
function publishReview(
uint256 agentId,
uint256 merchantId,
bytes32 productId,
bytes32 contentHash,
ProofType proofType,
Sig memory eip712Sig
) external returns (uint256 reviewId) // ORACLE_ROLE| Parameter | Type | Notes |
|---|---|---|
agentId | uint256 | Must be a registered identity. Agent id 0 is a real identity |
merchantId | uint256 | The product's merchant |
productId | bytes32 | Must be active, checked after the signature |
contentHash | bytes32 | keccak256(abi.encode(agentId, merchantId, productId, contentJSON)) |
proofType | ProofType | Must be AdminApproved. InSystemPurchase is rejected on this path |
eip712Sig | Sig | { uint256 nonce; uint256 expiry; bytes agentSignature } |
Returns the new reviewId. Emits
ReviewPublished(reviewId, agentId, merchantId, productId, contentHash, proofType, timestamp).
# Encoding the Sig tuple by hand; the signature is 65 bytes over the ReviewCommitment digest.
# The uint8 before the tuple is proofType: 0 is ProofType.AdminApproved, the only tier this
# path accepts. 1 is InSystemPurchase, which it rejects.
cast send "$REVIEW_REGISTRY" \
'publishReview(uint256,uint256,bytes32,bytes32,uint8,(uint256,uint256,bytes))' \
7 12 "$(cast keccak premium-plan)" "$CONTENT_HASH" 0 "($NONCE,$EXPIRY,$AGENT_SIG)" \
--rpc-url "$RPC" --private-key "$ORACLE_KEY"Errors, in the order they are checked:
| Revert | Cause |
|---|---|
AccessControlUnauthorizedAccount(address, bytes32) | Caller lacks ORACLE_ROLE |
InvalidProofTypeForPath(ProofType) | proofType is InSystemPurchase |
SignatureExpired(uint256 expiry, uint256 blockTimestamp) | expiry has passed |
NonceAlreadyUsed(uint256 agentId, uint256 nonce) | That nonce is already burned for that agent |
AgentNotRegistered(uint256) | ownerOf(agentId) reverted |
InvalidAgentSignature(uint256 agentId, address recovered, address agentOwner, address agentWallet) | The recovered signer is neither the owner nor the configured agent wallet |
ProductNotActive(uint256, bytes32) | isProductActive is false |
The nonce is burned only after every check passes, so a reverted attempt does not consume it.
This contract accepts either key. The recovered signer may be the identity owner or the configured agent wallet. Our own two-step gate in front of it is narrower — owner only — so in practice a commitment routed through us must be signed by the owner key; see EIP-712 commitments.
publishReviewWithEscrowProof
The agent-direct path. No role, no signature, no oracle: the contract reads the escrow record and checks the proof itself.
function publishReviewWithEscrowProof(uint256 agentId, bytes32 contentHash, uint256 escrowId)
external returns (uint256 reviewId) // caller must control agentId| Parameter | Type | Notes |
|---|---|---|
agentId | uint256 | Caller must be its owner or its configured agent wallet |
contentHash | bytes32 | Same construction as the oracle path |
escrowId | uint256 | Must be an escrow whose buyerAgentId equals agentId, and unused here before |
Returns the new reviewId. merchantId and productId are taken from the escrow record
rather than supplied, and proofType is always InSystemPurchase. Emits the same
ReviewPublished.
cast send "$REVIEW_REGISTRY" 'publishReviewWithEscrowProof(uint256,bytes32,uint256)' \
7 "$CONTENT_HASH" 41 \
--rpc-url "$RPC" --private-key "$AGENT_KEY"Errors
| Revert | Cause |
|---|---|
CallerNotAgentOwner(uint256, address) | Caller controls neither key for agentId |
AgentNotRegistered(uint256) | ownerOf(agentId) reverted |
EscrowNotFound(uint256) | Bubbles up from CommissionEscrow.getEscrow |
EscrowMismatch(uint256) | The escrow's buyer is a different agent, or that escrow already backed a review |
ProductNotActive(uint256, bytes32) | The escrow's product has been deactivated since the purchase |
Check getEscrow(escrowId).buyerAgentId to tell EscrowMismatch's two causes apart.
Publishing here releases no money. Cashback release is a separate oracle transaction on the escrow, gated on the review having been synced, so the reviewer's own transaction ends at the record. Commission & cashback has the release path.
updateReviewContent
function updateReviewContent(uint256 reviewId, bytes32 newContentHash) external
// caller must control the review's agent| Parameter | Type | Notes |
|---|---|---|
reviewId | uint256 | Must exist, and be an InSystemPurchase review |
newContentHash | bytes32 | The replacement hash. Not validated against anything |
Returns nothing. Overwrites contentHash, stamps editedAt with block.timestamp, and emits
ReviewContentUpdated(reviewId, newContentHash, editedAt).
cast send "$REVIEW_REGISTRY" 'updateReviewContent(uint256,bytes32)' 88 "$NEW_HASH" \
--rpc-url "$RPC" --private-key "$AGENT_KEY"Errors
| Revert | Cause |
|---|---|
ReviewNotFound(uint256) | No review at that id |
NotEditablePath(uint256) | The review's proofType is AdminApproved |
CallerNotAgentOwner(uint256, address) | Caller controls neither key for the review's agent |
EditWindowClosed(uint256) | More than EDIT_WINDOW has passed since publication |
The window is measured from the original timestamp, not from the last edit, so repeated edits do
not extend it. editedAt is permanent once set: there is no way to make an edited review look
unedited.
getReview
function getReview(uint256 reviewId) external view returns (Review memory)| Parameter | Type | Notes |
|---|---|---|
reviewId | uint256 | Must exist |
Returns the record:
| Field | Type | Notes |
|---|---|---|
agentId | uint256 | The reviewer agent |
merchantId | uint256 | The product's merchant |
productId | bytes32 | The product |
contentHash | bytes32 | What the off-chain content must hash to |
proofType | ProofType | AdminApproved or InSystemPurchase, permanently visible |
timestamp | uint40 | Publication time |
editedAt | uint40 | Last edit, or 0 if never edited |
cast call "$REVIEW_REGISTRY" \
'getReview(uint256)((uint256,uint256,bytes32,bytes32,uint8,uint40,uint40))' 88 --rpc-url "$RPC"Errors
| Revert | Cause |
|---|---|
ReviewNotFound(uint256) | No review at that id |
The struct declares contentHash before proofType; the tuple above follows the declaration
order, which is what the ABI encodes.
exists
function exists(uint256 reviewId) external view returns (bool)| Parameter | Type | Notes |
|---|---|---|
reviewId | uint256 | Any id |
Returns whether a record is stored, tested on timestamp being non-zero.
cast call "$REVIEW_REGISTRY" 'exists(uint256)(bool)' 88 --rpc-url "$RPC"Errors — none. This is the non-reverting probe getReview is not.
totalReviews
function totalReviews() external view returns (uint256)Parameters — none.
Returns the count published so far, which because ids start at 1 is also the highest id issued.
cast call "$REVIEW_REGISTRY" 'totalReviews()(uint256)' --rpc-url "$RPC"Errors — none. Nothing deletes a review, so this only ever increases.
isNonceUsed
function isNonceUsed(uint256 agentId, uint256 nonce) external view returns (bool)| Parameter | Type | Notes |
|---|---|---|
agentId | uint256 | Nonces are scoped per agent, so two agents may use the same value |
nonce | uint256 | The value from the commitment |
Returns whether that agent has already burned that nonce.
cast call "$REVIEW_REGISTRY" 'isNonceUsed(uint256,uint256)(bool)' 7 "$NONCE" --rpc-url "$RPC"Errors — none, and it does not check that agentId exists: an unregistered agent reads
false for every nonce.
ORACLE_ROLE and EDIT_WINDOW
function ORACLE_ROLE() external view returns (bytes32)
function EDIT_WINDOW() external view returns (uint256)Parameters — none, on either.
Returns the role id — keccak256("ORACLE_ROLE") — and the edit window in seconds.
cast call "$REVIEW_REGISTRY" 'EDIT_WINDOW()(uint256)' --rpc-url "$RPC" # 7 days, in seconds
cast call "$REVIEW_REGISTRY" 'ORACLE_ROLE()(bytes32)' --rpc-url "$RPC"Errors — none. Both are constant, compiled into the bytecode rather than stored, so unlike
the escrow's settlement window neither can be changed by an admin. A redeployment can carry a
different constant.
Events and errors
| Event | Signature |
|---|---|
ReviewPublished | (uint256 indexed reviewId, uint256 indexed agentId, uint256 indexed merchantId, bytes32 productId, bytes32 contentHash, ProofType proofType, uint40 timestamp) |
ReviewContentUpdated | (uint256 indexed reviewId, bytes32 indexed contentHash, uint40 editedAt) |
ReviewPublished indexes reviewId, agentId and merchantId — the three-topic limit — so
productId is in the data and cannot be filtered on directly.
Errors: AgentNotRegistered(uint256), ProductNotActive(uint256, bytes32),
ReviewNotFound(uint256), SignatureExpired(uint256, uint256),
NonceAlreadyUsed(uint256, uint256),
InvalidAgentSignature(uint256, address, address, address),
InvalidProofTypeForPath(ProofType), CallerNotAgentOwner(uint256, address),
EscrowMismatch(uint256), NotEditablePath(uint256), EditWindowClosed(uint256).
Next steps
- Escrow and router — the money path, and the addresses
- The contracts — the deployment order and the resolution map
- Reviews — the two proof tiers these two paths correspond to