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:
| Field | Type | Notes |
|---|---|---|
reviewerAgentId | uint256 | Derived by the router from the review, not supplied by the payer |
buyerAgentId | uint256 | SENTINEL_BUYER — type(uint256).max — for an agent-less purchase |
buyerWallet | address | The wallet that actually paid. Never zero |
merchantId | uint256 | The merchant identity |
productId | bytes32 | The product |
heldBackAmt | uint128 | USDC held for the reviewer |
cashbackAmt | uint128 | USDC reserved for the buyer, funded out of the reviewer's commission |
originalPurchaseAmt | uint128 | The gross amount, kept for the settlement policy's own maths |
depositTimestamp | uint40 | Non-zero for any existing record — this is what existence is tested on |
windowEnd | uint40 | depositTimestamp plus the window in force at deposit |
productType | uint8 | 0 and 1 mirror ProductType, but this field is a plain uint8, not the enum |
state | EscrowState | Holding or Settled |
cashbackReleased | bool | Set 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| Parameter | Type | Notes |
|---|---|---|
reviewerAgentId | uint256 | May be 0 — agent id 0 is a real identity, so this is not a sentinel |
buyerAgentId | uint256 | SENTINEL_BUYER when the buyer had no identity |
buyerWallet | address | Must not be zero, or the cashback reserve would be unclaimable |
productType | uint8 | Passed through from the payment, not read from ProductRegistry |
reviewId | uint256 | Emitted 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
| Revert | Cause |
|---|---|
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| Parameter | Type | Notes |
|---|---|---|
escrowId | uint256 | Must exist, and its buyerAgentId must still be SENTINEL_BUYER |
agentId | uint256 | Must 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
| Revert | Cause |
|---|---|
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| Parameter | Type | Notes |
|---|---|---|
escrowId | uint256 | Must exist and be Holding |
unlockPct | uint8 | 0 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
| Revert | Cause |
|---|---|
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| Parameter | Type | Notes |
|---|---|---|
escrowId | uint256 | Must 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
| Revert | Cause |
|---|---|
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| Parameter | Type | Notes |
|---|---|---|
escrowId | uint256 | Must 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
| Revert | Cause |
|---|---|
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| Parameter | Type | Notes |
|---|---|---|
escrowId | uint256 | Must 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
| Revert | Cause |
|---|---|
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| Parameter | Type | Notes |
|---|---|---|
newWindow | uint40 | Seconds. 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 daysErrors
| Revert | Cause |
|---|---|
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_ROLEParameters — 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
| Revert | Cause |
|---|---|
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)| Parameter | Type | Notes |
|---|---|---|
escrowId | uint256 | Must 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
| Revert | Cause |
|---|---|
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
| Event | Signature |
|---|---|
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.
| Field | Type | Notes |
|---|---|---|
reviewId | uint256 | 0 means "no review", and then no commission may be owed |
buyerAgentId | uint256 | SENTINEL_BUYER for an agent-less buyer |
merchantId | uint256 | Must be active |
productId | bytes32 | Must be active |
commissionMultiplierBps | uint16 | Must be 4000 to 10000. Splits commission into immediate and held-back |
lockedCashbackBps | uint16 | Cashback as a share of commission, capped by the admin cap |
productType | uint8 | Copied 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_ROLERouteParams 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.
| Parameter | Type | Notes |
|---|---|---|
routeParams.grossAmount | uint256 | Must be non-zero |
routeParams.buyer | address | Must be non-zero, and must own buyerAgentId unless it is the sentinel |
erc3009auth | ERC3009Params | Consumed 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
| Revert | Cause |
|---|---|
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| Parameter | Type | Notes |
|---|---|---|
paymentRef | bytes32 | The charge transfer's own transaction hash. Consumed once, forever |
amount | uint256 | Must be non-zero, and the router must already hold at least this much USDC |
payer | address | Recorded as buyerWallet. Must be non-zero |
attr | Attribution | The fields above. lockedCashbackBps is clamped, not rejected, if it exceeds the cap |
oracleSig | bytes | 65 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
| Revert | Cause |
|---|---|
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.
routerequiresownerOf(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
lockedCashbackBpsis silently reduced to the cap.CashbackExceedsCommissionCaptherefore cannot fire here, only onroute. - 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.
ChargeAttestationalso carries no deadline, so a signed attestation stays usable until itspaymentRefis 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| Parameter | Type | Notes |
|---|---|---|
to | address | Must not be zero. Otherwise unconstrained |
amount | uint256 | Not 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
| Revert | Cause |
|---|---|
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| Parameter | Type | Notes |
|---|---|---|
newBps | uint16 | 0 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
| Revert | Cause |
|---|---|
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| Parameter | Type | Notes |
|---|---|---|
newBps | uint16 | 0 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 commissionErrors
| Revert | Cause |
|---|---|
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
| Event | Signature |
|---|---|
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.
| Contract | Address |
|---|---|
ProtocolAddressRegistry | 0x… |
MerchantRegistry | 0x… |
ProductRegistry | 0x… |
ReviewRegistry | 0x… |
CommissionEscrow | 0x… |
SplitRouter | 0x… |
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