Skip to main content

Escrow Mechanism

The EscrowVault is a smart contract that holds USDC funds during ACTP transactions. It implements a non-custodial, bilateral escrow pattern - neither requester nor provider can unilaterally access funds.

What You'll Learn

By the end of this page, you'll understand:

  • Why escrow is essential for agent-to-agent payments
  • How the EscrowVault locks and releases funds
  • What security guarantees protect your funds
  • When funds are released (settlement, milestones, refunds)

Reading time: 15 minutes

Prerequisite: Transaction Lifecycle - understanding of state transitions


Quick Reference

Escrow Flow

Escrow Flow

Key Guarantees

GuaranteeDescription
SolvencyVault always has funds to cover all escrows
Access ControlOnly ACTPKernel can release funds
Non-CustodialPlatform cannot withdraw user funds
Reentrancy SafeProtected against callback attacks

Why Escrow?

Traditional payment systems have asymmetric risk:

Payment MethodRequester RiskProvider RiskWho Has Power
Prepayment❌ High (pay before delivery)✅ Low (get paid upfront)Provider
Post-payment✅ Low (pay after delivery)❌ High (work for free first)Requester
Platform Escrow⚠️ Medium (trust platform)⚠️ Medium (trust platform)Platform
ACTP Escrow✅ Low (smart contract)✅ Low (smart contract)Code

ACTP escrow enforces bilateral fairness:

  • Requester protected: Funds only released when provider delivers
  • Provider protected: Funds locked and guaranteed if delivery is valid
  • Platform neutral: Code enforces rules, not human discretion

Architecture

Escrow Architecture - Fund flow between wallets and contracts


The Escrow Flow

Step 1: Approve USDC

Before creating escrow, requester must approve the vault:

import { ethers, parseUnits } from 'ethers';

const usdcContract = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, signer);

// Approve exact amount (security best practice)
const amount = parseUnits('100', 6); // $100 USDC
await usdcContract.approve(ESCROW_VAULT_ADDRESS, amount);

What happens:

  • Requester signs approval transaction
  • USDC contract records: allowance[requester][vault] = amount
  • Vault can now pull USDC (but hasn't yet)
// Generate escrow ID
const escrowId = ethers.id(`escrow-${txId}-${Date.now()}`);

// Link escrow (auto-transitions to COMMITTED)
await client.kernel.linkEscrow(txId, ESCROW_VAULT_ADDRESS, escrowId);

On-chain flow:

// ACTPKernel.sol
function linkEscrow(bytes32 txId, address vault, bytes32 escrowId) external {
require(tx.state == State.INITIATED || tx.state == State.QUOTED);
require(msg.sender == tx.requester);

// Pull USDC into vault
IEscrowValidator(vault).createEscrow(escrowId, tx.requester, tx.provider, tx.amount);

// Auto-transition to COMMITTED
tx.state = State.COMMITTED;
}
Auto-Transition

linkEscrow() is the only function that auto-transitions state. Linking escrow = point of no return.

Step 3: Funds Are Locked

Once escrow is created:

StatusDescription
✅ In vaultNo longer in requester's wallet
✅ TaggedMapped to specific escrowId
✅ ProtectedNeither party can access directly
✅ TrackedOnly kernel can authorize release

Escrow Mapping Visual

Step 4: Release Escrow

When transaction settles, funds are released by transitioning to SETTLED state:

// releaseEscrow() is called INTERNALLY when transitioning to SETTLED state
// Users should call transitionState() instead:
await client.kernel.transitionState(txId, State.SETTLED, '0x');
// This internally triggers releaseEscrow() if all conditions are met

Fund distribution for $100 transaction:

RecipientAmountPercentage
Provider$99.0099%
Platform$1.001%

Security Guarantees

1. Solvency Invariant

Guarantee: Vault always has enough USDC to cover all active escrows.

// Invariant (tested via fuzzing):
assert(vaultBalance >= sumOfAllLockedEscrows);

Enforcement:

  • createEscrow() pulls funds before creating escrow
  • payout() checks balance before transferring
  • No admin function to withdraw locked funds

2. Access Control

Guarantee: Only ACTPKernel can create/release escrow. The EscrowVault uses a validator pattern with the onlyKernel modifier - NOT a multisig.

modifier onlyKernel() {
require(msg.sender == kernel, "Only kernel");
_;
}

function createEscrow(...) external onlyKernel { }
function payout(...) external onlyKernel { }

Important: Users interact with ACTPKernel, which then calls EscrowVault. Direct calls to EscrowVault functions will revert.

3. Non-Custodial

Guarantee: Platform cannot steal user funds.

Custodial (Stripe/PayPal)Non-Custodial (ACTP)
Platform holds funds in bankSmart contract holds funds
Platform can freeze/seizeCode enforces rules (immutable)
Requires trust in platformRequires trust in code (audited)

4. No Emergency Withdrawal

Design Decision

The EscrowVault has no emergency withdrawal function. If tokens are accidentally sent directly to the vault (not through createEscrow()), they are permanently locked. This prevents any admin backdoor to user funds.

5. Reentrancy Protection

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract EscrowVault is ReentrancyGuard {
function payout(...) external onlyKernel nonReentrant {
// Checks-Effects-Interactions pattern
require(escrow.amount >= amount); // Check
escrow.released += amount; // Effect
USDC.safeTransfer(recipient, amount); // Interaction
}
}

Escrow Lifecycle

Escrow Lifecycle


Scenarios

Scenario 1: Happy Path Settlement

// 1. Create transaction
const txId = await client.kernel.createTransaction({...});

// 2. Fund escrow
const usdcContract = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, signer);
await usdcContract.approve(ESCROW_VAULT_ADDRESS, amount);

const escrowId = ethers.id(`escrow-${txId}-${Date.now()}`);
await client.kernel.linkEscrow(txId, ESCROW_VAULT_ADDRESS, escrowId);
// Escrow: $100, State: COMMITTED

// 3. Provider delivers
await client.kernel.transitionState(txId, State.IN_PROGRESS, '0x');
await client.kernel.transitionState(txId, State.DELIVERED, '0x');

// 4. Settle transaction (internally releases escrow)
await client.kernel.transitionState(txId, State.SETTLED, '0x');
// Provider receives: $99, Platform: $1

Scenario 2: Milestone Releases

// 1. Create and fund $1,000 transaction
const txId = await client.kernel.createTransaction({...});

const usdcContract = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, signer);
await usdcContract.approve(ESCROW_VAULT_ADDRESS, parseUnits('1000', 6));

const escrowId = ethers.id(`escrow-${txId}-${Date.now()}`);
await client.kernel.linkEscrow(txId, ESCROW_VAULT_ADDRESS, escrowId);
// Escrow: $1,000

// 2. Release milestone 1
await client.kernel.releaseMilestone(txId, parseUnits('250', 6));
// Provider: $247.50, Escrow remaining: $750

// 3. Release milestone 2
await client.kernel.releaseMilestone(txId, parseUnits('250', 6));
// Provider: $247.50, Escrow remaining: $500

// 4. Final settlement
await client.kernel.transitionState(txId, State.SETTLED, '0x');
// Provider: $495, Total received: $990

Scenario 3: Cancellation Refund

// Requester cancels after deadline
await client.kernel.transitionState(txId, State.CANCELLED, '0x');

// Distribution:
// Requester refund: $475 (95%)
// Provider penalty: $25 (5%)
// Platform: $0

Scenario 4: Dispute Resolution

Admin-Only

Dispute resolution can only be performed by admin/pauser role via transitionState.

// Admin resolves: 60% provider, 30% requester, 10% mediator
// Encode resolution proof with fund distribution
const resolutionProof = ethers.AbiCoder.defaultAbiCoder().encode(
['uint256', 'uint256', 'uint256', 'address'],
[
parseUnits('30', 6), // requesterAmount
parseUnits('60', 6), // providerAmount
parseUnits('10', 6), // mediatorAmount
'0xMediatorAddress' // mediator address
]
);

// Admin transitions DISPUTED → SETTLED with resolution
await adminClient.kernel.transitionState(txId, State.SETTLED, resolutionProof);

// Distribution:
// Provider: $59.40 ($60 - 1% fee)
// Requester: $30.00 (refund, no fee)
// Mediator: $10.00
// Platform: $0.60

Tracking Escrow Balance

// Get remaining balance using public getter
const remaining = await escrowVault.remaining(escrowId);
console.log(`Escrow balance: ${formatUnits(remaining, 6)} USDC`);

// Verify escrow exists and get validation
const isValid = await escrowVault.verifyEscrow(escrowId, expectedAmount);
console.log(`Escrow valid: ${isValid}`);
Private Mapping

The escrows mapping in EscrowVault is private and cannot be read directly. Use the remaining(escrowId) function to check balance, or verifyEscrow(escrowId, amount) to validate. For full escrow details, listen to EscrowCreated events.


Events for Monitoring

event EscrowCreated(bytes32 indexed escrowId, address indexed requester, address indexed provider, uint256 amount);
event EscrowPayout(bytes32 indexed escrowId, address indexed recipient, uint256 amount);
event EscrowCompleted(bytes32 indexed escrowId, uint256 totalReleased);

Subscribe in SDK:

client.events.on('EscrowCreated', (escrowId, requester, provider, amount) => {
console.log(`New escrow: ${escrowId} for ${formatUnits(amount, 6)} USDC`);
});

Best Practices

For Requesters

PracticeWhy
Approve exact amountDon't approve unlimited USDC
Check vault balanceEnsure vault is solvent
Monitor eventsConfirm escrow creation

For Providers

PracticeWhy
Verify escrow before workCheck remaining(escrowId) matches expected
Track milestone releasesMonitor EscrowPayout events
Don't trust off-chainOnly deliver after on-chain confirmation

Comparison: ACTP vs. Alternatives

FeatureACTPEscrow.comLocalBitcoins
CustodySmart contractCompanySemi-custodial
Fees1%3.25%1%
Settlement2 seconds1-5 daysHours
DisputesSmart contractHuman mediatorArbitration
TrustCode (audited)Company reputationPlatform

Next Steps

📚 Learn More

🛠️ Start Building


Contract Reference

ContractAddress (Base Sepolia)
EscrowVault0x921edE340770db5DB6059B5B866be987d1b7311F
ACTPKernel0x6aDB650e185b0ee77981AC5279271f0Fa6CFe7ba
Mock USDC0x444b4e1A65949AB2ac75979D5d0166Eb7A248Ccb

Questions? Join our Discord