Skip to main content
Compressed PDAs (Program-Derived Accounts) allow you to store program state in Merkle trees, reducing storage costs by up to 10,000x while maintaining full Solana program compatibility.

Overview

Light Protocol provides a familiar developer experience for working with compressed accounts. If you know Anchor, you already know most of what you need.

Key Concepts

  • LightAccount: Wrapper for compressed account data (similar to Anchor’s Account)
  • Address Derivation: Deterministic addresses derived from seeds (like PDAs)
  • Zero-Knowledge Proofs: Automatic proof generation for state transitions
  • CPI Support: Full cross-program invocation compatibility

Defining Compressed Account Types

Compressed accounts use standard Rust structs with Light Protocol traits:
use anchor_lang::prelude::*;
use light_sdk::{LightAccount, LightDiscriminator, LightHasher};

#[event]
#[derive(Clone, Debug, Default, LightHasher, LightDiscriminator)]
pub struct MyCompressedAccount {
    #[hash]
    pub name: String,
    pub nested: NestedData,
}

#[derive(LightHasher, Clone, Debug, AnchorSerialize, AnchorDeserialize)]
pub struct NestedData {
    pub one: u16,
    pub two: u16,
    pub three: u16,
    pub four: u16,
    pub five: u16,
    pub six: u16,
    pub seven: u16,
    pub eight: u16,
    pub nine: u16,
    pub ten: u16,
    pub eleven: u16,
    pub twelve: u16,
}
Data Hashing: Use #[hash] attribute to include fields in the account’s state hash. The LightHasher macro automatically derives the hashing implementation.

Creating Compressed Accounts

With Address (PDA-style)

Create accounts with deterministic addresses derived from seeds:
Anchor Program
use light_sdk::{
    account::LightAccount,
    address::v1::derive_address,
    cpi::{v1::CpiAccounts, InstructionDataInvokeCpiWithReadOnly},
};

pub fn create_compressed_account<'info>(
    ctx: Context<'_, '_, '_, 'info, CreateAccount<'info>>,
    proof: ValidityProof,
    address_tree_info: PackedAddressTreeInfo,
    output_tree_index: u8,
    name: String,
) -> Result<()> {
    let light_cpi_accounts = CpiAccounts::new(
        ctx.accounts.signer.as_ref(),
        ctx.remaining_accounts,
        crate::LIGHT_CPI_SIGNER,
    );

    // Derive address from seeds (similar to PDA derivation)
    let (address, address_seed) = derive_address(
        &[b"compressed", name.as_bytes()],
        &address_tree_info
            .get_tree_pubkey(&light_cpi_accounts)
            .map_err(|_| ErrorCode::AccountNotEnoughKeys)?,
        &crate::ID,
    );

    let new_address_params = address_tree_info
        .into_new_address_params_assigned_packed(address_seed, Some(0));

    // Create new compressed account
    let mut my_compressed_account = LightAccount::<MyCompressedAccount>::new_init(
        &crate::ID,
        Some(address),
        output_tree_index,
    );

    // Set account data
    my_compressed_account.name = name;
    my_compressed_account.nested = NestedData::default();

    // Invoke CPI to create account on-chain
    InstructionDataInvokeCpiWithReadOnly::new_cpi(LIGHT_CPI_SIGNER, proof)
        .mode_v1()
        .with_light_account(my_compressed_account)?
        .with_new_addresses(&[new_address_params])
        .invoke(light_cpi_accounts)?;

    Ok(())
}

#[derive(Accounts)]
pub struct CreateAccount<'info> {
    #[account(mut)]
    pub signer: Signer<'info>,
}

Without Address

Create anonymous accounts without deterministic addresses:
Anonymous Account
let mut my_compressed_account = LightAccount::<CounterAccount>::new_init(
    &program_id,
    None, // No address
    output_tree_index,
);

my_compressed_account.owner = owner;
my_compressed_account.counter = 0;

InstructionDataInvokeCpiWithReadOnly::new_cpi(LIGHT_CPI_SIGNER, proof)
    .mode_v1()
    .with_light_account(my_compressed_account)?
    .invoke(light_cpi_accounts)?;

Updating Compressed Accounts

Update existing compressed accounts by reading, modifying, and writing back:
Update Account
use light_sdk::instruction::account_meta::CompressedAccountMeta;

pub fn update_compressed_account<'info>(
    ctx: Context<'_, '_, '_, 'info, UpdateAccount<'info>>,
    proof: ValidityProof,
    my_compressed_account: MyCompressedAccount,
    account_meta: CompressedAccountMeta,
    nested_data: NestedData,
) -> Result<()> {
    // Load existing account for mutation
    let mut my_compressed_account = LightAccount::<MyCompressedAccount>::new_mut(
        &crate::ID,
        &account_meta,
        my_compressed_account,
    )?;

    // Update account data
    my_compressed_account.nested = nested_data;

    let light_cpi_accounts = CpiAccounts::new(
        ctx.accounts.signer.as_ref(),
        ctx.remaining_accounts,
        crate::LIGHT_CPI_SIGNER,
    );

    // Invoke CPI to update account
    InstructionDataInvokeCpiWithReadOnly::new_cpi(LIGHT_CPI_SIGNER, proof)
        .mode_v1()
        .with_light_account(my_compressed_account)?
        .invoke(light_cpi_accounts)?;

    Ok(())
}

#[derive(Accounts)]
pub struct UpdateAccount<'info> {
    #[account(mut)]
    pub signer: Signer<'info>,
}
Account Metadata Required: When updating accounts, you must provide the CompressedAccountMeta which contains tree info, indices, and proof data. This is provided by the client after querying the account.

Closing Compressed Accounts

Close accounts to reclaim lamports:
Close Account
pub fn close_compressed_account<'info>(
    ctx: Context<'_, '_, '_, 'info, UpdateAccount<'info>>,
    proof: ValidityProof,
    my_compressed_account: MyCompressedAccount,
    account_meta: CompressedAccountMeta,
) -> Result<()> {
    // Create close operation
    let my_compressed_account = LightAccount::<MyCompressedAccount>::new_close(
        &crate::ID,
        &account_meta,
        my_compressed_account,
    )?;

    let light_cpi_accounts = CpiAccounts::new(
        ctx.accounts.signer.as_ref(),
        ctx.remaining_accounts,
        crate::LIGHT_CPI_SIGNER,
    );

    // Invoke CPI to close account
    InstructionDataInvokeCpiWithReadOnly::new_cpi(LIGHT_CPI_SIGNER, proof)
        .mode_v1()
        .with_light_account(my_compressed_account)?
        .invoke(light_cpi_accounts)?;

    Ok(())
}

Permanent Close (Burn)

Permanently close an account that cannot be re-initialized:
Burn Account
use light_sdk::instruction::account_meta::CompressedAccountMetaBurn;

pub fn close_compressed_account_permanent<'info>(
    ctx: Context<'_, '_, '_, 'info, UpdateAccount<'info>>,
    proof: ValidityProof,
    account_meta: CompressedAccountMetaBurn,
) -> Result<()> {
    let my_compressed_account = LightAccount::<MyCompressedAccount>::new_burn(
        &crate::ID,
        &account_meta,
        MyCompressedAccount::default(),
    )?;

    let light_cpi_accounts = CpiAccounts::new(
        ctx.accounts.signer.as_ref(),
        ctx.remaining_accounts,
        crate::LIGHT_CPI_SIGNER,
    );

    InstructionDataInvokeCpiWithReadOnly::new_cpi(LIGHT_CPI_SIGNER, proof)
        .mode_v1()
        .with_light_account(my_compressed_account)?
        .invoke(light_cpi_accounts)?;

    Ok(())
}

CPI Signer Setup

Define a CPI signer for your program to interact with Light Protocol:
CPI Signer
use light_sdk::{derive_light_cpi_signer, CpiSigner};

declare_id!("YourProgramID111111111111111111111111111");

pub const LIGHT_CPI_SIGNER: CpiSigner =
    derive_light_cpi_signer!("YourProgramID111111111111111111111111111");
This signer is used for all CPIs to the Light System Program.

Working with V2 Trees

V2 introduces batched Merkle trees with improved performance:
V2 Account Creation
use light_sdk::address::v2::*;

pub fn create_compressed_account_v2<'info>(
    ctx: Context<'_, '_, '_, 'info, CreateAccount<'info>>,
    proof: ValidityProof,
    address_tree_info: PackedAddressTreeInfo,
    output_tree_index: u8,
    name: String,
) -> Result<()> {
    // Use V2 CPI accounts
    let light_cpi_accounts = light_sdk_types::cpi_accounts::v2::CpiAccounts::new(
        ctx.accounts.signer.as_ref(),
        ctx.remaining_accounts,
        crate::LIGHT_CPI_SIGNER,
    );

    // V2 address derivation
    let (address, address_seed) = derive_address(
        &[b"compressed", name.as_bytes()],
        &address_tree_info
            .get_tree_pubkey(&light_cpi_accounts)
            .map_err(|_| ErrorCode::AccountNotEnoughKeys)?,
        &crate::ID,
    );

    let new_address_params = address_tree_info
        .into_new_address_params_assigned_packed(address_seed, Some(0));

    let mut my_compressed_account = LightAccount::<MyCompressedAccount>::new_init(
        &crate::ID,
        Some(address),
        output_tree_index,
    );

    my_compressed_account.name = name;
    my_compressed_account.nested = NestedData::default();

    // V2 CPI invocation (no mode_v1() call)
    InstructionDataInvokeCpiWithReadOnly::new_cpi(LIGHT_CPI_SIGNER, proof)
        .with_light_account(my_compressed_account)?
        .with_new_addresses(&[new_address_params])
        .invoke(light_cpi_accounts)?;

    Ok(())
}
V1 vs V2: The main difference is the CPI accounts type and the absence of .mode_v1() call in V2. V2 uses batched trees for better performance.

Client-Side Integration

Query and interact with compressed accounts from TypeScript:
Client Code
import {
  bn,
  createRpc,
  deriveAddress,
  deriveAddressSeed,
  PackedAccounts,
  SystemAccountMetaConfig,
} from '@lightprotocol/stateless.js';
import { PublicKey } from '@solana/web3.js';

const rpc = createRpc(
  "http://127.0.0.1:8899",
  "http://127.0.0.1:8784",
  "http://127.0.0.1:3001"
);

// Derive address
const name = "test-account";
const accountSeed = new TextEncoder().encode("compressed");
const nameSeed = new TextEncoder().encode(name);
const seed = deriveAddressSeed(
  [accountSeed, nameSeed],
  programId
);
const address = deriveAddress(seed, addressTree);

// Get validity proof for creating new address
const proofRpcResult = await rpc.getValidityProofV0(
  [], // No input accounts
  [
    {
      tree: addressTree,
      queue: addressQueue,
      address: bn(address.toBytes()),
    },
  ]
);

// Build remaining accounts for CPI
const systemAccountConfig = SystemAccountMetaConfig.new(programId);
let remainingAccounts = PackedAccounts.newWithSystemAccounts(systemAccountConfig);

const addressMerkleTreePubkeyIndex = remainingAccounts.insertOrGet(addressTree);
const addressQueuePubkeyIndex = remainingAccounts.insertOrGet(addressQueue);
const outputMerkleTreeIndex = remainingAccounts.insertOrGet(outputMerkleTree);

const packedAddressTreeInfo = {
  addressMerkleTreePubkeyIndex,
  addressQueuePubkeyIndex,
  rootIndex: proofRpcResult.rootIndices[0],
};

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

// Create transaction
let tx = await program.methods
  .createCompressedAccount(
    proof,
    packedAddressTreeInfo,
    outputMerkleTreeIndex,
    name
  )
  .accounts({
    signer: signer.publicKey,
  })
  .remainingAccounts(remainingAccounts.toAccountMetas().remainingAccounts)
  .signers([signer])
  .transaction();

tx.recentBlockhash = (await rpc.getRecentBlockhash()).blockhash;
tx.sign(signer);

const sig = await rpc.sendTransaction(tx, [signer]);
await rpc.confirmTransaction(sig);

// Query the created account
const compressedAccount = await rpc.getCompressedAccount(bn(address.toBytes()));
console.log("Created account:", compressedAccount);

Hashing Strategies

Use SHA256 for most use cases - it’s fast and well-supported:
SHA256
use light_sdk::account::LightAccount;

// Default LightAccount uses SHA256 with borsh serialization
let my_account = LightAccount::<MyCompressedAccount>::new_init(
    &program_id,
    Some(address),
    output_tree_index,
);

Poseidon (ZK-Friendly)

Use Poseidon hashing for zero-knowledge applications:
Poseidon
use light_sdk::account::poseidon::LightAccount;
use light_sdk::LightHasher;

#[derive(LightHasher, Clone, Debug, Default)]
pub struct ZkAccount {
    #[hash]
    pub value: u64,
    pub metadata: [u8; 32],
}

let my_zk_account = LightAccount::<ZkAccount>::new_init(
    &program_id,
    Some(address),
    output_tree_index,
);

// Use Poseidon-specific CPI
InstructionDataInvokeCpiWithReadOnly::new_cpi(LIGHT_CPI_SIGNER, proof)
    .mode_v1()
    .with_light_account_poseidon(my_zk_account)?
    .invoke(light_cpi_accounts)?;
Poseidon Limitations: Poseidon hashing is more compute-intensive and has input size limitations. Only use it when you need ZK-friendly operations.

Best Practices

1
Use Type-Safe Account Definitions
2
Define clear account structures with proper traits:
3
#[derive(Clone, Debug, Default, LightDiscriminator, BorshSerialize, BorshDeserialize)]
pub struct MyAccount {
    pub owner: Pubkey,
    pub data: u64,
}
4
Validate Account Ownership
5
Always verify the account owner in your program:
6
if my_account.owner != ctx.accounts.signer.key() {
    return Err(ErrorCode::Unauthorized.into());
}
7
Handle Tree Indices
8
Use appropriate tree indices for output accounts:
9
// Get a random state tree
let state_tree_info = rpc.get_random_state_tree_info();
let output_tree_index = 0; // First tree
10
Minimize Hash Inputs
11
Only hash fields that need to be part of the state proof:
12
#[derive(LightHasher)]
pub struct Optimized {
    #[hash]
    pub critical_field: u64,
    // Non-critical fields are not hashed
    pub metadata: String,
}

Common Patterns

Counter Program

Complete Counter
use anchor_lang::prelude::*;
use light_sdk::*;

declare_id!("Counter11111111111111111111111111111111");

pub const LIGHT_CPI_SIGNER: CpiSigner =
    derive_light_cpi_signer!("Counter11111111111111111111111111111111");

#[program]
pub mod counter {
    use super::*;

    pub fn initialize<'info>(
        ctx: Context<'_, '_, '_, 'info, Initialize<'info>>,
        proof: ValidityProof,
        address_tree_info: PackedAddressTreeInfo,
        output_tree_index: u8,
    ) -> Result<()> {
        let light_cpi_accounts = CpiAccounts::new(
            ctx.accounts.signer.as_ref(),
            ctx.remaining_accounts,
            LIGHT_CPI_SIGNER,
        );

        let (address, address_seed) = derive_address(
            &[b"counter", ctx.accounts.signer.key().as_ref()],
            &address_tree_info.get_tree_pubkey(&light_cpi_accounts)?,
            &crate::ID,
        );

        let new_address_params = address_tree_info
            .into_new_address_params_assigned_packed(address_seed, Some(0));

        let mut counter = LightAccount::<Counter>::new_init(
            &crate::ID,
            Some(address),
            output_tree_index,
        );

        counter.owner = ctx.accounts.signer.key();
        counter.count = 0;

        InstructionDataInvokeCpiWithReadOnly::new_cpi(LIGHT_CPI_SIGNER, proof)
            .mode_v1()
            .with_light_account(counter)?
            .with_new_addresses(&[new_address_params])
            .invoke(light_cpi_accounts)?;

        Ok(())
    }

    pub fn increment<'info>(
        ctx: Context<'_, '_, '_, 'info, Update<'info>>,
        proof: ValidityProof,
        counter_data: Counter,
        account_meta: CompressedAccountMeta,
    ) -> Result<()> {
        let mut counter = LightAccount::<Counter>::new_mut(
            &crate::ID,
            &account_meta,
            counter_data,
        )?;

        require_keys_eq!(counter.owner, ctx.accounts.signer.key());
        counter.count += 1;

        let light_cpi_accounts = CpiAccounts::new(
            ctx.accounts.signer.as_ref(),
            ctx.remaining_accounts,
            LIGHT_CPI_SIGNER,
        );

        InstructionDataInvokeCpiWithReadOnly::new_cpi(LIGHT_CPI_SIGNER, proof)
            .mode_v1()
            .with_light_account(counter)?
            .invoke(light_cpi_accounts)?;

        Ok(())
    }
}

#[derive(Clone, Debug, Default, LightDiscriminator, BorshSerialize, BorshDeserialize)]
pub struct Counter {
    pub owner: Pubkey,
    pub count: u64,
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(mut)]
    pub signer: Signer<'info>,
}

#[derive(Accounts)]
pub struct Update<'info> {
    #[account(mut)]
    pub signer: Signer<'info>,
}

Next Steps

Custom Programs

Build complete programs with Light Protocol

Testing Guide

Learn testing strategies for compressed accounts

Rust SDK Reference

Explore the complete Rust SDK documentation

SDK Examples

View real-world SDK integration examples

Resources

Build docs developers (and LLMs) love