tfpdev
01 / 06Concurrency

Double-Spend

A card with $100 on it. A hundred $5 payments hit it in the same instant. Three of the five ways to guard it pay out more than $100. You are the one taking the money.

01Step 1

Attack

Pick how the card is guarded. Press attack. Watch what happens when a hundred payments hit one card in the same instant.

  • $5one payment, on its way
  • read?shop asks how much is left
  • $10the answer it got
  • =$5tells the bank the new total
  • at the bank, waiting for an answer
  • ✓paid, the money was there
  • ✓paid with money that isn't there
  • ✗refused
example

3 payments, 3 at the same instant. Go.

0 / 230 ms
$?

Press Attack. 100 payments hit a card holding $100, all at once. Whatever you get out beyond $100 is money that never existed.

Limit: 120 attacks / hour per visitor

Limits: 120 attacks per hour per IP, 200 requests and 40 in flight per attack. Each attack has its own account row; rows older than 24 h are deleted. Timing is measured on the server with no added sleeps, so results vary run to run — that is the point.

02Step 2

What keeps the card safe

The database decides

If the app asks 'how much is there?' and then writes a new number, dozens of other payments ask the same question in between and get the same wrong answer.

One queue per copy is no queue

Real services run several copies of the app at once. Each copy has its own queue and does not know the others exist. Two orderly lines, both selling the same last seat.

Check and pay in one step

The check and the payment are one single action that cannot be split, and it is the fastest safe option. A rule in the vault itself refuses an empty balance, whatever new code gets written later.

03Step 3

The code

What runs behind the attack button, in one sentence. The code itself is one click away.

The bad version reads the balance, decides in the app, then writes a new number. The good version asks the database to take the money only if it is there, in one step.

Show the code anyway
BreaksBreaks
The version that loses money. Read the grey notes under it, not the code.
debit-check-in-code.tsts
// Level 1 — load, check, saveconst { rows: [acct] } = await db.query(  "SELECT balance_cents FROM accounts WHERE id = $1",  [accountId]);if (acct.balance_cents < amount) return "declined";await db.query(  "UPDATE accounts SET balance_cents = $2 WHERE id = $1",  [accountId, acct.balance_cents - amount]   // ← stale number);return "approved";
Two round trips. Every request that reads before the first one writes sees the old balance.
HoldsHolds
The version running behind the buttons above.
debit.tsts
// Level 4 — let the database decideconst { rows } = await db.query(  `UPDATE accounts      SET balance_cents = balance_cents - $2    WHERE id = $1 AND balance_cents >= $2RETURNING balance_cents`,  [accountId, amount]);return rows.length === 1 ? "approved" : "declined";// schema: CHECK (balance_cents >= 0)// If a future refactor forgets the WHERE, this still refuses.
Holds. Postgres evaluates the condition under the row lock and writes or refuses in one step. The CHECK constraint stands behind it.
04Step 4

Still curious?

A few more questions people ask. Open what you need.

Why does one round trip of gap lose money?

The app asks the database "how much is there?", gets an answer, thinks, then says "write this new number". Thinking takes a few thousandths of a second. In that time dozens of other requests asked the same question and got the same, now wrong, answer.

You cannot fix this by being careful in the app. The only place that knows the true number at the moment of writing is the database itself.

Level 2 has a lock. Why does it still leak?

The queue lives inside one copy of the app. Real services run several copies at once so they can handle traffic. Each copy has its own queue and does not know the others exist. Two orderly lines, both selling the same last seat.

Your take is roughly half of what Level 1 gave you. Half the hole is closed. The business still bleeds.

Level 3 and Level 4 both hold. Why ship Level 4?

Level 3 is one till with one line. Nobody is served until the person in front is done. Safe, and slow at rush hour. Run both with the same settings and compare the time in the verdict.

Level 4 makes the check and the payment one single action that cannot be split. It is also the fastest safe option, so there is no trade-off to argue about.

What is the CHECK constraint for, if the WHERE already works?

The rule is written into the vault, not into the clerk's manual. New staff, new tools, new mistakes: the vault still refuses to open on an empty balance.

How is “extracted” calculated, exactly?

extracted = approved × amount − clamp(start − final, 0, start)

Take everything that was approved, subtract what the card really paid for, and what is left is money that came from nowhere. Both numbers come from the database, so the score cannot be inflated from the browser.

Is this the bug from the crypto card case?

Yes, the same kind. The case page has the story, within what I am allowed to share. Read the case →

Why this exists

Crypto card: balance checks that did not hold

This article is the toy version of a problem from a real project. The case page tells what happened and what it cost.

Read the case