← home

Writing

Where trading platforms actually lose money

19 September 2026 · Prasanta Sahoo

Exchange post-mortems tend to blame the contract. In practice the money usually leaves through the accounting layer — and that layer is almost never in scope.

A smart contract audit is a bounded problem. The code is small, public, deterministic and has an established literature. That is precisely why the category is crowded and why it is the first thing anyone buys.

A matching engine is none of those things. It is large, stateful, concurrent, private, and its failure modes are arithmetic rather than syntactic. There is no checklist for it, and a reviewer who has not built one will read the code, find nothing obviously wrong, and be correct — because nothing is obviously wrong. The defect is that two correct-looking operations disagree about a number.

What follows is the set of failure classes I look for first. None of them are exotic. All of them have moved real funds.

1. Refund asymmetry on partial cancel

An order locks funds at placement. Fills consume part of the lock. Cancellation releases the lock. The bug is releasing what was locked rather than what remains.

place   lock += 100
fill 40 lock should now be 60
cancel release 100    <-- 40 minted from nothing

It survives review because both halves are individually reasonable: placement locks the order amount, cancellation releases the order amount. The symmetry is the bug. It is usually found by a test that cancels a partially filled order — which is exactly the case a happy-path test suite skips.

InvariantSum of all locks equals sum of all unfilled order remainders, at every point in the lifecycle — not just at rest.

2. Time-of-check to time-of-use on the balance

Read balance, validate, debit. If those are three statements rather than one transaction, two concurrent orders both pass validation against the same balance.

This is the single most common way an exchange goes negative, and it is invisible to sequential testing. The test that finds it issues the same request twice in parallel. Almost nobody writes that test, because every other test in the suite is sequential and the harness makes sequential easy.

InvariantNo account balance is ever negative, under any interleaving. Assert it after every operation, not at the end of the run.

3. The withdraw/trade race

A variant worth naming separately, because it crosses a service boundary. Withdrawal and order placement both debit available balance, but they frequently live in different services with different locks — or one takes a lock the other does not know about.

Withdraw the full balance and place an order in the same instant and you may get both. Bugs live at seams, and this is the highest-value seam in the system.

4. Rounding that always favours the same party

Fees, rebates, conversions and interest all round. The question is never whether there is rounding error — it is whether the direction is consistent.

Floor the fee charged and ceil the rebate paid and each trade leaks a fraction of the smallest unit. Individually negligible; in a loop, an income stream. The same asymmetry appears in vault share maths as mint-rounds-up / redeem-rounds-down.

InvariantFor any sequence of operations returning to the starting state, the actor is never better off. Fuzz the loop, not the single call.

5. Self-trade economics

If the maker rebate exceeds the taker fee for any tier or pair, trading with yourself is profitable. This is not a bug in any single function — the fee schedule and the rebate schedule are each defensible. It only appears when you multiply them together for the same account on both sides of a fill.

Worth checking on every pair and tier combination, because it is usually one exception in a fee table rather than a systemic error.

6. Partial-fill accounting drift

If remaining_quantity and locked_amount are maintained by separate code paths, they will eventually disagree. The failure is not a single dramatic event but slow divergence — which is worse, because by the time anyone notices, the reconciliation question is "since when?"

7. Credit before finality

Deposits credited at N confirmations where the chain can reorganise deeper than N. Or, more often, a confirmation policy that is correct for one chain applied uniformly to all of them, including one with materially weaker finality.

The related failure is having no reorg handler at all — the system is correct until the first reorg, at which point it has no mechanism to claw back a credit it has already let the user trade against.

8. Cancel-replace idempotency

Amend is often implemented as cancel-then-place. Under retry, that becomes two live orders, or one cancel and two places. The idempotency key usually exists; the question is its scope and TTL. A key scoped per-user but validated globally — or validated per-user when it should be global — fails in opposite but equally expensive directions.

9. API key and sub-account scope

The boring one, and it still lands. A key scoped to read-only accepted on a trading endpoint. A key for sub-account A accepted for sub-account B. Authorisation checked at the wrapper and not at the internal it delegates to.

Trivial to test, frequently missed, because the test requires two accounts and most suites are written with one.

10. Cliff boundaries

Where token distribution meets the exchange. >= versus > at the cliff timestamp. Truncation in amount * elapsed / duration such that the sum of all claims is less than the total — or the final claim reverts on underflow. Claims reachable after revocation because revoke sets a flag the claim path never reads.

Vesting maths is simple enough that everyone assumes someone checked it, and short enough that the bug is a single comparison operator.

How you actually find these

Two things, neither of which is a scanner.

Write the invariants down. Read the documentation and write out, in plain sentences, what the system claims to guarantee. Balance conservation. Locks reconcile to remainders. No negative balances. Claims never exceed allocation. Those sentences are the test suite. A fuzzer breaking one is evidence; everything else is a hypothesis.

Make concurrency an input. Most of the classes above are invisible to sequential tests. The harness must be able to interleave operations and jump time — vesting boundary bugs only appear when the fuzzer can warp across the cliff, and balance races only appear when two calls genuinely overlap.

Both are cheap. Neither is standard. That gap is most of why these bugs are still in production systems that have passed an audit.

If you run a trading platform and none of the above is in your current test suite or assessment scope, that is the conversation worth having. research@deebug.io