Appearance
Money movement
Every dollar and every token that moves through pocket agent travels one of four flows: funding a card, cashing a card out, transferring from your wallet, or withdrawing from your wallet. On top of those sits a fifth motion you never trigger by hand — a card purchase, authorized and settled by the card network while your agent shops.
This page is the map of all of them, and — more importantly — the map of the guardrails underneath. Moving real money is unforgiving: a dropped network response, a crash mid-send, a duplicated retry, or a reused idempotency key can each turn into a double-spend or a phantom credit. The engine is built so that none of those become a loss. The guiding rule across every flow is simple:
Record the intent before you broadcast. Credit only after you confirm. Do each exactly once.

The money map
| Flow | Endpoint | Direction | Value moves | Settles when |
|---|---|---|---|---|
| Card funding | POST /api/cards/:cardId/fund-crypto | crypto → card | your wallet → Reap deposit / treasury | the deposit transfer confirms on-chain |
| Redeem / cash-out | POST /api/cards/:cardId/redeem | card → crypto | treasury or Reap → your wallet | the payout transfer confirms on-chain |
| Wallet transfer | POST /api/transfer | crypto → crypto | your wallet → a user or agent | the send confirms on-chain |
| Wallet withdraw | POST /api/wallet/withdraw | crypto → crypto | your wallet → any address | the send confirms on-chain |
| Card purchase | REAP webhook (no user call) | card → merchant | your card balance → merchant | REAP clearing event |
Every one of these touches value, so every one is gated behind the master switch ENABLE_WITHDRAWALS (off by default) and runs under a per-user lock. The diagram below shows how the clients and the single Hono API funnel every money flow through the same two choke points before anything leaves.
Notice there is no arrow from a client straight to sendWithdrawal. Nothing broadcasts without passing a guard first — that is the invariant this whole page defends.
The money-safety invariants
Before walking the flows, here is the engine they all share. Each invariant exists because of a specific way real money can go wrong.
1. One guarded send path
src/provider/withdraw.ts → executeWithdraw() is the single protected wallet-send path. Both the HTTP withdraw endpoints and the agent's wallet_withdraw tool route through it, so every wallet send gets the same five guarantees:
- a per-user Redis money lock (
money:${userId}, 90s TTL, heartbeat-renewed) that serializes it against all the user's other money ops; - a fresh, uncached balance check (the cached balance lags ~20s — long enough to green-light a duplicate after a first send confirms);
- an in-flight double-send guard on the
(user, agent, chain, asset)rail; - a record-intent-before-broadcast write (a
transfer_outrow always exists after broadcast, even on a crash); - confirm-before-success —
settleSendreportsconfirmed/pending/ a realreverted, and never a phantom "sent".
The file's own comment is blunt about it: "Calling sendWithdrawal directly bypasses ALL of this — never do that." The redeem, transfer, and funding flows get the same treatment through a sibling wrapper, withMoneyGuard, which holds the identical lock and gate.
2. No phantom credit — exactly-once settlement
A credit (or debit) is the irreversible part, so it is the most carefully guarded. The three settlement functions each flip a status and mutate the ledger inside one transaction, using the amount recorded on the row — never a value supplied by the caller, so a replay can never over-credit:
sql
-- creditFundOp: pending → credited AND bump the ledger, atomically
UPDATE fund_ops SET status = 'credited'
WHERE op_key = $1 AND status = 'pending' RETURNING agent_id, usd_minor;
-- rowCount must be 1; if it is already credited, we do nothing
UPDATE ledger_agents SET balance_minor = balance_minor + <usd_minor from the row>
WHERE agent_id = <agent_id from the row>;The same shape backs finalizeRedeemOp (hold → debit) and markFundOpCredited (User-Funded, flips status with no ledger bump because Reap is the balance of record). For card settlement, matchAuthorization, settleClearing, reverse, and refund each call #claimEvent, which does an INSERT … ON CONFLICT DO NOTHING into processed_events in the same transaction as the balance change — so a webhook redelivered during a Redis-dedup outage, or landing on a different replica, can never apply a reversal or refund twice.
3. Idempotency and the opId
Clients attach an opId to every money POST. From it the server derives a deterministic ref — wd:${agent|user}:${opId} for withdraws, fund:…, redeem:…, transfer:…, swap:… for the guarded ops. A duplicate request (a client timeout-and-retry, or an agent re-emitting a tool call) looks that ref up under the lock and replays the first outcome instead of broadcasting again. The idempotency table claims the key with beginIdempotent (INSERT … ON CONFLICT DO NOTHING), and each op-table carries its own ON CONFLICT DO NOTHING as a second backstop. On top, the REAP client auto-attaches an Idempotency-Key header to every money POST.
4. Double-spend guards
An unmined transaction still reads the full balance on-chain, so a naive retry would re-broadcast the same funds. Three guards block a fresh leg while an earlier one is unresolved:
| Guard | Rail it protects | Keyed on |
|---|---|---|
hasInFlightFundOp | card funding | (user, chain, asset) |
hasPendingTransferOut | transfer / withdraw | (user, agent-or-null, chain, asset) |
hasUnresolvedRedeemForCard | cash-out | (cardId), 2-minute window |
And a DB-level backstop survives even a Redis outage: a partial unique index, uq_tx_pending_transfer, enforces at most one pending transfer_out per (user, agent, chain, asset). If the lock ever fails open across replicas, the losing request's intent-INSERT fails before any broadcast instead of double-sending.
5. Kill-switch, suspend, caps, and screening
These live in src/provider/moneygate.ts and src/provider/compliance.ts, and — importantly — they are wired into both choke points (executeWithdraw and withMoneyGuard), so they cover withdraw, redeem, transfer, and swap alike:
operatorBlock— a global kill-switch (kv admin:kill_withdrawals = 1) and a per-user suspend (kv admin:suspend:<userId> = 1), both flippable from/adminwith no redeploy. It readskvand fails open on a read error, because a transient DB hiccup must never freeze all money movement —ENABLE_WITHDRAWALSstays the hard gate underneath.- Outflow caps (
enforceOutflowCaps) — a per-transaction USD ceiling (WITHDRAW_MAX_USD), a rolling 24-hour USD total (WITHDRAW_DAILY_MAX_USD), and a 24-hour count (WITHDRAW_DAILY_MAX_COUNT). Counters live inkv, keyed by UTC day, read and written under the money lock so there is no race, and incremented only after a successful broadcast. A cap of0means unlimited — so these are config-gated and off until an operator sets a value. They apply to money-out only; card funding is money-in and skips them. - Compliance screening (
screenAddress,isSanctionedCountry) — a destination denylist plus an optional external KYT provider hook, and an OFAC country set (defaultCU, IR, KP, SY, RU, BY, overridable). It is safe-by-default: a no-op untilCOMPLIANCE_DENY_ADDRESSES/COMPLIANCE_SCREEN_URLis configured, but the call sites are already wired, so turning it on is pure config.COMPLIANCE_FAIL_CLOSEDchooses strict (block the un-screenable) vs. permissive (allow and log).
6. Reconcilers
A short confirm window (25s) keeps the money lock from being held to expiry, so some sends return pending. A leader-locked scheduler (one replica wins the Redis leader lock, ticks every 30s) finishes them:
| Reconciler | What it heals |
|---|---|
reconcilePendingFunds | credits / fails top-ups once their deposit confirms (7-day window; only drops a tx after a 15-minute grace) |
reconcilePendingRedeems | debits a confirmed payout, or releases a stuck hold after 15 minutes |
reconcilePendingTransfers | advances pending wallet sends to confirmed / failed |
The one thing a reconciler never touches is a hashless sending straggler — a row recorded before broadcast whose process then died. The transfer may have gone through with no hash to credit against, so auto-resolving it could double-spend. Instead it is surfaced via alert() for a human to reconcile against the chain by hand.
Flow A — Card funding (crypto → card)
You send a stablecoin (USDC, USDT, or EURC — the only accepted assets) from your wallet. Where it lands depends on the funding model: under User-Funded (Reap holds your balance) it goes to your own per-chain Reap deposit address; under the legacy Program-Funded model it goes to the ecosystem treasury. Either way, the card is credited only after the deposit confirms.
A funding op walks a strict state machine. The order matters: the sending row is written before the broadcast so that a crash in the send window leaves a blocking record rather than a silent loss.
Flow B — Redeem / cash-out (card → crypto)
Cashing out is funding in reverse: the card is debited only after the payout confirms, and the USD is held in the meantime so it cannot be double-spent. There are two code paths, chosen by the funding model.
B1 — Program-Funded: treasury payout
The legacy path pays out USDC on Base only — one predictable, cheap-gas rail — from the treasury to your own wallet. The client's requested asset/chain are ignored so a cash-out can't be steered onto an expensive chain. (The demo reviewer account is blocked from cashing out, because its balance is mock.)
B2 — User-Funded: a cash-out the user signs
When Reap holds your balance, there is no treasury payout and no ledger debit — cash-out is a two-step Reap crypto-withdrawal that you authorize with your own embedded wallet. The destination is always the auth-verified embedded wallet from your Privy token, never a mutable stored signer — so a compromised client can't re-point the payout to an attacker.
Both paths share one state machine; only the terminal effect differs (finalizeRedeemOp debits the local ledger; markReapRedeemSigned does not, because Reap debits).
Flow C — Wallet transfer
A transfer moves crypto from your wallet to another user (resolved @nickname → wallet address, so no address ever leaks) or to one of your own agents. It runs inside withMoneyGuard and broadcasts through the same sendWithdrawal the withdraw path uses, with the same in-flight guard and uq_tx_pending_transfer backstop.
Flow D — Wallet withdraw
The withdraw endpoint and the agent wallet_withdraw tool both call executeWithdraw, the single protected path from invariant #1. An agent can never withdraw on the model's say-so: the tool only creates a pending approval carrying the exact amount and destination, which you tap to approve — and only then does the approval route call executeWithdraw.
Card authorization & settlement
The purchase flow is the one you don't call directly. Your agent reserves funds with a policy hold; REAP sends a real-time authorization request; the ledger decides against that hold; and asynchronous clearing / reversal / refund webhooks settle it — each exactly once.
matchAuthorization is deliberately strict: it takes SELECT … FOR UPDATE on the card row, expires stale holds, and matches a reservation by currency, merchant substring, and amount within ±15% (or a $1 floor). Zero or more than one candidate → DECLINE. It also checks available funds excluding this reservation's own hold, so a clearing that lands slightly high can't eat into funds another payment (or a redeem-in-flight) has reserved. Unmatched always means DECLINE — fail-closed.
Configuration gates
Nothing on this page moves real value until it is turned on. The relevant flags (defined in src/config.ts):
| Flag | Default | Gates |
|---|---|---|
ENABLE_WITHDRAWALS | false | all on-chain sends: withdraw, funding, transfer, redeem, swap, DeFi |
REAP_USER_FUNDED | false | User-Funded card model (effective only with a real REAP key) |
EMBEDDED_WALLETS | false | user-signed, non-custodial wallet path |
GASLESS_EVM | true | sponsored EVM sends (authoritative — no gas fallback after a sponsored attempt) |
WITHDRAW_MAX_USD / WITHDRAW_DAILY_MAX_USD / WITHDRAW_DAILY_MAX_COUNT | 0 (= unlimited) | outflow velocity caps |
COMPLIANCE_DENY_ADDRESSES / COMPLIANCE_SCREEN_URL | unset (no-op) | destination sanctions screening |
COMPLIANCE_FAIL_CLOSED | false | block vs. allow-and-log an un-screenable address |
ENABLE_WITHDRAWALS uses a deliberately strict parser (boolFlag): only 1, true, yes, or on count as true. This sidesteps the classic footgun where a string "false" coerces to true.
What is not covered yet
In the spirit of being honest about the engine:
- A standalone treasury-solvency reconciler does not exist. The Program-Funded redeem path checks the treasury's live balance before paying out, but there is no background job that continuously reconciles total card liabilities against treasury holdings.
- Hashless
sendingstragglers are reconciled by hand. By design, they are never auto-resolved (the outcome is unknown); they are surfaced viaalert()for an operator. - Velocity caps and compliance screening ship off. They are fully wired but default to unlimited / no-op until an operator sets values — so "turn them on" is a configuration step, not a code change.
Together these give the money engine a clear property: it can lose track of status temporarily (a pending that needs reconciling), but it is built never to lose track of funds — no double-spend, no phantom credit, every settlement exactly once.