Recourse Protocol · our on-chain escrow and enforcement layer
Recourse Protocol
Recourse's own contracts for autonomous commerce. A payer's stablecoins go into a Recourse transaction against hashed terms, with a delivery deadline and an inspection window. Most transactions resolve without us — release, refund, matched settlement, timeout. When one is disputed, the venue decides in minutes and the contract executes the ruling, proportionally, with an appeal round. The venue never holds the money and pays no gas. This is what the on-chain integrations sit on: a Safe (or any ERC-7579 account) installs our module to put its agent under these terms; the same contracts deploy on Tempo; agents paying over x402 file disputes about these transactions.
The transaction contract. Holds a payer's stablecoins against termsHash; resolves by release, refund, mutual settlement, timeout, or a venue-signed ruling. OpenZeppelin primitives only; no fees on chain.
Our adapter for smart accounts (ERC-7579 executor). A Safe, Kernel or Nexus account installs it, names the agent key, sets caps; the agent can then only commit funds into Recourse transactions within those caps. Optional — a plain wallet or a Safe with Zodiac Roles can call the escrow directly.
Every final determination is signed EIP-712 by Recourse's settlement key: (transactionId, payeeAmount, round, notBefore, recourseDisputeId). Anyone submits it; the contract pays the split.
The transaction lifecycle
| Step | Who | Contract call | Result |
|---|---|---|---|
| Commit | payer (or its agent through the module) | open(payee, token, amount, termsHash, termsURI, deadline, acceptWindow) | funds locked; status Open |
| Deliver | payee | deliver(id) | inspection window starts; status Delivered |
| Accept | payer | release(id) | payee paid in full |
| Concede | payee | refund(id) | payer refunded in full |
| Settle | both | proposeSettlement(id, payeeAmount) | executes when both figures match |
| Timeout | anyone | claimTimeout(id) | delivered and unchallenged past the window → payee paid; deadline passed with nothing delivered → payer refunded |
| Dispute | either party | dispute(id, ref) | frozen: no timeout applies; only a ruling or a concession resolves it |
| Rule | anyone with the venue's signature | submitRuling(…) / submitAndExecuteRuling(…), then executeRuling(id) | the ruled split is paid; status Resolved |
Rulings carry a round (1 first determination, 2 appeal) and a notBefore time. A round-2 ruling replaces an unexecuted round-1 ruling, so the appeal the Rules provide works on chain; the venue sets notBefore to the end of the appeal window you asked for in the bundle (appeal_window_hours), so the contract enforces the window too. Escalated determinations carry no signature — the transaction stays frozen until the bench finalises.
Putting an agent under commercial terms (ERC-7579)
The module is an executor (module type 2). Install it from the account with the policy as init data, then hand the agent its key. Works with Safe via Safe7579, ZeroDev Kernel, Biconomy Nexus and any other account exposing executeFromExecutor — the module never calls anything else on the account.
// init data — abi.encode(agent, tokens[], maxPerTransaction, periodCap, period, minAcceptWindow)
bytes memory policy = abi.encode(
agentKey, // the key the agent holds
[USDC], // tokens the agent may commit
1_000e6, // per transaction
5_000e6, 1 days, // per period
1 hours // never an inspection window shorter than this
);
account.installModule(2, RECOURSE_MODULE, policy);
// the agent, later — the account is the payer of record
uint256 id = module.commit(account, seller, USDC, 500e6, keccak256(terms), "ipfs://terms", deadline, 2 hours);
module.dispute(account, id, ref); // or release / proposeSettlement
Revoking the agent is uninstallModule; tightening the caps is setPolicy from the account. Because the module is the only path this key has to the account's funds, the policy is the agent's spending authority — and every dollar it commits is recoverable by determination.
On a Safe
Safe is the first account we target. A Safe becomes an ERC-7579 account through the Safe7579 adapter (Rhinestone's Safe module + fallback handler); after that, installing Recourse is a normal Safe transaction — installModule(2, RECOURSE_MODULE, policy) signed by the owners' threshold, from Safe{Wallet}'s transaction builder, the Safe SDK, or permissionless.js. A plain Safe without the adapter can use the protocol today with Zodiac Roles instead: scope the agent's role to USDC.approve(escrow, ≤cap) and the escrow's open / release / dispute / proposeSettlement with parameter conditions, and the Safe is the payer of record directly. Roles is the authority layer; Recourse is the terms-and-enforcement layer. Full walkthrough for the Safe community: Recourse for Safe.
Filing the dispute with the venue
Once a transaction is disputed on chain, either agent files the record over the Verdict API — wallet signature as identity and consent, case fee paid with the request over x402, no account needed (details). The bundle names the transaction:
"settlement": {
"rail": "recourse_escrow",
"chain_id": 8453,
"escrow": "0x…", // RecourseEscrow
"transaction_id": "17",
"amount": "500000000", // escrowed token units (500 USDC)
"token_decimals": 6
},
"appeal_window_hours": 48 // optional — becomes the ruling's notBefore
The record comes back with the signed instruction:
"rail_instruction": {
"rail": "recourse_escrow", "transaction_id": "17",
"payee_amount": "175000000", // 175 USDC to the payee, the rest to the payer
"round": 1, "not_before": 0,
"recourse_dispute_id": "dsp_…", "signature": "0x…",
"execute": "RecourseEscrow(0x…).submitAndExecuteRuling(17, 175000000, 1, 0, keccak256(\"dsp_…\"), signature)"
}
The determination's cent-denominated split is applied proportionally to the escrowed amount and floored, so the contract can never over-pay. Whoever the ruling favours submits it; anyone may. Recourse pays no gas and never holds the funds.
Signature format
EIP-712 domain: { name: "RecourseEscrow", version: "1", chainId, verifyingContract: escrow }
Ruling(uint256 transactionId, uint256 payeeAmount, uint32 round, uint64 notBefore, bytes32 recourseDisputeId)
recourseDisputeId = keccak256(utf8(dispute id))
signer = RecourseEscrow.venue()
Where it runs
| Network | Status |
|---|---|
| Tempo Moderato testnet (chain 42431) — the live demo runs here | RecourseEscrow 0xa237…9A8A · RecourseEscrowModule 0x9cB1…5820 · escrow token PathUSD 0x20c0…0000 · fees paid in PathUSD |
| Tempo mainnet (chain 4217) | same contracts; after audit |
| Base Sepolia · Base | deploy scripts ready (contracts/script/DeployProtocol.s.sol); addresses published here once deployed |
| Recourse devnet (anvil, chain 31337) | local development |
Source: contracts/src/RecourseEscrow.sol, contracts/src/erc7579/RecourseEscrowModule.sol (Foundry; forge test covers every lifecycle path, forged and inflated rulings, appeal rounds, policy caps, and a fuzzed conservation-of-funds check). The contracts have not yet been externally audited; treat mainnet deployments accordingly until the audit is published here.
How this differs from a jury escrow
Token-juror escrows ask humans to open a case and wait for incentivised jurors, and rule in binary. The Recourse Protocol is built for machine-to-machine commerce: structured task specifications hashed into the transaction, agents filing evidence over an API, a determination under the Standard Rules v1.0 in minutes with full reasoning, proportional outcomes, an appeal to a human panel, and case fees priced for small transactions. The venue is named in the terms; the contract is what makes the terms enforceable without anyone's cooperation.