Skip to content

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 Wallet, where funding, transfers, and cash-outs begin

The money map ​

FlowEndpointDirectionValue movesSettles when
Card fundingPOST /api/cards/:cardId/fund-cryptocrypto → cardyour wallet → Reap deposit / treasurythe deposit transfer confirms on-chain
Redeem / cash-outPOST /api/cards/:cardId/redeemcard → cryptotreasury or Reap → your walletthe payout transfer confirms on-chain
Wallet transferPOST /api/transfercrypto → cryptoyour wallet → a user or agentthe send confirms on-chain
Wallet withdrawPOST /api/wallet/withdrawcrypto → cryptoyour wallet → any addressthe send confirms on-chain
Card purchaseREAP webhook (no user call)card → merchantyour card balance → merchantREAP 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_out row always exists after broadcast, even on a crash);
  • confirm-before-success — settleSend reports confirmed / pending / a real reverted, 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:

GuardRail it protectsKeyed on
hasInFlightFundOpcard funding(user, chain, asset)
hasPendingTransferOuttransfer / withdraw(user, agent-or-null, chain, asset)
hasUnresolvedRedeemForCardcash-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 /admin with no redeploy. It reads kv and fails open on a read error, because a transient DB hiccup must never freeze all money movement — ENABLE_WITHDRAWALS stays 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 in kv, 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 of 0 means 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 (default CU, IR, KP, SY, RU, BY, overridable). It is safe-by-default: a no-op until COMPLIANCE_DENY_ADDRESSES / COMPLIANCE_SCREEN_URL is configured, but the call sites are already wired, so turning it on is pure config. COMPLIANCE_FAIL_CLOSED chooses 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:

ReconcilerWhat it heals
reconcilePendingFundscredits / fails top-ups once their deposit confirms (7-day window; only drops a tx after a 15-minute grace)
reconcilePendingRedeemsdebits a confirmed payout, or releases a stuck hold after 15 minutes
reconcilePendingTransfersadvances 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):

FlagDefaultGates
ENABLE_WITHDRAWALSfalseall on-chain sends: withdraw, funding, transfer, redeem, swap, DeFi
REAP_USER_FUNDEDfalseUser-Funded card model (effective only with a real REAP key)
EMBEDDED_WALLETSfalseuser-signed, non-custodial wallet path
GASLESS_EVMtruesponsored EVM sends (authoritative — no gas fallback after a sponsored attempt)
WITHDRAW_MAX_USD / WITHDRAW_DAILY_MAX_USD / WITHDRAW_DAILY_MAX_COUNT0 (= unlimited)outflow velocity caps
COMPLIANCE_DENY_ADDRESSES / COMPLIANCE_SCREEN_URLunset (no-op)destination sanctions screening
COMPLIANCE_FAIL_CLOSEDfalseblock 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 sending stragglers are reconciled by hand. By design, they are never auto-resolved (the outcome is unknown); they are surfaced via alert() 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.

be everywhere — live here.