An exchange is three machines that have to agree with each other. The matching engine decides who traded with whom. The ledger records what every customer is owed. The wallet subsystem is the only part that ever touches a blockchain. The expensive failures happen in the seams between those three rather than inside any one of them. This sheet covers how to order and replay matching, how to keep a ledger that cannot invent or lose value, what each crossing onto a chain does to your books, and why a proof of reserves is a much smaller claim than it sounds.
An exchange looks like a blockchain product and is mostly a bookkeeping product. Most transactions never touch a chain at all: a trade between two customers is a pair of ledger entries, instant and free and final. The chain only appears at the edges.
Where does the trading system end and the wallet system begin?
At the ledger. The matching engine never talks to a chain, and the wallet system never talks to the order book. Only four things cross that boundary: deposits, withdrawals, sweeps, and rebalances.
Why is the internal ledger the system of record?
Because you cannot rebuild it from chain state. Trades, fees, and internal transfers leave no on-chain trace, so a chain scan can tell you what you hold but never what you owe.
Why integers and never floating point?
Token precision differs per asset: USDT is six decimals, most ERC-20s are eighteen. Floats drop the low digits silently, so balances are held as integer minor units with the per-asset precision recorded alongside them.
What does proof of reserves actually prove?
That you controlled certain assets at one moment. Assets are the easy half. Liabilities are the hard half, and a naive customer-balance tree still allows omitted accounts and negative balances. It is not proof of solvency.
If you remember one lineThe ledger is the system of record. The chain is an external effect you reconcile against it.
Six stages and one equation
The six stages an order passes through, and the balance equation that has to hold at every instant in between. Every value here is explained in the sections below.
Continuous assertion: when the equation breaks, halt withdrawals first and work out why second. The instinct to find the bug before touching the kill switch is what turns a discrepancy into a loss.
Where the exchange stops being reversible
Everything above the heavy line is a database row you can correct with another row. Everything below it is a broadcast transaction nobody can take back. That line is the wallet boundary, and it earns more review than the rest of the system put together.
Numbered badges are the step order within each flow. Only the deposit and the withdrawal cross the heavy line; a trade between two customers is settled entirely in the ledger and never reaches a wallet, a signer, or a chain. The withdrawal is dashed because it is the one path where a mistake cannot be corrected with another row.
Matching engine
The book lives in memory and is rebuilt from a log, so all five of these come down to one requirement: the same input has to produce the same book every single time, on any machine, months later.
Single-writer sequencer
One process stamps every incoming command with the next number, and nothing else is allowed to write. Replay that log from any snapshot and you get the identical book back, which is what makes an incident investigable at all.
Concrete use: Sequence order 9,184,201 reserves balance, enters a BTC-USD limit order, and emits all resulting fills before 9,184,202.
Failure mode: Let two processes write and price-time priority becomes a race. Two replays of the same day produce two different books, and you can no longer tell a customer why their order missed.
Price-level book
Prices are keys in an ordered map and each price holds a FIFO queue of orders. Adding or cancelling inside a level you already have a handle on is close to O(1); finding the best bid or ask is a walk to the end of the ordered structure.
Concrete use: A post-only bid at $62,500 joins the back of that price level, or is rejected outright if it would cross the spread.
Failure mode: Key the map by a floating-point price and two orders a trader entered at the same number can land in different buckets. Key by integer ticks.
Order semantics
Every order type is a promise about when quantity enters the book and what happens to whatever does not fill. Limit, market, stop, IOC, FOK, post-only, and iceberg each answer that differently.
Concrete use: A 5 BTC fill-or-kill either takes all 5 BTC on arrival or cancels whole. A 4.9 BTC partial is a defect, not a fill.
Failure mode: Ship the label without the exact behavior and you get disputes you cannot win, because the customer read the same public definition you did.
Self-trade prevention
Stop accounts under common control from trading with each other. Pick one policy and publish it: cancel the incoming order, cancel the resting one, or decrement both.
Concrete use: Evaluate the account-group rule before the fill reaches the journal, never after.
Failure mode: You can reverse the ledger entry. You cannot recall the trade print that already went out on the market data feed and moved somebody else's algorithm.
Recovery
Recovery is a snapshot plus every command logged since it. Because replay is deterministic, the rebuilt book matches the one that died, down to who was standing where in each queue.
Concrete use: Load snapshot at sequence 9,180,000, replay 4,201 commands, compare state hash before reopening.
Failure mode: A balance snapshot from the database restores who owns what and nothing about queue position. Time priority only comes back from the journal.
Every state, and what it does to the money
An order status is a promise to three audiences at once: the customer reading it in the UI, the API client automating against it, and the ledger that has to have already moved the matching hold. Disputes come from statuses that mean different things to those three. Every transition below names who owns it and exactly what it does to the balance. Positions carry their own separate lifecycle — margin, funding, and liquidation live in the risk section, not here.
Ten states, and the two questions every transition has to answer: who set it, and what happened to the hold. Amber states are the ones holding customer money.
State
Set by
Hold effect
Journal effect
Client may
FIX OrdStatus (39)
received
Edge gateway, after the client order ID is deduped
None yet — nothing is reserved
None
Poll by client order ID. A retry carrying the same ID returns this order rather than creating a second one
A Pending New
rejected
Risk, compliance, or the matcher (a post-only that would cross, or an STP hit)
None placed, or released in full if risk had already placed one
None, ever. A rejected order never posts
Read the reject reason and resubmit under a new client order ID
8 Rejected
pending_new
Risk, once the hold is placed and the command is handed to the sequencer
Hold placed for the full order value; available falls immediately
None — a hold is not a posting
Nothing yet. The order has no queue position and cannot be cancelled by exchange ID
A Pending New — the same code as received. FIX has one status where the balance has two
working
Matcher, on acknowledgement
Unchanged; the full order value stays held
None until something fills
Cancel, or amend where amend is offered. The queue position is now real
0 New
partially_filled
Matcher, on each execution
Reduced by the executed quantity; the unfilled remainder stays held
One balanced posting per fill: quote liability down, base liability up, taker fee to the fee account, all inside the same transaction
Cancel the remainder. May not assume the remainder is safe from a further fill in the same instant
1 Partially filled
filled
Matcher, on the execution that completes the quantity
Closed at zero. Held value has become posted value
Final balanced posting. Any hold left over from price improvement is released, not kept
Nothing — terminal. Reconcile against the fills, not against the order
2 Filled
pending_cancel
Edge gateway, on accepting a cancel request
Unchanged. The hold stands until the matcher acknowledges
None for the request, but a fill can still post while the order sits here
Wait. Do not release the hold client-side and do not resubmit
6 Pending Cancel
cancelled
Matcher, on acknowledging the cancel. Never the API that accepted the request
Residual hold released. Anything already filled stays filled and stays posted
None for the cancellation itself
Nothing — terminal. Check the filled quantity before assuming nothing traded
4 Canceled
expired
The time-in-force clock, or the matcher on arrival for an IOC or FOK remainder
Residual hold released
None for the expiry itself
Nothing — terminal
C Expired
untriggered
Trigger evaluator, on accepting a stop or take-profit
None. A stop reserves nothing until it fires
None
Cancel or amend the trigger. Must not assume funds are set aside
No distinct code. FIX carries the trigger in OrdType (40), not OrdStatus; 7 Stopped means something else entirely
FIX codes above are FIX 4.4, tag 39. Venue vocabularies do not match FIX and do not match each other: as of September 2026 Binance spot publishes NEW, PENDING_NEW, PARTIALLY_FILLED, FILLED, CANCELED, PENDING_CANCEL, REJECTED, EXPIRED and EXPIRED_IN_MATCH, while Kraken publishes just pending, open, closed, canceled and expired. Two traps in that one sentence: Binance's PENDING_NEW means an order-list order waiting on its working order, not the hold-placed state above, and EXPIRED_IN_MATCH is specifically a self-trade-prevention outcome rather than a clock expiry. Map a venue's vocabulary to yours in writing before you integrate, because the words collide where the meanings do not.
Ledger design rules
Every rule below can be enforced in the database. Write them as constraints and triggers rather than as team convention, because an invariant that lives only in application code is one hotfix away from being gone.
Rule
Concrete form
Invariant
Failure prevented
Double entry
Every journal transaction sums debits and credits per asset
Σ postings = 0
Value creation/loss
Integer minor units
BTC satoshis; token base units; wide integer/decimal
Exact arithmetic
18-decimal rounding drift
Immutable journal
Corrections are compensating entries
History never rewritten
Untraceable balance edits
Available vs total
Holds reserve open orders/withdrawals
available = total − holds
Double-spend of customer liability
Idempotent posting
Unique business event + version
One event, one journal result
Duplicate webhook/fill credit
Projection
Balance is derived/cached with journal checkpoint
Projection equals replay
Authoritative mutable balance row
Domain boundary
Book and wallet only post through ledger API
No direct chain/book coupling
Hidden liability changes
What you actually store
The invariants above are only real if the schema can carry them. Four mutability classes, and nothing may sit in the wrong one: append-only (the journal), derived (projections, always rebuildable), operational (mutable working state that is never a source of truth about money), and reference (slow-moving lookups). The chain-facing tables are shaped by the pipelines in deposits & withdrawals.
Group tables by what is allowed to change them, not by subject area. Every argument about correctness reduces to a table sitting in the wrong band.
Table
Grain — one row per…
Mutability
Written by
Invariant it carries
journal_transaction
Balanced business event: a fill, a deposit credit, a fee, a correction
Append-only. Never updated, never deleted; corrections are new compensating transactions
Ledger service only
Carries the idempotency key. One business event produces one transaction, however many times it is delivered
journal_entry
Account, asset and signed amount inside one transaction
Append-only, written in the same database transaction as its parent
Ledger service only
Σ amount = 0 per transaction per asset, and amounts are signed integers in minor units — never floats, in any column, for any reason
account
Account in the chart of accounts: customer liability, firm position, fee income, in-flight, and one asset-location account per wallet tier
Reference. Slow-moving and under change control
Operations, through a reviewed change
Every entry points at an account that already existed when the entry was written. Accounts are retired, never repurposed
asset
Asset the exchange holds or quotes
Reference
Operations
Decimals and minor unit are set once. Changing them silently reprices every historical balance in the journal
market
Tradable pair
Reference
Operations
Tick size, lot size and minimum notional are integers, and the matcher reads them from here rather than from a config file it was deployed with
account_balance_projection
Account and asset
Derived. Freely rebuildable and never authoritative
The ledger's projector, reading the journal
Holds total, held, and the journal checkpoint it was computed at. If it disagrees with a replay, the journal wins and the projection is rebuilt
order
Order, for its whole life
Operational. State advances in place
Gateway on creation, matcher on every state change
Client order ID is unique per account. The current state is one of the ten above and nothing else — no free-text status column
fill
Execution
Operational, but append-only in practice
Matcher
Every fill carries the sequence number that produced it, so a journal posting and a book event can still be tied together months later
hold
Reservation against one account and asset, keyed to the order or withdrawal that caused it
Operational. The amount decreases on partial fill and the row closes on release; never deleted
Risk engine on placement, matcher on fill or cancel, withdrawal service on approval
available = total − Σ open holds, and a hold may only be released by the subsystem that placed it
deposit_observation
Chain transaction output the wallet watcher has seen against a known address
Operational. Confirmation count and status advance
Chain adapter
An observation is not a credit. The credit is a separate journal transaction referencing this row, written only once the finality policy and the compliance screen have both passed
withdrawal_request
Customer withdrawal
Operational. State and approvals advance
Gateway, compliance, wallet service
The ledger hold and debit exist before anything is signed, and the row can only reach signed from approved — never from requested
chain_transaction
Transaction the exchange broadcast or observed
Operational. Status advances with confirmations, and a replacement carries a link to what it replaced
Chain adapter
The only table allowed to hold a txid. Nothing in the journal references a chain identifier directly, so a reorg cannot reach the books
outbox
Side effect the ledger owes the outside world: a notification, a webhook, a signing instruction
Operational. Written once, then marked dispatched
Ledger service, in the same database transaction as the journal write
The row and the journal transaction commit together or neither commits. A dispatcher retries until acknowledged, so every consumer must be idempotent
The hold row is the piece most teams get wrong, because it is tempting to implement it as a mutable frozen_balance column on a balance row. Purpose-built ledgers model it as a two-phase transfer instead: a pending transfer moves value into a pending bucket rather than a posted one, and is later posted (in full or in part, with the remainder returned), voided, or expired by a timeout it was created with. A pending transfer can be resolved exactly once. That is the shape to copy — a reservation is a record with its own lifecycle, not a number you decrement.
Wallet-boundary crossings
Most of what an exchange does never touches a chain at all. A trade between two customers is a pair of ledger rows. Only these five operations actually cross onto a blockchain, and each one means something different to your books. The wallet system and the order book never speak to each other; both speak to the ledger.
No customer-liability change; fee/asset location only
Internal address movement
Sweep booked as customer flow
Rebalance
policy → transfer between tiers → reconcile
Asset-location accounts move
Internal custody movement
Reserve invariant changes
Addressing model comparison
How you hand out deposit addresses decides your gas bill, your privacy story, and how hard it is to prove whose coins are whose. It is also a regulatory question. MiCA Article 75 and NYDFS guidance both care about the internal position register and how customer assets are kept separate, so pick a model you can walk an examiner through.
Model
Attribution
Sweep/gas
Privacy
Provability
Operational risk
Omnibus address
Memo/internal ledger
Lowest
Poor on-chain separation
Aggregate only
Tag mistakes; ledger critical
Per-user deposit address → pool
Address derivation
High; gas station often needed
Better inbound separation
Address history visible
Gap/lookahead and sweep backlog
Segregated custody
Address is position boundary
Highest
On-chain linkability varies
Individual control easier to evidence
Large key/policy/address surface
Withdrawal pipeline at scale
A withdrawal path that works at ten a day breaks at ten thousand. These four are where it breaks: what you reserve up front, what you batch together, what you do when fees spike, and how you drain a backlog without looking like an attacker.
Reservation
Put the hold on before anything external happens. Until the hold exists, that same balance is still available to the next request that walks in.
Concrete use: A $25,000 USDC withdrawal moves available → withdrawal-hold in one serializable transaction.
Failure mode: Debit after broadcast and two withdrawals can spend the same balance inside the gap. The chain will honor both of them.
Batching
Pack compatible withdrawals into one chain transaction so they share a fee, and keep a hard mapping from every output back to the request that asked for it.
Concrete use: Ten Bitcoin P2WPKH withdrawals share one version/locktime/input set; each output maps to one internal ID.
Failure mode: One tainted destination holds up the other nine. Build the escape hatch for pulling a single output out of a batch before the night you need it.
Fee spike
Write down four separate things: how urgent this withdrawal is, what fee the customer was quoted, the ceiling you will not cross, and how you bump a transaction that is stuck.
Concrete use: Hold low-priority withdrawals above 200 sat/vB while expiring quotes and showing queue state.
Failure mode: Charge a flat fee, account for none of it, and a fee spike quietly moves money out of your treasury and into whoever happens to withdraw during the spike.
Outage drain
After an outage the whole backlog wants out at once. Release it at a rate your policy engine, your signers, the chain, and your support queue can all absorb.
Concrete use: Drain 1,000 queued transfers in value tiers with fresh rescreening and nonce/UTXO allocation.
Failure mode: A thousand simultaneous withdrawals look exactly like a compromise to your own monitoring, and they drain the hot wallet before a sweep can refill it.
Proof-of-reserves constructions
A proof of reserves shows what you held at one instant. Solvency is what you hold minus what you owe, continuously. An exchange can satisfy every construction below on Tuesday morning and be insolvent by Tuesday afternoon, either because the liabilities were never in the proof or because the assets were borrowed for the day.
Construction
Proves
Does not prove
Attack / control
Signed address message
Control of listed keys at a time
Ownership, unencumbered asset, completeness
Borrowed assets; repeat unpredictably
Asset self-transfer
Ability to move listed asset
No hidden lien/liability
Snapshot gaming; combine with books
Merkle liabilities
A customer leaf is included in committed set
No omitted users or negative balances by itself
Omission/negative leaf; audit construction
ZK liabilities
Committed sum and constraints such as non-negative balances
Off-balance-sheet claims unless included
Circuit/scope audit
Reserve oracle/feed
Publisher's stated observation
Independent solvency
Staleness and publisher trust
Risk, APIs & market data
Margin, pricing, and the client-facing API each have their own way of quietly creating a liability the ledger has not heard about yet.
Margin boundary
Cross margin pools collateral across positions, so one bad position can pull the others down with it. Isolated margin fences each position and caps what it can cost.
Concrete use: Give collateral, unrealized PnL, fees, funding, and liquidation their own ledger accounts instead of one net number.
Failure mode: The risk engine deals in estimates and the ledger deals in settled truth. Blend the two and a growing hole reads like a rounding difference.
Mark price
Margin calls and liquidations should run off an index built from several markets. Your own last trade is the cheapest number in the system for somebody to move.
Concrete use: Pull from several spot venues, drop any feed that goes stale, and cap how far the mark may move from the index in one step.
Failure mode: A thin book at 3am is cheap to push. Anchor liquidation to it and somebody will shove the price far enough to close real positions, then let it snap back.
Snapshot + delta
A client fetches one full book and then applies updates in unbroken sequence order. Those sequence numbers are the only thing telling it the local copy is still true.
Concrete use: On gap 81,104 → 81,107, discard local book and resnapshot.
Failure mode: Apply deltas across a gap and the book still looks perfectly reasonable. It is simply wrong, and nothing downstream will tell you.
Client order ID
The client chooses the ID, so a retry carrying the same ID cannot create a second order.
Concrete use: Query cli_20260831_771 after timeout before another create.
Failure mode: A timeout tells you the answer never came back. It says nothing about the order, which may be resting on the book right now.
Common mistakes & anti-patterns
None of this stops an operator with the right credentials from overriding a control, and no architecture can. Separation of duties, dual approval, and an audit trail somebody outside the team actually reads are what make the override loud and expensive.
Never hold a price, quantity, fee, balance, or PnL in binary floating point. Integers in minor units, everywhere, with no exceptions for the field somebody thinks is only cosmetic.
Never let the matching engine or the risk engine read live chain state. A reorg must not be able to change a fill that already happened.
Credit a deposit only once the chain adapter calls it final and the compliance check has passed, in that order.
Book sweeps and rebalances as movements between your own asset locations. They never change what a customer is owed.
Place the hold and the debit before anything is signed or broadcast, and close the in-flight entry only once the chain has given a final answer.
Allow zero unexplained difference. Anything that needs an explanation gets its own ledger account, not a tolerance band.
Persist the HD derivation range and gap limit, and alert while unused addresses remain rather than after deposits start landing where nobody is watching.
Never publish an assets-only proof as solvency. Say plainly what the proof covers and what it leaves out.
Throw the local book away and resnapshot on any sequence gap. Never patch across one.
Reconcile customer liabilities, firm positions, and fees against on-chain holdings, in-flight transfers, and receivables continuously, not on a nightly job.
Primary sources & scope
Operational and fast-moving claims were checked against these first-party documents on 2026-08-31. The examples are illustrative controls, not legal, investment, or vendor-selection advice.