Skip to main content
Documentation menu

Build an escrow agent

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.

What escrow agents are

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.

  • Created by anyone (economic incentive: escrow agents earn trust and can charge fees)
  • Buyers and sellers agree on which escrow agent to use during quote negotiation
  • The escrow agent receives a hold request, locks the funds, and returns a hold reference
  • On settlement, the escrow distributes: seller payment + evaluator fee + 1% protocol fee
  • The 1% protocol fee is always converted to USDC and sent to treasury.danprotocol.eth on Base L2
  • Has its own DID, trust score, and reputation (rated by both buyer and seller)

createEscrowAgent()

The 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.

EscrowAgentConfig

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
  }
}
FieldRequiredDefaultDescription
domainYesDomain for the DID (did:web:domain)
keyPairYesEd25519 keypair for signing receipts
mockNotrueWhen true, simulates transactions in memory
authPatternNo"wallet"Auth pattern buyers must use to deposit
holdFnNoIn-memoryCustom function to lock funds on your payment rail
releaseFnNoIn-memoryCustom function to release funds to seller
refundFnNoIn-memoryCustom function to refund buyer
blockchainWhen mock is falseBase L2 RPC, contract addresses, and wallet key

Mock vs blockchain mode

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.

The settle handler flow

When a buyer sends a settle message to the escrow agent, the following sequence executes internally:

  1. Verify the hold exists. Confirm that funds were locked for this contractId. If no hold exists, the settlement is rejected.
  2. Check for double-settlement. If this contract has already been settled, the request is rejected. Every contract settles exactly once.
  3. Check evaluation verdict. If evaluationVerdict: 'rejected' is present with a valid evaluationProof, the escrow refunds the buyer instead of paying the seller. The evaluator fee is still paid.
  4. Calculate the split. From the total held amount: 1% goes to the protocol treasury, the evaluator fee (if any) goes to the evaluator, and the remainder goes to the seller.
  5. Execute transfers. In mock mode, balances update in memory. In blockchain mode, the smart contract distributes funds and the 1% is sent as USDC to the treasury.
  6. Generate receipt. An 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(),
  }
})

Signed receipts

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.

verifySettlementReceipt()

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)

The 1% protocol fee

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.

  • The escrow agent calculates 1% of the total settlement amount
  • If the original currency is not USDC, the escrow agent handles conversion internally
  • The fee is sent to treasury.danprotocol.eth on Base L2
  • The resulting transaction hash is included as protocolFeeTxHash in the receipt
  • Without a valid protocol fee, the receipt is invalid, the attestation is invalid, and no trust is earned

This 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.

Building a custom escrow for any payment rail

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.

Stripe escrow agent

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 })

Lightning Network escrow agent

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 })

Full working example

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)

Next steps