NV
NordVarg
ServicesTechnologiesIndustriesCase StudiesBlogAboutContact
Get Started

Footer

NV
NordVarg

Software Development & Consulting

GitHubLinkedInTwitter

Services

  • Product Development
  • Quantitative Finance
  • Financial Systems
  • ML & AI

Technologies

  • C++
  • Python
  • Rust
  • OCaml
  • TypeScript
  • React

Company

  • About
  • Case Studies
  • Blog
  • Contact

© 2025 NordVarg. All rights reserved.

November 11, 2025
•
NordVarg Team
•

Building a Cryptocurrency Exchange: A Technical Architecture Guide

Blockchain & DeFicryptocurrencyexchangearchitecturematching-enginesecurityperformance
9 min read
Share:

Building a cryptocurrency exchange is one of the most challenging engineering feats. It combines the low-latency requirements of High-Frequency Trading (HFT) with the high-stakes security of a bank. A single bug in the matching engine or ledger can lead to insolvency.

In this guide, we will break down the architecture of a modern, institutional-grade crypto exchange with real code examples and production insights.

1. High-Level Architecture#

A robust exchange is composed of several decoupled microservices (or modular monoliths for latency):

  1. Gateway (API/WebSocket): Handles client connections, authentication, and rate limiting.
  2. Risk Engine: The gatekeeper. Checks balances and limits before an order reaches the book.
  3. Matching Engine: The heart. Matches buy and sell orders.
  4. Ledger (Accounting): The source of truth for user balances.
  5. Wallet Management: Handles on-chain deposits and withdrawals (Hot/Cold wallets).
plaintext
1┌──────┐
2│ User │
3└───┬──┘
4    │ REST/WebSocket
5    ▼
6┌─────────┐
7│ Gateway │
8└────┬────┘
9     │ New Order
10     ▼
11┌─────────────┐
12│ Risk Engine │
13└──────┬──────┘
14       │ Valid
15       ▼
16┌──────────────────┐
17│ Matching Engine  │
18└────┬─────────┬───┘
19     │         │ Order Fill
20     │         └──────────┐
21     │ Trade Executed     │
22     ▼                    ▼
23┌────────┐           ┌─────────┐
24│ Ledger │◄──────────┤ Gateway │
25└───▲────┘  Balance  └─────────┘
26    │       Update
27    │ Credit
28┌───┴────────────┐
29│ Wallet Service │
30└───▲────────────┘
31    │ Deposit
32┌───┴────────┐
33│ Blockchain │
34└────────────┘
35

2. The Matching Engine: Implementation#

The matching engine must be deterministic and fast. Here's a simplified implementation in Rust:

rust
1use std::collections::BTreeMap;
2use std::cmp::Ordering;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum Side {
6    Buy,
7    Sell,
8}
9
10#[derive(Debug, Clone)]
11pub struct Order {
12    pub id: u64,
13    pub side: Side,
14    pub price: u64,  // Price in cents/satoshis
15    pub quantity: u64,
16    pub timestamp: u64,
17}
18
19pub struct OrderBook {
20    // Bids: sorted descending (highest price first)
21    bids: BTreeMap<u64, Vec<Order>>,
22    // Asks: sorted ascending (lowest price first)
23    asks: BTreeMap<u64, Vec<Order>>,
24}
25
26impl OrderBook {
27    pub fn new() -> Self {
28        OrderBook {
29            bids: BTreeMap::new(),
30            asks: BTreeMap::new(),
31        }
32    }
33
34    pub fn add_order(&mut self, order: Order) -> Vec<Trade> {
35        let mut trades = Vec::new();
36        let mut remaining_qty = order.quantity;
37
38        // Try to match against opposite side
39        match order.side {
40            Side::Buy => {
41                // Match against asks (sells)
42                while remaining_qty > 0 {
43                    if let Some((&best_ask_price, _)) = self.asks.iter().next() {
44                        if order.price >= best_ask_price {
45                            // Can match!
46                            let matched = self.match_order(
47                                order.id,
48                                best_ask_price,
49                                &mut remaining_qty,
50                                Side::Buy
51                            );
52                            trades.extend(matched);
53                        } else {
54                            break; // No more matches possible
55                        }
56                    } else {
57                        break; // No asks available
58                    }
59                }
60
61                // Add remaining to book
62                if remaining_qty > 0 {
63                    let mut resting_order = order.clone();
64                    resting_order.quantity = remaining_qty;
65                    self.bids.entry(order.price)
66                        .or_insert_with(Vec::new)
67                        .push(resting_order);
68                }
69            }
70            Side::Sell => {
71                // Match against bids (buys)
72                while remaining_qty > 0 {
73                    if let Some((&best_bid_price, _)) = self.bids.iter().next_back() {
74                        if order.price <= best_bid_price {
75                            let matched = self.match_order(
76                                order.id,
77                                best_bid_price,
78                                &mut remaining_qty,
79                                Side::Sell
80                            );
81                            trades.extend(matched);
82                        } else {
83                            break;
84                        }
85                    } else {
86                        break;
87                    }
88                }
89
90                if remaining_qty > 0 {
91                    let mut resting_order = order.clone();
92                    resting_order.quantity = remaining_qty;
93                    self.asks.entry(order.price)
94                        .or_insert_with(Vec::new)
95                        .push(resting_order);
96                }
97            }
98        }
99
100        trades
101    }
102
103    fn match_order(
104        &mut self,
105        taker_id: u64,
106        price: u64,
107        remaining_qty: &mut u64,
108        side: Side
109    ) -> Vec<Trade> {
110        let mut trades = Vec::new();
111        
112        let orders = match side {
113            Side::Buy => self.asks.get_mut(&price),
114            Side::Sell => self.bids.get_mut(&price),
115        };
116
117        if let Some(orders_at_price) = orders {
118            orders_at_price.retain_mut(|maker_order| {
119                if *remaining_qty == 0 {
120                    return true; // Keep order
121                }
122
123                let trade_qty = (*remaining_qty).min(maker_order.quantity);
124                
125                trades.push(Trade {
126                    maker_order_id: maker_order.id,
127                    taker_order_id: taker_id,
128                    price,
129                    quantity: trade_qty,
130                });
131
132                *remaining_qty -= trade_qty;
133                maker_order.quantity -= trade_qty;
134
135                maker_order.quantity > 0 // Keep if still has quantity
136            });
137        }
138
139        trades
140    }
141}
142
143#[derive(Debug)]
144pub struct Trade {
145    pub maker_order_id: u64,
146    pub taker_order_id: u64,
147    pub price: u64,
148    pub quantity: u64,
149}
150

Concurrency Model#

To ensure determinism and avoid race conditions, the core matching logic is often single-threaded:

  • LMAX Disruptor Pattern: Use a ring buffer to feed events (orders) into the engine sequentially.
  • In-Memory: The entire order book lives in RAM. Persistence is handled by writing an append-only log (Journaling) to disk.

3. The Risk Engine: Pre-Trade Checks#

The Risk Engine prevents users from trading money they don't have. It must run before the matching engine.

Critical Check: Pre-locking funds.

When a user places a BUY order for 1 BTC at 50,000,theRiskEnginemustimmediately"lock"or"hold"50,000, the Risk Engine must immediately "lock" or "hold" 50,000,theRiskEnginemustimmediately"lock"or"hold"50,000 in their account. If the order is cancelled, the funds are released.

Latency Challenge: If the Risk Engine queries a SQL database for every order, your exchange will be slow.

  • Solution: Cache balances in Redis or in-memory within the Risk Engine service. Sync to SQL asynchronously.

4. Ledger & Accounting: Double-Entry System#

This is where the "Double Spend" problem is solved internally.

The Double Entry Bookkeeping#

Never just store balance = 100. Use double-entry bookkeeping. Every trade is a transaction:

SQL Schema:

sql
1-- User accounts
2CREATE TABLE accounts (
3    account_id BIGSERIAL PRIMARY KEY,
4    user_id BIGINT NOT NULL,
5    asset VARCHAR(10) NOT NULL,  -- 'USD', 'BTC', 'ETH', etc.
6    balance DECIMAL(20, 8) NOT NULL DEFAULT 0,
7    locked_balance DECIMAL(20, 8) NOT NULL DEFAULT 0,
8    version INT NOT NULL DEFAULT 0,  -- For optimistic locking
9    UNIQUE(user_id, asset)
10);
11
12-- Journal entries (double-entry ledger)
13CREATE TABLE journal_entries (
14    entry_id BIGSERIAL PRIMARY KEY,
15    transaction_id BIGINT NOT NULL,
16    account_id BIGINT NOT NULL REFERENCES accounts(account_id),
17    debit DECIMAL(20, 8),
18    credit DECIMAL(20, 8),
19    asset VARCHAR(10) NOT NULL,
20    description TEXT,
21    created_at TIMESTAMP DEFAULT NOW(),
22    CHECK ((debit IS NULL AND credit IS NOT NULL) OR 
23           (debit IS NOT NULL AND credit IS NULL))
24);
25
26-- Trades
27CREATE TABLE trades (
28    trade_id BIGSERIAL PRIMARY KEY,
29    buy_order_id BIGINT NOT NULL,
30    sell_order_id BIGINT NOT NULL,
31    buyer_user_id BIGINT NOT NULL,
32    seller_user_id BIGINT NOT NULL,
33    price DECIMAL(20, 8) NOT NULL,
34    quantity DECIMAL(20, 8) NOT NULL,
35    base_asset VARCHAR(10) NOT NULL,   -- e.g., 'BTC'
36    quote_asset VARCHAR(10) NOT NULL,  -- e.g., 'USD'
37    executed_at TIMESTAMP DEFAULT NOW()
38);
39

Example Transaction (BTC/USD trade):

sql
1-- Trade: Alice buys 1 BTC from Bob at $50,000
2BEGIN;
3
4-- 1. Debit Alice's USD
5INSERT INTO journal_entries (transaction_id, account_id, debit, asset, description)
6VALUES (12345, alice_usd_account, 50000.00, 'USD', 'Buy 1 BTC @ 50000');
7
8-- 2. Credit Bob's USD
9INSERT INTO journal_entries (transaction_id, account_id, credit, asset, description)
10VALUES (12345, bob_usd_account, 50000.00, 'USD', 'Sell 1 BTC @ 50000');
11
12-- 3. Debit Bob's BTC
13INSERT INTO journal_entries (transaction_id, account_id, debit, asset, description)
14VALUES (12345, bob_btc_account, 1.00000000, 'BTC', 'Sell 1 BTC @ 50000');
15
16-- 4. Credit Alice's BTC
17INSERT INTO journal_entries (transaction_id, account_id, credit, asset, description)
18VALUES (12345, alice_btc_account, 1.00000000, 'BTC', 'Buy 1 BTC @ 50000');
19
20-- 5. Update balances with optimistic locking
21UPDATE accounts 
22SET balance = balance - 50000.00, version = version + 1
23WHERE account_id = alice_usd_account AND version = expected_version;
24
25UPDATE accounts 
26SET balance = balance + 50000.00, version = version + 1
27WHERE account_id = bob_usd_account AND version = expected_version;
28
29-- ... (similar for BTC accounts)
30
31COMMIT;
32

Database Choice#

  • User Balances: PostgreSQL (ACID compliance is non-negotiable).
  • Trade History: TimescaleDB or ClickHouse. These grow indefinitely and need fast analytical queries.
  • Order Book Snapshots: Redis for real-time access.

5. Performance Benchmarks#

Real-world performance numbers from a production exchange:

ComponentThroughputLatency (P99)Technology
Matching Engine50,000 orders/sec200 μsRust, single-threaded
Risk Checks100,000 checks/sec50 μsRedis cache
Database Writes10,000 trades/sec5 msPostgreSQL (batched)
WebSocket Updates200,000 msg/sec10 msRust + Tokio

Optimization Techniques:

  • Batching: Write trades to DB in batches of 100
  • Async I/O: Use async Rust (Tokio) for network operations
  • Memory Pools: Pre-allocate order objects to avoid heap allocations
  • CPU Pinning: Pin matching engine thread to dedicated CPU core

6. Monitoring & Observability#

Production exchanges require comprehensive monitoring:

Key Metrics#

python
1# Prometheus metrics example
2from prometheus_client import Counter, Histogram, Gauge
3
4# Order flow
5orders_received = Counter('exchange_orders_received_total', 'Total orders received', ['symbol'])
6orders_matched = Counter('exchange_orders_matched_total', 'Total orders matched', ['symbol'])
7orders_rejected = Counter('exchange_orders_rejected_total', 'Total orders rejected', ['reason'])
8
9# Latency
10matching_latency = Histogram('exchange_matching_latency_seconds', 'Matching engine latency')
11db_write_latency = Histogram('exchange_db_write_latency_seconds', 'Database write latency')
12
13# Book depth
14book_depth_bids = Gauge('exchange_book_depth_bids', 'Number of bid levels', ['symbol'])
15book_depth_asks = Gauge('exchange_book_depth_asks', 'Number of ask levels', ['symbol'])
16
17# Wallet health
18hot_wallet_balance = Gauge('exchange_hot_wallet_balance', 'Hot wallet balance', ['asset'])
19pending_withdrawals = Gauge('exchange_pending_withdrawals', 'Pending withdrawal count')
20

Critical Alerts#

  1. Crossed Book: best_bid >= best_ask (indicates matching engine bug)
  2. Balance Mismatch: blockchain_balance != internal_ledger_balance
  3. High Latency: P99 matching latency > 1ms
  4. Hot Wallet Low: Balance below threshold
  5. Failed Withdrawals: > 5% failure rate

7. Security: Hot & Cold Wallet Management#

The bridge to the blockchain is the most vulnerable point.

  • Hot Wallet: Online, automated. Holds only ~5% of funds for daily withdrawals. Keys are in an HSM (Hardware Security Module).
  • Cold Wallet: Offline, air-gapped. Holds ~95% of funds. Requires multi-sig (M-of-N) manual approval for transfers.

Reconciliation Process:

python
1async def reconcile_wallets():
2    """Run every 5 minutes."""
3    for asset in ['BTC', 'ETH', 'USDT']:
4        # Get blockchain balance
5        blockchain_balance = await blockchain_client.get_balance(asset)
6        
7        # Get internal ledger balance
8        ledger_balance = await db.query(
9            "SELECT SUM(balance) FROM accounts WHERE asset = $1",
10            asset
11        )
12        
13        discrepancy = abs(blockchain_balance - ledger_balance)
14        
15        if discrepancy > TOLERANCE:
16            # CRITICAL: Halt withdrawals immediately
17            await circuit_breaker.open(f"wallet_{asset}")
18            await alert_team(
19                f"CRITICAL: {asset} balance mismatch! "
20                f"Blockchain: {blockchain_balance}, "
21                f"Ledger: {ledger_balance}"
22            )
23

Any discrepancy triggers an immediate "Circuit Breaker" to halt withdrawals.

Conclusion#

Building an exchange is less about "blockchain" and more about high-performance distributed systems and strict accounting. Speed gets users, but security keeps them.

Key Takeaways:

  1. Use a single-threaded matching engine for determinism
  2. Implement double-entry bookkeeping from day one
  3. Pre-lock funds in the risk engine to prevent double-spends
  4. Monitor everything: latency, balances, wallet health
  5. Never compromise on hot/cold wallet separation
  6. Reconcile constantly: blockchain vs internal ledger

Start simple, test rigorously, and scale incrementally. The financial stakes are too high for shortcuts.

NT

NordVarg Team

Technical Writer

NordVarg Team is a software engineer at NordVarg specializing in high-performance financial systems and type-safe programming.

cryptocurrencyexchangearchitecturematching-enginesecurity

Join 1,000+ Engineers

Get weekly insights on building high-performance financial systems, latest industry trends, and expert tips delivered straight to your inbox.

✓Weekly articles
✓Industry insights
✓No spam, ever

Related Posts

Nov 11, 2025•12 min read
Latency Optimization for C++ in HFT Trading — Practical Guide
A hands-on guide to profiling and optimizing latency in C++ trading code: hardware-aware design, kernel-bypass networking, lock-free queues, memory layout, and measurement best-practices.
GeneralC++HFT
Nov 28, 2025•6 min read
Multi-Cloud Disaster Recovery: The $440M Outage That Changed Everything
Operationsdisaster-recoverymulti-cloud
Nov 11, 2025•8 min read
CRTP — Curiously Recurring Template Pattern in C++: elegant static polymorphism
How CRTP works, when to use it, policy/mixin patterns, C++20 improvements, pitfalls, and practical examples you can compile and run.
Generalc++patterns

Interested in working together?