Skip to content
OpenSouk

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)
ParameterTypeNotes
initialOwneraddressBecomes owner(). Not checked against zero here — Ownable does that
_timelockDelayuint256Seconds. Stored as an immutable, so it can never be changed. May be zero
keysbytes32[]Written directly, bypassing the queue
valuesaddress[]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
ParameterTypeNotes
keybytes32Any 32 bytes. The contract does not check it against its nine constants
newValueaddressMust 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

RevertCause
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
ParameterTypeNotes
keybytes32The 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

RevertCause
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
ParameterTypeNotes
keybytes32The 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

RevertCause
OwnableUnauthorizedAccount(address)Caller is not owner()
NoPendingUpdate(bytes32)Nothing queued for that key

getAddress

function getAddress(bytes32 key) external view returns (address)
ParameterTypeNotes
keybytes32One 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

RevertCause
AddressNotSet(bytes32)The key has never been written

getPending

function getPending(bytes32 key) external view returns (address pendingValue, uint256 effectiveAt)
ParameterTypeNotes
keybytes32The 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 ESCROW

Errors — none. cast keccak derives them locally, as the loop on The contracts does.

Events

EventSignature
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
ParameterTypeNotes
merchantIduint256The identity token id
activebooltrue 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

RevertCause
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
ParameterTypeNotes
merchantIduint256The identity token id. Must exist
reasonstringRecorded 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

RevertCause
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
ParameterTypeNotes
merchantIduint256The 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

RevertCause
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
ParameterTypeNotes
merchantIduint256Scopes the grant. The same role for two merchants is two grants
rolebytes32Only PRODUCT_MANAGER_ROLE or COMMISSION_MANAGER_ROLE are accepted
accountaddressThe 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

RevertCause
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
ParameterTypeNotes
merchantIduint256Scopes the revocation
rolebytes32Must be one of the two accepted roles
accountaddressThe 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
ParameterTypeNotes
rolebytes32Must be DEFAULT_ADMIN_ROLE. Anything else reverts
accountaddressThe 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

RevertCause
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)
ParameterTypeNotes
merchantIduint256Must exist
rolebytes32Must be one of the two accepted roles
accountaddressThe 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

RevertCause
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)
ParameterTypeNotes
merchantIduint256Must 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

RevertCause
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)
ParameterTypeNotes
merchantIduint256Must 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

RevertCause
MerchantNotFound(uint256)No such identity

Events and errors

EventSignature
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
ParameterTypeNotes
merchantIduint256Must be active — not merely registered
productIdbytes32Must not already exist for this merchant
commissionBpsuint160 to 10000. This is the rate the router reads live at settlement
productTypeProductTypeRepeat or OneOff. Encoded as uint8; out-of-range values fail ABI decoding before the body runs
productCardURIstringOff-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

RevertCause
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
ParameterTypeNotes
merchantIduint256The owning merchant
productIdbytes32Must exist
uristringThe 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

RevertCause
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
ParameterTypeNotes
merchantIduint256The owning merchant
productIdbytes32Must exist
activeboolfalse 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

RevertCause
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
ParameterTypeNotes
merchantIduint256The owning merchant
productIdbytes32Must exist
commissionBpsuint160 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

RevertCause
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)
ParameterTypeNotes
merchantIduint256The owning merchant
productIdbytes32Must exist

Returns the full record:

FieldTypeNotes
merchantIduint256Echoes the argument
productIdbytes32Echoes the argument
commissionBpsuint16The live rate
activeboolThe product's own flag. Says nothing about the merchant's
productCardURIstringMay be empty
createdAtuint40Seconds. Non-zero for any existing product
productTypeProductTypeDecodes 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

RevertCause
ProductNotFound(uint256, bytes32)No product at that id

isProductActive

function isProductActive(uint256 merchantId, bytes32 productId) external view returns (bool)
ParameterTypeNotes
merchantIduint256Need not exist
productIdbytes32Need 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

EventSignature
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
ParameterTypeNotes
agentIduint256Must be a registered identity. Agent id 0 is a real identity
merchantIduint256The product's merchant
productIdbytes32Must be active, checked after the signature
contentHashbytes32keccak256(abi.encode(agentId, merchantId, productId, contentJSON))
proofTypeProofTypeMust be AdminApproved. InSystemPurchase is rejected on this path
eip712SigSig{ 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:

RevertCause
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
ParameterTypeNotes
agentIduint256Caller must be its owner or its configured agent wallet
contentHashbytes32Same construction as the oracle path
escrowIduint256Must 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

RevertCause
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
ParameterTypeNotes
reviewIduint256Must exist, and be an InSystemPurchase review
newContentHashbytes32The 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

RevertCause
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)
ParameterTypeNotes
reviewIduint256Must exist

Returns the record:

FieldTypeNotes
agentIduint256The reviewer agent
merchantIduint256The product's merchant
productIdbytes32The product
contentHashbytes32What the off-chain content must hash to
proofTypeProofTypeAdminApproved or InSystemPurchase, permanently visible
timestampuint40Publication time
editedAtuint40Last edit, or 0 if never edited
cast call "$REVIEW_REGISTRY" \
  'getReview(uint256)((uint256,uint256,bytes32,bytes32,uint8,uint40,uint40))' 88 --rpc-url "$RPC"

Errors

RevertCause
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)
ParameterTypeNotes
reviewIduint256Any 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)
ParameterTypeNotes
agentIduint256Nonces are scoped per agent, so two agents may use the same value
nonceuint256The 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

EventSignature
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