Payments
Double withdrawals: the race condition that drains a hot wallet
Two withdrawal requests arrive a few milliseconds apart and both pass the balance check. Why check-then-act fails, and how locks, idempotency and state machines prevent it.
- Author
- Gamicy Engineering
- Published
- Reading time
- 3 min
A player has a balance of 1,000 USDT and requests a withdrawal of 1,000 USDT. A moment later — a double click, a second browser tab, a mobile client retrying on a flaky connection, or a deliberate script — a second identical request arrives. Both requests are approved. The hot wallet sends 2,000 USDT.
This is one of the most common and most expensive bugs in real-money platforms. It rarely shows up in testing, because tests send one request at a time. It shows up in production, under load, and the money is already on-chain by the time anyone notices.
Why check-then-act fails
Most vulnerable implementations look reasonable when read top to bottom:
async function requestWithdrawal(userId: string, amount: bigint) {
const balance = await getBalance(userId); // both requests read 1000
if (balance < amount) throw new InsufficientFunds();
await createWithdrawal(userId, amount); // both requests create one
await debit(userId, amount); // balance goes to -1000
}Each line is correct in isolation. The bug is the gap between them. Two requests interleave: both read the balance before either writes, so both pass the check. Adding more checks in application code doesn't close the gap — it only moves it.
Make the database decide
The fix is to make the balance check and the debit a single atomic operation that the database serialises. There are two standard approaches.
UPDATE balances
SET available_minor = available_minor - $2,
held_minor = held_minor + $2
WHERE user_id = $1
AND available_minor >= $2
RETURNING available_minor;
-- 0 rows updated => insufficient funds, reject the requestBEGIN;
SELECT available_minor FROM balances WHERE user_id = $1 FOR UPDATE;
-- second request blocks here until the first commits, then sees the new balance
-- ... check, insert withdrawal, write ledger entries ...
COMMIT;Note the hold. A withdrawal should not simply subtract from the balance; it should move funds from available to held. If the withdrawal is later rejected or fails, the hold is released. If it completes, the hold becomes a final debit. Both transitions are ledger entries, so the history is auditable.
Idempotency keys for retries
Locks stop two different requests from overspending. They don't stop one logical request from being submitted twice — a mobile client that times out and retries is sending the same withdrawal again, and the player may well have enough balance for both. The client should send an idempotency key with each withdrawal intent, and the server should store it with a unique constraint. A repeated key returns the original result instead of creating a second withdrawal.
Broadcasting is the second danger zone
Even with a perfect request flow, duplicates can appear at the signing and broadcasting step. A worker signs and broadcasts a transaction, then crashes before recording that it did. On restart it sees a withdrawal still marked as approved, and sends it again.
- Persist the signed transaction (or its hash and, on account-based chains, its nonce) before broadcasting. On retry, rebroadcast the same signed transaction instead of creating a new one.
- On account-based chains, a reused nonce means a retried transaction can replace, but never duplicate, the original.
- On UTXO chains, a rebroadcast of the same raw transaction spends the same inputs, so it cannot pay out twice.
Model withdrawals as a state machine
Withdrawals should move through explicit, one-directional states — requested → approved → signed → broadcast → confirmed, with rejected and failed as exits. Every transition is a conditional update that only succeeds from the expected previous state. Two workers can never both move the same withdrawal from approved to signed.
These are some of the first things we look at in a platform audit. If you are designing a withdrawal flow from scratch, see our approach to wallets and crypto payments.
Related services
- Wallets and crypto payments
Multi-chain deposits, withdrawals, hot/cold wallet architecture and a double-entry ledger that reconciles.
- Platform audit
A fixed-scope technical review of your wallet, payment and integration code — the lowest-risk way to start working with us.