TTorna

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.

bash
npm i torna-sdk @solana/web3.js      # TypeScript client
cargo add torna-sdk solana-sdk       # Rust client
Code samples in

Setup

The snippets use a few values you provide:

programthe Torna engine program. On devnet:
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.

typescript
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.

typescript
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.

typescript
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);            // remove

Each 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.

typescript
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.

typescript
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.

rust
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.

typescript
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.

code
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

Reference