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:
Anchor-Style Account
Simple Counter
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:
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:
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:
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:
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:
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:
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:
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:
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
SHA256 (Recommended)
Use SHA256 for most use cases - it’s fast and well-supported:
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:
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
Use Type-Safe Account Definitions
Define clear account structures with proper traits:
#[derive( Clone , Debug , Default , LightDiscriminator , BorshSerialize , BorshDeserialize )]
pub struct MyAccount {
pub owner : Pubkey ,
pub data : u64 ,
}
Validate Account Ownership
Always verify the account owner in your program:
if my_account . owner != ctx . accounts . signer . key () {
return Err ( ErrorCode :: Unauthorized . into ());
}
Use appropriate tree indices for output accounts:
// Get a random state tree
let state_tree_info = rpc . get_random_state_tree_info ();
let output_tree_index = 0 ; // First tree
Only hash fields that need to be part of the state proof:
#[derive( LightHasher )]
pub struct Optimized {
#[hash]
pub critical_field : u64 ,
// Non-critical fields are not hashed
pub metadata : String ,
}
Common Patterns
Counter Program
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