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-payinstalled- A funded wallet on Base mainnet with USDC balance
- Contractor’s Base wallet address (starting
0x...) WALLET_PRIVATE_KEYandBASE_RPC_URLin.env- CrewAI:
pip install crewai(Python) ornpm install @crewai/core(JS)
Install
npm install @pqsafe/agent-pay viem-
Generate envelope with
usdc-baserail constraintThe envelope is scoped to a single contractor address. The agent cannot pay any other
0xaddress.import {generateKeyPair,createSpendEnvelope,createSignedEnvelope,} from '@pqsafe/agent-pay'const CONTRACTOR_ADDRESS = '0x742d35Cc6634C0532925a3b8D4C9b8F1234Abcd' // vendor's Base walletconst { secretKey } = await generateKeyPair()const envelope = createSpendEnvelope({agentId: 'procurement-agent',maxAmount: 5000, // Cap: $5,000 USDC per envelopecurrency: '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,000memo: 'Contractor invoice — Base USDC',})const signedEnvelope = createSignedEnvelope(envelope, secretKey) -
Allowlist the vendor’s
0xaddressThe
allowedRecipientsfield 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 paymentimport { 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`)} -
Dispatch via
usdc-baserail adapterimport {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 hashconst ledgerEntry = await submitToLedger(buildLedgerRecord(signedEnvelope, result))return {status: result.status,txHash: result.txHash, // Base L2 transaction hashblockNumber: result.blockNumber,ledgerId: ledgerEntry.id,}} -
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}`) -
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, 8sconsole.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 // USDCconst INVOICE_REF = 'INV-2026-042'
// Setupconst { 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 retryfor (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: 18432901Gas used: 65432Ledger entry: ld_01J4X...Troubleshooting
| Problem | Solution |
|---|---|
RECIPIENT_NOT_ALLOWED | Check allowedRecipients contains exact address (case-insensitive) |
RAIL_TIMEOUT | Base L2 congestion — retry logic handles this automatically |
INSUFFICIENT_BALANCE | Wallet USDC balance < invoice amount; check via cast balance --erc20 |
| Transaction reverted | USDC transfer() reverted — likely balance issue; check on Basescan |
ENVELOPE_EXPIRED | validUntil passed; re-issue envelope |
APPROVAL_REQUIRED | Invoice > approvalThreshold; approve via Telegram |
Next steps
- Wrap an AP2 Mandate — for recurring SEPA/BACS vendor payments
- Telegram Approval Gate — add human approval for invoices over $1,000
- USDC Rail docs