Crypto exchange architecture

Crypto custody & compliance · engineering reference

Crypto exchange architecture

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.

VOLATILE CLAIMS: DATE-TAGGEDPRINT: LANDSCAPE TABLES

Start here if this is new

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.

Stages6client → wallet boundary
Ledgerdouble entryΣ postings = 0
Arithmeticintegersminor units only
Availabletotal − holdsnever a mutable row
States10published vocabulary
Crossings5deposit · withdrawal · sweep …
On breakSTOPhalt withdrawals first
CLIENT

REST · WebSocket · FIX

RISK

Balance holds · limits · STP

SEQUENCER

Total ordered commands

MATCHER

Deterministic in-memory book

LEDGER

Immutable double-entry truth

WALLET BOUNDARY

Deposit · withdrawal · sweep · rebalance

customer liabilities + fees + firm position = on-chain holdings + in-flight settlements + receivables

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.

Deposit, trade and withdrawal drawn across six exchange lanes Six lanes run top to bottom: client, risk and compliance, sequencer and matcher, ledger, wallet and signing, and the chain. A deposit starts on the chain, is observed by the wallet, screened by compliance, credited by the ledger, and only then becomes visible to the client. A trade goes client, risk hold, sequencer and matcher, ledger settlement, and back to the client, touching neither the wallet nor the chain at any point. A withdrawal goes client, screening and approval, ledger hold and debit, signing, broadcast, and finally back up to the ledger to close the in-flight entry. A heavy line between the wallet lane and the chain lane marks the wallet boundary. Above that line a mistake is another row; below it, a mistake is somebody else's coins. lane DEPOSIT TRADE WITHDRAWAL Clientrest · ws · fix Riskholds · screen Matchersingle writer Ledgerthe truth Walletkeys · policy Chainirreversible Balance appearslast, not first Screensanctions · travel rule Credit liabilitywhat you owe goes up Observe · finalitya policy, not a flag Chainincoming transaction 1 2 3 4 Submit orderclient order id Reserve balancehold · limits · stp Sequence, then matchone writer, replayable Settletwo liabilities move 1 2 3 4 a trade is two ledger rows it never comes down here Requestamount · destination Screen, then approvepolicy · dual control Hold, then debitin-flight opens, closes Signkeys · quorum Broadcastno take-backs 1 2 3 4 5 WALLET BOUNDARY Above the line a mistake is another row. Below it, a mistake is somebody else's coins. If the balance invariant breaks, stop withdrawals before explaining the difference
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.

Order lifecycle state machine with hold and journal effects on every transition Ten states. The main path runs received, pending new, working, partially filled, filled. Risk rejects out of received with no hold and no journal; the matcher rejects out of pending new and releases the hold. Risk places the hold on the way into pending new; the sequencer acknowledges it into working with no balance change. The matcher posts to the journal on each fill and reduces the hold by the filled quantity, then zeroes the hold on the final fill. An order can also leave the book upward into expired, when the time-in-force clock elapses and the residual hold is released, or into pending cancel and then cancelled, where the residual hold is released only when the matcher acknowledges. A stop or take-profit waits in untriggered, off the book, holding nothing until the trigger evaluator fires it into pending new. Three warnings: cancel is a request, not a result, and a fill can still win the race; reject before the sequence number, because a printed trade cannot be recalled; and a stop holds nothing until it triggers, so the balance check happens at trigger time. transient resting · held filled ended, no fill new order receiveddeduped pending_newheld, unacked workingon the book partially_filledremainder held filledterminal expiredtif elapsed pending_cancelrequest sent cancelledterminal rejectednever booked untriggeredoff book RISK / COMPLIANCEno hold, no journal MATCHERhold released RISKhold placed SEQUENCERno change MATCHERposts each fill MATCHERhold zeroed MATCHER · FILLS WHOLE ON ARRIVAL TIF CLOCKresidual released CUSTOMERcancel requested MATCHER ACKresidual released TRIGGER EVALhold placed now cancel is a request, not a result — a fill can still win the race reject before the sequence number — a printed trade cannot be recalled a stop holds nothing until it triggers — the balance check happens at trigger time
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.
StateSet byHold effectJournal effectClient mayFIX OrdStatus (39)
receivedEdge gateway, after the client order ID is dedupedNone yet — nothing is reservedNonePoll by client order ID. A retry carrying the same ID returns this order rather than creating a second oneA Pending New
rejectedRisk, 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 oneNone, ever. A rejected order never postsRead the reject reason and resubmit under a new client order ID8 Rejected
pending_newRisk, once the hold is placed and the command is handed to the sequencerHold placed for the full order value; available falls immediatelyNone — a hold is not a postingNothing yet. The order has no queue position and cannot be cancelled by exchange IDA Pending New — the same code as received. FIX has one status where the balance has two
workingMatcher, on acknowledgementUnchanged; the full order value stays heldNone until something fillsCancel, or amend where amend is offered. The queue position is now real0 New
partially_filledMatcher, on each executionReduced by the executed quantity; the unfilled remainder stays heldOne balanced posting per fill: quote liability down, base liability up, taker fee to the fee account, all inside the same transactionCancel the remainder. May not assume the remainder is safe from a further fill in the same instant1 Partially filled
filledMatcher, on the execution that completes the quantityClosed at zero. Held value has become posted valueFinal balanced posting. Any hold left over from price improvement is released, not keptNothing — terminal. Reconcile against the fills, not against the order2 Filled
pending_cancelEdge gateway, on accepting a cancel requestUnchanged. The hold stands until the matcher acknowledgesNone for the request, but a fill can still post while the order sits hereWait. Do not release the hold client-side and do not resubmit6 Pending Cancel
cancelledMatcher, on acknowledging the cancel. Never the API that accepted the requestResidual hold released. Anything already filled stays filled and stays postedNone for the cancellation itselfNothing — terminal. Check the filled quantity before assuming nothing traded4 Canceled
expiredThe time-in-force clock, or the matcher on arrival for an IOC or FOK remainderResidual hold releasedNone for the expiry itselfNothing — terminalC Expired
untriggeredTrigger evaluator, on accepting a stop or take-profitNone. A stop reserves nothing until it firesNoneCancel or amend the trigger. Must not assume funds are set asideNo 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.

RuleConcrete formInvariantFailure prevented
Double entryEvery journal transaction sums debits and credits per assetΣ postings = 0Value creation/loss
Integer minor unitsBTC satoshis; token base units; wide integer/decimalExact arithmetic18-decimal rounding drift
Immutable journalCorrections are compensating entriesHistory never rewrittenUntraceable balance edits
Available vs totalHolds reserve open orders/withdrawalsavailable = total − holdsDouble-spend of customer liability
Idempotent postingUnique business event + versionOne event, one journal resultDuplicate webhook/fill credit
ProjectionBalance is derived/cached with journal checkpointProjection equals replayAuthoritative mutable balance row
Domain boundaryBook and wallet only post through ledger APINo direct chain/book couplingHidden 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.

Exchange tables grouped by mutability class Four bands. Append-only holds journal_transaction and journal_entry, where the entries of one transaction sum to zero per asset. Derived holds account_balance_projection, which is rebuildable from the journal and loses any argument with it. Operational holds order, fill, hold, outbox, deposit_observation, withdrawal_request and chain_transaction; the order book itself is not stored here because it lives in memory and is rebuilt from the log. Reference holds account, asset and market. No service writes a balance: services post transactions. The ledger commit and the outbox row are written in one database transaction. Append-onlynever updated journal_transactionseq · eventidempotency key journal_entrytxn · account · assetsigned integer, minor units 1n Σ amount = 0per txn, per asset Derivedrebuildable account_balance_projectiontotal · held · journal checkpoint REPLAY rebuildable from the journal —if it disagrees, the journal wins no service writes a balance Operationalnever the truth orderclient order id · state fillprice · qty · seq holdopen reservation outboxsame txn as commit commit + outboxin one txn deposit_observationaddress · confirmations withdrawal_requestapprovals · state chain_transactiontxid · fee · status the book itself is not here — it lives in memory and is rebuilt from the log Referenceslow-moving accountchart of accounts · tiers assetdecimals · minor unit markettick · lot · min notional
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.
TableGrain — one row per…MutabilityWritten byInvariant it carries
journal_transactionBalanced business event: a fill, a deposit credit, a fee, a correctionAppend-only. Never updated, never deleted; corrections are new compensating transactionsLedger service onlyCarries the idempotency key. One business event produces one transaction, however many times it is delivered
journal_entryAccount, asset and signed amount inside one transactionAppend-only, written in the same database transaction as its parentLedger service onlyΣ amount = 0 per transaction per asset, and amounts are signed integers in minor units — never floats, in any column, for any reason
accountAccount in the chart of accounts: customer liability, firm position, fee income, in-flight, and one asset-location account per wallet tierReference. Slow-moving and under change controlOperations, through a reviewed changeEvery entry points at an account that already existed when the entry was written. Accounts are retired, never repurposed
assetAsset the exchange holds or quotesReferenceOperationsDecimals and minor unit are set once. Changing them silently reprices every historical balance in the journal
marketTradable pairReferenceOperationsTick 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_projectionAccount and assetDerived. Freely rebuildable and never authoritativeThe ledger's projector, reading the journalHolds total, held, and the journal checkpoint it was computed at. If it disagrees with a replay, the journal wins and the projection is rebuilt
orderOrder, for its whole lifeOperational. State advances in placeGateway on creation, matcher on every state changeClient order ID is unique per account. The current state is one of the ten above and nothing else — no free-text status column
fillExecutionOperational, but append-only in practiceMatcherEvery fill carries the sequence number that produced it, so a journal posting and a book event can still be tied together months later
holdReservation against one account and asset, keyed to the order or withdrawal that caused itOperational. The amount decreases on partial fill and the row closes on release; never deletedRisk engine on placement, matcher on fill or cancel, withdrawal service on approvalavailable = total − Σ open holds, and a hold may only be released by the subsystem that placed it
deposit_observationChain transaction output the wallet watcher has seen against a known addressOperational. Confirmation count and status advanceChain adapterAn 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_requestCustomer withdrawalOperational. State and approvals advanceGateway, compliance, wallet serviceThe ledger hold and debit exist before anything is signed, and the row can only reach signed from approved — never from requested
chain_transactionTransaction the exchange broadcast or observedOperational. Status advances with confirmations, and a replacement carries a link to what it replacedChain adapterThe only table allowed to hold a txid. Nothing in the journal references a chain identifier directly, so a reorg cannot reach the books
outboxSide effect the ledger owes the outside world: a notification, a webhook, a signing instructionOperational. Written once, then marked dispatchedLedger service, in the same database transaction as the journal writeThe 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.

CrossingOrdered sequenceLedger effectChain effectBreak signal
Depositobserve → validate → finality → screen → creditLiability increasesExternal asset arrivesOn-chain asset without liability
Trade/internal transferreserve → match → settle journalLiabilities move between accountsNoneAny chain dependency
Withdrawalhold/debit → screen → approve → sign → broadcast → settleLiability decreases / in-flight closesAsset leavesBroadcast before debit/hold
Sweepdetect → authorize → move → reconcile feeNo customer-liability change; fee/asset location onlyInternal address movementSweep booked as customer flow
Rebalancepolicy → transfer between tiers → reconcileAsset-location accounts moveInternal custody movementReserve 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.

ModelAttributionSweep/gasPrivacyProvabilityOperational risk
Omnibus addressMemo/internal ledgerLowestPoor on-chain separationAggregate onlyTag mistakes; ledger critical
Per-user deposit address → poolAddress derivationHigh; gas station often neededBetter inbound separationAddress history visibleGap/lookahead and sweep backlog
Segregated custodyAddress is position boundaryHighestOn-chain linkability variesIndividual control easier to evidenceLarge 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.

ConstructionProvesDoes not proveAttack / control
Signed address messageControl of listed keys at a timeOwnership, unencumbered asset, completenessBorrowed assets; repeat unpredictably
Asset self-transferAbility to move listed assetNo hidden lien/liabilitySnapshot gaming; combine with books
Merkle liabilitiesA customer leaf is included in committed setNo omitted users or negative balances by itselfOmission/negative leaf; audit construction
ZK liabilitiesCommitted sum and constraints such as non-negative balancesOff-balance-sheet claims unless includedCircuit/scope audit
Reserve oracle/feedPublisher's stated observationIndependent solvencyStaleness 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.

  1. 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.
  2. 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.
  3. Credit a deposit only once the chain adapter calls it final and the compliance check has passed, in that order.
  4. Book sweeps and rebalances as movements between your own asset locations. They never change what a customer is owed.
  5. 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.
  6. Allow zero unexplained difference. Anything that needs an explanation gets its own ledger account, not a tolerance band.
  7. Persist the HD derivation range and gap limit, and alert while unused addresses remain rather than after deposits start landing where nobody is watching.
  8. Never publish an assets-only proof as solvency. Say plainly what the proof covers and what it leaves out.
  9. Throw the local book away and resnapshot on any sequence gap. Never patch across one.
  10. 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.