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.
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.
Core, plus what is built on it
One audited engine, many trees on top, with thin typed layers between.
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.
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.
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.
15 instructions
The discriminator is the first instruction-data byte. Hot ops parallelize; cold ops touch the allocator and serialize.
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.
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.
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).
PDA seeds are creator-namespaced, so a tree_id is local to its creator:
header ["thdr", creator, tree_id]
node(i) ["tnode", creator, tree_id, node_idx]
allocator ["talloc", creator, tree_id]
delegate ["tdlg", creator, tree_id]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.
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.
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).
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 youIt 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.
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.
npm install torna-sdk @solana/web3.jsimport { 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.insertIxPerformance
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.
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.
Compute units at production scale, measured on the real engine (make cu):
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.
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).
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.
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.)
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.
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:
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.
Reproduce
Everything here is reproducible from the repo. Add the Solana platform-tools to PATH, then from the engine directory:
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:
cd bench && ./run.shHand-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.
Use cases, honestly scoped
- CLOB / DEX order book (the wedge)
- Liquidation queues
- Leaderboards / top-N
- Token-weight governance
- Proposal / expiry queues
- Options-per-strike trees (rent)
- Trait-per-tree marketplaces (rent)
- Full on-chain time-series (rent)
- Tiny datasets, RFQ, rate oracles (no parallelism need)
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.