How to Create Compressed Accounts

Guide to create compressed accounts in Solana programs with full code examples.

How to Create Compressed Accounts

Overview

Compressed accounts and addresses are created via CPI to the Light System Program.

  • Compressed and regular Solana accounts share the same functionality and are fully composable.

  • A compressed account has two identifiers: the account hash and its address (optional). In comparison, regular Solana accounts are identified by their address.

  • The account hash is not persistent and changes with every write to the account.

  • For Solana PDA like behavior your compressed account needs an address as persistent identifier. Fungible state like compressed token accounts do not need addresses.

Implementation Guide

This guide will cover the components of a Solana program that creates compressed accounts. Here is the complete flow:

1

Dependencies

Add dependencies to your program.

[dependencies]
light-sdk = "0.16.0"
anchor_lang = "0.31.1"
  • The light-sdk provides macros, wrappers and CPI interface to create and interact with compressed accounts.

  • Add the serialization library (borsh for native Rust, or use AnchorSerialize).

2

Constants

Set program address and derive the CPI authority PDA to call the Light System program.

declare_id!("rent4o4eAiMbxpkAM1HeXzks9YeGuz18SEgXEizVvPq");

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

CPISigner is the configuration struct for CPIs to the Light System Program.

  • CPI to the Light System program must be signed with a PDA derived by your program with the seed b"authority"

  • derive_light_cpi_signer! derives the CPI signer PDA for you at compile time.

3

Compressed Account

#[event] // declared as event so that it is part of the idl.
#[derive(
    Clone,
    Debug,
    Default,
    LightDiscriminator
)]
pub struct MyCompressedAccount {
    pub owner: Pubkey,
    pub message: String,
}

Define your compressed account struct and derive

  • the standard traits (Clone, Debug, Default),

  • borsh or AnchorSerialize to serialize account data, and

  • LightDiscriminator to implements a unique type ID (8 bytes) to distinguish account types. The default compressed account layout enforces a discriminator in its own field, .

The traits listed above are required for LightAccount. LightAccount wraps my-compressed-account in Step 7 to set the discriminator and create the compressed account's data.

4

Instruction Data

Define the instruction data with the following parameters:

Anchor handles instruction deserialization automatically. Pass the parameters directly to the instruction function:

pub fn create_account<'info>(
    ctx: Context<'_, '_, '_, 'info, GenericAnchorAccounts<'info>>,
    proof: ValidityProof,
    address_tree_info: PackedAddressTreeInfo,
    output_state_tree_index: u8,
    message: String,
) -> Result<()>
  1. Validity Proof

  • Define proof to include the proof that the address does not exist yet in the specified address tree.

  • Clients fetch a validity proof with getValidityProof() from an RPC provider that supports ZK Compression (Helius, Triton, ...).

  1. Specify Merkle trees to store address and account hash

  • Define address_tree_info: PackedAddressTreeInfo to reference the address tree account used to derive the address in the next step.

  • Define output_state_tree_index to reference the state tree account that stores the compressed account hash.

Clients pack accounts into the accounts array to reduce transaction size. Packed structs like PackedAddressTreeInfo contain account indices (u8) instead of 32 byte pubkeys. The indices point to the account in the accounts array to retrieve the public key and other metadata.

  1. Initial account data

  • Define fields for your program logic. Clients pass the initial values.

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

5

Derive Address

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

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

Pass these parameters to derive_address():

  • &custom_seeds: Arbitrary byte slices that uniquely identify the account. This example uses b"message" and the signer's pubkey.

  • &address_tree_pubkey: The pubkey of the address tree where the address will be created.

    • Retrieved by calling get_tree_pubkey() on address_tree_info, which unpacks the index from the accounts array.

    • This parameter ensures an address is unique to an address tree. Different trees produce different addresses from identical seeds.

  • &program_id: Your program's ID.

The SDK returns:

  • address: The derived address for the compressed account.

  • address_seed: Pass this to the Light System Program CPI in Step 8 to create the address.

6

Address Tree Check (optional)

Ensure global uniqueness of an address by verifying that the address tree pubkey matches the program's tree constant.

Every address is unique, but the same seeds can be used to create different addresses in different address trees. To enforce that a compressed PDA can only be created once with the same seed, you must check the address tree pubkey.

let address_tree = light_cpi_accounts.tree_pubkeys().unwrap()
    [address_tree_info.address_merkle_tree_pubkey_index as usize];

if address_tree != light_sdk::constants::ADDRESS_TREE_V2 {
    return Err(ProgramError::InvalidAccountData.into());
}
7

Initialize Compressed Account

Initialize the compressed account struct with LightAccount::new_init().

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

my_compressed_account.owner = ctx.accounts.signer.key();
my_compressed_account.message = message.clone();

Pass these parameters to new_init():

  • &owner: The program's ID that owns the compressed account.

  • Some(address): The derived address from Step 5. Pass None for accounts without addresses.

  • output_state_tree_index: References the state tree account that will store the updated account hash, defined in instruction data (Step 4)

The SDK creates:

  • A LightAccount wrapper similar to Anchor's Account.

  • new_init() lets the program set the initial data. This example sets:

    • owner to the signer's pubkey

    • message to an arbitrary string

8

Light System Program CPI

Invoke the Light System Program to create the compressed account and its address.

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

let new_address_params = address_tree_info
    .into_new_address_params_packed(address_seed);

LightSystemProgramCpi::new_cpi(LIGHT_CPI_SIGNER, proof)
    .with_light_account(my_compressed_account)?
    .with_new_addresses(&[new_address_params])
    .invoke(light_cpi_accounts)?;

Set up CpiAccounts::new():

CpiAccounts::new() parses accounts for the CPI call to Light System Program.

Pass these parameters:

  • ctx.accounts.signer.as_ref(): the transaction signer

  • ctx.remaining_accounts: Slice with [system_accounts, ...packed_tree_accounts]. The client builds this with PackedAccounts and passes it to the instruction.

  • &LIGHT_CPI_SIGNER: Your program's CPI signer PDA defined in Constants.

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

Build the CPI instruction:

new_cpi() initializes the CPI instruction with the proof to prove that an address does not exist yet in the specified address tree - defined in the Instruction Data (Step 4).

  • with_light_account adds the LightAccount with the initial compressed account data to the CPI instruction - defined in Step 7.

  • with_new_addresses adds the address_seed and metadata to the CPI instruction data - returned by derive_address in Step 5.

  • invoke(light_cpi_accounts) calls the Light System Program with CpiAccounts.

Full Code Example

The example programs below implement all steps from this guide. Make sure you have your developer environment set up first, or simply run:

npm -g i @lightprotocol/[email protected]
light init testprogram

Find the source code here.

#![allow(unexpected_cfgs)]
#![allow(deprecated)]

use anchor_lang::{prelude::*, AnchorDeserialize, AnchorSerialize};
use light_sdk::{
    account::LightAccount,
    address::v1::derive_address,
    cpi::{v1::CpiAccounts, CpiSigner},
    derive_light_cpi_signer,
    instruction::{PackedAddressTreeInfo, ValidityProof},
    LightDiscriminator,
};

declare_id!("Hps5oaKdYWqjVZJnAxUE1uwbozwEgZZGCRA57p2wdqcS");

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

#[program]
pub mod create {

    use super::*;
    use light_sdk::cpi::{
        v1::LightSystemProgramCpi, InvokeLightSystemProgram, LightCpiInstruction,
    };

    /// Creates a new compressed account
    pub fn create_account<'info>(
        ctx: Context<'_, '_, '_, 'info, GenericAnchorAccounts<'info>>,
        proof: ValidityProof,
        address_tree_info: PackedAddressTreeInfo,
        output_state_tree_index: u8,
        message: String,
    ) -> Result<()> {
        let light_cpi_accounts = CpiAccounts::new(
            ctx.accounts.signer.as_ref(),
            ctx.remaining_accounts,
            crate::LIGHT_CPI_SIGNER,
        );

        let (address, address_seed) = derive_address(
            &[b"message", ctx.accounts.signer.key().as_ref()],
            &address_tree_info
                .get_tree_pubkey(&light_cpi_accounts)
                .map_err(|_| ErrorCode::AccountNotEnoughKeys)?,
            &crate::ID,
        );

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

        my_compressed_account.owner = ctx.accounts.signer.key();
        my_compressed_account.message = message.clone();

        msg!(
            "Created compressed account with message: {}",
            my_compressed_account.message
        );

        LightSystemProgramCpi::new_cpi(LIGHT_CPI_SIGNER, proof)
            .with_light_account(my_compressed_account)?
            .with_new_addresses(&[address_tree_info.into_new_address_params_packed(address_seed)])
            .invoke(light_cpi_accounts)?;

        Ok(())
    }
}

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

// declared as event so that it is part of the idl.
#[event]
#[derive(Clone, Debug, Default, LightDiscriminator)]
pub struct MyCompressedAccount {
    pub owner: Pubkey,
    pub message: String,
}

Next Steps

Build a client for your program or learn how to update compressed accounts.

Last updated

Was this helpful?