Rust

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

The Rust Client SDK provides two test environments:

  • For local testing, use light-program-test.

    • Initializes a LiteSVM optimized for ZK Compression with auto-funded payer, local prover server and TestIndexer to generate proofs instantly. Requires Light CLI for program binaries.

    • Use for unit and integration tests of your program or client code.

  • For devnet and mainnet use light-client

    • light-client is an RPC client for compressed accounts and tokens. Find a full list of JSON RPC methods here.

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

  • LightClient and LightProgramTest implement the same Rpc and Indexer traits. Seamlessly switch between light-program-test, local test validator, and public Solana networks.

Implementation Guide

Ask anything via Ask DeepWiki.

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

1

Dependencies

[dependencies]
light-client = "0.16.0"
light-sdk = "0.16.0"
tokio = { version = "1", features = ["full"] }
solana-program = "2.2"
anchor-lang = "0.31.1"  # if using Anchor programs

The light-sdk provides abstractions similar to Anchor's Account: macros, wrappers and CPI interface to create and interact with compressed accounts in Solana programs.

2

Environment

Connect to local, devnet or mainnet with LightClient.

use light_client::{LightClient, LightClientConfig};
use solana_sdk::signature::read_keypair_file;

let config = LightClientConfig::new(
    "https://api.mainnet-beta.solana.com".to_string(),
    Some("https://mainnet.helius.xyz".to_string()),
    Some("YOUR_API_KEY".to_string())
);

let mut client = LightClient::new(config).await?;

client.payer = read_keypair_file("~/.config/solana/id.json")?;
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.

let address_tree_info = rpc.get_address_tree_v1();
let output_state_tree_info = rpc.get_random_state_tree_info().unwrap();

Address Trees: get_address_tree_v1() / get_address_tree_v2() returns TreeInfo with the public key and other metadata for the address tree.

  • TreeInfo is used

    • to derive addresses and

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

State Trees:

  • get_random_state_tree_info() / get_random_state_tree_info_v2() returns TreeInfo with pubkeys and metadata for 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.

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

  • cpi_context (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

  • next_tree_info: 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.

use light_sdk::address::v1::derive_address;

let (address, _) = derive_address(
    &[b"my-seed"],
    &address_tree_info.tree,
    &program_id,
);

Pass these parameters:

  • &[b"my-seed"]: Arbitrary byte slices that uniquely identify the account

  • &address_tree_info.tree: Specify the tree pubkey to ensure an address is unique to this address tree. Different trees produce different addresses from identical seeds.

  • &program_id: Specify the program owner pubkey.

Use the same address_tree_info.tree for both derive_address() and all subsequent operations on that account in your client and program.

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

  • To update/close, use the address to fetch the current account with get_compressed_account(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 get_validity_proof() method.

let rpc_result = rpc
    .get_validity_proof(
        vec![],
        vec![AddressWithTree {
            address: *address,
            tree: address_tree_info.tree,
        }],
        None,
    )
    .await?
    .value;

Pass these parameters:

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

  • Specify in (vec![AddressWithTree]) the new address to create with its address tree.

The RPC returns ValidityProofWithContext with

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

  • addresses with the public key and metadata of the address tree to pack accounts in the next step.

  • An empty accounts field, since you do not reference an existing account, when you create a compressed account.

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 instructions accounts

    • in anchor to remainingAccounts, and

    • in native programs to the account info slice.

1. Initialize PackedAccounts

let mut remaining_accounts = PackedAccounts::default();

PackedAccounts::default() creates a helper struct with three empty vectors that you'll populate in the next steps:

  1. pre_accounts: program-specific accounts (optional and not typically used with Anchor)

  2. system_accounts: eight Light System accounts required to create or interact with compressed accounts

  3. packed_accounts: Merkle tree and queue accounts from the validity proof

[pre_accounts] [system_accounts] [packed_accounts]
       ↑               ↑                ↑
    Signers,    Light System      state trees,
   fee payer      accounts      address trees

2. Add Light System Accounts

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

let config = SystemAccountMetaConfig::new(program_create::ID);
remaining_accounts.add_system_accounts(config);
  • Pass your program ID in SystemAccountMetaConfig::new(program_create::ID) to derive the CPI signer PDA

  • Call add_system_accounts(config) - the SDK will populate the system_accounts vector with 8 Light System accounts in the sequence below.

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 the account compression program do not interact directly.

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

getValidityProof() returns pubkeys and other metadata of Merkle trees. With pack_tree_infos, you will convert the pubkeys to u8 indices that reference accounts in the accounts array to optimize your instruction data.

let packed_accounts = rpc_result.pack_tree_infos(&mut remaining_accounts);

Call pack_tree_infos(&mut remaining_accounts) to extract tree pubkeys and add them to the accounts array.

The returned PackedTreeInfos contain .address_trees as Vec<PackedAddressTreeInfo>:

  • address_merkle_tree_pubkey_index: Points to the address tree account

  • address_queue_pubkey_index: Points to the address queue account

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

  • root_index: The Merkle root index from the validity proof

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

4. Add Output State Tree (Create Only)

This step only applies when you create accounts.

  • With native Rust, the output tree index is added using insert_or_get() in Step 3.

  • For other interactions with compressed accounts (using both Anchor and Native), the output tree is included in the packed tree accounts from Step 3.

let output_state_tree_index = output_state_tree_info
    .pack_output_tree_index(&mut remaining_accounts)?;
  • Use output_state_tree_info variable from Step 3 with the TreeInfo metadata for the randomly selected state tree

  • Call pack_output_tree_index(&mut remaining_accounts) to add the tree to packed accounts and return its u8 index.

5. Summary

You initialized PackedAccounts::default() to merge accounts into an array to optimize instruction data:

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

  • added tree accounts from the validity proof to prove an address does not exist (create) or existence of the account hash (update/close)

  • added the output state tree to store the new compressed account hash

The accounts are referenced in instruction data by u8 indices in Packed structs.

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.

let instruction_data = program_create::instruction::Create {
    proof: rpc_result.proof,
    address_tree_info: packed_accounts.address_trees[0],
    output_state_tree_index,
    message,
};
  1. Validity Proof

  • Add the ValidityProof you fetched with getValidityProof() from your RPC provider 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 packed in Step 6:

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

  • output_state_tree_index points to the state tree account in remaining_accounts that will store the compressed account hash.

  1. Pass initial account data

  • This example passes message as the initial data for the compressed account.

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

8

Instruction

Build a standard Solana Instruction struct with your program_id, accounts, and data from Step 7. Pass the remaining_accounts array you built in Step 6.

let accounts = program_create::accounts::Create { // for non-Anchor build Vec

    signer: payer.pubkey(),
};

let (remaining_accounts_metas,
    _system_accounts_offset,
    _tree_accounts_offset)
        = remaining_accounts.to_account_metas();

let instruction = Instruction {
    program_id: program_create::ID,
    accounts: [
        accounts.to_account_metas(Some(true)),
        remaining_accounts_metas,
    ]
    .concat(),
    data: instruction_data.data(),
};

What to include in accounts:

  1. Create your program-specific accounts struct with any accounts required by your program. Use AnchorAccounts, or manually build Vec<AccountMeta> - it won't interfere with compression-related accounts.

  2. Client-program coordination: The client builds accounts in the order defined by PackedAccounts (arbitrary pre accounts, system accounts, packed (tree) accounts).

  • You can safely ignore _system_accounts_offset and _tree_accounts_offset.

How account offsets are used by programs

to_account_metas() returns offset indices that mark where account groups start in the flattened array:

  • system_accounts_offset: Index where Light System accounts start

  • tree_accounts_offset: Index where Merkle tree and queue accounts start

Your program extracts system_accounts_offset from instruction data and uses it to slice the AccountInfo array before passing to CpiAccounts::new().

  • CpiAccounts::new() requires the slice to start at the Light System Program account.

  1. Get Light System accounts by calling remaining_accounts.to_account_metas() to return the merged accounts array with Light System and tree account indices.

  2. Merge all account indices into one vector:

  • accounts.to_account_metas(Some(true)) converts your Anchor struct to Vec<AccountMeta> (Anchor auto-generates this method)

  • remaining_accounts_metas returns the indices for the Light System and tree accounts.

This is the final account array:

  1. Account struct:

  • Signers

  • Fee payer

  1. Remaining accounts:

[0]    Light System Program
[1]    CPI Signer PDA
[2-7]  Other Light System accounts
[8+]   Merkle trees, queues
9

Send Transaction

rpc.create_and_send_transaction(&[instruction],
  &payer.pubkey(), &[payer])
      .await?;

Full Code Examples

Full Rust test examples using light-program-test.

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

npm i -g @lightprotocol/[email protected]
  1. Then build and run tests:

cargo test-sbf
#![cfg(feature = "test-sbf")]

use anchor_lang::AnchorDeserialize;
use light_program_test::{
    program_test::LightProgramTest, AddressWithTree, Indexer, ProgramTestConfig, Rpc, RpcError,
};
use light_sdk::{
    address::v1::derive_address,
    instruction::{PackedAccounts, SystemAccountMetaConfig},
};
use create::MyCompressedAccount;
use solana_sdk::{
    instruction::{AccountMeta, Instruction},
    signature::{Keypair, Signature, Signer},
};

#[tokio::test]
async fn test_create() {
    let config = ProgramTestConfig::new(true, Some(vec![("create", create::ID)]));
    let mut rpc = LightProgramTest::new(config).await.unwrap();
    let payer = rpc.get_payer().insecure_clone();

    let address_tree_info = rpc.get_address_tree_v1();

    let (address, _) = derive_address(
        &[b"message", payer.pubkey().as_ref()],
        &address_tree_info.tree,
        &create::ID,
    );

    create_compressed_account(&mut rpc, &payer, &address, "Hello, compressed world!".to_string())
        .await
        .unwrap();

    let account = get_message_account(&mut rpc, address).await;
    assert_eq!(account.owner, payer.pubkey());
    assert_eq!(account.message, "Hello, compressed world!");
}

async fn create_compressed_account(
    rpc: &mut LightProgramTest,
    payer: &Keypair,
    address: &[u8; 32],
    message: String,
) -> Result<Signature, RpcError> {
    let config = SystemAccountMetaConfig::new(create::ID);
    let mut remaining_accounts = PackedAccounts::default();
    remaining_accounts.add_system_accounts(config)?;

    let address_tree_info = rpc.get_address_tree_v1();

    let rpc_result = rpc
        .get_validity_proof(
            vec![],
            vec![AddressWithTree {
                address: *address,
                tree: address_tree_info.tree,
            }],
            None,
        )
        .await?
        .value;
    let packed_accounts = rpc_result.pack_tree_infos(&mut remaining_accounts);

    let output_state_tree_index = rpc
        .get_random_state_tree_info()
        .unwrap()
        .pack_output_tree_index(&mut remaining_accounts)
        .unwrap();

    let (remaining_accounts, _, _) = remaining_accounts.to_account_metas();

    let instruction = Instruction {
        program_id: create::ID,
        accounts: [
            vec![AccountMeta::new(payer.pubkey(), true)],
            remaining_accounts,
        ]
        .concat(),
        data: {
            use anchor_lang::InstructionData;
            create::instruction::CreateAccount {
                proof: rpc_result.proof,
                address_tree_info: packed_accounts.address_trees[0],
                output_state_tree_index: output_state_tree_index,
                message,
            }
            .data()
        },
    };

    rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[payer])
        .await
}

async fn get_message_account(
    rpc: &mut LightProgramTest,
    address: [u8; 32],
) -> MyCompressedAccount {
    let account = rpc
        .get_compressed_account(address, None)
        .await
        .unwrap()
        .value
        .unwrap();
    let data = &account.data.as_ref().unwrap().data;
    MyCompressedAccount::deserialize(&mut &data[..]).unwrap()
}

Next Steps

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

Guides

Last updated

Was this helpful?