Typescript

Build a Typescript client to create or interact with compressed accounts. Includes a step-by-step implementation guide and full code examples.

The TypeScript Client SDK provides two test environments:

  • For local testing, use TestRpc.

    • TestRpc is an in-memory indexer that parses events and builds Merkle trees on-demand to generate proofs instantly without persisting state.

    • Requires a running test validator with Light System Programs and Merkle tree accounts.

  • For test-validator, devnet and mainnet use Rpc.

    • Rpc is a thin wrapper extending Solana's web3.js Connection class with compression-related endpoints. Find a full list of JSON RPC methods here.

    • Connects to Photon indexer to query compressed accounts and prover service to generate validity proofs.

  • Rpc and TestRpc implement the same CompressionApiInterface. Seamlessly switch between TestRpc, local test validator, and public Solana networks.

Implementation Guide

Ask anything via Ask DeepWiki.

This guide covers the components of a Typescript client. Here is the complete flow:

1

Dependencies

npm install --save \
    @lightprotocol/[email protected] \
    @lightprotocol/[email protected] \
    @solana/web3.js

@lightprotocol/stateless.js provides the core SDK to create and interact with compressed accounts, including Rpc and TestRpc classes.

2

Environment

Connect to local, devnet or mainnet with Rpc.

import { createRpc } from '@lightprotocol/stateless.js';

const rpc = createRpc('https://mainnet.helius-rpc.com/?api-key=YOUR_API_KEY');
  • For Helius mainnet: The endpoint serves RPC, Photon indexer, and prover API.

3

Tree Configuration

Before creating a compressed account, your client must fetch metadata of two Merkle trees:

  • an address tree to derive and store the account address and

  • a state tree to store the compressed account hash.

import { getDefaultAddressTreeInfo } from '@lightprotocol/stateless.js';

const addressTree = getDefaultAddressTreeInfo();
const stateTreeInfos = await rpc.getStateTreeInfos();
const outputStateTree = selectStateTreeInfo(stateTreeInfos);
  • V1: getDefaultAddressTreeInfo() returns TreeInfo with the public key and other metadata for the address tree.

Address Trees: getDefaultAddressTreeInfo() / rpc.getAddressTreeInfoV2() returns TreeInfo with the public key and other metadata for the address tree.

  • TreeInfo is used

    • to derive addresses and

    • for getValidityProofV0() to prove the address does not exist yet.

State Trees:

  • getStateTreeInfos() returns TreeInfo[] with pubkeys and metadata for all active state trees.

  • selectStateTreeInfo() selects a random state tree to store the compressed account hash.

    • Selecting a random state tree prevents write-lock contention on state trees and increases throughput.

    • Account hashes can move to different state trees after each state transition.

    • Best practice is to minimize different trees per transaction. Still, since trees fill up over time, programs must handle accounts from different state trees within the same transaction.

TreeInfo contains pubkeys and other metadata of a Merkle tree.

  • tree: Merkle tree account pubkey

  • queue: Queue account pubkey

    • Buffers updates of compressed accounts before they are added to the Merkle tree.

    • Clients and programs do not interact with the queue. The Light System Program inserts values into the queue.

  • treeType: Identifies tree version (StateV1, AddressV2) and account for hash insertion

  • cpiContext (currently on devnet): Optional CPI context account for batched operations across multiple programs (may be null)

    • Allows a single zero-knowledge proof to verify compressed accounts from different programs in one instruction

    • First program caches its signer checks, second program reads them and combines instruction data

    • Reduces instruction data size and compute unit costs when multiple programs interact with compressed accounts

  • nextTreeInfo: The tree to use for the next operation when the current tree is full (may be null)

    • When set, use this tree as output tree.

    • The protocol creates new trees, once existing trees fill up.

4

Derive Address

Derive a persistent address as a unique identifier for your compressed account.

Use the derivation method that matches your address tree type from the previous step.

const seed = deriveAddressSeed(
  [Buffer.from('my-seed')],
  programId
);
const address = deriveAddress(seed, addressTree.tree);

Derive the seed:

  • Pass arbitrary byte slices in the array to uniquely identify the account

  • Specify programId to combine with your seeds

Then, derive the address:

  • Pass the derived 32-byte seed from the first step

  • Specify addressTree.tree pubkey

The addressTree.tree pubkey ensures an address is unique to an address tree. Different trees produce different addresses from identical seeds.

Use the same addressTree for both address derivation and all subsequent operations:

  • To create a compressed account, pass the address to getValidityProofV0() to prove the address does not exist yet.

  • To update/close, use the address to fetch the current account with getCompressedAccount(address).

5

Validity Proof

Fetch a validity proof from your RPC provider that supports ZK Compression (Helius, Triton, ...). The proof type depends on the operation:

  • To create a compressed account, you must prove the address doesn't already exist in the address tree.

  • To update or close a compressed account, you must prove its account hash exists in a state tree.

  • You can combine multiple addresses and hashes in one proof to optimize compute cost and instruction data.

Here's a full guide to the getValidityProofV0() method.

const proof = await rpc.getValidityProofV0(
  [],
  [{ address: bn(address.toBytes()), tree: addressTree.tree, queue: addressTree.queue }]
);

Pass these parameters:

  • Leave ([]) empty to create compressed accounts, since no compressed account exists yet.

  • Specify the new address with its tree and queue pubkeys in [{ address: bn(address.toBytes()), tree, queue }] and convert it to the required format.

The RPC returns proof result with

  • compressedProof: The proof that the address does not exist in the address tree, passed to the program in your instruction data.

  • rootIndices: An array with root index from the validity proof for the address tree.

  • Empty leafIndices array, since no compressed account exists yet.

6

Pack Accounts

To optimize instruction data we pack accounts into an array:

  • Every packed account is assigned to an u8 index.

  • Indices are included in instruction data, instead of 32 byte pubkeys.

  • The indices point to the remainingAccounts in Anchor.

1. Initialize PackedAccounts

import { PackedAccounts, SystemAccountMetaConfig } from '@lightprotocol/stateless.js';

const packedAccounts = new PackedAccounts();

PackedAccounts creates the accounts array that you'll pass to .remainingAccounts(). It automatically:

  • Assigns pubkeys sequential u8 indices, and

  • deduplicates pubkeys to make sure each unique pubkey appears only once in the array.

  • For example, if the input state tree is the same as the output state tree, both reference the same pubkey and return the same index.

2. Add Light System Accounts

Add the Light System accounts your program needs to create and interact with compressed accounts.

const systemAccountConfig = new SystemAccountMetaConfig(programId);
packedAccounts.addSystemAccounts(systemAccountConfig);
  • Pass your program ID in new SystemAccountMetaConfig(programId) to derive the CPI signer PDA

  • Call addSystemAccounts(systemAccountConfig) - the SDK will add 8 Light System accounts in the sequence below

Program-specific accounts (signers, fee payer) are passed to .accounts(), not added to remainingAccounts.

System Accounts List

1

Verifies validity proofs, compressed account ownership checks, cpis the account compression program to update tree accounts

2

CPI Signer

- PDA to sign CPI calls from your program to Light System Program - Verified by Light System Program during CPI - Derived from your program ID

3

Registered Program PDA

- Access control to the Account Compression Program

4

- Logs compressed account state to Solana ledger. Only used in v1. - Indexers parse transaction logs to reconstruct compressed account state

5

Signs CPI calls from Light System Program to Account Compression Program

6

- Writes to state and address tree accounts - Client and program do not directly interact with this program

7

Invoking Program

Your program's ID, used by Light System Program to: - Derive the CPI Signer PDA - Verify the CPI Signer matches your program ID - Set the owner of created compressed accounts

8

Solana System Program to transfer lamports

3. Pack Tree Accounts from Validity Proof

getValidityProofV0() returns pubkeys and other metadata of Merkle trees. You will convert the pubkeys to u8 indices that reference accounts in the remainingAccounts array to optimize your instruction data.

const addressTreeIndex = packedAccounts.insertOrGet(addressTree.tree);
const addressQueueIndex = packedAccounts.insertOrGet(addressTree.queue);

const packedAddressTreeInfo = {
  addressMerkleTreePubkeyIndex: addressTreeIndex,
  addressQueuePubkeyIndex: addressQueueIndex,
  rootIndex: proof.rootIndices[0]
};
  • Call insertOrGet() with each tree and queue pubkey from the validity proof

  • Create PackedAddressTreeInfo with three fields:

  1. addressMerkleTreePubkeyIndex: Points to the address tree account in remainingAccounts

    • The address tree is used to derive addresses and verify the address does not already exist

  2. addressQueuePubkeyIndex: Points to the address queue account in remainingAccounts

    • The queue buffers new addresses before they are inserted into the address tree

  3. rootIndex: The Merkle root index from proof.rootIndices[0] (Validity Proof step)

    • Specifies the root to verify the address does not exist in the tree

4. Add Output State Tree

Specify the state tree to store the new account hash.

const outputTreeIndex = packedAccounts.insertOrGet(outputStateTree.tree);
  • Use outputStateTree variable from Tree Configuration (Step 3) with the TreeInfo containing pubkey and metadata for the randomly selected state tree

  • Call insertOrGet(outputStateTree.tree) to add the tree and get its index for instruction data

The output tree is separate from the trees in your validity proof. The validity proof references trees that verify existing state (or prove an address doesn't exist), while the output tree specifies where to write the new account hash.

5. Finalize Accounts

const { remainingAccounts } = packedAccounts.toAccountMetas();

Call toAccountMetas() to build the complete accounts structure for .remainingAccounts().

  • Packed struct indices reference accounts by their position in this array.

  • The method returns an object with a remainingAccounts property containing the AccountMeta[] array.

The method returns accounts in two sections:

 [systemAccounts] [packedAccounts]
       ↑               ↑
  Light System     Merkle tree &
    accounts      queue accounts
  1. System accounts as read-only (indices 0-7):

    • System accounts like the noop program log state changes but don't modify their own state.

    • Light System Program expects these accounts at these exact positions.

  2. Tree and queue accounts as writable (indices 8+):

    • All tree and queue accounts with writable flags in sequential order.

    • Light System Program writes new hashes and addresses to these accounts.

6. Summary

You built the remainingAccounts array to merge accounts into an array:

  • Light System accounts to create and interact with compressed accounts via the Light System Program

  • Tree accounts from the validity proof to prove address non-existence (create) or existence of the account hash (update/close/reinit/burn)

  • The output state tree to store the new account hash

The accounts receive a sequential u8 index. Instruction data references accounts via these indices in this order.

7

Instruction Data

Build your instruction data with the validity proof, tree account indices, and complete account data.

Compressed account data must be passed in instruction data because only the Merkle root hash is stored on-chain. Regular accounts store full data on-chain for programs to read data directly.

The program hashes this data and the Light System Program verifies the hash against the root in a Merkle tree account to ensure its correctness.

const proof = {
  0: proofRpcResult.compressedProof,
};

const instructionData = {
  proof,
  addressTreeInfo: packedAddressTreeInfo,
  outputStateTreeIndex: outputTreeIndex,
};
  1. Validity Proof

  • Add and wrap the compressedProof you fetched to prove that the address does not exist yet in the specified address tree.

  1. Specify Merkle trees to store address and account hash

Include the Merkle tree metadata from the Pack Accounts section:

  • PackedAddressTreeInfo specifies the index to the address tree account used to derive the address. The index points to the address tree account in the accounts array.

  • outputStateTreeIndex points to the state tree account in the accounts array that will store the compressed account hash.

  1. Pass initial account data

  • Add custom fields to your instruction struct for any initial data your program requires.

  • This example passes a message field to define the initial state of the account.

8

Instruction

Build the instruction with your program_id, accounts, and data from Step 7. Pass the accounts array you built in Step 6.

const instruction = await program.methods
  .createAccount(proof, packedAddressTreeInfo, outputStateTreeIndex, message)
  .accounts({
    signer: payer.publicKey
  })
  .remainingAccounts(remainingAccounts.toAccountMetas().remainingAccounts)
  .instruction();

Pass the proof, packed address tree info, output state tree index, and initial account data (e.g., message) as separate parameters to .createAccount().

What to include in accounts:

  1. Pass program-specific accounts as defined by your program's IDL (signer, feepayer).

  2. Add all remaining accounts with .remainingAccounts():

    • Light System accounts, added via PackedAccounts.addSystemAccounts().

    • Merkle tree and queue accounts, added via PackedAccounts.insertOrGet().

  3. Build the instruction:

    • Anchor converts .accounts({ signer }) to AccountMeta[] using the program's IDL.

    • .remainingAccounts() appends the complete packed accounts array.

    • Returns TransactionInstruction with programId, merged keys, and serialized instruction data.

Final account array:

[0-N]     
  Program-specific accounts from .accounts() 
  (e.g., signer)
[N+1-N+8] 
  Light System accounts from .remainingAccounts()
[N+9+]   
  Merkle trees and queues from .remainingAccounts()
9

Send Transaction

Submit the instruction to the network.

const blockhash = await rpc.getLatestBlockhash();
const signedTx = buildAndSignTx(
  [instruction],
  payer,
  blockhash.blockhash,
  [] // additional signers if needed
);
const signature = await sendAndConfirmTx(rpc, signedTx);

Full Code Examples

Full TypeScript test examples using local test validator with createRpc().

  1. Install the Light CLI first to download program binaries:

npm -g i @lightprotocol/[email protected]
  1. Start local test validator:

light test-validator
  1. Then run tests in a separate terminal:

anchor test --skip-local-validator
// Create Compressed Account Example
import * as anchor from "@coral-xyz/anchor";
import { Program, web3 } from "@coral-xyz/anchor";
import { Create } from "../target/types/create";
import idl from "../target/idl/create.json";
import {
  bn,
  CompressedAccountWithMerkleContext,
  confirmTx,
  createRpc,
  defaultStaticAccountsStruct,
  defaultTestStateTreeAccounts,
  deriveAddress,
  deriveAddressSeed,
  LightSystemProgram,
  PackedAccounts,
  Rpc,
  sleep,
  SystemAccountMetaConfig,
} from "@lightprotocol/stateless.js";
import * as assert from "assert";

const path = require("path");
const os = require("os");
require("dotenv").config();

const anchorWalletPath = path.join(os.homedir(), ".config/solana/id.json");
process.env.ANCHOR_WALLET = anchorWalletPath;

describe("test-anchor", () => {
  const program = anchor.workspace.Create as Program<Create>;
  const coder = new anchor.BorshCoder(idl as anchor.Idl);

  it("create compressed account", async () => {
    let signer = new web3.Keypair();
    let rpc = createRpc(
      "http://127.0.0.1:8899",
      "http://127.0.0.1:8784",
      "http://127.0.0.1:3001",
      {
        commitment: "confirmed",
      },
    );
    let lamports = web3.LAMPORTS_PER_SOL;
    await rpc.requestAirdrop(signer.publicKey, lamports);
    await sleep(2000);

    const outputStateTree = defaultTestStateTreeAccounts().merkleTree;
    const addressTree = defaultTestStateTreeAccounts().addressTree;
    const addressQueue = defaultTestStateTreeAccounts().addressQueue;

    const messageSeed = new TextEncoder().encode("message");
    const seed = deriveAddressSeed(
      [messageSeed, signer.publicKey.toBytes()],
      new web3.PublicKey(program.idl.address),
    );
    const address = deriveAddress(seed, addressTree);

    // Create compressed account with message
    const txId = await createCompressedAccount(
      rpc,
      addressTree,
      addressQueue,
      address,
      program,
      outputStateTree,
      signer,
      "Hello, compressed world!",
    );
    console.log("Transaction ID:", txId);

    // Wait for indexer to process the transaction
    const slot = await rpc.getSlot();
    await rpc.confirmTransactionIndexed(slot);

    let compressedAccount = await rpc.getCompressedAccount(bn(address.toBytes()));
    let myAccount = coder.types.decode(
      "MyCompressedAccount",
      compressedAccount.data.data,
    );

    console.log("Decoded data owner:", myAccount.owner.toBase58());
    console.log("Decoded data message:", myAccount.message);

    // Verify account data
    assert.ok(
      myAccount.owner.equals(signer.publicKey),
      "Owner should match signer public key"
    );
    assert.strictEqual(
      myAccount.message,
      "Hello, compressed world!",
      "Message should match the created message"
    );
  });
});

async function createCompressedAccount(
  rpc: Rpc,
  addressTree: anchor.web3.PublicKey,
  addressQueue: anchor.web3.PublicKey,
  address: anchor.web3.PublicKey,
  program: anchor.Program<Create>,
  outputStateTree: anchor.web3.PublicKey,
  signer: anchor.web3.Keypair,
  message: string,
) {
  const proofRpcResult = await rpc.getValidityProofV0(
    [],
    [
      {
        tree: addressTree,
        queue: addressQueue,
        address: bn(address.toBytes()),
      },
    ],
  );
  const systemAccountConfig = new SystemAccountMetaConfig(program.programId);
  let remainingAccounts = new PackedAccounts();
  remainingAccounts.addSystemAccounts(systemAccountConfig);

  const addressMerkleTreePubkeyIndex =
    remainingAccounts.insertOrGet(addressTree);
  const addressQueuePubkeyIndex = remainingAccounts.insertOrGet(addressQueue);
  const packedAddressTreeInfo = {
    rootIndex: proofRpcResult.rootIndices[0],
    addressMerkleTreePubkeyIndex,
    addressQueuePubkeyIndex,
  };
  const outputStateTreeIndex =
    remainingAccounts.insertOrGet(outputStateTree);
  let proof = {
    0: proofRpcResult.compressedProof,
  };
  const computeBudgetIx = web3.ComputeBudgetProgram.setComputeUnitLimit({
    units: 1000000,
  });
  let tx = await program.methods
    .createAccount(proof, packedAddressTreeInfo, outputStateTreeIndex, message)
    .accounts({
      signer: signer.publicKey,
    })
    .preInstructions([computeBudgetIx])
    .remainingAccounts(remainingAccounts.toAccountMetas().remainingAccounts)
    .signers([signer])
    .transaction();
  tx.recentBlockhash = (await rpc.getRecentBlockhash()).blockhash;
  tx.sign(signer);

  const sig = await rpc.sendTransaction(tx, [signer]);
  await confirmTx(rpc, sig);
  return sig;
}

Next Steps

Start building programs to create, or interact with compressed accounts.

Guides

Last updated

Was this helpful?