Architecture
One Express process, five money providers, one canonical ledger row per payment. AgentWallet is a single Node.js + TypeScript service that talks to Postgres (system of record, ~192 tables) and a handful of external providers for the things we don't build ourselves: Payouts.com for fiat rails, our card issuer (Stripe or Airwallex, pluggable) for virtual cards, Coinbase CDP for USDC key custody on Base, a PCI vault for card-token outbound, telephony and email providers for comms. No microservices, no message queue, no Redis (yet — widget approval state is the one stateful exception). The agent_identities table is the core entity: one row per agent, scoped to a clientId, owned by one or more Principals, with twelve foreign-key children covering fiat ledger, AP2 keys, channels, card, crypto wallet and audit log. Balance is never stored — it is computed by aggregation against the append-only agent_wallet_transactions table inside a SERIALIZABLE transaction, protected by a Postgres advisory lock on the client master so concurrent allocations never race.
- Single Express 5 service — pino-http, CORS, cookie-parser, body parsers, session, loadAuth, CSRF guard, routes, errorHandler — wired in that exact order so every request runs through the same gate.
- Five money providers (and only five) — Payouts.com (17 fiat rails), Card Issuer (Stripe Issuing or Airwallex, pluggable by clients.banking_provider), Coinbase CDP (USDC custody on Base, HSM-managed), PCI Vault (VGS outbound proxy that substitutes card-token aliases back to real PANs at the network edge), On-chain Anchor (Base mainnet for mandate-hash anchoring).
- Postgres + Drizzle ORM — ~192 tables. agent_identities owns identity + ownership + channels + card + limits + crypto + AP2 keys; agent_wallet_transactions is the append-only fiat ledger; ap2_mandates plus ap2_mandate_audit is the mandate chain; payouts, cards.transactions, agent_inbox_messages, agent_outbox_messages and agent_identity_activity_log feed the unified activity timeline.
- Balance is computed, never stored — SELECT SUM(amount) FILTER (WHERE type IN ('credit','refund','release','transfer_in','settlement_credit','settlement_refund') AND status='completed') minus SUM for debits, against agent_wallet_transactions. Single source of truth; audit-perfect by construction; concurrent-safe inside a SERIALIZABLE transaction.
- Advisory lock on master-balance allocation — allocateWalletFunds acquires pg_advisory_xact_lock(hashClientId), re-reads master balance INSIDE the lock, throws INSUFFICIENT_MASTER_BALANCE if headroom is short, then inserts the credit row atomically. Concurrent allocations against the same client master serialize cleanly.
- Transaction types — credit (+, allocation from client master), debit (−, card load / payout draw / x402 settlement), hold (−, reserves funds for a pending pre-auth, no money moves), release (+, reverses a hold), refund (+, returns money from card or payout rail), transfer_in / transfer_out (±, direct agent-to-agent), settlement_credit / debit / refund (±, cross-client agent settlement).
- Six funding paths — manual top-up by Principal (portal:<principalId>), manual top-up by admin (admin:<adminId>), bank inbound webhook (bank:<txId>), settlement credit from another agent (ap2:<mandateId>), card refund webhook (card_refund:<authId>), opening balance at provisioning (provision:<agentId>). Each writes one row with a traceable reference.
- Capability cascade for outbound — Layer 1 banking-API capabilities (what the client account supports) ⊇ Layer 2 Payout Products tab (what the client has enabled) ⊇ Layer 3a agent_identities.payout_rails (per-agent rail subset) ⊇ Layer 3b agent_identities.allowed_countries (per-agent country narrowing) ⊇ Layer 3c routing sources per rail ⊇ Layer 3d per-agent velocity caps. The intersection is the agent's effective rail set.
- Two wallet implementations, one ledger — legacy WalletStorage and canonical agent-wallet.storage.ts coexist; new code uses the canonical path; both write to agent_wallet_transactions. The Principal-facing wallet-credit endpoint is being migrated to allocateWalletFunds so it picks up the advisory lock (tracked as R-01).
- Webhook dispatcher with HMAC signing — outbound events signed with X-Payouts-Signature: t=…,v1=hex over the JSON body, keyed by per-endpoint secret. In-process worker on a 5 s tick leases pending/failed deliveries, exponential backoff (30 s → 1 h cap), dead-letters after 8 attempts. Every attempt is auditable.
- Lock-order convention — Principal row → Agent row(s), never reverse. PATCH /agents/:id pre-fetches the parent ID outside the tx, locks parent FOR SHARE before agent FOR UPDATE (only when params change). PATCH /principals/:id locks principal FOR UPDATE then children FOR UPDATE. Both orderings agree, so deadlocks are impossible by construction.
- Observability namespaces — every log line is prefixed [Agent Portal], [Agent Wallet], [x402], [MCP Server], [AP2] or [Approvals] so one grep tells you which subsystem spoke. Metrics that page on-call include master CDP USDC balance < 10, master CDP ETH balance < 0.01, MCP session count > 1000, widget approval queue depth > 50 pending, and AP2 mandate failure spikes.
- Deployment — one Node.js + TypeScript process per environment. Postgres serverless (HTTP driver in dev, WebSocket in prod) via Drizzle ORM. In-memory Map plus Postgres advisory locks for hot paths. Managed HSM for crypto signing (keys never exposed). Card issuing pluggable per client. Outbound card traffic routed through the PCI vault proxy.
Frequently asked questions
- Why one Express process instead of microservices?
- One process = one ledger transaction boundary, one place to enforce the policy cascade, no distributed-transaction problems, no eventual-consistency surprises. A single Express 5 service serves all 17 rails, the card issuer, the CDP signer and the on-chain anchor — Postgres is the only stateful dependency.
- What database does AgentWallet use?
- PostgreSQL with Drizzle ORM. One ledger table, one trace table, idempotency by (account_id, key). No Redis, no Kafka, no event store in the runtime path — Postgres handles row-level locking, advisory locks for hot accounts, and ACID guarantees across multi-rail payments.
- How does AgentWallet handle deadlocks?
- By convention. The lock order is always Principal row → Agent row(s), never reverse. PATCH /agents/:id pre-fetches the parent ID outside the transaction, then inside the transaction locks the parent FOR SHARE before the agent FOR UPDATE. PATCH /principals/:id locks the principal FOR UPDATE then children FOR UPDATE. Both orderings agree → deadlock-free.
- How does AgentWallet handle webhook retries?
- An in-process worker leases pending/failed webhook_deliveries rows every 5 seconds, POSTs with a 10-second timeout, exponential backoff (30 s → 1 h cap) and dead-letters after 8 attempts. Every attempt is auditable.
- What is the agent_identities table and why is it central?
- agent_identities is the core entity — one row per agent, scoped to a clientId (the tenant), owned by one or more Principals through agent_principal_links. Each row carries the agent's identity (name, did:agentwallet:ag_…, AP2 ES256 public JWK), its channels (email, SMS, WhatsApp endpoint IDs), its card (vault alias + last4), its crypto wallet (CDP wallet ID + Base address), its limits (per-tx / daily / monthly / per-MCC / per-country) and its observability metadata. Twelve foreign-key children fan out from it: agent_wallet_transactions (fiat ledger), ap2_mandates + ap2_mandate_audit (signed mandate chain), payouts, cards_transactions, agent_inbox_messages, agent_outbox_messages, agent_identity_activity_log, acp_spt_issuances, x402_settlements and approval_requests. Multi-tenancy is enforced at every storage call — no query ever crosses clientId without an explicit cross-client settlement primitive.
- How is balance computed without storing it?
- By aggregation against agent_wallet_transactions inside a SERIALIZABLE transaction. The formula is SUM(amount) FILTER (WHERE type IN ('credit','refund','release','transfer_in','settlement_credit','settlement_refund') AND status='completed') minus SUM for debits ('debit','hold','transfer_out','settlement_debit'). The append-only ledger is the single source of truth — there's no balance column to drift, no nightly reconciliation, no eventual-consistency window. Holds reserve funds for a pending pre-auth without moving money; releases reverse a hold without a money rail leg. Concurrent allocations against the same client master serialize cleanly via pg_advisory_xact_lock(hashClientId), so headroom is checked INSIDE the lock and over-allocation is impossible by construction.
- What are the six funding paths into an agent wallet?
- Each funding path writes one credit row with a traceable reference, so the source of every dollar in every agent wallet is reconstructable. (1) Manual top-up by Principal (portal:<principalId>) — the Principal moves funds from the client master into the agent's wallet via the portal widget. (2) Manual top-up by admin (admin:<adminId>) — a Company admin acting on the Principal's behalf, dual-logged. (3) Bank inbound webhook (bank:<txId>) — fiat lands in the master and the routing layer credits the right agent. (4) Settlement credit from another agent (ap2:<mandateId>) — cross-agent payment via the unified ledger. (5) Card refund webhook (card_refund:<authId>) — refund posts back to the originating agent's wallet. (6) Opening balance at provisioning (provision:<agentId>) — initial allocation at agent create.
- What's the capability cascade for outbound payments?
- Five layers, intersected to produce the agent's effective rail set. Layer 1: banking-API capabilities (what the underlying Payouts.com client account literally supports — discovered live, never cached). Layer 2: Payout Products tab (what the Company has flipped on for itself). Layer 3a: agent_identities.payout_rails (per-agent rail subset selected by the Owner). Layer 3b: agent_identities.allowed_countries (per-agent country narrowing). Layer 3c: routing sources per rail (which Payouts.com provider routes a given rail for this account). Layer 3d: per-agent velocity caps (per-tx / daily / monthly). The runtime evaluates all five on every payout — the intersection is what the agent can actually do today, surfaced live via GET /accounts/mine/capabilities so the dashboard greys out anything the agent can't reach.
- What's the observability story?
- Every log line is namespaced — [Agent Portal], [Agent Wallet], [x402], [MCP Server], [AP2], [Approvals] — so one grep tells you which subsystem spoke. Pino-http emits structured JSON with request ID, agent ID, principal ID and trace ID on every line. Metrics that page on-call include master CDP USDC balance < 10, master CDP ETH balance < 0.01 (gas), MCP session count > 1000, widget approval queue depth > 50 pending, and AP2 mandate failure spikes (more than 5 verify failures per minute usually means a key rotation problem). The unified activity timeline in the Agent Portal merges seven sources — ledger, payouts, card transactions, inbox, outbox, MCP tool calls, mandate audit — into one chronological feed per agent for operator debugging.