Build on Torna
A sorted, parallel index in a few lines
You bring a 32-byte key and a value; the SDK resolves every account off-chain and hands you a ready instruction. Node indices, PDA bumps, the descent path, and split spares never appear in your code.
npm i torna-sdk @solana/web3.js # TypeScript client
cargo add torna-sdk solana-sdk # Rust clientSetup
The snippets use a few values you provide:
creatorthe pubkey that namespaces your trees (your project key).authority / signer / payerthe keypair that signs writes and pays fees.Deploy your own engine, or build against the devnet program above; the trade demo and the reference order book both run on it.
Quickstart
Insert a key and read it back. The only thing you implement is an AccountReader over your transport.
import { Connection, PublicKey, Transaction, sendAndConfirmTransaction } from "@solana/web3.js";
import { Tree, keys, type AccountReader } from "torna-sdk";
const connection = new Connection("https://api.devnet.solana.com", "confirmed");
// the SDK reads the tree through this; back it with RPC, a cache, or LiteSVM
const reader: AccountReader = {
async accountData(key) {
const acc = await connection.getAccountInfo(key, "confirmed");
return acc ? Uint8Array.from(acc.data) : null;
},
};
const tree = new Tree(program, creator, 1);
// a 32-byte key + your value bytes (maker is your account; value_size for this tree)
const key = keys.orderKey(keys.Side.Ask, 100n, 0n, maker, 1n);
const value = orderValue;
// the first insert into a fresh tree uses the cold path (it bootstraps the tree and splits):
const ix = await tree.insertIx(reader, payer, key, value, rentNode);
await sendAndConfirmTransaction(connection, new Transaction().add(ix), [payer]);
// once leaves exist, writes go hot (header read-only, leaf-only, so they run in parallel); see Write
// read it back: no transaction, no fee
const top = await tree.best(reader);Create a tree (once)
Pick a value_size (1 to 128 bytes, fixed per tree) and a fanout (both required; 64 is a sensible choice). You get header and allocator PDAs, namespaced by your creator key.
const ix = tree.initTreeIx(payer, valueSize, /* fanout */ 64, rentHeader, rentAlloc);Write (hot path)
Header read-only, only the target leaf writable, no CPI. So writes to different leaves from different fee-payers commit in the same slot.
const insert = await tree.insertFastIx(reader, authority, key, value); // add
const update = await tree.updateFastIx(reader, authority, key, newValue); // overwrite in place
const remove = await tree.deleteFastIx(reader, authority, key); // removeEach parallel writer must fund with its own fee-payer, or they serialize on the fee debit.
Read (off-chain, free)
Reads walk the tree over your reader. No transaction, no fee. They return the key and value as raw bytes; decode the value with your own layout.
const top = await tree.best(reader); // smallest key (top of book)
const page = await tree.scan(reader, 16); // first 16 entries in order
const val = await tree.get(reader, key); // one value by key
if (top) {
const player = new PublicKey(top.value); // e.g. a leaderboard value is a 32-byte pubkey
}When a leaf splits
A hot insert into a full leaf returns error 102. Fall back to the cold path, which splits the leaf and grows the tree; subsequent inserts at that depth go hot again.
import { ERR_NEED_SPLIT_SLOT } from "torna-sdk"; // 102, surfaced on-chain as 0x66
const sendIx = (ix: TransactionInstruction) =>
sendAndConfirmTransaction(connection, new Transaction().add(ix), [signer]);
try {
await sendIx(await tree.insertFastIx(reader, authority, key, value));
} catch (e) {
// a full leaf makes the engine return custom error 0x66 (ERR_NEED_SPLIT_SLOT)
if (String(e).includes("0x" + ERR_NEED_SPLIT_SLOT.toString(16))) {
// cold path: the maker pays spare-node rent; the engine splits the leaf
await sendIx(await tree.insertIx(reader, payer, key, value, rentNode));
} else throw e;
}From your program (CPI)
When invariants must live on-chain (escrow, access control), your program owns the tree as a PDA authority and CPIs Torna with the torna-cpi crate. This is inherently Rust, and exactly how the reference order book inserts and cancels.
use torna_cpi;
// your program signs as the tree-authority PDA; the client resolved `path`
let seeds: &[&[u8]] = &[b"book", &market_id.to_le_bytes(), &[bump]];
torna_cpi::insert_fast(
torna_program, // the Torna engine program
authority, // your authority PDA
header, // the tree header
path, // root..leaf accounts (client-resolved)
&key,
&value,
&[seeds],
)?;A complete example: a leaderboard
The whole design is choosing what the key and value mean. Encode the sort field big-endian; add a writer-unique tail so two writers never collide.
import { Tree } from "torna-sdk";
const MAX = 2n ** 64n - 1n;
// key = (MAX - score) big-endian, so the highest score sorts first
function scoreKey(score: bigint, player: PublicKey): Uint8Array {
const k = new Uint8Array(32);
new DataView(k.buffer).setBigUint64(0, MAX - score, false); // best first
k.set(player.toBytes().subarray(0, 16), 16); // unique tail
return k;
}
// created once with value_size = 32 (the value is a 32-byte pubkey)
const board = new Tree(program, creator, /* treeId */ 7);
// submit a score (value = the player); many players write in parallel
const ix = await board.insertFastIx(reader, authority, scoreKey(score, player), player.toBytes());
// read the top 10, off-chain, no transaction
const top10 = await board.scan(reader, 10);Swap the key encoding and you have a liquidation queue (key = health), an expiry queue (key = deadline), or an order book (key = price-time). Same engine, same reads.
Errors and staleness
Between resolving a path and landing, a concurrent writer may split or merge a node. The engine returns ERR_BAD_PATH; re-resolve from fresh state and retry. The SDK ships a small retry helper for this.
102 ERR_NEED_SPLIT_SLOT leaf is full -> fall back to the cold insert (split)
103 ERR_DUPLICATE_KEY key already exists
104 ERR_KEY_NOT_FOUND update/delete on an absent key
105 ERR_BAD_PATH a concurrent split/merge moved the path -> re-resolve and retry