Skip to main content

Escrow

The EscrowVault smart contract is where USDC actually sits during a transaction's COMMITTED → DELIVERED → SETTLED window. The ACTPKernel kernel calls EscrowVault.createEscrow() on linkEscrow, holds funds until releaseEscrow() (success) or refundEscrow() (dispute or cancellation).

EscrowVault is the only contract that holds user funds. Its solvency invariant — vault USDC balance ≥ sum of all active escrows — is the bedrock guarantee of ACTP and is asserted by the test suite + Echidna fuzz.

Lifecycle

linkEscrow(txId, amount)

└─ EscrowVault.createEscrow(txId, requester, provider, amount)
• requester USDC.transferFrom → vault
• escrow record stored with state machine state machine ref
• emits EscrowCreated(txId, amount)

transitionState(txId, SETTLED) | releaseEscrow(txId)

└─ EscrowVault.releaseEscrow(txId)
• computes platformFee = max(amount * feeBps / 10000, MIN_FEE)
• providerNet = amount - platformFee
• USDC.transfer(provider, providerNet)
• USDC.transfer(feeRecipient, platformFee)
• emits EscrowReleased(txId, providerNet, platformFee)

transitionState(txId, DISPUTED)

└─ EscrowVault.lockForDispute(txId, disputer)
• disputer USDC.transferFrom (bond) → vault
• escrow locked until mediator resolution
• emits EscrowDisputed(txId, disputer, bondAmount)

AIP-14 dispute bond

A disputer (requester or provider) must post a $1 USDC minimum bond when transitioning a tx to DISPUTED. The bond returns per fault attribution after mediator resolution:

OutcomeBond returned to
Mediator sides with disputerDisputer (bond returned)
Mediator sides against disputerCounterparty (bond awarded to other side)
Mediator returns no decisionVault treasury (bond burned)

Bond amount = max(amount * disputeBondBps / 10000, MIN_DISPUTE_BOND).

  • disputeBondBps default: 500 (5%)
  • MIN_DISPUTE_BOND default: 1_000_000 micro-USDC ($1.00)

Enforced in _payoutProviderAmount since the V3 mainnet redeploy on 2026-05-19.

INV-30 — per-transaction locked-bps

disputeBondBpsLocked is captured at transaction creation time and immutable thereafter. This means admin-side updateDisputeBondBps() changes affect only new transactions; in-flight transactions use the rate they were created under.

Same locking applies to platformFeeBpsLocked (AIP-5) and requesterPenaltyBpsLocked. Three fields total, all per-transaction, all immutable post-creation.

The implication: a malicious or compromised admin cannot retroactively raise dispute bonds, platform fees, or requester penalties on transactions that have already been initiated. The kernel maintains "frozen economic terms" for the lifetime of every transaction.

Refund paths

From stateRefund
INITIATEDCANCELLEDNo funds locked yet; no refund needed
QUOTEDCANCELLEDNo funds locked yet (escrow attaches at COMMITTED)
COMMITTEDCANCELLEDFull amount refunded to requester
IN_PROGRESSCANCELLEDAmount minus requesterPenaltyBpsLocked refunded; penalty awarded to provider for partial work
DELIVEREDDISPUTED → mediator → CANCELLEDPer mediator decision (full / partial / penalty split)

The requester-penalty BPS exists to prevent griefing — cancellation after the provider has begun work shouldn't be free.

See also