Wallet Double-Spend Races: Spending the Same Balance Twice
A wallet debit is a state transition: balance >= amount then balance -= amount. Two requests that both pass the check before either write finishes spend the same coins twice.
You are hunting double-spend, not XSS. Keep other users' wallets out of scope.
Fund small, strike parallel
Load a wallet with a known amount—say $10 in sandbox credit. Create two checkouts or transfers that each cost $10. Fire both debits together with grouped requests. Inspect:
- Both orders marked paid from wallet
- Ending balance negative or zero after two full spends
- Two ledger rows debiting the same
transaction_idsource - Idempotency ignored when keys differ per tab
Transfer-to-self or transfer-between-two-accounts-you-own is ideal. Buy-with-wallet on two merchants in the same platform also works when each order calls debitWallet.
Try one debit and one cash-out/withdraw in parallel. I've seen spend succeed while withdrawal also succeeds because both paths read the pre-debit balance.
Ledger design tells on itself
If GET /wallet is eventually consistent, poll until stable and still show a negative or overspent state. If the API hides negatives with a floor at zero while two fulfillments proceeded, call out the inconsistency between ledger and orders.
Optimistic concurrency helps when the app sends version or If-Match. Replay two requests with the same version; both should not apply. If the client omits the version and the server does not enforce one, note that.
Promo credit deposited into the same wallet should follow identical debit rules. Race a purchase debit against a "expire promo credit" job if you can trigger expiry manually; inconsistent ledgers sometimes allow spend after expiry or double application of the promo slice.
Do not script thousands of attempts. A tight burst of 2–20 requests is plenty. Unique idempotency keys per request often unlock the bug; a shared key may hide it—test both.
Evidence finance will accept
Provide starting balance, both debit requests with timestamps, both success responses, final balance, and order IDs. A simple table in the report helps: expected spend $10, observed spend $20, final balance $0 or -10.
Remediation: single-row lock or atomic UPDATE wallet SET balance = balance - ? WHERE id = ? AND balance >= ?, append-only ledger with unique constraints, and PSP-style idempotency on debit endpoints. Reject client-supplied balances outright.
Hold-and-capture wallets need a special case. Place two authorizations that each "hold" the full balance, then capture both. If holds are soft flags instead of reserved amounts, captures can still double-spend. Show hold rows and capture rows side by side.
Tips and donations funded from the same wallet are easy to forget in test plans. Race a cart debit and a tip debit that together exceed the balance; both succeeding is still double-spend even if neither alone exceeds it.
Wallet races feel abstract until two deliveries ship against one top-up. Keep the proof tiny, owned, and numerical—then the severity writes itself.