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:

// Level 2: Advanced API - Direct protocol control
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)
// Level 2: Advanced API - Direct protocol control
// Generate escrow ID
const escrowId = ethers.id(`escrow-${txId}-${Date.now()}`);

// Link escrow (auto-transitions to COMMITTED)
await client.advanced.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:

// Level 2: Advanced API - Direct protocol control
// releaseEscrow() is called INTERNALLY when transitioning to SETTLED state
// Users should call transitionState() instead:
await client.advanced.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โ€‹

// Level 2: Advanced API - Direct protocol control
import { ACTPClient, State } from '@agirails/sdk';
import { ethers, parseUnits } from 'ethers';

// 1. Create transaction
const txId = await client.advanced.createTransaction({
provider: '0xProvider...',
requester: client.address,
amount: parseUnits('100', 6),
deadline: Math.floor(Date.now() / 1000) + 86400,
disputeWindow: 7200,
});

// 2. Fund escrow
await client.advanced.approveUsdc(parseUnits('100', 6));
const escrowId = ethers.id(`escrow-${txId}-${Date.now()}`);
await client.advanced.linkEscrow(txId, escrowId);
// Escrow: $100, State: COMMITTED

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

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

Scenario 2: Milestone Releasesโ€‹

// Level 2: Advanced API - Direct protocol control
import { ACTPClient, State } from '@agirails/sdk';
import { ethers, parseUnits } from 'ethers';

// 1. Create and fund $1,000 transaction
const txId = await client.advanced.createTransaction({
provider: '0xProvider...',
requester: client.address,
amount: parseUnits('1000', 6),
deadline: Math.floor(Date.now() / 1000) + 7 * 86400,
disputeWindow: 172800,
});

await client.advanced.approveUsdc(parseUnits('1000', 6));
const escrowId = ethers.id(`escrow-${txId}-${Date.now()}`);
await client.advanced.linkEscrow(txId, escrowId);
// Escrow: $1,000

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

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

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

Scenario 3: Cancellation Refundโ€‹

// Level 2: Advanced API - Direct protocol control
// Requester cancels after deadline
await client.advanced.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.

// Level 2: Advanced API - Direct protocol control
// 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.advanced.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โ€‹

// Level 2: Advanced API - Direct protocol control
import { formatUnits } from 'ethers';

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

// Verify escrow exists and get validation
const isValid = await client.advanced.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:

// Level 2: Advanced API - Direct protocol control
import { formatUnits } from 'ethers';

client.advanced.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)Address (Base Mainnet)
EscrowVault0x62eED95B2B7cEfC201C45D17C5d24A34aFC0C38E0xb7bCadF7F26f0761995d95105DFb2346F81AF02D
ACTPKernel0xD199070F8e9FB9a127F6Fe730Bc13300B4b3d9620xeaE4D6925510284dbC45C8C64bb8104a079D4c60
USDC0x444b4e1A65949AB2ac75979D5d0166Eb7A248Ccb (Mock)0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913

Questions? Join our Discord