Escrow agents hold and release funds. They are not hardcoded in the SDK — they are agents in the economy whose vocation is secure payment handling.
Every transaction in the protocol requires escrow. The buyer deposits funds before the seller starts working, and those funds are released only when the work is delivered and approved. This is the Calvinist Total Depravity principle: the system assumes everyone will try to cheat and makes cheating costlier than honesty.
Escrow agents are not a special type of entity. They are regular CommerceAgent instances that implement the escrow interface: hold(), release(), refund(), and status(). Each escrow agent handles one payment rail internally (USDC, Stripe, Lightning, PayPal). The SDK knows nothing about specific payment methods.
hold request, locks the funds, and returns a hold referencetreasury.danprotocol.eth on Base L2The SDK provides a factory that returns a fully functional CommerceAgent pre-configured to handle hold, release, refund, and settlement operations.
import { createEscrowAgent, generateKeyPair } from '@dan-protocol/sdk'
const escrow = createEscrowAgent({
domain: 'escrow.example.com',
keyPair: generateKeyPair(),
})
await escrow.listen({ port: 3010 })That is a working escrow agent in mock mode. It holds funds in a local map and releases them on settlement. No blockchain needed for development.
interface EscrowAgentConfig {
domain: string
name?: string // Default: "Reference Escrow Agent"
keyPair: AgentKeyPair
didResolver?: DIDResolver
mock?: boolean // Default: true (in-memory fund tracking)
authPattern?: 'oauth2' | 'api-key' | 'wallet' // Default: 'wallet'
holdFn?: HoldFn // Custom hold logic
releaseFn?: ReleaseFn // Custom release logic
refundFn?: RefundFn // Custom refund logic
treasuryAddress?: string // Base L2 treasury for 1% fee
blockchain?: { // Required when mock is false
rpcUrl: string
escrowContractAddress: string
treasuryAddress: string
walletPrivateKey: string
}
}| Field | Required | Default | Description |
|---|---|---|---|
domain | Yes | — | Domain for the DID (did:web:domain) |
keyPair | Yes | — | Ed25519 keypair for signing receipts |
mock | No | true | When true, simulates transactions in memory |
authPattern | No | "wallet" | Auth pattern buyers must use to deposit |
holdFn | No | In-memory | Custom function to lock funds on your payment rail |
releaseFn | No | In-memory | Custom function to release funds to seller |
refundFn | No | In-memory | Custom function to refund buyer |
blockchain | When mock is false | — | Base L2 RPC, contract addresses, and wallet key |
Mock mode (mock: true, the default) keeps everything in memory. Holds are tracked in a Map, releases decrement the balance, and settlement receipts contain deterministic placeholder transaction hashes. No real funds move.
Blockchain mode (mock: false) interacts with the AgentEscrow.sol smart contract on Base L2. The escrow agent calls hold() to lock USDC in the contract, release() to pay the seller, and sends the 1% protocol fee to the treasury address.
// Mock mode — default, for development and testing
const escrow = createEscrowAgent({
domain: 'escrow.local',
keyPair: generateKeyPair(),
mock: true,
})
// Blockchain mode — real USDC on Base L2
const escrow = createEscrowAgent({
domain: 'escrow.production.com',
keyPair: generateKeyPair(),
mock: false,
blockchain: {
rpcUrl: 'https://mainnet.base.org',
escrowContractAddress: '0x1234...abcd',
treasuryAddress: '0xabcd...1234',
walletPrivateKey: process.env.WALLET_PRIVATE_KEY!,
},
})Switching from mock to blockchain mode requires only changing mock to false and providing the blockchain config. The rest of your code stays the same.
When a buyer sends a settle message to the escrow agent, the following sequence executes internally:
contractId. If no hold exists, the settlement is rejected.evaluationVerdict: 'rejected' is present with a valid evaluationProof, the escrow refunds the buyer instead of paying the seller. The evaluator fee is still paid.EscrowSettlementReceipt is assembled, signed with Ed25519, and returned to the buyer.You can override the default settle handler with custom logic if needed:
const escrow = createEscrowAgent({
domain: 'escrow.example.com',
keyPair: generateKeyPair(),
mock: false,
})
escrow.handle('settle', async (params, ctx) => {
const { holdTxHash, sellerDid, amount, evaluatorDid, evaluatorFee } = params
// 1. Verify hold exists on-chain
const hold = await verifyHoldOnChain(holdTxHash)
if (!hold) throw new Error('Hold not found or expired')
// 2. Calculate distribution
const protocolFee = amount * 0.01
const evalFee = evaluatorFee || 0
const sellerAmount = amount - protocolFee - evalFee
// 3. Release funds
const sellerTx = await releaseFunds(sellerDid, sellerAmount)
const evalTx = evaluatorDid
? await releaseFunds(evaluatorDid, evalFee)
: null
const feeTx = await sendUsdcToTreasury(protocolFee)
// 4. Return receipt data (SDK signs it automatically)
return {
contractId: params.contractId,
sellerTxHash: sellerTx.hash,
evaluatorTxHash: evalTx?.hash || '',
protocolFeeTxHash: feeTx.hash,
sellerAmount,
evaluatorFee: evalFee,
protocolFee,
settledAt: new Date().toISOString(),
}
})Every settlement produces a receipt signed by the escrow agent's Ed25519 key. This is the proof that funds were distributed correctly.
// Receipt structure (generated automatically)
{
contractId: 'contract-abc-123',
sellerDid: 'did:web:seller.example.com',
buyerDid: 'did:web:buyer.example.com',
escrowDid: 'did:web:escrow.example.com',
sellerTxHash: '0xaaa...',
evaluatorTxHash: '0xbbb...', // empty string if no evaluator
protocolFeeTxHash: '0xccc...',
protocolFeeAmount: '0.050000',
totalAmount: '5.000000',
currency: 'USD',
escrowSignature: 'ed25519-hex...', // Signature over all fields above
settledAt: '2026-04-07T12:00:00Z',
}The escrowSignature covers all other fields. The receipt is canonicalized (deterministic JSON with sorted keys) before signing. Anyone with the escrow agent's public key can verify it was not tampered with.
The SDK exports a verification function that checks the receipt signature against the escrow agent's public key.
import { verifySettlementReceipt } from '@dan-protocol/sdk'
// Verify receipt authenticity
const isValid = await verifySettlementReceipt(receipt, {
didResolver: resolver, // Fetches escrow agent's DID document for public key
})
if (isValid) {
console.log('Receipt is authentic — signed by the escrow agent')
} else {
console.log('Receipt is invalid — signature does not match')
}
// Independently verify the protocol fee arrived on Base L2
import { verifyProtocolFee } from '@dan-protocol/sdk'
const feeVerified = await verifyProtocolFee(receipt.protocolFeeTxHash, {
rpcUrl: 'https://mainnet.base.org',
treasuryAddress: '0x...',
expectedAmount: receipt.protocolFeeAmount,
})
console.log('Protocol fee verified on-chain:', feeVerified)Regardless of what payment rail the escrow agent uses internally, the 1% protocol fee is always paid in USDC on Base L2. This is the one thing that is hardcoded in the protocol.
treasury.danprotocol.eth on Base L2protocolFeeTxHash in the receiptThis creates a natural incentive: paying the 1% fee is cheaper than not having trust. Agents that skip the fee can transact between known parties, but they cannot grow their reputation in the broader market.
The power of escrow-as-agent is that anyone can build an escrow agent for any payment method. Here are two examples showing the pattern.
import { createEscrowAgent, generateKeyPair } from '@dan-protocol/sdk'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const escrow = createEscrowAgent({
domain: 'stripe-escrow.example.com',
name: 'Stripe Escrow Agent',
keyPair: generateKeyPair(),
mock: false,
authPattern: 'api-key', // Buyers authenticate with Stripe customer token
holdFn: async ({ buyerDid, amount, timeout, authToken }) => {
// Authorize charge without capturing (manual capture)
const intent = await stripe.paymentIntents.create({
amount: Math.round(amount * 100), // Stripe uses cents
currency: 'usd',
capture_method: 'manual',
metadata: { buyerDid, timeout: String(timeout) },
})
return { holdTxHash: intent.id, amount, timeout }
},
releaseFn: async ({ holdTxHash, sellerDid, amount }) => {
// Capture the authorized charge
await stripe.paymentIntents.capture(holdTxHash)
// Transfer to seller's connected Stripe account
const transfer = await stripe.transfers.create({
amount: Math.round(amount * 100),
currency: 'usd',
destination: sellerDid,
})
return { sellerTxHash: transfer.id }
},
refundFn: async ({ holdTxHash }) => {
// Cancel the uncaptured PaymentIntent
const cancelled = await stripe.paymentIntents.cancel(holdTxHash)
return { refundTxHash: cancelled.id }
},
})
await escrow.listen({ port: 3010 })import { createEscrowAgent, generateKeyPair } from '@dan-protocol/sdk'
const escrow = createEscrowAgent({
domain: 'lightning-escrow.example.com',
name: 'Lightning Escrow Agent',
keyPair: generateKeyPair(),
mock: false,
authPattern: 'api-key',
holdFn: async ({ buyerDid, amount, timeout }) => {
// Create a HODL invoice on your Lightning node
const invoice = await lndClient.addHoldInvoice({
value: amount,
expiry: timeout,
memo: buyerDid,
})
return { holdTxHash: invoice.paymentHash, amount, timeout }
},
releaseFn: async ({ holdTxHash, sellerDid, amount }) => {
// Settle the HODL invoice to release funds
await lndClient.settleInvoice({ preimage: holdTxHash })
// Pay the seller via Lightning
const payment = await lndClient.sendPayment({
dest: sellerDid,
amt: amount,
})
return { sellerTxHash: payment.paymentHash }
},
refundFn: async ({ holdTxHash }) => {
// Cancel the HODL invoice to refund buyer
await lndClient.cancelInvoice({ paymentHash: holdTxHash })
return { refundTxHash: holdTxHash }
},
})
await escrow.listen({ port: 3011 })import {
createEscrowAgent,
CommerceAgent,
CommerceClient,
LocalDIDResolver,
generateKeyPair,
verifySettlementReceipt,
} from '@dan-protocol/sdk'
async function main() {
const resolver = new LocalDIDResolver()
// --- Escrow agent (mock mode for testing) ---
const escrow = createEscrowAgent({
domain: 'escrow.local',
name: 'Test Escrow',
keyPair: generateKeyPair(),
didResolver: resolver,
mock: true,
})
await escrow.listen({ port: 3010 })
resolver.register('did:web:escrow.local', escrow.commerceEndpoint)
// --- Seller agent ---
const seller = new CommerceAgent({
domain: 'seller.local',
name: 'Echo Agent',
description: 'Echoes input back as output',
keyPair: generateKeyPair(),
didResolver: resolver,
acceptedEscrows: ['did:web:escrow.local'],
})
seller.service('echo', {
name: 'Echo',
category: 'utility',
price: { amount: 10, currency: 'USD', per: 'request' },
handler: async (input) => {
return { echoed: input.message, timestamp: Date.now() }
},
})
await seller.listen({ port: 3001 })
resolver.register('did:web:seller.local', seller.commerceEndpoint)
// --- Buyer ---
const client = new CommerceClient({
did: 'did:web:buyer.local',
keyPair: generateKeyPair(),
didResolver: resolver,
})
// Step-by-step flow with escrow
const pricing = await client.discoverPricing(seller.commerceEndpoint)
console.log('Seller accepts escrows:', pricing.acceptedEscrows)
const quote = await client.requestQuote(seller.commerceEndpoint, {
serviceId: 'echo',
input: { message: 'Hello from the escrow test' },
budget: 15,
preferredEscrows: ['did:web:escrow.local'],
})
const contract = await client.acceptQuote(quote.quoteId, {
escrowProof: {
holdTxHash: '0x' + 'a'.repeat(64), // Test tx hash
amount: 5,
currency: 'USD',
timeout: new Date(Date.now() + 7200_000).toISOString(),
},
})
const delivery = await contract.waitForDelivery()
console.log('Delivered:', delivery.deliverable)
// Settle via the escrow agent
const settlement = await client.settle(contract.contractId, {
escrowUrl: escrow.commerceEndpoint,
verdict: 'approved',
})
console.log('Seller paid:', settlement.sellerAmount)
console.log('Protocol fee:', settlement.protocolFee)
console.log('Fee tx:', settlement.protocolFeeTxHash)
// Verify the receipt
const isValid = await verifySettlementReceipt(settlement.receipt, {
didResolver: resolver,
})
console.log('Receipt valid:', isValid)
// Rate the seller
await client.rate(contract.contractId, {
agentUrl: seller.commerceEndpoint,
score: 5,
category: 'utility',
protocolFeeTxHash: settlement.protocolFeeTxHash,
})
console.log('Transaction complete with escrow.')
await seller.close()
await escrow.close()
}
main().catch(console.error)