Cryptocurrency Exchange Requirements: The Spec I Wish We Had Written First
Almost every requirement in this list exists because something in production forced it. It is the checklist I would hand a team on day one, and for each area it shows what Bitsten actually does today: the service, the file, the metric, the config.
By Oleksii Vasylenko, Technical Lead · Published · Updated · 19 min read
Where this comes from. Shipping Bitsten’s order path, matching engine, live market data, liquidity integrations, and candle generation, then rebuilding the consistency, duplicate-protection, durability, and recovery layers after production showed which requirements had been left implicit.
Why cryptocurrency exchange requirements come before the architecture
This is a list of cryptocurrency exchange system requirements, written after building one rather than before. The first artifact on our project was an architecture diagram. It showed where the boxes were and said nothing about whether the system was allowed to fill an order twice, which is the question that decides whether the business survives its first bad week.
“Build a fast matching engine” is not a requirement, because it cannot fail. “Under the approved peak workload, every accepted command produces exactly one authoritative outcome, price-time priority holds, and p99 acknowledgement stays under the agreed limit” can fail, so it is one. The first sentence invites a demo. The second can reject a system that is fast and financially wrong.
So write down the venue model, jurisdictions, asset classes, custody boundary, customer types, order types, matching policy, settlement model, operating hours, and launch markets, and name the person who approves each one. Leave the unresolved ones visible as TBD with an owner and a date. Otherwise an engineer picks a behaviour at 2am and it becomes policy by accident. I have done this myself.
- Define the boundary. Trading venue, broker, custodian, wallet operator, clearing function, or some combination. Bitsten is a spot venue with custody delegated to a provider.
- Classify each requirement as functional, integrity, performance, security, compliance, operational, or reporting.
- Say what is out of scope (products, jurisdictions, order types, failure scenarios) as explicitly as what is in.
- Every critical requirement gets a business owner and a separate technical verification owner.
How Bitsten is put together, so the rest of this has something to point at
Bitsten’s backend is a set of NestJS services talking over RabbitMQ, with MySQL for durable records and Redis or Valkey for the matching state. Each service owns one kind of fact. Knowing which service owns what is most of what you need to read the requirements below.
users accounts, sessions, the order API, the order record a customer sees
calculator order admission, fees, balance postings for fills, deposits and withdrawals
balances the balance ledger: append-only versioned rows per (user, asset)
conductor the matching engine, one process per market partition, sole writer to its books
conductor-outbox drains committed matching events from Redis to RabbitMQ
aggregates tickers, public order book, trade history, candles ("kindles" in the code)
custody deposit addresses and withdrawal broadcast through the custody provider
aml address and transaction risk scoring through an external provider
kyc identity verification state
admin operator console: pairs, assets, limits, user balances, transfers
users-ws fan-out of order, deal, and balance updates to browser sessions
bots, futures, assets, api, loader liquidity bots, a futures bridge, asset metadata, public APIOne order takes this path. The customer calls the users service. Users publishes to calculator, which checks balance and fees and hands the order to the conductor for the pair’s partition. The conductor matches it against the Redis book, commits the result and its events in one transaction, and the outbox publishes those events. Calculator consumes deal events and posts balance changes; users updates the order record; aggregates updates the public book and candles; users-ws pushes the change to the browser. Every requirement below is a constraint on one of those hops.
The word “conductor” is just our name for the matching service. It exists once per partition, it is the only thing allowed to change the order books in that partition, and the partition is chosen by pair id modulo the partition count. That detail matters later.
Trading rules and arithmetic
A continuous order book, an RFQ venue, an auction, and an internal conversion service do not share priority rules. For an order book you need to state price priority, the tie-break at equal price, maker and taker determination, execution price, self-trade prevention, time-in-force, post-only, minimum quantity, tick size, lot size, minimum notional, price bands, market status transitions, and what happens to an unfilled remainder. Coinbase publishes its price-time priority and self-trade prevention rules. Your venue needs the same precision about its own rules, whatever they are.
Arithmetic is where this gets real. Every rounding decision needs a direction. In Bitsten’s matching domain the direction is fixed by side: an ask’s quantities round up and a bid’s round down, on both the base volume and the quote amount, so the two parties to a trade can never both gain from rounding and the sum of what is exchanged is conserved to the pair’s precision.
function quoteRounding(order: Pick<Order, 'side'>): Decimal.Rounding {
return order.side === OrderSide.Ask ? Decimal.ROUND_UP : Decimal.ROUND_DOWN;
}
// inside calculateBaseDeal
const makerVolume = oldMakerVolume.minus(executedVolume);
const takerVolume = oldTakerVolume.minus(executedVolume);
if (makerVolume.isNegative() || takerVolume.isNegative()) {
throw new MatchingInvariantError('Matching produced negative volume');
}
if (!makerVolumeChange.eq(takerVolumeChange)) {
throw new MatchingInvariantError('Maker and taker volume changes diverged');
}Every pair carries its own priceDecimals, amountDecimals, and quoteDecimals, and every calculation goes through decimal.js rather than IEEE 754 doubles. That costs throughput. The benchmark write-up linked below shows the cost is far smaller than people assume: the pure calculateDeal loop runs at over 100,000 calculations per second and is nowhere near the bottleneck. The function also refuses bot-to-bot matches outright and returns an explicit no-match result for zero executable volume instead of silently doing nothing.
The test I apply to any trading rule: if two engineers can read it and compute different balances, it is not finished. The unit tests in test/conductor/matching.domain.spec.ts encode that. They check that base volume is conserved across varied fills, that a market buy is capped by its quote limit, and that neither input order is mutated.
- Model every order type as a state machine with valid inputs, transitions, terminal states, and rejection reasons.
- Say whether an amendment keeps or loses time priority, separately for price and for quantity changes. Bitsten’s replace-batch command cancels and re-places, so it always loses priority. That was a decision, and it is written down.
- Cancel-versus-fill precedence is decided at the ordering boundary (the partition queue), not in the UI.
- Halts, maintenance, auctions, suspension, delisting, reopening.
- Version the rulebook and store the version that applied to each accepted order.
- No binary floating point anywhere a number becomes money.
Order lifecycle: what “accepted” means and the retry that broke it
Received, validated, accepted, rejected, partially filled, filled, cancelled, expired, suspended. Each needs an exact meaning, and “accepted” needs the most care. It is the moment after which the venue owes the customer a durable outcome. Before that point a timeout means nothing happened. After it, a timeout means ask again, do not resubmit.
Idempotency is what makes that distinction usable. Every matching command carries a commandId. The conductor checks a processed-command key in Redis before doing anything, and if the command was already processed it returns the stored result without opening a second transaction. The stored result is written inside the same MULTI as the state change, so there is no window in which the state changed and the record of having done so did not.
const command = parseMatchingCommand(value);
if (command.partition !== this.assignedPartition) {
throw new Error(`Conductor partition ${this.assignedPartition} cannot process command for partition ${command.partition}`);
}
const prior = await this.ordersStorage.getProcessedCommand(command.commandId, command.partition);
if (prior) {
await this.ordersStorage.ensureDurability();
this.duplicateCommands += 1;
return prior;
}
// ... stage book changes and events into one MULTI ...
this.ordersStorage.markCommandProcessed(result, command.partition, transaction);
await this.ordersStorage.execTransaction(transaction); // EXEC, then WAITAOF- Define the acknowledgement contract for submit, cancel, replace, deposit credit, withdrawal, and admin actions.
- Specify idempotency scope, retention, conflict behaviour, and what a client gets back after a lost response. Bitsten keeps processed-command records and accepted-order markers per partition, and they are not expired.
- Use monotonic versions so a stale update cannot overwrite a newer state. The event envelope carries aggregateVersion for this.
- Rejection codes are machine-readable and stable. Human text can change; codes cannot. The conductor’s own rejection reason for an unfillable market order is the string insufficient-liquidity, and consumers match on it.
- Say which operations fail closed when a dependency is unavailable. The conductor fails the command if WAITAOF does not return in time.
Consistency is decided per fact, not per system
Consistency is not a global setting. Assign a contract to each fact. Trade priority, order state, available and reserved balance, executed quantity, fees, deposits, withdrawals, and administrative adjustments need authoritative ordering and protection against duplicate application. Market summaries, charts, notifications, and search indexes may lag, but you have to say how much lag is acceptable and how a gap is recovered.
In Bitsten the left branch is two services. The conductor is authoritative for order state and executed quantity within its partition. The balances service is authoritative for money. Its balance table is append-only: every change inserts a new row with version = previous + 1, a unique index on (userId, assetId, version) rejects concurrent writers, and the row id is the id of the business event that caused it, so re-delivering the same deal event hits the primary key and returns the existing row instead of posting twice. A negative result throws before anything is written.
const increasedBalance = balance.increase(dto.id, dto.value); // throws on negative
await this.repository.insert(increasedBalance);
// duplicate event id -> row already exists -> return it, post nothing
if (e.message.includes('balances_balance.PRIMARY')) return balance;
// two writers raced on the same version -> caller retries
if (e.message.includes('balances_balance.balance_version_unqiue'))
throw new BalancesException(BalancesExceptionCode.VersionError);The calculator posts a fill as two balance increases with ids like deal.id + '-maker' and deal.id + '-taker', net of the user’s fee percentage from the users service. Because the ids are derived from the deal id, a redelivered deal event cannot post twice. That is the whole duplicate-protection story for money, and it is small enough to hold in your head.
These are the invariants the engine asserts rather than hopes for. Several of them abort the command outright when violated.
- Executed buy quantity equals executed sell quantity for every trade, within the configured rounding policy.
- No order fills beyond its accepted quantity, and remaining quantity is never negative.
- An open maker exists both in its order hash and exactly once in the correct side’s sorted set.
- A closed order is absent from the active book and never returns without a new approved identity.
- A market order is never promoted to a resting maker. In the code, an unfilled market order is closed with closedByEngine and rejected.
- An order id is accepted at most once per partition, even when a retry arrives under a new command id.
- Replaying the same accepted history produces the same orders, trades, fees, and closing balances.
Repeated delivery will happen at system boundaries. That is a property of messaging over a network, not a bug you can remove. What must not happen is a repeated financial effect. Design for at-least-once transport with once-only application, and give every consumer that touches money an inbox keyed by the stable event id. The balances table above is that inbox for the calculator.
Ledger, custody, deposits, and withdrawals
The ledger is the explanation for every balance change. Each entry needs an immutable identity, asset, amount, direction, account, business reason, related object, effective time, recording time, and the actor or process that caused it. Whether you use double-entry postings is an accounting choice. Conserving value after declared fees is not. Direct balance edits with no counter-record should be impossible rather than discouraged. In Bitsten the admin service can read a user’s aggregated balances and record transfers, and it has no endpoint that writes a balance value. Every posting arrives at the balances service with the id of the event that caused it.
Deposits and withdrawals are handled by calculator and custody together. Calculator keeps a transactions table (id, amount, fee, asset, network, address, hash, status, type deposit or withdraw). For a withdrawal it debits the balance including the fee first, then asks custody to create the withdrawal, then records the transaction as CREATED. Custody talks to the external provider and reports back status changes. The DTOs treat COMPLETED as success and REVERSAL or FAILED as failure, and a failed withdrawal credits the balance back through the same ledger path, as a posting with id refund-<transaction id>. A deposit that the chain later reverses arrives as a REVERSAL and is debited the same way.
Before any of that, the aml service scores the address and the transaction through an external provider, returning a risk score, a blacklist flag, and per-signal scores. High risk routes to manual review in the admin console. All of it has to be specified, because a credited deposit reversed by a reorg and a withdrawal broadcast after the client saw a timeout are ordinary Tuesday events.
- Reconcile customer liabilities, the internal ledger totals, hot wallets, cold storage, pending transfers, and the custodian’s records. State the frequency, the tolerance, the escalation owner, and the conditions that halt withdrawals or trading.
- Dual control for privileged financial operations above approved limits.
- Retain the full approval, signing, broadcast, confirmation, replacement, and failure history for every transfer. The transactions table plus the custody provider’s log is that history for us.
- State the negative-balance and insolvency invariants independently of any UI. Ours is enforced in one place, BalanceSnapshot.increase.
Capacity: describe the workload, then the boundary
A throughput number without a workload attached is decoration. The capacity model needs sustained and burst commands per second, burst duration, concurrent sessions, active markets, hot-market concentration, open orders, occupied price levels, cancel ratio, share of aggressive orders, fills per command, market-data fan-out, transfer rates, and admin traffic. An order that sweeps twenty price levels does roughly twenty times the downstream work of one that rests.
It also needs a boundary. The same Bitsten engine measures anywhere between about 130,000 and about 106 operations per second depending on where the stopwatch starts and stops: the pure calculateDeal loop, the conductor with its Redis commit and WAITAOF, or the full path through the outbox with broker confirms. All of those numbers are correct. Edge acknowledgement, authoritative acceptance, match completion, balance visibility, market-data publication, and client receipt are six different measurements, and the requirement has to name which one it is about. Approve p50, p95, p99, and p99.9 targets for each critical path at a named offered load.
- Define a normal profile, an approved peak profile, a launch-event profile, and an abusive-traffic profile.
- Maximum queue age and backlog at steady state, during bursts, and during recovery. For us the queue is the partition’s RabbitMQ quorum queue and the outbox stream, and both are visible as numbers.
- Headroom above forecast peak, with the forecast horizon named.
- Measure one dominant market separately from evenly spread traffic. Averages hide hot pairs, and with pair-modulo-partition routing a hot pair lands on one conductor.
- Count downstream amplification: fills, ledger postings, notifications, surveillance records, market-data updates per command. Our alternating benchmark workload produces two events per command, which is why the outbox and not the matcher was the ceiling.
- Zero invariant violations and zero unexplained message loss in every qualifying run. A fast run that loses a trade failed.
Availability, durability, and recovery
Define availability per capability. Public market data, order entry, cancellation, account reads, deposits, withdrawals, administration, and reconciliation do not deserve the same target. More important, say what stops when a dependency fails. Accepting orders while balance reservation is uncertain is manufacturing an unknown liability and calling it uptime.
Recovery point and recovery time objectives belong to datasets, and they change when infrastructure changes. Swapping KeyDB’s every-second append-only file for a datastore that snapshots every five minutes moved the worst-case unreplicated loss from one second to five minutes. Nobody had asked for that. It arrived attached to a datastore migration. Durability is now a named policy the conductor reads at boot, and the process refuses to start if the datastore cannot honour it.
MATCHING_DURABILITY=aof-replicated # default; memory is the only other value
MATCHING_DURABILITY_TIMEOUT_MS=2000
// onModuleInit: probe WAITAOF once, fail the boot if the datastore lacks it
await this.redis.call('WAITAOF', 0, 0, 1);
// -> "MATCHING_DURABILITY=aof-replicated requires a datastore with WAITAOF support"
// after every EXEC: fsync on primary and one replica, or the command fails
const result = await this.redis.call('WAITAOF', 1, 1, timeoutMs);Memory mode still exists. It is a statement that recovery comes from somewhere else, and it has to be chosen on purpose. The conductor exposes /live and /ready separately; /ready pings the datastore and returns 503 if it cannot, so an orchestrator pulls the pod rather than leaving a process that accepts commands it cannot commit.
- Safe-mode behaviour and customer messaging for every capability that can go away.
- Deterministic reconstruction and reconciliation before a recovered market reopens. The conductor’s /reconcile endpoint scans both book indexes against the order hashes and reports missing-order and index-mismatch entries; a truncated scan is reported as unhealthy.
- Test failover during active orders, partial fills, cancellations, deposits, and withdrawal approval. Not on an idle system.
- Prove backups by restoring them into an isolated environment on a schedule.
- Document who has authority to halt, resume, cancel, or roll back market operations during an incident.
Security, identity, abuse controls, and compliance
Security requirements should be threat-driven and testable. Define assurance levels for customer identity, employee identity, service identity, privileged administration, API keys, withdrawals, recovery flows, and support actions. Authentication is the first gate, not the control. Every operation needs authorization against the customer, account, market, role, jurisdiction, session risk, and current limits. Privileged access has to be attributable to one person and bounded in purpose and time. In Bitsten that is the admin service with its own role model, MFA and TOTP modules, and a separate deployment from the customer API.
OWASP ASVS 5.0 is a reasonable testable baseline for the web layer. It says nothing about custody, trading abuse, insider risk, market manipulation, or irreversible external transfers, so the threat model has to be extended to cover them.
- Rate and exposure limits per identity, account, network, market, API key, and globally.
- Step-up controls for sensitive actions, and an immutable security event for each one.
- Administrative interfaces separated from customer interfaces, deny by default.
- Credentials, private keys, raw secrets, and unnecessary personal data kept out of logs and telemetry.
- Launch blocked on unresolved critical findings or unowned high-risk exceptions.
Compliance obligations depend on jurisdiction and venue model: licensing, customer due diligence, transaction monitoring, sanctions, travel rule, market surveillance, retention, reporting, listing, custody, disclosure, customer protection. Qualified legal and compliance owners identify them before scope is approved. FATF guidance expects virtual asset service providers to be licensed or registered where applicable and to assess and mitigate money-laundering risk, which is a baseline for analysis and not a substitute for local law. SEC Regulation SCI does not generally apply to crypto venues, but its subject list (capacity, integrity, resiliency, availability, security, corrective action, review, records, continuity testing) is a decent completeness check. This article is an engineering framework, not legal advice. In the code, the kyc service holds verification state and the aml service holds risk reports; what those states permit is a compliance decision recorded in the admin service’s tiers and limits.
Market data, APIs, and event contracts
Every external interface is a versioned contract: validation, authentication, authorization, idempotency, pagination, error codes, rate limits, time semantics, precision, ordering, compatibility, deprecation. A field called timestamp must say whether it means receipt, acceptance, execution, persistence, or publication. A field called balance must say whether pending deposits, open-order reservations, and withdrawal holds are inside it. Bitsten answers that last one with two different endpoints in the users service, one for available balance and one for aggregated balance including futures and open positions, because customers kept asking why the two numbers differed.
Internal events deserve the same discipline, because they are the contract between your own services and the thing you will be reading during an incident. Bitsten’s matching events carry an envelope that makes ordering, causation, and deduplication explicit, and the event id is derived, not random. It is a SHA-256 of the schema version, the command id, the event’s ordinal within that command, and the event type, so replaying a command produces the same event ids and a consumer can dedup on them.
interface MatchingEventEnvelope<TData = unknown> {
schemaVersion: 1
eventId: string // sha256(schema:commandId:ordinal:type), first 32 hex chars
type: MatchingEventType // 'deal.executed.v1' | 'order.rested.v1' | 'order.partially-filled.v1'
// | 'order.filled.v1' | 'order.cancelled.v1' | 'order.rejected.v1'
aggregateType: 'order' | 'deal'
aggregateId: string
aggregateVersion: number // stale-update detection on the order record
pairId: number
partition: number // which conductor wrote it
causationId: string // the command that caused it
correlationId: string // the client request that started it
occurredAt: string
exchange: string // RabbitMQ exchange the outbox publishes to
routingKey: string
data: TData
}Market-data consumers need a defined snapshot and incremental-update model with enough ordering information to detect loss, duplication, reordering, and stale reconnection. State the maximum publication delay, snapshot age, sequence scope, reset behaviour, and gap recovery. Candles need a stated construction rule. In the aggregates service a deal updates the open candle for each configured interval of its pair (min, max, last, volume), and a candle is closed when a deal arrives past its end time. Rebuilding candles from the same deal history has to reproduce the same open, high, low, close, and volume, or the chart and the ledger disagree in public. We had that disagreement once. It came from a candle service that consumed trades from a different source than the ledger did.
Observability, audit, and operational control
You must be able to explain any order, trade, balance change, halt, withdrawal, or privileged action from retained evidence alone. Correlation identifiers should connect the client request to validation, authoritative state, fills, ledger postings, outbound notifications, and operator actions. In Bitsten the correlationId in the event envelope is that thread, and the balance row id ties the ledger back to the deal id. Audit records need to be tamper-evident, access-controlled, time-synchronised, retained to policy, and exportable without touching production state.
Decide what you measure before you need it. The matching path exposes counters whose names are boring and stable on purpose, because an alert that fires on a name that changed last sprint is not an alert.
matching_commands_processed_total
matching_duplicate_commands_total # redelivery caught by the processed-command key
matching_duplicate_orders_total # retry under a new command id, caught by the accepted-order key
matching_command_failures_total
matching_events_created_total
matching_last_command_duration_ms
matching_outbox_published_total
matching_outbox_publish_retries_total
matching_outbox_dead_letter_total # events that could not be decoded, quarantined to a second stream
matching_outbox_stream_length
matching_outbox_pending # entries read but not yet confirmed by the broker
matching_outbox_owns_lease # 1 if this worker holds the partition lease (10 s, renewed)Alerts should describe customer or integrity risk, not host business. “Oldest unacknowledged event is four minutes old” tells an operator that balances and the public book are stale. “CPU is at 80%” does not. Every alert that pages someone needs a named responder and a first action they have practised.
- Clocks synchronised within a stated tolerance, while authoritative ordering never depends on wall-clock precision. The conductor’s ordering comes from the queue and the promise chain, and the timestamp in the book member is only used to break ties within a price.
- Release identifiers and configuration versions in operational and audit evidence.
- Approval and a retained before/after record for material rule, limit, permission, or configuration changes.
- Support tools held to the same business controls as the production API. A support console that can edit a balance is a production API with worse authorization. Ours cannot edit one; the only write path into the ledger is a posting with an event id.
- Daily reconciliation distinguishes detected duplicates from duplicated financial effects. The first is the system working, and it has its own counter.
Launch acceptance: evidence, not features
Map each mandatory requirement to at least one acceptance test, and retain the version, configuration, workload, seed, environment, result, and approver for every run. The portfolio needs unit and model tests for trading rules, property tests for invariants, deterministic replay, contract tests, permission tests, security verification, migration tests, sustained load, burst load, soak, dependency degradation, fault injection, restoration, reconciliation, and operator exercises.
The conductor’s own suite in test/conductor is a small example of tests that map to requirements rather than to functions. One preserves FIFO in a price level larger than one Redis page. One returns a stored result without opening a second transaction. One refuses to match an accepted order again under a new command id. One serialises concurrent entry points before checking order identity. Each of those is a sentence from this article turned into an assertion.
Fault injection deserves a line of its own because it finds things nothing else does. During a benchmark we reset the matching Redis namespace while the outbox was running, which deleted its consumer group. The worker retried the NOGROUP error forever and its metrics endpoint started returning HTTP 500. No unit test would have found that. The outbox now recreates a missing consumer group when it sees NOGROUP, and its pending metric returns zero instead of throwing while the group is being restored.
A demo that places and fills an order proves almost nothing. The system passes when it preserves money and priority through retry, concurrency, overload, failure, recovery, operator error, and investigation, and when the team can produce the evidence without writing new code to find it.
- Zero unresolved failures of financial, order-priority, identity, authorization, or replay invariants.
- Capacity tests meet every declared load and latency objective with headroom and without unbounded backlog.
- Recovery exercises meet the approved RPO and RTO, then reconcile against an uninterrupted reference run.
- Security verification meets the adopted baseline. Critical exceptions block launch.
- Runbooks executed by the people who will carry the pager, not by the people who wrote them.
- A go/no-go record listing evidence, residual risks, accountable approvers, and rollback authority.
Traceability Matrix: What Must Be True, and How You Prove It
| Area | Required decision | Acceptance evidence | Failure if omitted |
|---|---|---|---|
| Trading logic | Priority, order states, precision, fees, halts | Deterministic examples and rule tests | Two users get incompatible outcomes from one rule |
| Consistency | Authority, idempotency, ordering, replay | Invariant and failure-injection results | Duplicate trades and unexplained balances |
| Capacity | Workload, sustained and burst load, percentile SLOs, boundary | Saturation, soak, and recovery-load reports | A peak number hides backlog and tail failure |
| Recovery | RPO, RTO, safe modes, reconciliation gate | Restore and failover exercises | Trading resumes from unknown state |
| Security | Identity, authorization, custody, abuse controls | Threat model and independent verification | Account or asset compromise |
| Operations | Metrics, alerts, audit, runbooks, decision rights | Incident exercise and retained evidence | Nobody can explain or contain the failure |
| Compliance | Applicable jurisdictions and control mappings | Qualified review and traceable obligations | The product ships outside its permitted scope |
A record of what must be true and how it will be proved, one row per area.
Related Matching Engine Guides
The lock that could delete someone else’s lock, and what replaced it.
One implementation of the consistency and recovery obligations above.
Turning the approved capacity envelope into a reproducible test program.
Where the trading rules meet the ordered state the engine maintains.
Related reading
This article defines obligations and shows where Bitsten enforces each one. The architecture guide covers the implementation boundaries, failure modes, and production evidence in full.
Read the matching engine architecture guide →