Skip to content
OpenSouk

Escrow and router

The two contracts that move money, and their addresses

SplitRouter receives a purchase and divides it; CommissionEscrow holds the part that does not pay out immediately. Between them they are the only two contracts that hold or transfer USDC. Payment flow defines the split arithmetic, the escrow's two states and its window; x402 and MPP defines the two payment rails.

Every example below uses these four shell variables:

export RPC=https://mainnet.base.org
 
# Both addresses are derived from the registry in The addresses, below.
export ESCROW=
export SPLIT_ROUTER=
 
# The block CommissionEscrow was created in. Log queries start here so they never
# scan the pre-deploy range, which some RPCs refuse outright.
export DEPLOY_BLOCK=

CommissionEscrow

One record per purchase, created by the router and never by anyone else. AccessControl plus Pausable plus ReentrancyGuard: every function that moves USDC or creates a record is nonReentrant and whenNotPaused, the permissionless fallback included, so an admin pause stops all of them. The three admin setters carry neither guard.

Escrow ids start at 1.

EscrowState

Two members, Holding and Settled, and no others. Settled is terminal — there is no path back — and it says nothing about the cashback leg, which is tracked by the separate cashbackReleased boolean. So a record can be Holding with its cashback already paid, or Settled with its cashback never paid. Read both fields.

The enum encodes as uint8 and its member names do not reach the ABI, so decode against a pinned ABI — see the note on enums on The contracts.

EscrowRecord

What getEscrow returns, in declaration order:

FieldTypeNotes
reviewerAgentIduint256Derived by the router from the review, not supplied by the payer
buyerAgentIduint256SENTINEL_BUYERtype(uint256).max — for an agent-less purchase
buyerWalletaddressThe wallet that actually paid. Never zero
merchantIduint256The merchant identity
productIdbytes32The product
heldBackAmtuint128USDC held for the reviewer
cashbackAmtuint128USDC reserved for the buyer, funded out of the reviewer's commission
originalPurchaseAmtuint128The gross amount, kept for the settlement policy's own maths
depositTimestampuint40Non-zero for any existing record — this is what existence is tested on
windowEnduint40depositTimestamp plus the window in force at deposit
productTypeuint80 and 1 mirror ProductType, but this field is a plain uint8, not the enum
stateEscrowStateHolding or Settled
cashbackReleasedboolSet by either cashback path, and by the fallback

windowEnd is stamped once. An admin changing the settlement window later does not move it, so two escrows deposited a day apart can have different windows.

deposit

Called by SplitRouter and nobody else, so it is a call you decode rather than send.

function deposit(
  uint256 reviewerAgentId, uint256 buyerAgentId, address buyerWallet,
  uint256 merchantId, bytes32 productId, uint8 productType,
  uint128 heldBackAmt, uint128 cashbackAmt, uint128 originalPurchaseAmt, uint256 reviewId
) external returns (uint256 escrowId) // caller must be the registry's SPLIT_ROUTER
ParameterTypeNotes
reviewerAgentIduint256May be 0 — agent id 0 is a real identity, so this is not a sentinel
buyerAgentIduint256SENTINEL_BUYER when the buyer had no identity
buyerWalletaddressMust not be zero, or the cashback reserve would be unclaimable
productTypeuint8Passed through from the payment, not read from ProductRegistry
reviewIduint256Emitted only. It is not stored on the record

Returns the new escrowId. Emits EscrowDeposited(escrowId, reviewerAgentId, buyerAgentId, buyerWallet, reviewId, merchantId, productId, heldBackAmt, cashbackAmt).

# You decode these rather than send them. Every escrow ever created, newest last:
cast logs --from-block "$DEPLOY_BLOCK" --address "$ESCROW" \
  'EscrowDeposited(uint256,uint256,uint256,address,uint256,uint256,bytes32,uint128,uint128)' \
  --rpc-url "$RPC"

Errors

RevertCause
CallerNotSplitRouter(address)Caller is not the address under the registry's SPLIT_ROUTER key
ZeroBuyerWallet()buyerWallet is the zero address
EnforcedPause()The escrow is paused — which is what makes a pause stop all purchases

The router gate is resolved live from the registry on every call, so a matured SPLIT_ROUTER change immediately locks out the old router and admits the new one. reviewId living only in the event is why an indexer has to read the log to link an escrow to its review.

bind

The agent-less claim path: a wallet that paid without an identity attaches one afterwards.

function bind(uint256 escrowId, uint256 agentId) external // buyer wallet, controlling agentId
ParameterTypeNotes
escrowIduint256Must exist, and its buyerAgentId must still be SENTINEL_BUYER
agentIduint256Must be an identity the caller owns, or whose agent wallet it is

Returns nothing. Sets buyerAgentId and emits EscrowBound(escrowId, agentId, boundBy).

cast send "$ESCROW" 'bind(uint256,uint256)' 41 7 \
  --rpc-url "$RPC" --private-key "$BUYER_KEY"

Errors

RevertCause
EscrowNotFound(uint256)No record at that id
AlreadyBound(uint256)buyerAgentId is not the sentinel — either already bound, or never agent-less
NotBuyerWallet(uint256, address)Caller is not the buyerWallet on the record
NotAgentController(uint256, address)Caller controls neither key for agentId
EnforcedPause()The escrow is paused

One shot, and no unbind. The ownerOf(agentId) read here is not wrapped, so an unminted agentId reverts with the identity registry's own ERC-721 error rather than a custom one from this contract.

Binding is what makes the cashback releasable — releaseCashback refuses a sentinel buyer — but binding alone releases nothing.

settleHeldBack

Ends the escrow and splits the held-back commission between the reviewer and the treasury.

function settleHeldBack(uint256 escrowId, uint8 unlockPct) external // ORACLE_ROLE
ParameterTypeNotes
escrowIduint256Must exist and be Holding
unlockPctuint80 to 100. The reviewer's share; the remainder goes to the treasury

Returns nothing. Sets state to Settled before transferring, then pays heldBackAmt * unlockPct / 100 to the reviewer's wallet and the rest to the treasury. Emits HeldBackSettled(escrowId, reviewer, treasury, reviewerAmt, treasuryAmt).

cast send "$ESCROW" 'settleHeldBack(uint256,uint8)' 41 80 \
  --rpc-url "$RPC" --private-key "$ORACLE_KEY"

Errors

RevertCause
AccessControlUnauthorizedAccount(address, bytes32)Caller lacks ORACLE_ROLE
UnlockPctOutOfRange(uint8)Above 100
EscrowNotFound(uint256)No record at that id
AlreadySettled(uint256)Already Settled, by this or by the fallback
EnforcedPause()The escrow is paused
AddressNotSet(bytes32)The registry's TREASURY key is unset — bubbles up from the registry

Not time-gated. There is no earliest or latest moment: the oracle may settle an escrow the block after it is created, or long after its window ends. The window bounds the cashback paths and the fallback, not this one, and this function touches the cashback leg not at all.

The policy behind the percentage is Payment flow, and the key that makes the choice is in the operated half of Protocol.

The reviewer's destination is resolved as "the identity's configured agent wallet, or its owner if none is configured" — the same rule the router uses — so transferring an identity moves where its unsettled commission lands. The treasury is resolved live from the registry, not stored here.

releaseCashback

Pays the buyer their cashback.

function releaseCashback(uint256 escrowId) external // ORACLE_ROLE
ParameterTypeNotes
escrowIduint256Must exist, and its buyer must be bound

Returns nothing. Sets cashbackReleased, transfers cashbackAmt to the buyer's wallet, and emits CashbackReleased(escrowId, buyer, amount). With a zero cashbackAmt it still emits, with a zero address and a zero amount.

cast send "$ESCROW" 'releaseCashback(uint256)' 41 \
  --rpc-url "$RPC" --private-key "$ORACLE_KEY"

Errors

RevertCause
AccessControlUnauthorizedAccount(address, bytes32)Caller lacks ORACLE_ROLE
EscrowNotFound(uint256)No record at that id
BuyerNotBound(uint256)buyerAgentId is still SENTINEL_BUYER
EnforcedPause()The escrow is paused

Calling it twice is a silent no-op, not a revert. The second call returns without emitting anything, so the release is safe to retry. It also works on a Settled escrow: the two legs are independent, so settlement does not close the cashback path.

cashbackToReviewer

Returns unclaimed cashback to the reviewer who funded it, once the window has ended.

function cashbackToReviewer(uint256 escrowId) external // ORACLE_ROLE
ParameterTypeNotes
escrowIduint256Must exist, cashback not yet released, and windowEnd passed

Returns nothing. Sets cashbackReleased, transfers cashbackAmt to the reviewer's wallet, and emits CashbackReturnedToReviewer(escrowId, reviewer, amount).

cast send "$ESCROW" 'cashbackToReviewer(uint256)' 41 \
  --rpc-url "$RPC" --private-key "$ORACLE_KEY"

Errors

RevertCause
AccessControlUnauthorizedAccount(address, bytes32)Caller lacks ORACLE_ROLE
EscrowNotFound(uint256)No record at that id
CashbackAlreadyReleased(uint256)The cashback leg is already closed, either way
TooEarly(uint256 escrowId, uint256 eligibleAt, uint256 currentTime)Called before windowEnd
EnforcedPause()The escrow is paused

Unlike releaseCashback, a second call here reverts rather than no-opping. A zero cashbackAmt still burns the flag but emits nothing, which makes it the one path that closes the leg without a log.

triggerSettleFallback

The path that needs nobody: it ends an escrow the oracle never settled.

function triggerSettleFallback(uint256 escrowId) external // no caller gate
ParameterTypeNotes
escrowIduint256Must exist, be Holding, and be past windowEnd + 10 days

Returns nothing. Sets state to Settled, sends the entire heldBackAmt to the treasury, and — only if the cashback leg is still open — sends cashbackAmt to the reviewer. Emits FallbackSettled(escrowId, treasury, heldBackAmt, cashbackAmt), where the last field is zero when the cashback had already been released.

# Anyone may send this. Check eligibility first — TooEarly reports the timestamp it wants.
cast call "$ESCROW" \
  'getEscrow(uint256)((uint256,uint256,address,uint256,bytes32,uint128,uint128,uint128,uint40,uint40,uint8,uint8,bool))' \
  41 --rpc-url "$RPC"
cast send "$ESCROW" 'triggerSettleFallback(uint256)' 41 \
  --rpc-url "$RPC" --private-key "$ANY_KEY"

Errors

RevertCause
EscrowNotFound(uint256)No record at that id
AlreadySettled(uint256)Already Settled
TooEarly(uint256 escrowId, uint256 eligibleAt, uint256 currentTime)Before windowEnd + 10 days; eligibleAt tells you when
EnforcedPause()The escrow is paused

It has no caller gate but it is not unconditional: it is whenNotPaused, so an admin pause blocks the one path that was meant to work without us. And it pays the reviewer none of the held-back commission — the whole amount goes to the treasury, where ordinary settlement pays the reviewer strictly more. The grace period is a fixed ten days, not a settable parameter.

setSettlementWindow

function setSettlementWindow(uint40 newWindow) external // DEFAULT_ADMIN_ROLE
ParameterTypeNotes
newWindowuint40Seconds. Must be between 1 day and 180 days inclusive

Returns nothing. Emits SettlementWindowUpdated(oldWindow, newWindow).

cast send "$ESCROW" 'setSettlementWindow(uint40)' 7776000 \
  --rpc-url "$RPC" --private-key "$ADMIN_KEY"   # 90 days

Errors

RevertCause
AccessControlUnauthorizedAccount(address, bytes32)Caller lacks DEFAULT_ADMIN_ROLE
SettlementWindowOutOfBounds(uint40)Outside the 1-to-180-day range

New deposits only. Existing escrows keep the windowEnd stamped at deposit, so a change can never move money that is already held — including by shortening a live escrow's window into the past. The bounds are compiled-in constants, not settable. The default is 60 days.

pause and unpause

function pause() external   // DEFAULT_ADMIN_ROLE
function unpause() external // DEFAULT_ADMIN_ROLE

Parameters — none, on either.

Returns nothing. Emit Pausable's Paused(account) and Unpaused(account).

cast send "$ESCROW" 'pause()' --rpc-url "$RPC" --private-key "$ADMIN_KEY"
cast call "$ESCROW" 'paused()(bool)' --rpc-url "$RPC"

Errors

RevertCause
AccessControlUnauthorizedAccount(address, bytes32)Caller lacks DEFAULT_ADMIN_ROLE
ExpectedPause()unpause while not paused
EnforcedPause()pause while already paused

A pause stops deposit, which stops every purchase on both rails. On the charge rail the buyer's funds have already arrived at the router, so those payments strand rather than fail.

getEscrow

function getEscrow(uint256 escrowId) external view returns (EscrowRecord memory)
ParameterTypeNotes
escrowIduint256Must exist

Returns the whole record — every field in the table above.

cast call "$ESCROW" \
  'getEscrow(uint256)((uint256,uint256,address,uint256,bytes32,uint128,uint128,uint128,uint40,uint40,uint8,uint8,bool))' \
  41 --rpc-url "$RPC"

Errors

RevertCause
EscrowNotFound(uint256)depositTimestamp is zero, meaning no record

totalEscrows

function totalEscrows() external view returns (uint256)

Parameters — none.

Returns how many escrows exist, which because ids start at 1 is also the highest id issued.

cast call "$ESCROW" 'totalEscrows()(uint256)' --rpc-url "$RPC"

Errors — none. Settling does not decrement it; nothing does.

settlementWindow, usdc and paused

function settlementWindow() external view returns (uint40)
function usdc() external view returns (address)
function paused() external view returns (bool)

Parameters — none, on any of them.

Returns the window that will be stamped on the next deposit, the USDC token this escrow was constructed with, and whether it is currently paused.

cast call "$ESCROW" 'settlementWindow()(uint40)' --rpc-url "$RPC"
cast call "$ESCROW" 'usdc()(address)' --rpc-url "$RPC"
cast call "$ESCROW" 'paused()(bool)' --rpc-url "$RPC"

Errors — none. usdc is an immutable, so unlike the peer addresses this contract resolves through the registry it cannot be repointed — changing the settlement token means redeploying.

Events and errors

EventSignature
EscrowDeposited(uint256 indexed escrowId, uint256 indexed reviewerAgentId, uint256 indexed buyerAgentId, address buyerWallet, uint256 reviewId, uint256 merchantId, bytes32 productId, uint128 heldBackAmt, uint128 cashbackAmt)
EscrowBound(uint256 indexed escrowId, uint256 indexed agentId, address indexed boundBy)
HeldBackSettled(uint256 indexed escrowId, address indexed reviewer, address indexed treasury, uint128 reviewerAmt, uint128 treasuryAmt)
CashbackReleased(uint256 indexed escrowId, address indexed buyer, uint128 amount)
CashbackReturnedToReviewer(uint256 indexed escrowId, address indexed reviewer, uint128 amount)
FallbackSettled(uint256 indexed escrowId, address indexed treasury, uint128 heldBackAmt, uint128 cashbackAmt)
SettlementWindowUpdated(uint40 oldWindow, uint40 newWindow)

EscrowDeposited indexes buyerAgentId, which is what makes finding agent-less purchases for one wallet a single filtered query on the sentinel value.

Errors: CallerNotSplitRouter(address), AlreadySettled(uint256), CashbackAlreadyReleased(uint256), TooEarly(uint256, uint256, uint256), EscrowNotFound(uint256), UnlockPctOutOfRange(uint8), SettlementWindowOutOfBounds(uint40), ZeroBuyerWallet(), BuyerNotBound(uint256), AlreadyBound(uint256), NotBuyerWallet(uint256, address), NotAgentController(uint256, address).

SplitRouter

Two entry points, one split. route is the x402 rail and is role-gated; splitCharge is the MPP charge rail and is not. Both end in the same private core, which divides the gross amount, makes four transfers and creates the escrow record.

The four transfers, in order

The core pays out in a fixed sequence: the reviewer's immediate net, then the escrow's held-back plus cashback in one transfer, then the platform fee to the treasury, then the merchant's net. Payment flow has the arithmetic and where each number comes from.

The commission rate is read live from ProductRegistry at this moment and clamped so that commission plus platform fee can never exceed the gross. The merchant's net goes to the merchant identity's configured agent wallet, or its owner — not to the payTo address on the payment.

Attribution

The rail-independent facts a split needs. route projects one from its own parameters; splitCharge takes one directly, bound by the oracle's attestation.

FieldTypeNotes
reviewIduint2560 means "no review", and then no commission may be owed
buyerAgentIduint256SENTINEL_BUYER for an agent-less buyer
merchantIduint256Must be active
productIdbytes32Must be active
commissionMultiplierBpsuint16Must be 4000 to 10000. Splits commission into immediate and held-back
lockedCashbackBpsuint16Cashback as a share of commission, capped by the admin cap
productTypeuint8Copied onto the escrow record. Not cross-checked against ProductRegistry

No commission rate appears here. No commission rate is locked or signed anywhere on-chain — the commission rate is read live from ProductRegistry at settlement. The cashback share is the opposite case: lockedCashbackBps, the row above, is a field the oracle signs into a ChargeAttestation on the charge rail.

route

function route(RouteParams calldata routeParams, ERC3009Params calldata erc3009auth)
  external returns (uint256 escrowId) // FACILITATOR_ROLE

RouteParams carries buyerAgentId, reviewId, merchantId, productId, grossAmount, buyer, commissionMultiplierBps, lockedCashbackBps and productType. ERC3009Params carries validAfter, validBefore, nonce, v, r and s — the buyer's ERC-3009 authorization, which this function uses to pull the funds itself.

Sign ReceiveWithAuthorization. ERC-3009 defines a separate typehash per entry point, and receiveWithAuthorization — the function the router calls — validates ReceiveWithAuthorization, whose payee-must-be-the-caller rule is the point of that separate type. TransferWithAuthorization carries identical fields under a different struct name, so it produces a different EIP-712 digest and Circle's USDC rejects it inside the token. If you are working from the x402 spec, which signs TransferWithAuthorization by default, that default does not apply on this rail.

ParameterTypeNotes
routeParams.grossAmountuint256Must be non-zero
routeParams.buyeraddressMust be non-zero, and must own buyerAgentId unless it is the sentinel
erc3009authERC3009ParamsConsumed by receiveWithAuthorization on the USDC contract

Returns the new escrowId. Emits PaymentRouted(escrowId, buyerAgentId, reviewId, buyerWallet, merchantId, productId, grossAmount, immediateCommissionAmt, heldBackAmt, platformFeeAmt, cashbackAmt, merchantAmt), where immediateCommissionAmt is the reviewer's immediate commission after cashback is carved out.

# Facilitator-only. Validate first — a dry run costs nothing and reports the exact revert.
cast call "$SPLIT_ROUTER" \
  'route((uint256,uint256,uint256,bytes32,uint256,address,uint16,uint16,uint8),(uint256,uint256,bytes32,uint8,bytes32,bytes32))(uint256)' \
  "($BUYER_AGENT_ID,$REVIEW_ID,$MERCHANT_ID,$PRODUCT_ID,$GROSS,$BUYER,10000,500,0)" \
  "(0,$VALID_BEFORE,$AUTH_NONCE,$V,$R,$S)" \
  --from "$FACILITATOR" --rpc-url "$RPC"

Errors

RevertCause
AccessControlUnauthorizedAccount(address, bytes32)Caller lacks FACILITATOR_ROLE
ZeroAmount()grossAmount is zero
ZeroBuyerAddress()buyer is the zero address
InvalidBuyerAgent(uint256, address)buyer does not own buyerAgentId
MerchantNotActive(uint256)MerchantRegistry.isActive is false
ProductNotActive(uint256, bytes32)ProductRegistry.isProductActive is false
MissingReviewerForCommission()Commission is owed but reviewId is 0
RatesExceedTotal(uint16, uint16)Commission plus platform fee above 100% — unreachable after the clamp
MultiplierOutOfRange(uint16)commissionMultiplierBps outside 4000 to 10000
CashbackExceedsCommissionCap(uint16, uint16, uint16)lockedCashbackBps above the admin cap
EnforcedPause()Bubbles up from a paused escrow at deposit

Every one of those either precedes the ERC-3009 pull or reverts the whole transaction with it, so a failed route leaves the buyer's balance untouched. This function is deliberately not nonReentrant; it is role-gated and atomic over a single pull instead.

splitCharge

The charge rail. No role gate. Authorised by an EIP-712 ChargeAttestation signed by whatever address the registry's ORACLE key currently names.

function splitCharge(
  bytes32 paymentRef, uint256 amount, address payer,
  Attribution calldata attr, bytes calldata oracleSig
) external returns (uint256 escrowId) // nonReentrant, no role
ParameterTypeNotes
paymentRefbytes32The charge transfer's own transaction hash. Consumed once, forever
amountuint256Must be non-zero, and the router must already hold at least this much USDC
payeraddressRecorded as buyerWallet. Must be non-zero
attrAttributionThe fields above. lockedCashbackBps is clamped, not rejected, if it exceeds the cap
oracleSigbytes65 bytes over the attestation digest

Returns the new escrowId. Emits ChargeSettled(paymentRef, escrowId, buyerAgentId, payer, amount, immediateCommissionAmt, heldBackAmt, platformFeeAmt, cashbackAmt, merchantAmt) — with no reviewId, merchantId or productId, which are on the EscrowDeposited log for the same escrowId instead.

The attestation's own domain is name SplitRouter, version 1, verifyingContract the router, with type string:

ChargeAttestation(bytes32 paymentRef,uint256 amount,address payer,uint256 reviewId,uint256 buyerAgentId,uint256 merchantId,bytes32 productId,uint16 commissionMultiplierBps,uint16 lockedCashbackBps,uint8 productType)

This is not the ChargeCommitment on EIP-712 commitments. That one is the buyer's signature over four fields, sent to the facilitator under domain ReferrerMPPCharge. This one is the oracle's signature over ten, recovered on-chain. A charge involves both, signed by different keys against different domains.

# Anyone may submit a valid attestation. Check the reference is unconsumed first.
cast call "$SPLIT_ROUTER" \
  'splitCharge(bytes32,uint256,address,(uint256,uint256,uint256,bytes32,uint16,uint16,uint8),bytes)(uint256)' \
  "$PAYMENT_REF" "$AMOUNT" "$PAYER" \
  "($REVIEW_ID,$BUYER_AGENT_ID,$MERCHANT_ID,$PRODUCT_ID,10000,500,0)" "$ORACLE_SIG" \
  --rpc-url "$RPC"

Errors

RevertCause
ZeroAmount()amount is zero
ChargeAlreadySettled(bytes32)That paymentRef has already settled — this is the replay guard
OracleAttestationInvalid()The recovered signer is not the registry's current ORACLE
ZeroBuyerAddress()payer is the zero address
MerchantNotActive(uint256)Merchant inactive or suspended
ProductNotActive(uint256, bytes32)Product inactive
InsufficientChargeBalance()The router holds less USDC than amount
MissingReviewerForCommission()Commission owed with reviewId 0
MultiplierOutOfRange(uint16)commissionMultiplierBps outside 4000 to 10000
EnforcedPause()Bubbles up from a paused escrow

Four differences from route:

  • No buyer-agent ownership re-check. route requires ownerOf(buyerAgentId) == buyer; this path does not. By the time it runs the merchant has served the resource and the funds have arrived, so a buyer who transferred their identity after paying would otherwise have a completed sale bricked. The oracle checks ownership when it attests instead.
  • The cashback cap clamps instead of reverting. An admin lowering the cap after the oracle signed must not strand a paid-for sale, so lockedCashbackBps is silently reduced to the cap. CashbackExceedsCommissionCap therefore cannot fire here, only on route.
  • Every revert below the balance check strands money. The funds are pooled in the router before this runs, so an inactive product, a suspended merchant, a missing reviewer or a paused escrow leaves real buyer USDC sitting in the contract until an admin calls recoverUSDC.
  • The balance check proves quantity, not identity. It proves the router holds enough USDC, not that it holds this charge's USDC. Correct attribution rests on the off-chain rule that the oracle attests only after confirming the specific transfer. ChargeAttestation also carries no deadline, so a signed attestation stays usable until its paymentRef is consumed. Value is not at risk — the split arithmetic is exactly conservative — attribution is.

The _chargeSettled[paymentRef] guard is checked before any funds move and set before the split runs, so one payment reference settles exactly once.

recoverUSDC

function recoverUSDC(address to, uint256 amount) external // DEFAULT_ADMIN_ROLE
ParameterTypeNotes
toaddressMust not be zero. Otherwise unconstrained
amountuint256Not checked against the balance; the token transfer reverts if too high

Returns nothing. Emits USDCRecovered(to, amount).

cast send "$SPLIT_ROUTER" 'recoverUSDC(address,uint256)' "$RECIPIENT" 1000000 \
  --rpc-url "$RPC" --private-key "$ADMIN_KEY"

Errors

RevertCause
AccessControlUnauthorizedAccount(address, bytes32)Caller lacks DEFAULT_ADMIN_ROLE
ZeroRecipientAddress()to is the zero address

On the x402 rail the router holds nothing between payments, but charge-rail funds arrive before settlement, so this is the only way stranded buyer money comes back out — and it can send that money anywhere.

setPlatformFeeBps

function setPlatformFeeBps(uint16 newBps) external // DEFAULT_ADMIN_ROLE
ParameterTypeNotes
newBpsuint160 to 10000. The only ceiling is 100%

Returns nothing. Emits PlatformFeeUpdated(oldBps, newBps).

cast send "$SPLIT_ROUTER" 'setPlatformFeeBps(uint16)' 200 \
  --rpc-url "$RPC" --private-key "$ADMIN_KEY"   # 2%

Errors

RevertCause
AccessControlUnauthorizedAccount(address, bytes32)Caller lacks DEFAULT_ADMIN_ROLE
FeeTooHigh(uint16)Above 10000

Applies from the next settlement, never retroactively to a payment already split. A fee high enough to squeeze the commission triggers the clamp inside the split rather than a revert, so a merchant's commission rate is what gives way, not the payment.

setMaxCashbackBps

function setMaxCashbackBps(uint16 newBps) external // DEFAULT_ADMIN_ROLE
ParameterTypeNotes
newBpsuint160 to 4000. The ceiling is the split library's own constant

Returns nothing. Emits MaxCashbackBpsUpdated(oldBps, newBps).

cast send "$SPLIT_ROUTER" 'setMaxCashbackBps(uint16)' 2000 \
  --rpc-url "$RPC" --private-key "$ADMIN_KEY"   # 20% of commission

Errors

RevertCause
AccessControlUnauthorizedAccount(address, bytes32)Caller lacks DEFAULT_ADMIN_ROLE
CapAboveCeiling(uint16)Above 4000 — thrown from the split library, not the router

The ceiling is the worst-case immediate commission multiplier, which is what keeps cashback fundable out of the commission paying for it whatever an admin does. The constructor sets the cap to the ceiling, so an untouched deployment is at its loosest.

platformFeeBps, maxCashbackBpsOfCommission, usdc and SENTINEL_BUYER

function platformFeeBps() external view returns (uint16)
function maxCashbackBpsOfCommission() external view returns (uint16)
function usdc() external view returns (address)
function SENTINEL_BUYER() external view returns (uint256)

Parameters — none, on any of them.

Returns the current fee, the current cashback cap, the USDC token this router was constructed with, and the agent-less sentinel — type(uint256).max.

cast call "$SPLIT_ROUTER" 'platformFeeBps()(uint16)' --rpc-url "$RPC"
cast call "$SPLIT_ROUTER" 'maxCashbackBpsOfCommission()(uint16)' --rpc-url "$RPC"
cast call "$SPLIT_ROUTER" 'usdc()(address)' --rpc-url "$RPC"
cast call "$SPLIT_ROUTER" 'SENTINEL_BUYER()(uint256)' --rpc-url "$RPC"

Errors — none. Read SENTINEL_BUYER from the contract rather than assuming the value: the escrow shares the same constant through a library but exposes no getter of its own, so the router is where it is readable.

Events and errors

EventSignature
PaymentRouted(uint256 indexed escrowId, uint256 indexed buyerAgentId, uint256 indexed reviewId, address buyerWallet, uint256 merchantId, bytes32 productId, uint256 grossAmount, uint256 immediateCommissionAmt, uint256 heldBackAmt, uint256 platformFeeAmt, uint256 cashbackAmt, uint256 merchantAmt)
ChargeSettled(bytes32 indexed paymentRef, uint256 indexed escrowId, uint256 indexed buyerAgentId, address payer, uint256 amount, uint256 immediateCommissionAmt, uint256 heldBackAmt, uint256 platformFeeAmt, uint256 cashbackAmt, uint256 merchantAmt)
PlatformFeeUpdated(uint16 oldBps, uint16 newBps)
MaxCashbackBpsUpdated(uint16 oldBps, uint16 newBps)
USDCRecovered(address indexed to, uint256 amount)

The two settlement events carry the same five amounts in the same order, so one decoder covers both rails. What differs is the first topic: escrowId on the x402 rail, paymentRef on the charge rail.

Errors: ZeroAmount(), InvalidBuyerAgent(uint256, address), RatesExceedTotal(uint16, uint16), MultiplierOutOfRange(uint16), MissingReviewerForCommission(), MerchantNotActive(uint256), ProductNotActive(uint256, bytes32), ZeroBuyerAddress(), FeeTooHigh(uint16), ZeroRecipientAddress(), ChargeAlreadySettled(bytes32), InsufficientChargeBalance(), OracleAttestationInvalid(), plus CashbackExceedsCommissionCap(uint16, uint16, uint16) and CapAboveCeiling(uint16) from the split library.

The addresses

The contracts are not deployed on Base yet, so there are no addresses to publish. Nothing is deployed on Base Sepolia either. The table below names the six contracts and the order they are created in; the addresses are filled in from the deployment when it happens.

Until then, every cast example on these pages needs addresses from a chain you control — a local node with the contracts deployed, which is how the protocol is exercised at present.

ContractAddress
ProtocolAddressRegistry0x…
MerchantRegistry0x…
ProductRegistry0x…
ReviewRegistry0x…
CommissionEscrow0x…
SplitRouter0x…

Only the first of those is meant to be configured anywhere. Set it, and derive the rest — the PAR value is yours to supply until a deployment exists:

export RPC=https://mainnet.base.org
export PAR=<ProtocolAddressRegistry, from the table above>
 
export MERCHANT_REGISTRY=$(cast call "$PAR" 'getAddress(bytes32)(address)' "$(cast keccak MERCHANT_REGISTRY)" --rpc-url "$RPC")
export PRODUCT_REGISTRY=$(cast call "$PAR" 'getAddress(bytes32)(address)' "$(cast keccak PRODUCT_REGISTRY)" --rpc-url "$RPC")
export REVIEW_REGISTRY=$(cast call "$PAR" 'getAddress(bytes32)(address)' "$(cast keccak REVIEW_REGISTRY)" --rpc-url "$RPC")
export ESCROW=$(cast call "$PAR" 'getAddress(bytes32)(address)' "$(cast keccak ESCROW)" --rpc-url "$RPC")
export SPLIT_ROUTER=$(cast call "$PAR" 'getAddress(bytes32)(address)' "$(cast keccak SPLIT_ROUTER)" --rpc-url "$RPC")

If a derived address and a listed one disagree, the registry has been repointed since these constants were last verified, and the registry is the authority.

The addresses are lowercase here, not checksummed. They are compared case-insensitively against the broadcast artifact and are valid input to any tool; if you need the mixed-case form, cast to-check-sum-address produces it.

What the addresses do not tell you

The broadcast artifact those constants are checked against is a record of one deployment run, and the source has moved since. Two divergences are visible in it: the treasury was set through the timelocked path rather than seeded in the constructor, and the run granted a role on CommissionEscrow whose id matches keccak256("REVIEW_REGISTRY_ROLE") — a role the current escrow source does not declare at all.

Neither changes an address, and neither is a claim about the deployed bytecode, which this repository does not verify. Read live state. Read the roles with hasRole, the parameters with their getters, and the addresses out of the registry, rather than inferring any of them from the deployment record or from these pages. The contracts are at github.com/OpenSoukAI.

Next steps

  • The contracts — the deployment order, the resolution map, the roles
  • Registries — the four record-keeping contracts
  • Payment flow — the split arithmetic these two rails share