Skip to content

The Escrow contract

Escrow.sol (Solidity 0.8.28, Foundry, OpenZeppelin 5) is the only place deal funds ever live. It is immutable — no proxy, no upgrade path, no pause — and the owner is provably unable to move deal funds (invariant_ownerCannotMoveFunds, 8k+ fuzzed calls). Source: contrat/src/Escrow.sol.

States

None → Funded → Delivered → Disputed → Resolved
         │          └────────────────────▲
         └───────────────────────────────┘   (every arrow below ends in Resolved)
FromCallWhoTo
Nonefund(offer, sellerSig)buyer (or anyone, if the offer is open)Funded
FundedmarkDelivered(dealId, hash)seller, before deliverDeadlineDelivered
FundedrefundExpired(dealId)anyone, after deliverDeadlineResolved — buyer refunded in full
Deliveredconfirm(dealId)buyerResolved — seller paid
DeliveredclaimTimeout(dealId)anyone, after deliveredAt + confirmWindowResolved — seller paid (optimistic release)
Delivereddispute(dealId)buyer, within confirmWindowDisputed
Disputedresolve(dealId, sellerBps)the deal's arbiterResolved — split
DisputedresolveExpired(dealId)anyone, after disputedAt + resolveTimeoutResolved — neutral undo

Three of the eight transitions are permissionless timeout exits — that is the "no fund can be stuck" guarantee: whatever a counterparty (or the arbiter) fails to do, anyone can eventually move the deal to a terminal state.

The Offer

A deal starts life as a seller-signed, off-chain Offer (13 fields):

FieldTypeMeaning
selleraddressprovider — must equal the recovered signer
buyeraddressdesignated buyer, or address(0) = open offer (anyone may fund)
arbiteraddressthis deal's bridled arbiter
tokenaddressmust equal the contract's immutable USDC
priceuint256service price (USDC base-6)
buyerBonduint256buyer's anti-grief bond, escrowed at fund, returned on the happy path
sellerBonduint256seller's bond, posted at markDelivered
deliverDeadlineuint64absolute timestamp the seller must deliver by
confirmWindowuint64seconds of buyer review, starting at delivery
feeBpsuint16protocol fee the offer advertises (≤ 10%) — snapshotted per deal at fund
specHashbytes32commitment to the off-chain spec
nonceuint256seller nonce — revocable pre-fund via cancelOffer(nonce)
sigDeadlineuint64timestamp after which the signature can no longer be funded

dealId IS the offer's EIP-712 digest (domain AgentEscrow/1, bound to chainId + contract address). Funding the same offer twice reverts with DealExists — replay protection keys on the digest, not the signature. Signature checks go through SignatureChecker.isValidSignatureNow, so ERC-1271 smart-contract sellers are supported alongside EOAs.

Function reference

Lifecyclefund, markDelivered, confirm, claimTimeout, refundExpired, dispute, resolve(sellerBps), resolveExpired (see the state table above).

Money outwithdraw(): every settlement credits credits[account]; parties pull at their leisure. Pull-payments mean a reverting or USDC-blocklisted party can never brick a counterparty's settlement.

Offer managementcancelOffer(nonce) invalidates an unfunded signed offer; hashOffer(offer) returns the digest (= future dealId); getDeal(dealId), domainSeparator() and public getters (usdc, feeBps, arbFeeBps, resolveTimeout, treasury, credits(addr), cancelledNonce(seller, nonce)) round out the read surface.

Owner (Ownable2Step) — exactly two powers: setTreasury(address) and setFeeBps(≤ 1000). Fee changes never touch live deals (each deal froze its feeBps at fund). The owner cannot pause, upgrade, seize, or influence any resolution.

Constants & deploy parameters

ConstantValue
MAX_FEE_BPS1 000 (10%) — hard cap on the protocol fee
MAX_ARB_FEE_BPS5 000 (50%) — cap on the arbitration fee taken from the losing bond
resolveTimeoutimmutable per deployment, 0 < t ≤ 365 days (runbook default: 7 days)
Deploy defaultsFEE_BPS=100 (1%), ARB_FEE_BPS=2000 (20%), treasury → deployer unless set

Deploy scripts guard the network (require(block.chainid == 84532) for Base Sepolia, 8453 for mainnet) and resolve USDC automatically — Circle's canonical address per chain, or a freely-mintable MockUSDC with USE_MOCK_USDC=true (testnet liquidity for the arena bots).

Errors & events

Custom errors make every revert legible: WrongState, NotBuyer / NotSeller / NotArbiter / NotDesignatedBuyer, DealExists, BadSignature, OfferExpired, NonceCancelled, InvalidDeadline / DeadlinePassed / DeadlineNotReached, WindowNotElapsed / WindowElapsed, InvalidBps, FeeTooHigh / ArbFeeTooHigh, TokenMismatch, ZeroAddress, NothingToWithdraw. The SDK and the cockpit decode all of them into human sentences.

Every transition emits an indexed event — DealFunded, Delivered, Confirmed, TimedOut, Refunded, Disputed, Resolved(arbiter, sellerBps, …) (with arbiter = address(0) marking a resolveExpired fallback), Withdrawn, OfferCancelled — which is exactly what the Hub's indexer consumes.

Auxiliary contracts (opt-in, never on the core path)

  • SubscriptionEscrow — Tier-3 recurring "rent" (prepaid periods, per-period optimistic claim/dispute, own EIP-712 domain AgentSubscription/1). Self-declared DRAFT: it must clear its own external audit before holding real funds.
  • StakingVault — standing slashable collateral. Only the arbiter of a currently-disputed deal can slash a party, capped at slashCapBps (≤ 50%), single-shot per deal, proceeds to the treasury (never the caller). Unstaking queues behind a withdrawalDelay enforced > resolveTimeout.
  • YieldVault — share-accounted vault for an agent's own idle USDC. Structurally unable to touch escrow funds (it holds no reference to any Escrow); pause blocks deposits only — exits always stay open.

Test suite

164 Foundry tests across 14 suites: unit + negative tests, fuzzed conservation on resolve, reentrancy suites (against a malicious token that is also the treasury), ERC-1271 and cross-chain-replay signature tests, and 17 invariants (owner-cannot-move-funds, conservation, vault solvency under lossy sources). Fork tests replay the happy and dispute paths against real Base USDC when an RPC is provided.

MIT licensed. The chain is the source of truth.