What
Convert now moves money in both directions between USDC and the in-app USD fiat wallet. The card is no longer part of Convert: it is funded only through Transfer (wallet → KeySecure card).
Backend
- Quote accepts
USDC → USD(OFF_RAMP) andUSD → USDC(ON_RAMP). Any other pair returns 400. - Quotes no longer carry a card account. The expand-only migration
1790100000000makesramp_quotes.card_idnullable. - Settlement runs in one transaction and ends
COMPLETED:- USDC moves from the user to HOUSE.
- DR
reserve.USD/ CR the user's USD wallet. - DR
reserve.USD/ CRfee_revenue.USDfor the fee. - If
reserve.USDis short, the order endsREJECTED INSUFFICIENT_RESERVEand nothing is posted.
- ON_RAMP settlement also runs in one transaction and ends
COMPLETED:- DR the user's USD wallet / CR
reserve.USD(from − fee). - DR the user's USD wallet / CR
fee_revenue.USD(fee). - USDC moves from HOUSE to the user.
- If the wallet is short, the order ends
REJECTED INSUFFICIENT_FUNDS. If HOUSE USDC is short, it endsREJECTED INSUFFICIENT_RESERVE. Nothing is posted in either case. - Both directions take locks in the same order.
- DR the user's USD wallet / CR
- Reversal debits the user's USD wallet.
- Returns 409
RAMP_REVERSAL_INSUFFICIENT_WALLETwhen the wallet no longer holds the amount. - Returns 409
RAMP_REVERSAL_LEGACY_CARD_ORDERfor card-era orders. - An ON_RAMP reversal returns the USDC and refunds the wallet. It returns 409
RAMP_REVERSAL_INSUFFICIENT_CRYPTOwhen the user no longer holds the USDC.
- Returns 409
- The conversion module no longer depends on card-account: no CARD_TOPUP leg, no card_transfers hooks, and the timeout sweeper only fails stalled
ACCEPTED/LIQUIDITY_OKorders. - Treasury:
- Invariant 5 reads the USD fee book, counting only ramp-linked entries, because
fee_revenue.USDis shared with other fees. - Per-order checks split by era:
card_id IS NULLis checked against USD, otherwise against CARD_USD. - Only a short Fireblocks vault throttles a corridor, and only ON_RAMP. A KeySecure shortfall throttles nothing.
- Invariant 5 reads the USD fee book, counting only ramp-linked entries, because
Frontend
- The Convert screen is fixed to USDC → "USD wallet": no swap, no currency picker, and it shows the wallet balance.
- The receipt has a single COMPLETED state.
- After a submit, balances and history refresh through
invalidateBalanceQueries.
UI screens changed
| Screen | File | Before | After |
|---|---|---|---|
| Convert (form) | fe/src/app/convert/index.tsx |
From/To pickers (USDC/USDT |
USDC ⇄ USD wallet with a swap button (convert.swap). Swapping clears the amount and the held quote. No currency or asset picker, USDT removed. The source balance gate follows the source side: the USDC balance or the wallet balance. The "From" side shows the USDC balance, and Confirm is disabled with "Insufficient balance" when the amount exceeds it. The "To" side shows USD wallet with the current wallet balance (0 if the user has none) |
| Confirm convert sheet | fe/src/components/convert/confirm-convert-sheet.tsx |
"Card Target / Card Receive ••• last4" row | Shows To: USD wallet or From: USD wallet, depending on direction |
| Convert completed (receipt) | fe/src/app/convert/completed.tsx |
Three outcomes (Completed / "Conversion posted" settling / partial) plus a card row | A single Completed receipt with a USD wallet row (From or To) |
| Convert history |
fe/src/app/convert/history.tsx, components/convert/order-row.tsx
|
Example copy described card on-ramp | Copy updated to the off-ramp example ("20.00 USDC became 19.96 USD"). Old orders still render |
| Home / Transfer | (no change) | — | The Home header shows the fiat wallet, so it goes up after a convert. Balances and history refresh automatically. The Transfer form keeps the "Convert to USD first" link |
Removed: currency-select-sheet.tsx and the copy keys cardReceive, cardSend, noCardAdded, selectCard, selectCurrency, outcomeSettling*, outcomePartial*. Added convert.receiveWallet = "USD wallet".
Before deploy (staging/prod)
- Each query below must return 0. If not, resolve the orders per
be/src/modules/conversion/runbooks/ramp-partial-settlement.mdfirst.SELECT count(*) FROM ramp_orders WHERE status IN ('SETTLING','PARTIALLY_SETTLED'); SELECT count(*) FROM ramp_orders o WHERE o.status='FAILED' AND EXISTS (SELECT 1 FROM ramp_order_legs l WHERE l.order_id=o.id AND l.status='POSTED'); SELECT count(*) FROM card_transfers WHERE source_type='RAMP_ORDER' AND source_settled_at IS NULL; - Deploy BE (migration), then FE.
- Fund
reserve.USDthrough admin treasury fund-reserve. Without it, every USDC → USD convert returnsCONVERSION_UNAVAILABLE. - Check that the HOUSE USDC system row (
account_code = 'HOUSE') can cover the payouts. Otherwise every USD → USDC convert is rejected with insufficient reserve.
Verification
-
yarn buildpasses. Unit tests for conversion, treasury, card-account and transactions: 627 passed. DB specs (isolated DB): 445+ passed. FE convert tests: 73 passed.pnpm type-checkis clean. - API E2E on a local stack:
-
USD→USDCquote returns 400. - Converting 20 USDC returns
COMPLETED: the USD wallet goes up by 19.96, the fee is 0.04, andreserve.USDgoes down by 20. - USD 20.00 → USDC returns
COMPLETED: the wallet goes down by 20.00, the user gets 19.96 USDC, andreserve.USDgoes up by 19.96 (fee 0.04). - A wallet-short submit returns
INSUFFICIENT_FUNDSand no balance changes. - Reconciliation REVENUE and DOUBLE_ENTRY are OK.
-
- Each task was reviewed separately. The whole branch passed an Opus review gate in 2 rounds, and the ON_RAMP addition passed its own Opus gate in 1 round.
Known follow-ups
- Both convert directions lock USD accounts as reserve → fee → wallet. Card deposit and transfer lock the wallet first, then reserve. A concurrent convert and card deposit by the same user can deadlock: Convert retries, the deposit side returns a 500, and no money is misposted. Follow-up ticket needed.
- Simulator check was partial: the Convert screen renders correctly. QA still needs to run the receipt → Home → Transfer-to-card path on a device.
-
pnpm generate-apiwas not run. Orval rejects the current swagger because of the capitalised bearer scheme, which predates this branch. Convert uses the hand-writtenfe/src/api/convertwrapper. -
Pre-existing bug, not from this branch:
recordDepositon the Fireblocks path reads the user's USDC balance without a lock and writes an absolute value. A deposit that lands during a convert can overwrite the convert's change to the cached balance. The ledger entries stay correct, so it is detectable. Needs a high-priority ticket. - Admin console does not translate the three new reversal codes yet (admin changes are scheduled for SP7).