TTorna

Documentation

Two products, documented separately. Torna is the on-chain index primitive you build on; TornaDEX is the reference order book built on it.

Torna, the primitive

Torna is a sorted key to value store on Solana where every B+ tree node lives in its own account. Sorting comes from the B+ tree; parallelism comes from one-node-per-account, so writes to different leaves carry disjoint write sets and the Sealevel scheduler runs them in the same slot; and the SDK resolves the exact accounts off-chain. It is a generic index primitive, not a matching engine. The engine is written in C for SBF. Build any sorted state with concurrent writers on it; the TornaDEX tab is one worked example.

Honest note. Torna parallelizes maintenance (writes to different leaves), not a serial consumer like top-of-book matching. The numbers here are measured on a single-node validator banking stage; devnet is shared, so the controlled number is the honest one. In-house adversarial review is not an external audit, which is still pending.
The idea

A sorted index, without the slab

Sorted on-chain state with concurrent writers is hard on Solana. The usual answer is a single giant slab account plus an off-chain indexer to read it back. Torna replaces both: sorting comes from a B+ tree, parallelism from putting one node in each account, and the client SDK resolves the exact accounts off-chain so node indices, bumps, paths, and split spares never reach the developer.

The moat is not the algorithm, anyone can write a B-tree. It is the three-constraint design below plus a typed client that makes account resolution invisible, and being the canonical, audited primitive.

Architecture

Core, plus what is built on it

One audited engine, many trees on top, with thin typed layers between.

Torna engineC / SBFThe parallel ordered B+ tree, one node per account. 15 instructions.
torna-cpiRust / SBFinvoke_signed helpers so a program drives Torna as a book-authority PDA.
orderbook (TornaDEX)Rust / SBFTwo-sided escrow CLOB: a market = two trees + vaults, place/cancel/match.
torna-sdk (Rust)Rust clientThe PathPlanner: key-based ix builders, accounts resolved off-chain. On crates.io.
torna-sdk (npm)TypeScript1:1 port of the Rust SDK, byte-equivalent. On npm.
cpi-probeRust / SBFComposability proof: a program CPIs InsertFast and parallelism survives.
Why this shape

The three scarce resources

Every Solana program is bound by three limits. Torna's layout is chosen to win all three; a single-account slab loses all three.

  • Per-tx account budget (the scarcest, ~35 legacy / ~256 with ALT). High fanout means height ~3, so an operation touches ~3 node accounts, not one huge slab.
  • Account-lock parallelism (Sealevel). One node per account means writes to different leaves do not share a writable account, so the scheduler runs them in the same slot.
  • Rent. Node size is parameterized by value size; high fanout amortizes the per-account overhead.
How it works

One B+ tree, one account per node

The header is read-only on the hot path; only the target leaf is writable. So two writers at different leaves carry disjoint write sets and commit in the same slot. The allocator (a separate account) is touched only on the cold path, so plain inserts never write-lock the shared header.

Tree headerread-only on hot pathInternal nodeown accountLeaf 1prices 100-104Leaf 2prices 105-109Leaf 3prices 110-114Leaf 4prices 115+maker A → 106maker B → 117disjoint accounts→ committed in ONE slot

Hot path (InsertFast, UpdateFast, DeleteFast, Find, RangeScan, and the batch variants): header read-only, no CPI, only the leaf writable, fully parallel. Cold path (Insert with a split, Delete with a merge): touches the allocator and CPIs the system program to create or close node accounts. Keeper bots run compaction off-peak so the hot path stays split-free.

The engine

15 instructions

The discriminator is the first instruction-data byte. Hot ops parallelize; cold ops touch the allocator and serialize.

discnamepurposepath
0InitTreeCreate header + allocator PDAs for a new treeadmin
2InsertCold insert: descend, split via CPI-created spares, grow rootcold
3FindPoint lookup, returns [found, value] via return_dataread
4RangeScanForward/reverse scan into a caller scratch accountread
5StatsReturn the tree header via return_dataread
6CompactLeafKeeper: reclaim an empty leftmost leaf (rent to payer)cold
8DeleteCold delete: bottom-up borrow/merge, CPI close, root collapsecold
9BulkInsertFastInsert a batch of keys into one leafhot
11TransferAuthorityRotate the tree's authority (resets delegates)admin
12AddDelegateAdd a delegate signer (primary-only)admin
13RemoveDelegateRemove a delegate (primary-only)admin
14MultiLeafInsertFastAtomic insert across several leaves in one txhot
16InsertFastInsert one key/value into an existing leafhot
17UpdateFastOverwrite the value at an existing key in placehot
18DeleteFastRemove a key without rebalancinghot
Bytes on the wire

Instruction wire format

Every instruction is a discriminator byte followed by its fields. Multi-byte integers are little-endian on the wire; a 32-byte key inside is big-endian. The descent path (root to leaf) is resolved by the SDK and appended after the fixed accounts.

code
InsertFast (16)   [16][key 32][value vs][path_len u8]
UpdateFast (17)   [17][key 32][value vs][path_len u8]
DeleteFast (18)   [18][key 32][path_len u8]
Find       (3)    [ 3][key 32][path_len u8]   -> return_data: [found u8][value vs]
Insert     (2)    [ 2][key 32][value vs][path_len][spare_count][rent_node u64][bumps..]   (cold)
InitTree   (0)    [ 0][tree_id u32][hdr_bump][alloc_bump][value_size u16][fanout u16][rent_hdr u64][rent_alloc u64]

The hot-path account set is always: header (read-only), authority (read-only signer), then the path nodes with only the leaf writable. That exact shape, header read-only and a single leaf writable, is what lets writes to different leaves land in the same slot. value size (vs) is fixed per tree at init.

Frozen contract (v4)

On-chain layout

The byte layouts are frozen and locked by compile-time asserts in the engine. The tenant boundary is tree_uid = sha256(creator || tree_id)[..16], a 128-bit id stamped in every node and checked at every node-validation site (tree_id alone is a client-chosen u32 that collides across creators).

Node header · 44 bytes
@0is_leaf
@2key_count (u16)
@8tree_id (u32)
@12node_idx (u64)
@20next_leaf_idx (u64)
@28tree_uid (16)
Tree header · 146 bytes
@8creator (32)
@48fanout (u16)
@54root_node_idx
@62height (u32)
@82structure_epoch
@122tree_uid (16)

PDA seeds are creator-namespaced, so a tree_id is local to its creator:

code
header     ["thdr",   creator, tree_id]
node(i)    ["tnode",  creator, tree_id, node_idx]
allocator  ["talloc", creator, tree_id]
delegate   ["tdlg",   creator, tree_id]
Correctness under load

Concurrency, staleness, and errors

The header carries a structure_epoch bumped only on a structural change (split, merge, or root change), never on a plain insert. A client resolves a path from current state, then submits; between resolving and landing, a concurrent writer may have split or merged a node and invalidated the cached path. The engine returns ERR_BAD_PATH and the client just re-resolves from fresh state and resubmits. Comparing the cached structure_epoch detects this cheaply, with no hot-path contention (plain inserts never bump it).

The SDK ships a small retry model for this: resolve, submit, and on a stale path re-resolve and try again; a real error stops immediately.

102ERR_NEED_SPLIT_SLOTInsertFast hit a full leaf, fall back to the cold Insert (split)
103ERR_DUPLICATE_KEYthe key already exists
104ERR_KEY_NOT_FOUNDUpdateFast or DeleteFast on an absent key
105ERR_BAD_PATHnode_idx or tree_uid mismatch, the path went stale, re-resolve
Write access

Authority and delegates

Each tree has a primary authority (set at init, rotatable via TransferAuthority). Write instructions accept the primary authority OR a PDA-validated delegate. Delegates are added and removed by the primary only (AddDelegate / RemoveDelegate, up to 8), and each delegate list is stamped with the authorizing authority, so a TransferAuthority invalidates a stale delegate list. The authority signs as a read-only signer on the hot path, so a shared read lock does not serialize, writes stay parallel even when an integrating program drives Torna through a CPI as its own PDA.

Transfer to the all-zero authority is forbidden; it would silently open the tree.

The product

Account resolution is invisible

The client is most of the value. You call with a key and value; the planner reads the tree off-chain and returns the exact account set. Node indices, PDA bumps, the descent path, and split spares never leave the library. Published as torna-sdk on npm (TypeScript) and crates.io (Rust).

typescript
import { Tree, keys } from "torna-sdk";

const tree = new Tree(program, creator, treeId);
const key  = keys.orderKey(keys.Side.Ask, price, slot, maker, nonce);

// the planner resolves the exact accounts off-chain
const ix = await tree.insertFastIx(reader, authority, key, value);
// node_idx / bump / path / spares: never touched by you

It is a 1:1 port of the Rust SDK, asserted byte-for-byte against it with golden vectors and run end-to-end against the real engine.

Build with it

Quickstart

Install the SDK and web3.js, then resolve and send instructions. Reads walk the tree off-chain with no transaction; writes are one call each.

bash
npm install torna-sdk @solana/web3.js
typescript
import { Connection, PublicKey } from "@solana/web3.js";
import { Tree, keys, type AccountReader } from "torna-sdk";

const connection = new Connection("https://api.devnet.solana.com");
const reader: AccountReader = {
  async accountData(key) {
    const i = await connection.getAccountInfo(key);
    return i ? Uint8Array.from(i.data) : null;
  },
};

const tree = new Tree(program, creator, /* treeId */ 1);

// a 32-byte key and your value bytes (value_size for this tree)
const key   = keys.orderKey(keys.Side.Ask, price, slot, maker, nonce);
const value = orderValue;

// read off-chain (no tx): top of book, a page, a single value
const top  = await tree.best(reader);
const page = await tree.scan(reader, 16);
const val  = await tree.get(reader, key);

// the cold path bootstraps an empty tree and handles leaf splits: use it for the first insert
const first = await tree.insertIx(reader, payer, key, value, rentNode);

// once a leaf exists, the hot path writes it: header read-only, only the leaf writable -> parallel
const ix = await tree.insertFastIx(reader, authority, key, value);
// on a full leaf the engine returns 0x66 (102); fall back to the cold tree.insertIx
Measured, not claimed

Performance

Parallelism benchmark on a real single-node validator (the Agave banking stage, not a simulator). Identical compute per transaction; the only difference across workloads is the writable lock set. Metric: committed transactions per ~400ms slot under saturation.

workloadconfirmedslotspeak / slotp50 busy
A disjoint leaves28,862713,3879,641
B same leaf29,549212,8771,356
C same fee-payer6,26843,2831,266

Disjoint-leaf writes commit ~4.6x (peak slot) to ~7.1x (p50-busy) more transactions per slot than same-leaf writes, and ~4.1x to ~7.6x more than same fee-payer. The same fee-payer case serializes even across different leaves, because the fee debit makes the payer a writable lock, so each parallel writer must fund with its own payer.

Honest note. This measures maintenance throughput, not a serial consumer. A single-node validator has limited banking threads, so a real cluster would widen the ratio, not narrow it.

Compute units at production scale, measured on the real engine (make cu):

InsertFast (F = 16 / 64 / 128)8k / 23k / 43k
Insert + split + root-grow (F = 64 / 128)38k / 68k
Delete + merge + collapse50k
MultiLeafInsertFast (8 x 12)204k
BulkInsertFast (32 front-insert, worst case)400k

Single-key hot operations stay under the 200k default budget even at fanout 128; the batch operations request a higher limit and remain far under the 1.4M per-transaction cap.

What it costs

Economics (rent)

Solana charges a per-account rent deposit sized by bytes. These are real devnet numbers. Rent is a refundable deposit, not a fee: it is reclaimed when an account is closed, and Torna returns a merged node's rent to the payer (node indices are monotonic and never reused, so there is no stale-reference hazard).

accountbytesrent (SOL)
Tree header146 B0.001907
Allocator32 B0.001114
Node, fanout 8692 B0.005707
Node, fanout 644,724 B0.033770
Market config229 B0.002485
SPL token vault165 B0.002039
SPL mint82 B0.001462

Derived: an empty tree is ~0.003 SOL (header + allocator); standing up a full market (two trees + two vaults + config) is ~0.013 SOL (before any nodes) plus any new mints. A resting entry is one slot in an existing leaf, not a new account, so its marginal rent is the leaf rent amortized over the fanout, roughly 0.0005 SOL at fanout 64, reclaimed when the leaf later merges. The recurring per-transaction cost is just the ~5000-lamport (0.000005 SOL) network fee; new node accounts are created only on a split, which keepers run off-peak.

Honest posture

Security and testing

The engine and SDK went through in-house adversarial review to convergence (rounds until two consecutive clean passes), with independent skeptics attacking from distinct angles. (The orderbook's own review is in the TornaDEX tab.)

Engine

5 rounds + a class audit. The recurring class 'program-owned is not tenant-owned' struck four times (node, allocator, scratch, delegate); fixed by binding every caller-provided account to its 128-bit tree_uid.

SDK

5 rounds. A faithful 1:1 port with no critical or high finding; added input validation the Rust type system enforced for free.

Test suite, all green and reproducible:

host unit + differential 10,042inttest 55cpitest 7sdktest 14alttest 4on-chain differential 8,000 opsfuzz 60k, zero memory violationsTS SDK 12/12

The threat model covers cross-tenant splicing, delegate injection, scratch corruption, and rent theft, each with a fail-closed regression test. The engine only CPIs the system program, so there is no reentrancy surface.

Honest note. In-house adversarial review is not a substitute for an external audit, which is pending. The upgrade-authority policy (immutable after audit vs a timelocked multisig) is the one open pre-mainnet trust decision and is not yet decided.
Run it yourself

Reproduce

Everything here is reproducible from the repo. Add the Solana platform-tools to PATH, then from the engine directory:

bash
make test         # host unit + differential (assertions on)
make sbf          # build the on-chain program -> sbf/out/torna.so
make integration  # LiteSVM: smoke, inttest, cpitest, sdktest, obtest, alttest
make diff         # on-chain differential vs an oracle (8000 ops)
make fuzz FUZZ_ITERS=60000   # fuzz every handler for memory safety
make cu           # compute units at production scale (F = 16 / 64 / 128)
make all          # all of the above
make ts           # TS SDK: golden vectors + bankrun e2e (12/12)

The parallelism benchmark spins a real single-node validator and blasts disjoint vs conflicting workloads:

bash
cd bench && ./run.sh
What it replaces

Hand-rolled slab vs Torna

Teams hand-write a slab allocator and run an off-chain indexer to read it back. Torna is both: an on-chain index plus a client that resolves every account.

Hand-rolled slab + indexer
Torna
On-chain state
One slab account per side
One small account per node
Concurrent writes
Serialized, each side's slab is one lock
Parallel across disjoint leaves
Reading the book
Typically an off-chain indexer for usable reads
Walk the tree off-chain via the SDK
Ordering
Sorted, but one write lock
Always sorted (B+ tree invariant)
Account budget
Whole slab loaded per tx
~3 node accounts per op (height ~3)
Index plumbing you write
Slab layout + allocator + indexer
index<K,V>, accounts resolved for you
Where it fits

Use cases, honestly scoped

Good fit (sorted + parallel)
  • CLOB / DEX order book (the wedge)
  • Liquidation queues
  • Leaderboards / top-N
  • Token-weight governance
  • Proposal / expiry queues
Poor fit (drop)
  • Options-per-strike trees (rent)
  • Trait-per-tree marketplaces (rent)
  • Full on-chain time-series (rent)
  • Tiny datasets, RFQ, rate oracles (no parallelism need)
What it does not do

Limitations

Stated plainly, so integrators can plan around them:

  • Serial consumers stay serial. A single contention point like top-of-book matching cannot be parallelized; Torna parallelizes maintenance, not the consumer.
  • The fee-payer is a writable lock. Two transactions from the same payer serialize even on different leaves, so each parallel writer must fund with its own payer.
  • Ordering tiebreaks are client-supplied. A strict global counter is deliberately not used because it would serialize every write; uniqueness lives in a writer-chosen tail.
  • Keys are strict-unique, values fixed-width (1 to 128 bytes per tree). Larger payloads store a 32-byte pointer to a side account the developer owns.
  • Not externally audited yet, and the upgrade-authority policy (immutable vs timelocked multisig) is not yet decided.
Status

Roadmap

doneEngine: 15 instructions, C for SBF, 5 adversarial rounds to convergence
donetorna-sdk (Rust) + torna-cpi crate
doneOrderbook reference CLOB: two-sided escrow, place / cancel / match, cold split, keeper compact
donePublished: torna-sdk on npm + crates.io, and torna-cpi on crates.io, v0.1.0
doneDeployed + live seeded market on devnet, with this demo
nextExternal audit (in-house review is not an audit)
nextMulti-node devnet benchmark for a stronger number
nextHosted demo; cascade-aware keeper compaction