PageBook

A good-enough order book for Soroban: a contract that stores, matches, and settles limit orders on-chain while keeping every transaction's footprint predictable.

This is a high-level explainer that assumes familiarity with order books, SDEX, and basic Soroban concepts. Examples use the XLM/USDC market, with prices in USDC per XLM. The web client shows the live testnet book and trades on it with an in-page disposable wallet.

Little here is novel: the design leans heavily on on-chain CLOBs built elsewhere, including Serum, Phoenix, Manifest, DeepBook, Econia, and the LFJ Liquidity Book, surveyed in the prior-art notes.

Why does Soroban need an order book?

People like order books; why is a separate topic. Stellar already has a native one, SDEX, but the two systems cannot reach each other: Soroban contracts cannot call SDEX operations, and SDEX cannot call contracts.

The economics differ too. SDEX offers are backed by refundable base reserves rather than Soroban's resource metering and state rent, so they can rest on-ledger indefinitely. And SDEX has no mechanism for sending a configurable trading fee to a market operator.

Why is it not trivial?

Two Soroban constraints shape the design.

First, a transaction must declare its read/write set, its footprint, before execution. A naive one-entry-per-order design makes the matching footprint depend on the live queue. A Soroban order book needs predictable keys and footprints that clients can safely pad.

Second, ledger resources are metered and capped. Write bytes are especially scarce, so a Soroban order book needs small entries and bounded writes on the matching path.

Soroban has other hard limits too: a transaction gets a bounded instruction budget, can only touch so many entries, and no entry can grow past a fixed size. Those turned out to be budgets to stay under, not forces that shaped the design.

"Good enough" concessions

PageBook does not reproduce SDEX behavior exactly. It accepts four concessions:

  • Price Quantization. SDEX matches rational prices with no market configuration. PageBook quantizes each market with an admin-configured lot size, tick size, and tick range. The example market sets the lot to 10 XLM, the tick to 0.00001 USDC/XLM, and the range to the whole index. One lot at tick 15,800 (0.15800 USDC/XLM) costs 1.58 USDC. Every traditional exchange quantizes both price and size, with a tick table or a flat tick and board lots, so SDEX's rational prices are the unusual case. What PageBook concedes is that its grid is frozen: a traditional venue retunes its tick table by rule and resting liquidity stays put, while a PageBook market cannot change its quantization without a new market and a migration. Tick spacing is future work.
  • Async Maker Settlement. SDEX credits both sides during execution. In PageBook, the taker settles during execution and makers claim later.
  • (Slightly) Stale Best Prices. SDEX matches against the best offer at apply time. PageBook starts from the best price seen during simulation, so better offers added before inclusion are not visible to that transaction.
  • Footprint Padding. This one is against Soroban convention rather than SDEX. A typical invocation submits the footprint returned by simulation as-is; PageBook clients pad the simulated footprint with a band of price levels around the simulated best.

The design

Book state is split by function:

  • Tick index: finds price levels with available supply.
  • Level queue: stores FIFO supply at each tick.
  • Order record: points a maker to its queue position for later settlement.

Storage keys are derived from client-known coordinates. Queue entries are small, and a level is stored at the size of the queue it holds. This keeps footprints predictable and write bytes low.

Tick index

Each market side has a bitmap of active price levels. Bit T[i] = 1 means tick i may have resting supply. Matching uses it to find the next candidate tick without scanning every price.

Implementation

The index is stored in two levels: a summary identifies non-empty tick words, and each tick word identifies active ticks in its range. With 2,048-bit words under a 2,048-bit summary, one side covers 2^22, or 4,194,304, ticks. Each bitmap is a 256-byte value, 264 bytes serialized.

The index is derived state. A live level must have its bit set, but a set bit may point to an empty level. Matching verifies the level and clears stale bits lazily.

Tick index one per market side · two bitmap levels summary … 2,048 bits, one per tick word; bit w set = word w has set bits word 7 word 12 tick word 7 … … 15,795 15,800 15,805 = word 7, bit 1,469 set: this tick's level may have resting supply stale: still set, but the level is empty; cleared lazily clear: nothing rests at this tick
The tick index is two levels of bitmaps. A tick number splits into a word index and a bit index, so tick 15,805 lives at word 7, bit 1,469. The summary marks which words contain set bits; 2,048 words of 2,048 bits cover the 2^22-tick range, and each bitmap is one 256-byte entry. The index is derived state, which is why a set bit can be stale.

Level queue

Each active tick has one FIFO queue per market side. Each slot stores one maker order's quantity in lots. New maker orders append at the tail; takers consume from the head. The queue stores quantities only: maker identity and settlement data live in the order record.

At tick 15,800, every slot trades at 0.15800 USDC/XLM. A slot containing 25 represents 25 lots, or 250 XLM. A taker consumes older slots before newer ones at the same price.

Implementation

The FIFO never moves data. It appends quantities to a vector and advances a head coordinate through it. Each queue is one Level entry: the generation and head counters, total open lots, and the quantity slots, a vector as long as the queue has reached (124 bytes empty, 12 more per resting order, 892 bytes at 64). A level holds up to 64 resting orders per generation by default; a rest against a full level fails with a typed error. The whole queue sits under one key, so a deep queue adds bytes to that entry rather than entries to the footprint.

Each slot holds one order's open lots. A partial take decrements the head slot in place; filled slots remain as history; cancelled slots become tombstones. Nothing is moved or compacted.

open_lots tracks aggregate supply at the level. A taker that consumes the whole level can price and sweep it with one Level write, without reading each maker slot. Partial consumption walks slots from the head, bounded by the level's own depth.

Level entries are never deleted; the entry is reused across generations. When a taker sweeps the level, or when the first rest arrives at a level that cancels emptied, the generation increments, the vector empties, and sequence numbers restart. The generation preserves settlement correctness for makers from the previous queue.

Level queue asks at tick 15,800 · one FIFO per market side per active tick Level gen 3 head_seq 2 slots.len() 5 open_lots 80 head tail 30 25 40 15 … 0 1 2 3 4 5 6 entry size 124 B empty +12 B per slot held 892 B at the default level_cap of 64 filled: consumed history behind the head cancelled: tombstone; never moved or compacted open: one maker order's open lots (the head slot shrinks as it is taken) empty: the next append lands at the tail the entry is never deleted: a sweep, or the first rest into a queue that cancels emptied, increments gen, empties the vector, and restarts seqs
One queue, one entry. Level carries the counters and every quantity slot of the current generation; the vector's length is the tail. Filled slots stay behind the head as history, a cancelled slot becomes a tombstone, a partial take shrinks the head slot in place, and the head advances through the vector without moving data. Here seqs 0 and 1 are history, the head sits on 25, and open_lots totals the three open slots.

Order record

The level queue is optimized for matching and stores no maker identity, so each resting order has a separate Order record owned by the maker. The maker chooses a nonce as the order handle. The record stores side, tick, queue generation, sequence number, and original quantity, and it does not change when takers fill the order.

For example, a maker selling 25 lots at tick 15,800 has an order for 250 XLM at 0.15800 USDC/XLM. If fully filled, the claim is worth 39.50 USDC.

Implementation

The maker chooses the handle before simulation. The queue position is assigned during execution and stored inside the record, so concurrent orders can change that position without changing the transaction footprint.

PageBook supports point lookup by nonce, not enumeration of a maker's orders. The maker's client or indexer tracks active nonces off-chain from rested and settled events. This avoids an on-chain maker index and its write cost.

Matching updates the shared level queue but never writes maker order records or maker balances. This keeps maker-specific entries out of the taker's footprint.

When the maker calls settle, PageBook compares the order's generation and sequence number with the level's counters:

  • An order from an older generation, or behind the current head, is fully filled.
  • An order at the head may be partially filled.
  • An order ahead of the head is still open.

PageBook pays the filled amount, refunds any open amount, and deletes the order record. Settlement requires constant work regardless of how many other makers are in the queue.

Order record the maker's claim on a queue position · the section's 25-lot maker at tick 15,800 Order nonce 7 ask · tick 15,800 gen 3 · seq 2 qty 25 lots (250 XLM) unchanged while takers consume the queue; read once at settle, then deleted settle: compare (gen, seq) with level counters (gen 3, head_seq 2) head tail 0 1 2 3 4 behind the head: fully filled at the head: may be partially filled ahead of the head: still open gen < 3 older generation: fully filled this order settle pays the filled amount, refunds the open remainder, and deletes the record: constant work, no matter how many other makers are in the queue
The maker's claim, priced by position. The record never changes while takers consume the queue. At settle, its generation and sequence number are held against the level's counters: behind the head means fully filled, at the head possibly partially filled, ahead of the head still open, and an older generation fully filled regardless of position. The example maker sits at the head, so settlement pays any consumed part of the 25 lots and refunds the rest.

The taker walk

A taker walks the opposite side of the book from the best available tick toward its limit. A bid walks asks upward; an ask walks bids downward.

Suppose that, at some later moment, the XLM/USDC ask side contains 20 lots at tick 15,800 and 30 lots at tick 15,805, and a taker bids for 35 lots with a limit of tick 15,805:

  1. The tick index points to tick 15,800.
  2. The level has 20 open lots, less than the taker's remaining quantity. PageBook sweeps the whole level for 31.60 USDC, resets its queue generation, and clears its tick bit.
  3. The tick index finds the next ask at tick 15,805.
  4. The taker needs 15 more lots, so PageBook consumes them from the head of that level queue for 23.7075 USDC.
  5. The taker receives 350 XLM before fees and spends 55.3075 USDC. At the example market's 5 bps taker fee, the net output is 349.825 XLM.
  6. Maker order records are untouched. Makers later use them to claim the proceeds recorded by the updated level counters.
The taker walk buy 35 lots, limit 15,805 · asks: 20 lots at 15,800, 30 lots at 15,805 · steps match the list above tick index 15,800 15,805 … 1 3 2 Level 15,800 gen 5 · open_lots 20 not read swept whole by counters: one Level write, slots unread; gen reset, tick bit cleared 4 Level 15,805 open_lots 30 10 5 15 consume 15 from the head: 10 + 5 taken, 15 remain; 35 lots filled, the walk stops taken by this walk still resting not read by the sweep maker order records: never read or written by the walk; makers settle later against the updated counters
The walk over the real structures. The tick index hands the walk its starting tick (1) and, once the sweep clears that bit, the next one (3). The 20-lot level is priced and swept by its counters alone, slots unread (2); the 30-lot level is consumed from the head until the taker's 35 lots are filled (4). The queues update; maker order records are untouched.

Implementation

The client simulates the take to get its starting tick and the levels it expects to cross, then pads the footprint with a band of level keys around that path.

During execution, the walk alternates between the tick index and level queues. A full-level sweep uses open_lots and does not read individual slots. Partial consumption reads from the queue head under a fixed slot limit.

The walk stops when it fills the requested quantity, reaches the limit price, or hits a configured work limit. Depending on the order flags, any remainder rests at the limit price or is refunded. Walking beyond the padded tick band is the remaining footprint failure case.

Liquidity added at a better price after simulation is outside this walk. Covered changes inside the padded range are handled at execution without adding maker-specific entries to the footprint.

Observations

  • Taker cost scales with levels crossed, not orders crossed: sweeping a level is one Level write whether it clears one maker or sixty-four. At current caps — 32 levels per invocation, 64 orders per level — a single take can clear up to 2,048 resting orders. The tradeoff is that write bytes grow with each level crossed and are declared upfront, so a deep sweep reserves write capacity it may not use.
  • Take amounts are exact: quantization makes every quote amount an integer multiplication of lots consumed by the tick's per-lot price, so matching contains no division and nothing to round. The only division is the taker fee, which rounds up; the dust accrues to fees.

Results

A draft implementation is live on testnet. The web client reads a PageBook market directly from Soroban RPC — best bid and ask, depth per side, recent trades, the market's vault and fee balances — and trades on it with an in-page disposable testnet wallet.

Each row below is a constructed worst-or-typical shape measured on protocol 27 testnet by the repository's resource gates: entries and write bytes metered by the SDK host against the mainnet cost snapshot, with the fee estimate from the architecture's per-operation table (execution plus any rent the shape creates). The maximum take crosses the full 32-level limit in one call. Persistent-state rent dominates wherever an entry is created.

Measured entries, write bytes and estimated fee per invocation type
Invocation Entries read Entries written Write bytes (metered) Est. resource fee
Rest a maker quote (existing level) 13 5 1.1 KB ~0.048 XLM
Rest opening an empty side 15 8 1.9 KB ~0.24 XLM
Replace one quote 13 7 1.7 KB ~0.0024 XLM
Replace 40 quotes in one batch (same ticks) 90 83 21.3 KB ~0.031 XLM
Settle a maker order 9 5 0.8 KB ~0.0015 XLM
Take across 8 levels 22 17 3.9 KB ~0.006 XLM
Maximum take (32 levels, the budget cap) 77 72 21.0 KB ~0.025 XLM

Fees split into execution, which is small everywhere, and rent, which is paid where an entry is created. Settlement cost does not grow with queue depth.

Reading down the fee column: quoting costs fractions of a cent in XLM, and the heaviest legal taker transaction uses 72 of the 200 per-transaction writes and about a sixth of the write-byte cap. The full shape-by-shape record is the worst-case matrix.

Future work: tick spacing

PageBook uses linear ticks: the price of tick t is t × tick_size, a fixed quote amount. Every matching amount is then a plain integer multiplication, which keeps the code short and the fill accounting exact. The weakness is that a fixed quote amount is a different fraction of the price at every price.

At 0.158 USDC/XLM the example market's tick is about 0.6 bps. If XLM fell to 0.02, the same tick would be 5 bps, and a maker could not ladder quotes inside a 5 bps spread. If XLM rose to 1.58, the tick would be 0.06 bps, and a taker padding one percent of depth would declare 1,580 level keys, eight times the 200 writes a transaction may hold. Above 41.94 USDC/XLM the band ends. Quantization is frozen at market creation, so the only remedy is a new market and a migration of resting liquidity.

Tick spacing across price each box is one 0.1% slice of price · marks are ticks inside it 0.02 USDC/XLM 0.158 USDC/XLM 1.58 USDC/XLM linear tick = 0.00001 USDC tick = 5 bps 1% of depth = 20 keys tick = 0.63 bps 1% of depth = 158 keys tick = 0.063 bps 1% of depth = 1,580 keys a slice with more than 40 ticks is drawn solid
Linear ticks drift with price. A linear tick is a fixed quote amount, so it is coarse when the price is low and fine when the price is high, and the level keys a taker must pad for a given depth grow with the price.

Two families of grid avoid the drift while keeping ticks as dense integers, so the tick index, level queue, order record and taker walk would not change. A geometric grid sets price(t) = (1 + step)^(t − 2^21), so adjacent ticks are always step apart in percentage terms and one percent of depth costs the same number of keys at any price. Its cost is that prices are no longer whole numbers of quote atoms, so matching would have to round. A significant-figure grid, the rule Hyperliquid and traditional tick-size tables use, admits only prices with a fixed number of significant digits, so the tick grows by a factor of ten at each decade. Prices stay whole atoms and matching stays exact, but tick width as a fraction of price varies tenfold within a decade.

Neither is settled on. The quantization schemes note compares both, with worked numbers for the example market, and the tick grids page lets you drag the price and watch each grid respond. Whatever grid a later market type adopts, quantization is frozen per market, so existing markets stay linear.