Skip to content

Pay a USDC Vendor on Base

Scenario: A CrewAI procurement agent receives a contractor invoice. It generates a PQSafe envelope constrained to USDC on Base, validates the contractor’s 0x address against the allowlist, dispatches the payment, and confirms the on-chain transaction hash in the ledger.

Prerequisites

  • @pqsafe/agent-pay installed
  • A funded wallet on Base mainnet with USDC balance
  • Contractor’s Base wallet address (starting 0x...)
  • WALLET_PRIVATE_KEY and BASE_RPC_URL in .env
  • CrewAI: pip install crewai (Python) or npm install @crewai/core (JS)

Install

Terminal window
npm install @pqsafe/agent-pay viem
  1. Generate envelope with usdc-base rail constraint

    The envelope is scoped to a single contractor address. The agent cannot pay any other 0x address.

    import {
    generateKeyPair,
    createSpendEnvelope,
    createSignedEnvelope,
    } from '@pqsafe/agent-pay'
    const CONTRACTOR_ADDRESS = '0x742d35Cc6634C0532925a3b8D4C9b8F1234Abcd' // vendor's Base wallet
    const { secretKey } = await generateKeyPair()
    const envelope = createSpendEnvelope({
    agentId: 'procurement-agent',
    maxAmount: 5000, // Cap: $5,000 USDC per envelope
    currency: 'USDC',
    allowedRails: ['usdc-base'],
    allowedRecipients: [CONTRACTOR_ADDRESS],
    validUntil: new Date(Date.now() + 24 * 3600_000),
    requireApproval: true,
    approvalThreshold: 1000, // Telegram approval for invoices over $1,000
    memo: 'Contractor invoice — Base USDC',
    })
    const signedEnvelope = createSignedEnvelope(envelope, secretKey)
  2. Allowlist the vendor’s 0x address

    The allowedRecipients field enforces that only the pre-approved contractor can receive payment. This is checked at signature verification before any transaction is submitted.

    // Verify the address is in the allowlist before attempting payment
    import { verifyEnvelope } from '@pqsafe/agent-pay'
    function validateRecipient(recipientAddress: string, env: typeof envelope): boolean {
    const normalizedRecipient = recipientAddress.toLowerCase()
    return env.allowedRecipients.some(r => r.toLowerCase() === normalizedRecipient)
    }
    const invoiceAddress = '0x742d35Cc6634C0532925a3b8D4C9b8F1234Abcd'
    if (!validateRecipient(invoiceAddress, envelope)) {
    throw new Error(`Recipient ${invoiceAddress} not in envelope allowlist`)
    }
  3. Dispatch via usdc-base rail adapter

    import {
    executeAgentPayment,
    buildLedgerRecord,
    submitToLedger,
    } from '@pqsafe/agent-pay'
    async function payUSDCVendor(
    recipientAddress: string,
    amountUSDC: number,
    invoiceRef: string
    ) {
    const result = await executeAgentPayment(signedEnvelope, {
    recipient: recipientAddress,
    amount: amountUSDC,
    currency: 'USDC',
    rail: 'usdc-base',
    memo: `Invoice ${invoiceRef}`,
    })
    // Ledger entry includes on-chain tx hash
    const ledgerEntry = await submitToLedger(buildLedgerRecord(signedEnvelope, result))
    return {
    status: result.status,
    txHash: result.txHash, // Base L2 transaction hash
    blockNumber: result.blockNumber,
    ledgerId: ledgerEntry.id,
    }
    }
  4. Confirm on-chain: read tx hash from ledger entry

    import { createPublicClient, http } from 'viem'
    import { base } from 'viem/chains'
    const publicClient = createPublicClient({
    chain: base,
    transport: http(process.env.BASE_RPC_URL!),
    })
    async function confirmOnChain(txHash: `0x${string}`) {
    const receipt = await publicClient.waitForTransactionReceipt({
    hash: txHash,
    confirmations: 3, // Wait for 3 blocks
    })
    console.log('Block:', receipt.blockNumber)
    console.log('Gas used:', receipt.gasUsed)
    console.log('Status:', receipt.status) // 'success' | 'reverted'
    return receipt.status === 'success'
    }
    const payment = await payUSDCVendor(CONTRACTOR_ADDRESS, 500, 'INV-2026-042')
    const confirmed = await confirmOnChain(payment.txHash as `0x${string}`)
  5. Handle low-gas conditions (retry policy)

    Base L2 gas spikes are rare but possible. Implement exponential backoff with gas estimation:

    import { PQSafeError } from '@pqsafe/agent-pay'
    async function payWithRetry(
    recipientAddress: string,
    amountUSDC: number,
    invoiceRef: string,
    maxRetries = 3
    ) {
    for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
    return await payUSDCVendor(recipientAddress, amountUSDC, invoiceRef)
    } catch (err) {
    if (err instanceof PQSafeError && err.code === 'RAIL_TIMEOUT' && attempt < maxRetries) {
    const delayMs = Math.pow(2, attempt) * 1000 // 2s, 4s, 8s
    console.warn(`Payment attempt ${attempt} timed out. Retrying in ${delayMs}ms...`)
    await new Promise(resolve => setTimeout(resolve, delayMs))
    continue
    }
    throw err // Re-throw non-retryable errors
    }
    }
    throw new Error(`Payment failed after ${maxRetries} attempts`)
    }

Complete example

import {
generateKeyPair,
createSpendEnvelope,
createSignedEnvelope,
executeAgentPayment,
buildLedgerRecord,
submitToLedger,
PQSafeError,
} from '@pqsafe/agent-pay'
const CONTRACTOR = '0x742d35Cc6634C0532925a3b8D4C9b8F1234Abcd'
const INVOICE_AMOUNT = 500 // USDC
const INVOICE_REF = 'INV-2026-042'
// Setup
const { secretKey } = await generateKeyPair()
const signedEnvelope = createSignedEnvelope(
createSpendEnvelope({
agentId: 'procurement-agent',
maxAmount: 5000,
currency: 'USDC',
allowedRails: ['usdc-base'],
allowedRecipients: [CONTRACTOR],
validUntil: new Date(Date.now() + 86_400_000),
}),
secretKey
)
// Execute with retry
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const result = await executeAgentPayment(signedEnvelope, {
recipient: CONTRACTOR,
amount: INVOICE_AMOUNT,
currency: 'USDC',
rail: 'usdc-base',
memo: `Invoice ${INVOICE_REF}`,
})
await submitToLedger(buildLedgerRecord(signedEnvelope, result))
console.log('Payment settled:', result.txHash)
console.log('Block:', result.blockNumber)
break
} catch (err) {
if (err instanceof PQSafeError && err.code === 'RAIL_TIMEOUT' && attempt < 3) {
await new Promise(r => setTimeout(r, 2 ** attempt * 1000))
continue
}
throw err
}
}

Expected output

Payment settled: 0xabc123def456...
Block: 18432901
Gas used: 65432
Ledger entry: ld_01J4X...

Troubleshooting

ProblemSolution
RECIPIENT_NOT_ALLOWEDCheck allowedRecipients contains exact address (case-insensitive)
RAIL_TIMEOUTBase L2 congestion — retry logic handles this automatically
INSUFFICIENT_BALANCEWallet USDC balance < invoice amount; check via cast balance --erc20
Transaction revertedUSDC transfer() reverted — likely balance issue; check on Basescan
ENVELOPE_EXPIREDvalidUntil passed; re-issue envelope
APPROVAL_REQUIREDInvoice > approvalThreshold; approve via Telegram

Next steps