diff --git a/counter/anchor/programs/counter/src/lib.rs b/counter/anchor/programs/counter/src/lib.rs index 53f4fc1..db6b1dd 100644 --- a/counter/anchor/programs/counter/src/lib.rs +++ b/counter/anchor/programs/counter/src/lib.rs @@ -13,33 +13,49 @@ use light_sdk::{ LightDiscriminator, LightHasher, }; +/// Program ID for the Light Protocol counter program declare_id!("GRLu2hKaAiMbxpkAM1HeXzks9YeGuz18SEgXEizVvPqX"); +/// CPI signer derived from the program ID, used for Light Protocol cross-program invocations pub const LIGHT_CPI_SIGNER: CpiSigner = derive_light_cpi_signer!("GRLu2hKaAiMbxpkAM1HeXzks9YeGuz18SEgXEizVvPqX"); +/// Counter program implementing Light Protocol compressed state management +/// +/// This program demonstrates how to work with Light Protocol's compressed accounts, +/// which provide state compression benefits similar to Solana's account compression +/// but for arbitrary program state rather than just NFT metadata. #[program] pub mod counter { use super::*; + /// Creates a new counter account using Light Protocol's compressed state + /// + /// This instruction initializes a new counter with value 0. Unlike traditional Solana accounts, + /// the counter state is compressed using Merkle trees, significantly reducing on-chain storage costs. + /// + /// # Arguments + /// * `ctx` - Standard Anchor context with signer account + /// * `proof` - Zero-knowledge proof validating the state transition + /// * `address_tree_info` - Information about the address tree for compressed account addressing + /// * `output_state_tree_index` - Index in the state tree where the new account will be stored pub fn create_counter<'info>( ctx: Context<'_, '_, '_, 'info, GenericAnchorAccounts<'info>>, proof: ValidityProof, address_tree_info: PackedAddressTreeInfo, output_state_tree_index: u8, ) -> Result<()> { - // LightAccount::new_init will create an account with empty output state (no input state). - // Modifying the account will modify the output state that when converted to_account_info() - // is hashed with poseidon hashes, serialized with borsh - // and created with invoke_light_system_program by invoking the light-system-program. - // The hashing scheme is the account structure derived with LightHasher. + // Set up CPI accounts for interacting with the Light system program + // This is analogous to setting up accounts for a regular Solana CPI call let light_cpi_accounts = CpiAccounts::new( ctx.accounts.signer.as_ref(), ctx.remaining_accounts, crate::LIGHT_CPI_SIGNER, ); + // Derive a deterministic address for the counter based on the signer's pubkey + // This creates a PDA-like address that's unique per user let (address, address_seed) = derive_address( &[b"counter", ctx.accounts.signer.key().as_ref()], &address_tree_info @@ -48,40 +64,56 @@ pub mod counter { &crate::ID, ); + // Pack the address parameters for the new account creation let new_address_params = address_tree_info.into_new_address_params_packed(address_seed); + // Initialize a new Light account - this is similar to Account::try_from_slice() in regular Anchor + // but for compressed accounts that exist in Merkle trees rather than as individual accounts let mut counter = LightAccount::<'_, CounterAccount>::new_init( &crate::ID, Some(address), output_state_tree_index, ); + // Set initial values for the counter counter.owner = ctx.accounts.signer.key(); counter.value = 0; + // Create the CPI inputs with the proof and account data + // This is equivalent to preparing instruction data for a regular Solana CPI let cpi = CpiInputs::new_with_address( proof, vec![counter.to_account_info().map_err(ProgramError::from)?], vec![new_address_params], ); + + // Invoke the Light system program to create the compressed account + // This is like calling invoke() but for Light Protocol's compressed state system cpi.invoke_light_system_program(light_cpi_accounts) .map_err(ProgramError::from)?; Ok(()) } + /// Increments the counter value by 1 + /// + /// This demonstrates updating compressed state. The instruction reads the current compressed + /// account state, modifies it, and writes it back to the Merkle tree with a new proof. + /// + /// # Arguments + /// * `ctx` - Standard Anchor context + /// * `proof` - Zero-knowledge proof for the state transition + /// * `counter_value` - Current value of the counter (for verification) + /// * `account_meta` - Metadata about the compressed account being modified pub fn increment_counter<'info>( ctx: Context<'_, '_, '_, 'info, GenericAnchorAccounts<'info>>, proof: ValidityProof, counter_value: u64, account_meta: CompressedAccountMeta, ) -> Result<()> { - // LightAccount::new_mut will create an account with input state and output state. - // The input state is hashed immediately when calling new_mut(). - // Modifying the account will modify the output state that when converted to_account_info() - // is hashed with poseidon hashes, serialized with borsh - // and created with invoke_light_system_program by invoking the light-system-program. - // The hashing scheme is the account structure derived with LightHasher. + // Create a mutable reference to the compressed account + // This loads the current state from the Merkle tree and prepares it for modification + // Similar to how Account<'info, T>::try_from() works in regular Anchor programs let mut counter = LightAccount::<'_, CounterAccount>::new_mut( &crate::ID, &account_meta, @@ -92,33 +124,49 @@ pub mod counter { ) .map_err(ProgramError::from)?; + // Log current state for debugging (similar to msg! in regular Solana programs) msg!("counter {}", counter.value); msg!("counter {:?}", counter); + // Perform the increment with overflow protection counter.value = counter.value.checked_add(1).ok_or(CustomError::Overflow)?; + // Set up CPI accounts for the Light system program call let light_cpi_accounts = CpiAccounts::new( ctx.accounts.signer.as_ref(), ctx.remaining_accounts, crate::LIGHT_CPI_SIGNER, ); + // Prepare the CPI inputs with the modified account state let cpi_inputs = CpiInputs::new( proof, vec![counter.to_account_info().map_err(ProgramError::from)?], ); + + // Commit the state change to the compressed account system cpi_inputs .invoke_light_system_program(light_cpi_accounts) .map_err(ProgramError::from)?; Ok(()) } + /// Decrements the counter value by 1 + /// + /// Similar to increment but subtracts 1 from the counter value with underflow protection. + /// + /// # Arguments + /// * `ctx` - Standard Anchor context + /// * `proof` - Zero-knowledge proof for the state transition + /// * `counter_value` - Current value of the counter (for verification) + /// * `account_meta` - Metadata about the compressed account being modified pub fn decrement_counter<'info>( ctx: Context<'_, '_, '_, 'info, GenericAnchorAccounts<'info>>, proof: ValidityProof, counter_value: u64, account_meta: CompressedAccountMeta, ) -> Result<()> { + // Load the compressed account for modification let mut counter = LightAccount::<'_, CounterAccount>::new_mut( &crate::ID, &account_meta, @@ -129,8 +177,10 @@ pub mod counter { ) .map_err(ProgramError::from)?; + // Perform the decrement with underflow protection counter.value = counter.value.checked_sub(1).ok_or(CustomError::Underflow)?; + // Set up and execute the CPI to commit the change let light_cpi_accounts = CpiAccounts::new( ctx.accounts.signer.as_ref(), ctx.remaining_accounts, @@ -149,12 +199,22 @@ pub mod counter { Ok(()) } + /// Resets the counter value to 0 + /// + /// This instruction sets the counter back to its initial value of 0. + /// + /// # Arguments + /// * `ctx` - Standard Anchor context + /// * `proof` - Zero-knowledge proof for the state transition + /// * `counter_value` - Current value of the counter (for verification) + /// * `account_meta` - Metadata about the compressed account being modified pub fn reset_counter<'info>( ctx: Context<'_, '_, '_, 'info, GenericAnchorAccounts<'info>>, proof: ValidityProof, counter_value: u64, account_meta: CompressedAccountMeta, ) -> Result<()> { + // Load the compressed account for modification let mut counter = LightAccount::<'_, CounterAccount>::new_mut( &crate::ID, &account_meta, @@ -165,8 +225,10 @@ pub mod counter { ) .map_err(ProgramError::from)?; + // Reset the counter to 0 counter.value = 0; + // Set up and execute the CPI to commit the change let light_cpi_accounts = CpiAccounts::new( ctx.accounts.signer.as_ref(), ctx.remaining_accounts, @@ -184,15 +246,26 @@ pub mod counter { Ok(()) } + /// Closes the counter account and reclaims storage + /// + /// This instruction permanently deletes the compressed counter account. + /// Unlike regular Solana accounts, closed compressed accounts cannot be recreated + /// at the same address due to the Merkle tree structure. + /// + /// # Arguments + /// * `ctx` - Standard Anchor context + /// * `proof` - Zero-knowledge proof for the account closure + /// * `counter_value` - Current value of the counter (for verification) + /// * `account_meta` - Metadata about the compressed account being closed pub fn close_counter<'info>( ctx: Context<'_, '_, '_, 'info, GenericAnchorAccounts<'info>>, proof: ValidityProof, counter_value: u64, account_meta: CompressedAccountMetaClose, ) -> Result<()> { - // LightAccount::new_close() will create an account with only input state and no output state. - // By providing no output state the account is closed after the instruction. - // The address of a closed account cannot be reused. + // Create a close operation for the compressed account + // This is similar to calling close() on a regular Anchor account + // but works with the compressed account system let counter = LightAccount::<'_, CounterAccount>::new_close( &crate::ID, &account_meta, @@ -203,6 +276,7 @@ pub mod counter { ) .map_err(ProgramError::from)?; + // Set up and execute the CPI to close the account let light_cpi_accounts = CpiAccounts::new( ctx.accounts.signer.as_ref(), ctx.remaining_accounts, @@ -221,6 +295,10 @@ pub mod counter { } } +/// Custom error codes for the counter program +/// +/// These follow the same pattern as regular Anchor error codes +/// but are specific to counter operations. #[error_code] pub enum CustomError { #[msg("No authority to perform this action")] @@ -231,17 +309,29 @@ pub enum CustomError { Underflow, } +/// Generic account structure for instructions +/// +/// This is a standard Anchor accounts struct that requires a mutable signer. +/// The signer pays for transaction fees and must have write permissions. #[derive(Accounts)] pub struct GenericAnchorAccounts<'info> { #[account(mut)] pub signer: Signer<'info>, } -// declared as event so that it is part of the idl. +/// Counter account data structure for compressed state +/// +/// This struct defines the data that gets compressed and stored in the Merkle tree. +/// The #[hash] attribute on owner means it's included in the account's hash computation, +/// while value is not hashed (allowing for more efficient updates). +/// +/// The #[event] attribute makes this struct part of the IDL for client integration. #[event] #[derive(Clone, Debug, Default, LightDiscriminator, LightHasher)] pub struct CounterAccount { + /// The public key of the account owner (hashed for security) #[hash] pub owner: Pubkey, + /// Current counter value (not hashed for efficiency) pub value: u64, } diff --git a/counter/anchor/programs/counter/tests/test.rs b/counter/anchor/programs/counter/tests/test.rs index fa639df..c320d0e 100644 --- a/counter/anchor/programs/counter/tests/test.rs +++ b/counter/anchor/programs/counter/tests/test.rs @@ -1,3 +1,15 @@ +//! Compressed Counter Program Tests +//! +//! This module contains integration tests for a compressed counter program built on the Light Protocol. +//! The tests demonstrate how to interact with compressed accounts on Solana, which are stored +//! off-chain in Merkle trees but verified on-chain for reduced storage costs. +//! +//! Key concepts demonstrated: +//! - Compressed accounts: Account data stored in Merkle trees instead of on-chain +//! - Validity proofs: Zero-knowledge proofs that verify compressed account state +//! - Address derivation: Deterministic generation of compressed account addresses +//! - State transitions: How compressed accounts are updated through nullification and creation + #![cfg(feature = "test-sbf")] use anchor_lang::{AnchorDeserialize, InstructionData, ToAccountMetas}; @@ -18,26 +30,41 @@ use solana_sdk::{ signature::{Keypair, Signature, Signer}, }; +/// Integration test for the compressed counter program. +/// +/// This test demonstrates the full lifecycle of a compressed account: +/// 1. Create - Initialize a new compressed counter account +/// 2. Increment - Modify the counter value upward +/// 3. Decrement - Modify the counter value downward +/// 4. Reset - Set the counter back to zero +/// 5. Close - Permanently delete the compressed account +/// +/// Each operation requires generating validity proofs to verify the current state +/// and creating new compressed accounts with updated data. #[tokio::test] async fn test_counter() { + // Initialize the Light Protocol test environment with our counter program let config = ProgramTestConfig::new(true, Some(vec![("counter", counter::ID)])); let mut rpc = LightProgramTest::new(config).await.unwrap(); let payer = rpc.get_payer().insecure_clone(); + // Get the address tree info needed for deriving compressed account addresses let address_tree_info = rpc.get_address_tree_v1(); + // Derive a deterministic address for our counter using the payer's pubkey as a seed + // This creates a Program Derived Address (PDA) specific to this user's counter let (address, _) = derive_address( &[b"counter", payer.pubkey().as_ref()], &address_tree_info.tree, &counter::ID, ); - // Create the counter. + // Create the counter compressed account create_counter(&mut rpc, &payer, &address, address_tree_info) .await .unwrap(); - // Check that it was created correctly. + // Verify the counter was created correctly at leaf index 0 with value 0 let compressed_account = rpc .get_compressed_account(address, None) .await @@ -48,12 +75,12 @@ async fn test_counter() { let counter = CounterAccount::deserialize(&mut &counter[..]).unwrap(); assert_eq!(counter.value, 0); - // Increment the counter. + // Increment the counter (nullifies old account, creates new one with value 1) increment_counter(&mut rpc, &payer, &compressed_account) .await .unwrap(); - // Check that it was incremented correctly. + // Verify the counter was incremented and is now at leaf index 1 let compressed_account = rpc .get_compressed_account(address, None) .await @@ -65,12 +92,12 @@ async fn test_counter() { let counter = CounterAccount::deserialize(&mut &counter[..]).unwrap(); assert_eq!(counter.value, 1); - // Decrement the counter. + // Decrement the counter back to 0 decrement_counter(&mut rpc, &payer, &compressed_account) .await .unwrap(); - // Check that it was decremented correctly. + // Verify the counter was decremented and is now at leaf index 2 let compressed_account = rpc .get_compressed_account(address, None) .await @@ -83,12 +110,12 @@ async fn test_counter() { let counter = CounterAccount::deserialize(&mut &counter[..]).unwrap(); assert_eq!(counter.value, 0); - // Reset the counter. + // Reset the counter (should maintain value 0 but create new account) reset_counter(&mut rpc, &payer, &compressed_account) .await .unwrap(); - // Check that it was reset correctly. + // Verify the counter was reset correctly let compressed_account = rpc .get_compressed_account(address, None) .await @@ -98,12 +125,12 @@ async fn test_counter() { let counter = CounterAccount::deserialize(&mut &counter[..]).unwrap(); assert_eq!(counter.value, 0); - // Close the counter. + // Close the counter (permanently delete the compressed account) close_counter(&mut rpc, &payer, &compressed_account) .await .unwrap(); - // Check that it was closed correctly (no compressed accounts after closing). + // Verify no compressed accounts exist for this program after closing let compressed_accounts = rpc .get_compressed_accounts_by_owner(&counter::ID, None, None) .await @@ -111,6 +138,19 @@ async fn test_counter() { assert_eq!(compressed_accounts.value.items.len(), 0); } +/// Creates a new compressed counter account. +/// +/// This function demonstrates the process of creating a compressed account: +/// 1. Set up system accounts required for Light Protocol operations +/// 2. Generate a validity proof for the new address (proving it doesn't exist) +/// 3. Pack the tree information and account metadata +/// 4. Build and send the instruction to create the compressed account +/// +/// # Arguments +/// * `rpc` - The RPC client for interacting with the Light Protocol +/// * `payer` - The keypair that will pay for the transaction +/// * `address` - The derived address for the new compressed account +/// * `address_tree_info` - Information about the address tree where the account will be stored async fn create_counter( rpc: &mut R, payer: &Keypair, @@ -120,10 +160,14 @@ async fn create_counter( where R: Rpc + Indexer, { + // Initialize the account metadata container for system accounts let mut remaining_accounts = PackedAccounts::default(); let config = SystemAccountMetaConfig::new(counter::ID); remaining_accounts.add_system_accounts(config); + // Generate a validity proof for creating a new account at this address + // Empty vec for nullifier hashes (no accounts being nullified) + // AddressWithTree specifies where the new account will be created let rpc_result = rpc .get_validity_proof( vec![], @@ -135,25 +179,33 @@ where ) .await? .value; + + // Select an output state tree and pack its index into remaining accounts let output_state_tree_index = rpc .get_random_state_tree_info()? .pack_output_tree_index(&mut remaining_accounts)?; + + // Pack the address tree information into the remaining accounts let packed_address_tree_info = rpc_result .pack_tree_infos(&mut remaining_accounts) .address_trees[0]; + // Build the instruction data with the proof and tree information let instruction_data = counter::instruction::CreateCounter { proof: rpc_result.proof, address_tree_info: packed_address_tree_info, output_state_tree_index, }; + // Set up the primary accounts (just the signer in this case) let accounts = counter::accounts::GenericAnchorAccounts { signer: payer.pubkey(), }; + // Convert packed accounts to AccountMeta format for the instruction let (remaining_accounts_metas, _, _) = remaining_accounts.to_account_metas(); + // Build the complete instruction let instruction = Instruction { program_id: counter::ID, accounts: [ @@ -164,10 +216,22 @@ where data: instruction_data.data(), }; + // Send the transaction rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[payer]) .await } +/// Increments the counter value in a compressed account. +/// +/// This demonstrates the compressed account update pattern: +/// 1. Generate a validity proof for the current account (proving its existence and state) +/// 2. Nullify the old account and create a new one with incremented value +/// 3. Pack all required tree information and account metadata +/// +/// # Arguments +/// * `rpc` - The RPC client for interacting with the Light Protocol +/// * `payer` - The keypair that will pay for the transaction +/// * `compressed_account` - The current compressed account to increment #[allow(clippy::too_many_arguments)] async fn increment_counter( rpc: &mut R, @@ -177,44 +241,56 @@ async fn increment_counter( where R: Rpc + Indexer, { + // Set up system accounts required for the operation let mut remaining_accounts = PackedAccounts::default(); let config = SystemAccountMetaConfig::new(counter::ID); remaining_accounts.add_system_accounts(config); + // Get the hash of the account we want to nullify (the current counter state) let hash = compressed_account.hash; + // Generate validity proof for nullifying this account + // The hash goes in the first parameter (accounts to nullify) + // Empty vec for new addresses (we're updating, not creating new addresses) let rpc_result = rpc .get_validity_proof(vec![hash], vec![], None) .await? .value; + // Pack the state tree information where the nullified and new accounts will be stored let packed_tree_accounts = rpc_result .pack_tree_infos(&mut remaining_accounts) .state_trees .unwrap(); + // Deserialize the current counter data to read its value let counter_account = CounterAccount::deserialize(&mut compressed_account.data.as_ref().unwrap().data.as_slice()) .unwrap(); + // Create metadata for the compressed account being updated let account_meta = CompressedAccountMeta { tree_info: packed_tree_accounts.packed_tree_infos[0], address: compressed_account.address.unwrap(), output_state_tree_index: packed_tree_accounts.output_tree_index, }; + // Build instruction data with the proof, current value, and account metadata let instruction_data = counter::instruction::IncrementCounter { proof: rpc_result.proof, counter_value: counter_account.value, account_meta, }; + // Set up the primary accounts let accounts = counter::accounts::GenericAnchorAccounts { signer: payer.pubkey(), }; + // Convert packed accounts to AccountMeta format let (remaining_accounts_metas, _, _) = remaining_accounts.to_account_metas(); + // Build and send the instruction let instruction = Instruction { program_id: counter::ID, accounts: [ @@ -229,6 +305,16 @@ where .await } +/// Decrements the counter value in a compressed account. +/// +/// Similar to increment_counter, this nullifies the existing account and creates +/// a new one with the decremented value. The process is identical except for +/// the instruction type and the resulting counter value. +/// +/// # Arguments +/// * `rpc` - The RPC client for interacting with the Light Protocol +/// * `payer` - The keypair that will pay for the transaction +/// * `compressed_account` - The current compressed account to decrement #[allow(clippy::too_many_arguments)] async fn decrement_counter( rpc: &mut R, @@ -238,32 +324,39 @@ async fn decrement_counter( where R: Rpc + Indexer, { + // Set up system accounts let mut remaining_accounts = PackedAccounts::default(); let config = SystemAccountMetaConfig::new(counter::ID); remaining_accounts.add_system_accounts(config); + // Get the hash of the current account to nullify let hash = compressed_account.hash; + // Generate validity proof for the nullification let rpc_result = rpc .get_validity_proof(Vec::from(&[hash]), vec![], None) .await? .value; + // Pack state tree information let packed_tree_accounts = rpc_result .pack_tree_infos(&mut remaining_accounts) .state_trees .unwrap(); + // Deserialize current counter data let counter_account = CounterAccount::deserialize(&mut compressed_account.data.as_ref().unwrap().data.as_slice()) .unwrap(); + // Create compressed account metadata let account_meta = CompressedAccountMeta { tree_info: packed_tree_accounts.packed_tree_infos[0], address: compressed_account.address.unwrap(), output_state_tree_index: packed_tree_accounts.output_tree_index, }; + // Build decrement instruction let instruction_data = counter::instruction::DecrementCounter { proof: rpc_result.proof, counter_value: counter_account.value, @@ -290,6 +383,15 @@ where .await } +/// Resets the counter value to zero. +/// +/// This operation nullifies the current compressed account and creates a new one +/// with the counter value set to zero, regardless of the previous value. +/// +/// # Arguments +/// * `rpc` - The RPC client for interacting with the Light Protocol +/// * `payer` - The keypair that will pay for the transaction +/// * `compressed_account` - The current compressed account to reset async fn reset_counter( rpc: &mut R, payer: &Keypair, @@ -298,32 +400,39 @@ async fn reset_counter( where R: Rpc + Indexer, { + // Set up system accounts let mut remaining_accounts = PackedAccounts::default(); let config = SystemAccountMetaConfig::new(counter::ID); remaining_accounts.add_system_accounts(config); + // Get the hash of the current account let hash = compressed_account.hash; + // Generate validity proof let rpc_result = rpc .get_validity_proof(Vec::from(&[hash]), vec![], None) .await? .value; + // Pack Merkle tree context information let packed_merkle_context = rpc_result .pack_tree_infos(&mut remaining_accounts) .state_trees .unwrap(); + // Deserialize current counter data let counter_account = CounterAccount::deserialize(&mut compressed_account.data.as_ref().unwrap().data.as_slice()) .unwrap(); + // Create compressed account metadata let account_meta = CompressedAccountMeta { tree_info: packed_merkle_context.packed_tree_infos[0], address: compressed_account.address.unwrap(), output_state_tree_index: packed_merkle_context.output_tree_index, }; + // Build reset instruction let instruction_data = counter::instruction::ResetCounter { proof: rpc_result.proof, counter_value: counter_account.value, @@ -350,6 +459,16 @@ where .await } +/// Closes (permanently deletes) the compressed counter account. +/// +/// This operation nullifies the compressed account without creating a new one, +/// effectively removing it from the system. Note the use of CompressedAccountMetaClose +/// instead of CompressedAccountMeta since no output account is created. +/// +/// # Arguments +/// * `rpc` - The RPC client for interacting with the Light Protocol +/// * `payer` - The keypair that will pay for the transaction +/// * `compressed_account` - The compressed account to close async fn close_counter( rpc: &mut R, payer: &Keypair, @@ -358,32 +477,39 @@ async fn close_counter( where R: Rpc + Indexer, { + // Set up system accounts let mut remaining_accounts = PackedAccounts::default(); let config = SystemAccountMetaConfig::new(counter::ID); remaining_accounts.add_system_accounts(config); + // Get the hash of the account to close let hash = compressed_account.hash; + // Generate validity proof for nullification let rpc_result = rpc .get_validity_proof(Vec::from(&[hash]), vec![], None) .await .unwrap() .value; + // Pack tree information let packed_tree_infos = rpc_result .pack_tree_infos(&mut remaining_accounts) .state_trees .unwrap(); + // Deserialize current counter data let counter_account = CounterAccount::deserialize(&mut compressed_account.data.as_ref().unwrap().data.as_slice()) .unwrap(); + // Use CompressedAccountMetaClose since we're not creating an output account let account_meta = CompressedAccountMetaClose { tree_info: packed_tree_infos.packed_tree_infos[0], address: compressed_account.address.unwrap(), }; + // Build close instruction let instruction_data = counter::instruction::CloseCounter { proof: rpc_result.proof, counter_value: counter_account.value, diff --git a/counter/anchor/tests/test.ts b/counter/anchor/tests/test.ts index f706b6d..ebeb5ff 100644 --- a/counter/anchor/tests/test.ts +++ b/counter/anchor/tests/test.ts @@ -1,3 +1,11 @@ +/** + * Light Protocol Compressed Account Test Suite + * + * This test suite demonstrates how to work with compressed accounts using the Light Protocol. + * Compressed accounts provide state compression on Solana, reducing storage costs while + * maintaining security through Merkle tree proofs. + */ + import * as anchor from "@coral-xyz/anchor"; import { Program, web3 } from "@coral-xyz/anchor"; import { Counter } from "../target/types/counter"; @@ -19,37 +27,52 @@ const path = require("path"); const os = require("os"); require("dotenv").config(); +// Set up the default Solana wallet path for Anchor const anchorWalletPath = path.join(os.homedir(), ".config/solana/id.json"); process.env.ANCHOR_WALLET = anchorWalletPath; describe("test-anchor", () => { + // Initialize the Counter program from Anchor workspace const program = anchor.workspace.Counter as Program; + // Create a Borsh coder for serializing/deserializing account data const coder = new anchor.BorshCoder(idl as anchor.Idl); it("", async () => { + // Generate a new keypair to use as transaction signer let signer = new web3.Keypair(); + + // Create RPC connection to local Solana validator and Light Protocol services + // - Port 8899: Solana RPC + // - Port 8784: Light Protocol Prover + // - Port 3001: Light Protocol Indexer let rpc = createRpc( "http://127.0.0.1:8899", "http://127.0.0.1:8784", "http://127.0.0.1:3001", { commitment: "confirmed", - } + }, ); + + // Fund the signer account with 1 SOL for transaction fees let lamports = web3.LAMPORTS_PER_SOL; await rpc.requestAirdrop(signer.publicKey, lamports); await sleep(2000); + // Get default test Merkle tree accounts for compressed state storage const outputMerkleTree = defaultTestStateTreeAccounts().merkleTree; const addressTree = defaultTestStateTreeAccounts().addressTree; const addressQueue = defaultTestStateTreeAccounts().addressQueue; + // Derive a deterministic address for the compressed counter account + // Uses "counter" seed + signer's public key for uniqueness const counterSeed = new TextEncoder().encode("counter"); const seed = deriveAddressSeed( [counterSeed, signer.publicKey.toBytes()], - new web3.PublicKey(program.idl.address) + new web3.PublicKey(program.idl.address), ); const address = deriveAddress(seed, addressTree); + // Create counter compressed account. await CreateCounterCompressedAccount( rpc, @@ -58,59 +81,72 @@ describe("test-anchor", () => { address, program, outputMerkleTree, - signer + signer, ); // Wait for indexer to catch up. await sleep(2000); + // Fetch the created compressed account and decode its data let counterAccount = await rpc.getCompressedAccount(bn(address.toBytes())); let counter = coder.types.decode( "CounterAccount", - counterAccount.data.data + counterAccount.data.data, ); console.log("counter account ", counterAccount); console.log("des counter ", counter); + // Increment the counter value in the compressed account await incrementCounterCompressedAccount( rpc, counter.value, counterAccount, program, outputMerkleTree, - signer + signer, ); // Wait for indexer to catch up. await sleep(2000); + // Fetch and decode the updated counter account counterAccount = await rpc.getCompressedAccount(bn(address.toBytes())); - counter = coder.types.decode( - "CounterAccount", - counterAccount.data.data - ); + counter = coder.types.decode("CounterAccount", counterAccount.data.data); console.log("counter account ", counterAccount); console.log("des counter ", counter); + // Delete the compressed counter account await deleteCounterCompressedAccount( rpc, counter.value, counterAccount, program, outputMerkleTree, - signer + signer, ); // Wait for indexer to catch up. await sleep(2000); + // Verify the account has been deleted const deletedCounterAccount = await rpc.getCompressedAccount( - bn(address.toBytes()) + bn(address.toBytes()), ); console.log("deletedCounterAccount ", deletedCounterAccount); }); }); +/** + * Creates a new compressed counter account on-chain + * + * @param rpc - Light Protocol RPC client + * @param addressTree - Merkle tree storing compressed account addresses + * @param addressQueue - Queue for processing address tree updates + * @param address - Derived address for the counter account + * @param program - Anchor program instance for the Counter contract + * @param outputMerkleTree - Merkle tree where new compressed state will be stored + * @param signer - Keypair that will sign and pay for the transaction + */ async function CreateCounterCompressedAccount( rpc: Rpc, addressTree: anchor.web3.PublicKey, @@ -118,26 +154,33 @@ async function CreateCounterCompressedAccount( address: anchor.web3.PublicKey, program: anchor.Program, outputMerkleTree: anchor.web3.PublicKey, - signer: anchor.web3.Keypair + signer: anchor.web3.Keypair, ) { { + // Generate a validity proof for the address derivation + // This proves the address doesn't already exist in the address tree const proofRpcResult = await rpc.getValidityProofV0( - [], + [], // No existing compressed accounts to prove [ { tree: addressTree, queue: addressQueue, address: bn(address.toBytes()), }, - ] + ], ); + + // Configure system accounts required for Light Protocol operations const systemAccountConfig = SystemAccountMetaConfig.new(program.programId); let remainingAccounts = PackedAccounts.newWithSystemAccounts(systemAccountConfig); + // Add Merkle tree accounts to the remaining accounts list and get their indices const addressMerkleTreePubkeyIndex = remainingAccounts.insertOrGet(addressTree); const addressQueuePubkeyIndex = remainingAccounts.insertOrGet(addressQueue); + + // Package the address Merkle context for the instruction const packedAddreesMerkleContext = { rootIndex: proofRpcResult.rootIndices[0], addressMerkleTreePubkeyIndex, @@ -146,12 +189,17 @@ async function CreateCounterCompressedAccount( const outputMerkleTreeIndex = remainingAccounts.insertOrGet(outputMerkleTree); + // Format the compressed proof for the instruction let proof = { 0: proofRpcResult.compressedProof, }; + + // Set compute budget to handle complex compressed account operations const computeBudgetIx = web3.ComputeBudgetProgram.setComputeUnitLimit({ units: 1000000, }); + + // Build and send the create counter transaction let tx = await program.methods .createCounter(proof, packedAddreesMerkleContext, outputMerkleTreeIndex) .accounts({ @@ -170,15 +218,32 @@ async function CreateCounterCompressedAccount( } } +/** + * Increments the value in an existing compressed counter account + * + * This demonstrates the update pattern for compressed accounts: + * 1. Prove the current state exists + * 2. Create a new state with updated data + * 3. The old state is automatically nullified + * + * @param rpc - Light Protocol RPC client + * @param counterValue - Current value of the counter + * @param counterAccount - The compressed account data with Merkle context + * @param program - Anchor program instance for the Counter contract + * @param outputMerkleTree - Merkle tree where updated state will be stored + * @param signer - Keypair that will sign and pay for the transaction + */ async function incrementCounterCompressedAccount( rpc: Rpc, counterValue: anchor.BN, counterAccount: CompressedAccountWithMerkleContext, program: anchor.Program, outputMerkleTree: anchor.web3.PublicKey, - signer: anchor.web3.Keypair + signer: anchor.web3.Keypair, ) { { + // Generate a validity proof for the existing compressed account + // This proves the account exists and we know its current state const proofRpcResult = await rpc.getValidityProofV0( [ { @@ -187,24 +252,29 @@ async function incrementCounterCompressedAccount( queue: counterAccount.treeInfo.queue, }, ], - [] + [], // No new addresses being created ); + + // Configure system accounts for Light Protocol operations const systemAccountConfig = SystemAccountMetaConfig.new(program.programId); let remainingAccounts = PackedAccounts.newWithSystemAccounts(systemAccountConfig); + // Add required Merkle tree accounts and get their indices const merkleTreePubkeyIndex = remainingAccounts.insertOrGet( - counterAccount.treeInfo.tree + counterAccount.treeInfo.tree, ); const queuePubkeyIndex = remainingAccounts.insertOrGet( - counterAccount.treeInfo.queue + counterAccount.treeInfo.queue, ); const outputMerkleTreeIndex = remainingAccounts.insertOrGet(outputMerkleTree); + + // Package the compressed account metadata for the instruction const compressedAccountMeta = { treeInfo: { rootIndex: proofRpcResult.rootIndices[0], - proveByIndex: false, + proveByIndex: false, // Prove by hash, not by leaf index merkleTreePubkeyIndex, queuePubkeyIndex, leafIndex: counterAccount.leafIndex, @@ -213,12 +283,17 @@ async function incrementCounterCompressedAccount( outputStateTreeIndex: outputMerkleTreeIndex, }; + // Format the compressed proof for the instruction let proof = { 0: proofRpcResult.compressedProof, }; + + // Set compute budget for compressed account operations const computeBudgetIx = web3.ComputeBudgetProgram.setComputeUnitLimit({ units: 1000000, }); + + // Build and send the increment counter transaction let tx = await program.methods .incrementCounter(proof, counterValue, compressedAccountMeta) .accounts({ @@ -237,15 +312,29 @@ async function incrementCounterCompressedAccount( } } +/** + * Deletes a compressed counter account by nullifying it in the Merkle tree + * + * Unlike regular Solana accounts, compressed accounts are "deleted" by proving + * their existence and then nullifying them without creating new state. + * + * @param rpc - Light Protocol RPC client + * @param counterValue - Current value of the counter (for validation) + * @param counterAccount - The compressed account data with Merkle context + * @param program - Anchor program instance for the Counter contract + * @param outputMerkleTree - Merkle tree (unused in deletion, but kept for consistency) + * @param signer - Keypair that will sign and pay for the transaction + */ async function deleteCounterCompressedAccount( rpc: Rpc, counterValue: anchor.BN, counterAccount: CompressedAccountWithMerkleContext, program: anchor.Program, outputMerkleTree: anchor.web3.PublicKey, - signer: anchor.web3.Keypair + signer: anchor.web3.Keypair, ) { { + // Generate validity proof for the account to be deleted const proofRpcResult = await rpc.getValidityProofV0( [ { @@ -254,21 +343,26 @@ async function deleteCounterCompressedAccount( queue: counterAccount.treeInfo.queue, }, ], - [] + [], // No new addresses being created ); + + // Configure system accounts for Light Protocol operations const systemAccountConfig = SystemAccountMetaConfig.new(program.programId); let remainingAccounts = PackedAccounts.newWithSystemAccounts(systemAccountConfig); + // Add required Merkle tree accounts and get their indices const merkleTreePubkeyIndex = remainingAccounts.insertOrGet( - counterAccount.treeInfo.tree + counterAccount.treeInfo.tree, ); const queuePubkeyIndex = remainingAccounts.insertOrGet( - counterAccount.treeInfo.queue + counterAccount.treeInfo.queue, ); const outputMerkleTreeIndex = remainingAccounts.insertOrGet(outputMerkleTree); + // Package the compressed account metadata for deletion + // Note: No outputStateTreeIndex since we're not creating new state const compressedAccountMeta = { treeInfo: { rootIndex: proofRpcResult.rootIndices[0], @@ -280,12 +374,17 @@ async function deleteCounterCompressedAccount( address: counterAccount.address, }; + // Format the compressed proof for the instruction let proof = { 0: proofRpcResult.compressedProof, }; + + // Set compute budget for compressed account operations const computeBudgetIx = web3.ComputeBudgetProgram.setComputeUnitLimit({ units: 1000000, }); + + // Build and send the close counter transaction let tx = await program.methods .closeCounter(proof, counterValue, compressedAccountMeta) .accounts({ @@ -304,48 +403,75 @@ async function deleteCounterCompressedAccount( } } +/** + * PackedAccounts manages the complex account structure required for Light Protocol transactions. + * + * Solana transactions have a limit on the number of accounts they can reference directly. + * PackedAccounts helps organize accounts into categories and provides indices for referencing + * them in instruction data, allowing for more complex operations. + */ class PackedAccounts { + /** Accounts that must be included before system accounts (typically signers) */ private preAccounts: web3.AccountMeta[] = []; + /** Standard Light Protocol system accounts required for compressed operations */ private systemAccounts: web3.AccountMeta[] = []; + /** Counter for assigning unique indices to dynamically added accounts */ private nextIndex: number = 0; + /** Map to deduplicate accounts and track their assigned indices */ private map: Map = new Map(); + /** + * Creates a new PackedAccounts instance with Light Protocol system accounts pre-configured + */ static newWithSystemAccounts( - config: SystemAccountMetaConfig + config: SystemAccountMetaConfig, ): PackedAccounts { const instance = new PackedAccounts(); instance.addSystemAccounts(config); return instance; } + /** Adds a signer account to the pre-accounts list (read-only) */ addPreAccountsSigner(pubkey: web3.PublicKey): void { this.preAccounts.push({ pubkey, isSigner: true, isWritable: false }); } + /** Adds a signer account to the pre-accounts list (writable) */ addPreAccountsSignerMut(pubkey: web3.PublicKey): void { this.preAccounts.push({ pubkey, isSigner: true, isWritable: true }); } + /** Adds a custom account meta to the pre-accounts list */ addPreAccountsMeta(accountMeta: web3.AccountMeta): void { this.preAccounts.push(accountMeta); } + /** Adds all required Light Protocol system accounts */ addSystemAccounts(config: SystemAccountMetaConfig): void { this.systemAccounts.push(...getLightSystemAccountMetas(config)); } + /** + * Inserts an account or returns its existing index (writable by default) + * This is the most common method for adding Merkle tree accounts + */ insertOrGet(pubkey: web3.PublicKey): number { return this.insertOrGetConfig(pubkey, false, true); } + /** Inserts an account or returns its existing index (read-only) */ insertOrGetReadOnly(pubkey: web3.PublicKey): number { return this.insertOrGetConfig(pubkey, false, false); } + /** + * Core method for inserting accounts with custom signer/writable configuration + * Returns the index that can be used to reference this account in instruction data + */ insertOrGetConfig( pubkey: web3.PublicKey, isSigner: boolean, - isWritable: boolean + isWritable: boolean, ): number { const entry = this.map.get(pubkey); if (entry) { @@ -357,18 +483,24 @@ class PackedAccounts { return index; } + /** Converts the internal map to a sorted array of AccountMetas */ private hashSetAccountsToMetas(): web3.AccountMeta[] { const entries = Array.from(this.map.entries()); entries.sort((a, b) => a[1][0] - b[1][0]); return entries.map(([, [, meta]]) => meta); } + /** Calculates the starting indices for different account categories */ private getOffsets(): [number, number] { const systemStart = this.preAccounts.length; const packedStart = systemStart + this.systemAccounts.length; return [systemStart, packedStart]; } + /** + * Generates the final account list for transaction construction + * Returns the accounts in the correct order: pre-accounts, system accounts, then dynamic accounts + */ toAccountMetas(): { remainingAccounts: web3.AccountMeta[]; systemStart: number; @@ -388,17 +520,27 @@ class PackedAccounts { } } +/** + * Configuration for Light Protocol system accounts required in compressed account operations. + * + * Different operations may require different combinations of system accounts. + * This class provides a flexible way to configure which accounts are needed. + */ class SystemAccountMetaConfig { + /** The program making CPI calls to Light Protocol */ selfProgram: web3.PublicKey; + /** Optional: Account for storing CPI context data */ cpiContext?: web3.PublicKey; + /** Optional: Recipient account for SOL compression operations */ solCompressionRecipient?: web3.PublicKey; + /** Optional: PDA for managing SOL pool operations */ solPoolPda?: web3.PublicKey; private constructor( selfProgram: web3.PublicKey, cpiContext?: web3.PublicKey, solCompressionRecipient?: web3.PublicKey, - solPoolPda?: web3.PublicKey + solPoolPda?: web3.PublicKey, ) { this.selfProgram = selfProgram; this.cpiContext = cpiContext; @@ -406,27 +548,46 @@ class SystemAccountMetaConfig { this.solPoolPda = solPoolPda; } + /** Creates a basic configuration with just the calling program */ static new(selfProgram: web3.PublicKey): SystemAccountMetaConfig { return new SystemAccountMetaConfig(selfProgram); } + /** Creates a configuration with CPI context support */ static newWithCpiContext( selfProgram: web3.PublicKey, - cpiContext: web3.PublicKey + cpiContext: web3.PublicKey, ): SystemAccountMetaConfig { return new SystemAccountMetaConfig(selfProgram, cpiContext); } } +/** + * Generates the standard set of Light Protocol system accounts required for compressed operations. + * + * These accounts include: + * - Light System Program: Core compressed account logic + * - CPI Signer: PDA that signs on behalf of the calling program + * - Account Compression Program: Handles Merkle tree operations + * - Various other system accounts for protocol functionality + * + * @param config - Configuration specifying which optional accounts to include + * @returns Array of AccountMeta objects for all required system accounts + */ function getLightSystemAccountMetas( - config: SystemAccountMetaConfig + config: SystemAccountMetaConfig, ): web3.AccountMeta[] { + // Derive the CPI authority PDA that will sign Light Protocol transactions let signerSeed = new TextEncoder().encode("cpi_authority"); const cpiSigner = web3.PublicKey.findProgramAddressSync( [signerSeed], - config.selfProgram + config.selfProgram, )[0]; + + // Get default system account addresses const defaults = SystemAccountPubkeys.default(); + + // Build the core set of required accounts const metas: web3.AccountMeta[] = [ { pubkey: defaults.lightSystemProgram, isSigner: false, isWritable: false }, { pubkey: cpiSigner, isSigner: false, isWritable: false }, @@ -448,6 +609,8 @@ function getLightSystemAccountMetas( }, { pubkey: config.selfProgram, isSigner: false, isWritable: false }, ]; + + // Add optional accounts if configured if (config.solPoolPda) { metas.push({ pubkey: config.solPoolPda, @@ -462,11 +625,15 @@ function getLightSystemAccountMetas( isWritable: true, }); } + + // System program is always required metas.push({ pubkey: defaults.systemProgram, isSigner: false, isWritable: false, }); + + // CPI context is optional and added last if present if (config.cpiContext) { metas.push({ pubkey: config.cpiContext, @@ -477,13 +644,27 @@ function getLightSystemAccountMetas( return metas; } +/** + * Contains the public keys for all Light Protocol system accounts. + * + * These are the core accounts that make up the Light Protocol infrastructure + * on Solana. Most of these addresses are deterministic and don't change between + * deployments, but this class provides a clean way to access them. + */ class SystemAccountPubkeys { + /** The main Light Protocol system program */ lightSystemProgram: web3.PublicKey; + /** Solana's native system program */ systemProgram: web3.PublicKey; + /** Program that handles Merkle tree compression operations */ accountCompressionProgram: web3.PublicKey; + /** Authority account for the compression program */ accountCompressionAuthority: web3.PublicKey; + /** PDA that tracks registered programs authorized to use Light Protocol */ registeredProgramPda: web3.PublicKey; + /** No-op program for logging/event emission */ noopProgram: web3.PublicKey; + /** PDA for managing SOL compression pools */ solPoolPda: web3.PublicKey; private constructor( @@ -493,7 +674,7 @@ class SystemAccountPubkeys { accountCompressionAuthority: web3.PublicKey, registeredProgramPda: web3.PublicKey, noopProgram: web3.PublicKey, - solPoolPda: web3.PublicKey + solPoolPda: web3.PublicKey, ) { this.lightSystemProgram = lightSystemProgram; this.systemProgram = systemProgram; @@ -504,6 +685,10 @@ class SystemAccountPubkeys { this.solPoolPda = solPoolPda; } + /** + * Returns the standard set of Light Protocol system account addresses + * These addresses are typically the same across different environments + */ static default(): SystemAccountPubkeys { return new SystemAccountPubkeys( LightSystemProgram.programId, @@ -512,7 +697,7 @@ class SystemAccountPubkeys { defaultStaticAccountsStruct().accountCompressionAuthority, defaultStaticAccountsStruct().registeredProgramPda, defaultStaticAccountsStruct().noopProgram, - web3.PublicKey.default + web3.PublicKey.default, ); } } diff --git a/counter/native/src/lib.rs b/counter/native/src/lib.rs index d4c0c34..9b8e011 100644 --- a/counter/native/src/lib.rs +++ b/counter/native/src/lib.rs @@ -1,3 +1,9 @@ +//! # Light Counter Program +//! +//! A Solana program demonstrating compressed state management using the Light Protocol SDK. +//! This program implements a simple counter that can be created, incremented, decremented, +//! reset, and closed using compressed accounts for efficient state storage. + #![allow(unexpected_cfgs)] use borsh::{BorshDeserialize, BorshSerialize}; @@ -17,12 +23,20 @@ use light_sdk::{ use solana_program::{ account_info::AccountInfo, entrypoint, program_error::ProgramError, pubkey::Pubkey, }; + +/// Program ID for the Light Counter program pub const ID: Pubkey = pubkey!("GRLu2hKaAiMbxpkAM1HeXzks9YeGuz18SEgXEizVvPqX"); + +/// CPI signer derived from the program ID for Light Protocol operations pub const LIGHT_CPI_SIGNER: CpiSigner = derive_light_cpi_signer!("GRLu2hKaAiMbxpkAM1HeXzks9YeGuz18SEgXEizVvPqX"); entrypoint!(process_instruction); +/// Instruction discriminators for different counter operations +/// +/// Similar to Anchor's instruction discriminators, these are used to route +/// incoming instructions to the appropriate handler function. #[repr(u8)] pub enum InstructionType { CreateCounter = 0, @@ -35,6 +49,10 @@ pub enum InstructionType { impl TryFrom for InstructionType { type Error = LightSdkError; + /// Converts a u8 discriminator to an InstructionType + /// + /// This is similar to how Anchor handles instruction routing, but manually implemented + /// for Light Protocol compressed state programs. fn try_from(value: u8) -> Result { match value { 0 => Ok(InstructionType::CreateCounter), @@ -47,50 +65,82 @@ impl TryFrom for InstructionType { } } +/// The main account structure for our counter +/// +/// This is similar to an Anchor account struct, but uses Light Protocol traits +/// for compressed state management. The `#[hash]` attribute on owner means +/// this field is included in the account's hash calculation. #[derive( Debug, Default, Clone, BorshSerialize, BorshDeserialize, LightDiscriminator, LightHasher, )] pub struct CounterAccount { + /// The owner of this counter (included in hash for security) #[hash] pub owner: Pubkey, + /// The current counter value pub value: u64, } +/// Instruction data for creating a new counter +/// +/// Contains the validity proof and tree information needed for compressed account creation #[derive(BorshSerialize, BorshDeserialize)] pub struct CreateCounterInstructionData { + /// Zero-knowledge proof validating the transaction pub proof: ValidityProof, + /// Information about the address tree where the counter will be stored pub address_tree_info: PackedAddressTreeInfo, + /// Index of the state tree for output accounts pub output_state_tree_index: u8, } +/// Instruction data for incrementing a counter #[derive(BorshSerialize, BorshDeserialize)] pub struct IncrementCounterInstructionData { + /// Zero-knowledge proof validating the transaction pub proof: ValidityProof, + /// Current counter value (for verification) pub counter_value: u64, + /// Metadata for the compressed account being modified pub account_meta: CompressedAccountMeta, } +/// Instruction data for decrementing a counter #[derive(BorshSerialize, BorshDeserialize)] pub struct DecrementCounterInstructionData { + /// Zero-knowledge proof validating the transaction pub proof: ValidityProof, + /// Current counter value (for verification) pub counter_value: u64, + /// Metadata for the compressed account being modified pub account_meta: CompressedAccountMeta, } +/// Instruction data for resetting a counter to zero #[derive(BorshSerialize, BorshDeserialize)] pub struct ResetCounterInstructionData { + /// Zero-knowledge proof validating the transaction pub proof: ValidityProof, + /// Current counter value (for verification) pub counter_value: u64, + /// Metadata for the compressed account being modified pub account_meta: CompressedAccountMeta, } +/// Instruction data for closing a counter account #[derive(BorshSerialize, BorshDeserialize)] pub struct CloseCounterInstructionData { + /// Zero-knowledge proof validating the transaction pub proof: ValidityProof, + /// Current counter value (for verification) pub counter_value: u64, + /// Metadata for the compressed account being closed pub account_meta: CompressedAccountMetaClose, } +/// Custom error types for the counter program +/// +/// Similar to Anchor's error handling, but mapped to ProgramError for compatibility #[derive(Debug, Clone)] pub enum CounterError { Unauthorized, @@ -99,6 +149,7 @@ pub enum CounterError { } impl From for ProgramError { + /// Converts our custom errors to ProgramError with specific error codes fn from(e: CounterError) -> Self { match e { CounterError::Unauthorized => ProgramError::Custom(1), @@ -108,11 +159,16 @@ impl From for ProgramError { } } +/// Main instruction processor - similar to Anchor's instruction handler +/// +/// Routes incoming instructions based on the discriminator byte, similar to how +/// Anchor programs work but with manual instruction parsing for Light Protocol. pub fn process_instruction( program_id: &Pubkey, accounts: &[AccountInfo], instruction_data: &[u8], ) -> Result<(), ProgramError> { + // Verify this instruction is for our program if program_id != &crate::ID { return Err(ProgramError::IncorrectProgramId); } @@ -120,9 +176,11 @@ pub fn process_instruction( return Err(ProgramError::InvalidInstructionData); } + // Parse the instruction discriminator (first byte) let discriminator = InstructionType::try_from(instruction_data[0]) .map_err(|_| ProgramError::InvalidInstructionData)?; + // Route to appropriate handler based on discriminator match discriminator { InstructionType::CreateCounter => { let instuction_data = @@ -157,14 +215,23 @@ pub fn process_instruction( } } +/// Creates a new counter account with compressed state +/// +/// This is similar to an Anchor `init` constraint, but uses Light Protocol's +/// compressed account system. The counter is created with a PDA-like address +/// derived from the signer's pubkey and a "counter" seed. pub fn create_counter( accounts: &[AccountInfo], instuction_data: CreateCounterInstructionData, ) -> Result<(), ProgramError> { + // First account must be the signer (similar to Anchor's Signer account) let signer = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; + // Set up CPI accounts for Light Protocol system program calls let light_cpi_accounts = CpiAccounts::new(signer, &accounts[1..], LIGHT_CPI_SIGNER); + // Derive a deterministic address for this counter (like a PDA) + // Uses "counter" + signer's pubkey as seeds let (address, address_seed) = derive_address( &[b"counter", signer.key.as_ref()], &instuction_data @@ -174,10 +241,12 @@ pub fn create_counter( &ID, ); + // Prepare address parameters for the new account let new_address_params = instuction_data .address_tree_info .into_new_address_params_packed(address_seed); + // Initialize the counter account with compressed state let mut counter = LightAccount::<'_, CounterAccount>::new_init( &ID, Some(address), @@ -186,6 +255,7 @@ pub fn create_counter( counter.owner = *signer.key; counter.value = 0; + // Execute the CPI to Light system program to create the compressed account let cpi = CpiInputs::new_with_address( instuction_data.proof, vec![counter.to_account_info().map_err(ProgramError::from)?], @@ -197,12 +267,18 @@ pub fn create_counter( Ok(()) } +/// Increments the counter value by 1 +/// +/// This demonstrates updating compressed state - the account is loaded, +/// modified, and then committed back to the compressed state tree. pub fn increment_counter( accounts: &[AccountInfo], instuction_data: IncrementCounterInstructionData, ) -> Result<(), ProgramError> { let signer = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; + // Load the existing counter account for mutation + // The current value is provided in instruction data for verification let mut counter = LightAccount::<'_, CounterAccount>::new_mut( &ID, &instuction_data.account_meta, @@ -213,8 +289,10 @@ pub fn increment_counter( ) .map_err(ProgramError::from)?; + // Safely increment the counter, preventing overflow counter.value = counter.value.checked_add(1).ok_or(CounterError::Overflow)?; + // Set up CPI accounts and execute the state update let light_cpi_accounts = CpiAccounts::new(signer, &accounts[1..], LIGHT_CPI_SIGNER); let cpi_inputs = CpiInputs::new( @@ -228,12 +306,16 @@ pub fn increment_counter( Ok(()) } +/// Decrements the counter value by 1 +/// +/// Similar to increment but with underflow protection pub fn decrement_counter( accounts: &[AccountInfo], instuction_data: DecrementCounterInstructionData, ) -> Result<(), ProgramError> { let signer = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; + // Load the existing counter account for mutation let mut counter = LightAccount::<'_, CounterAccount>::new_mut( &ID, &instuction_data.account_meta, @@ -244,6 +326,7 @@ pub fn decrement_counter( ) .map_err(ProgramError::from)?; + // Safely decrement the counter, preventing underflow counter.value = counter .value .checked_sub(1) @@ -263,12 +346,16 @@ pub fn decrement_counter( Ok(()) } +/// Resets the counter value back to 0 +/// +/// Demonstrates arbitrary state updates in compressed accounts pub fn reset_counter( accounts: &[AccountInfo], instuction_data: ResetCounterInstructionData, ) -> Result<(), ProgramError> { let signer = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; + // Load the existing counter account for mutation let mut counter = LightAccount::<'_, CounterAccount>::new_mut( &ID, &instuction_data.account_meta, @@ -279,6 +366,7 @@ pub fn reset_counter( ) .map_err(ProgramError::from)?; + // Reset counter to zero counter.value = 0; let light_cpi_accounts = CpiAccounts::new(signer, &accounts[1..], LIGHT_CPI_SIGNER); @@ -294,12 +382,17 @@ pub fn reset_counter( Ok(()) } +/// Closes the counter account, removing it from compressed state +/// +/// This is similar to Anchor's `close` constraint - the account is marked +/// for closure and removed from the state tree. pub fn close_counter( accounts: &[AccountInfo], instuction_data: CloseCounterInstructionData, ) -> Result<(), ProgramError> { let signer = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; + // Load the counter account for closure (note: new_close instead of new_mut) let counter = LightAccount::<'_, CounterAccount>::new_close( &ID, &instuction_data.account_meta, diff --git a/counter/native/tests/test.rs b/counter/native/tests/test.rs index ad67128..002731c 100644 --- a/counter/native/tests/test.rs +++ b/counter/native/tests/test.rs @@ -20,21 +20,29 @@ use solana_sdk::{ signature::{Keypair, Signer}, }; +/// Integration test for the counter program using Light Protocol's compressed accounts. +/// This test demonstrates the complete lifecycle of a counter: create, increment, decrement, reset, and close. +/// +/// Light Protocol enables state compression on Solana, allowing for more efficient storage +/// by storing account data in Merkle trees rather than as individual accounts. #[tokio::test] async fn test_counter() { + // Initialize the Light Protocol test environment with the counter program let config = ProgramTestConfig::new(true, Some(vec![("counter", counter::ID)])); let mut rpc = LightProgramTest::new(config).await.unwrap(); let payer = rpc.get_payer().insecure_clone(); + // Get the address tree for compressed account address derivation let address_tree_info = rpc.get_address_tree_v1(); let address_tree_pubkey = address_tree_info.tree; - // Create counter + // Create counter - derive a deterministic address based on program ID and seed let (address, _) = derive_address( &[b"counter", payer.pubkey().as_ref()], &address_tree_pubkey, &counter::ID, ); + // Get a random Merkle tree to store the compressed account state let merkle_tree_pubkey = rpc.get_random_state_tree_info().unwrap().tree; create_counter( @@ -47,7 +55,7 @@ async fn test_counter() { .await .unwrap(); - // Get the created counter + // Verify the counter was created successfully by fetching it from the compressed state let compressed_counter = rpc .get_compressed_account(address, None) .await @@ -55,45 +63,62 @@ async fn test_counter() { .value; assert_eq!(compressed_counter.address.unwrap(), address); - // Test increment + // Test increment operation increment_counter(&payer, &mut rpc, &compressed_counter) .await .unwrap(); + // Fetch updated state after increment let compressed_counter = rpc .get_compressed_account(address, None) .await .unwrap() .value; - // Test decrement + // Test decrement operation decrement_counter(&payer, &mut rpc, &compressed_counter) .await .unwrap(); + // Fetch updated state after decrement let compressed_counter = rpc .get_compressed_account(address, None) .await .unwrap() .value; - // Test reset + // Test reset operation (sets counter back to 0) reset_counter(&payer, &mut rpc, &compressed_counter) .await .unwrap(); + // Fetch updated state after reset let compressed_counter = rpc .get_compressed_account(address, None) .await .unwrap() .value; - // Test close + // Test close operation (removes the account and reclaims rent) close_counter(&payer, &mut rpc, &compressed_counter) .await .unwrap(); } +/// Creates a new counter compressed account. +/// +/// This function demonstrates the pattern for creating compressed accounts: +/// 1. Set up account metadata configuration +/// 2. Generate validity proof for the new address +/// 3. Pack tree information and account metadata +/// 4. Build and send the instruction +/// +/// # Arguments +/// * `payer` - The keypair that will pay for transaction fees and sign the transaction +/// * `rpc` - The Light Protocol test RPC client +/// * `merkle_tree_pubkey` - The Merkle tree where the compressed account state will be stored +/// * `address_tree_pubkey` - The address tree used for address derivation and validation +/// * `address` - The derived address for the new counter account pub async fn create_counter( payer: &Keypair, rpc: &mut LightProgramTest, @@ -101,11 +126,14 @@ pub async fn create_counter( address_tree_pubkey: Pubkey, address: [u8; 32], ) -> Result<(), RpcError> { + // Configure system accounts needed for compressed account operations let system_account_meta_config = SystemAccountMetaConfig::new(counter::ID); let mut accounts = PackedAccounts::default(); accounts.add_pre_accounts_signer(payer.pubkey()); accounts.add_system_accounts(system_account_meta_config); + // Generate validity proof for creating a new compressed account at the specified address + // Empty input accounts (vec![]) since we're creating, not modifying existing accounts let rpc_result = rpc .get_validity_proof( vec![], @@ -118,10 +146,12 @@ pub async fn create_counter( .await? .value; + // Pack the Merkle tree and address tree information into the account metadata let output_merkle_tree_index = accounts.insert_or_get(*merkle_tree_pubkey); let packed_address_tree_info = rpc_result.pack_tree_infos(&mut accounts).address_trees[0]; let (accounts, _, _) = accounts.to_account_metas(); + // Build instruction data with proof and tree information let instruction_data = CreateCounterInstructionData { proof: rpc_result.proof, address_tree_info: packed_address_tree_info, @@ -129,6 +159,7 @@ pub async fn create_counter( }; let inputs = instruction_data.try_to_vec().unwrap(); + // Create the instruction with program ID, accounts, and serialized data let instruction = Instruction { program_id: counter::ID, accounts, @@ -139,37 +170,56 @@ pub async fn create_counter( .concat(), }; + // Submit the transaction to create the counter rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[payer]) .await?; Ok(()) } +/// Increments the counter value by 1. +/// +/// This demonstrates the pattern for modifying compressed accounts: +/// 1. Extract the current account hash for the validity proof +/// 2. Generate proof that the account exists and can be modified +/// 3. Deserialize current account data to get the current counter value +/// 4. Build instruction with proof and current state +/// +/// # Arguments +/// * `payer` - The keypair that will pay for transaction fees and sign the transaction +/// * `rpc` - The Light Protocol test RPC client +/// * `compressed_account` - The current compressed account state to increment pub async fn increment_counter( payer: &Keypair, rpc: &mut LightProgramTest, compressed_account: &CompressedAccount, ) -> Result<(), RpcError> { + // Set up system accounts for the compressed account operation let system_account_meta_config = SystemAccountMetaConfig::new(counter::ID); let mut accounts = PackedAccounts::default(); accounts.add_pre_accounts_signer(payer.pubkey()); accounts.add_system_accounts(system_account_meta_config); + // Get the hash of the current account state for the validity proof let hash = compressed_account.hash; + // Generate validity proof that this account exists and can be consumed let rpc_result = rpc .get_validity_proof(vec![hash], vec![], None) .await? .value; + // Pack the state tree information let packed_accounts = rpc_result .pack_tree_infos(&mut accounts) .state_trees .unwrap(); + // Deserialize the current counter account to access its value let counter_account = CounterAccount::deserialize(&mut compressed_account.data.as_ref().unwrap().data.as_slice()) .unwrap(); + // Build metadata for the compressed account being modified let meta = CompressedAccountMeta { tree_info: packed_accounts.packed_tree_infos[0], address: compressed_account.address.unwrap(), @@ -199,6 +249,15 @@ pub async fn increment_counter( Ok(()) } +/// Decrements the counter value by 1. +/// +/// This follows the same pattern as increment_counter but calls the decrement instruction. +/// The program logic will handle preventing underflow below zero. +/// +/// # Arguments +/// * `payer` - The keypair that will pay for transaction fees and sign the transaction +/// * `rpc` - The Light Protocol test RPC client +/// * `compressed_account` - The current compressed account state to decrement pub async fn decrement_counter( payer: &Keypair, rpc: &mut LightProgramTest, @@ -254,6 +313,15 @@ pub async fn decrement_counter( Ok(()) } +/// Resets the counter value back to 0. +/// +/// This operation modifies the compressed account state, similar to increment/decrement, +/// but sets the counter to a specific value (0) regardless of the current value. +/// +/// # Arguments +/// * `payer` - The keypair that will pay for transaction fees and sign the transaction +/// * `rpc` - The Light Protocol test RPC client +/// * `compressed_account` - The current compressed account state to reset pub async fn reset_counter( payer: &Keypair, rpc: &mut LightProgramTest, @@ -309,6 +377,16 @@ pub async fn reset_counter( Ok(()) } +/// Closes the counter account, removing it from the compressed state tree. +/// +/// This operation demonstrates account closure in the compressed account model. +/// Unlike regular Solana accounts, compressed accounts use CompressedAccountMetaClose +/// which doesn't specify an output tree since the account is being removed, not updated. +/// +/// # Arguments +/// * `payer` - The keypair that will pay for transaction fees and sign the transaction +/// * `rpc` - The Light Protocol test RPC client +/// * `compressed_account` - The compressed account to close pub async fn close_counter( payer: &Keypair, rpc: &mut LightProgramTest, @@ -335,6 +413,7 @@ pub async fn close_counter( CounterAccount::deserialize(&mut compressed_account.data.as_ref().unwrap().data.as_slice()) .unwrap(); + // Use CompressedAccountMetaClose for account closure (no output_state_tree_index needed) let meta_close = CompressedAccountMetaClose { tree_info: packed_accounts.packed_tree_infos[0], address: compressed_account.address.unwrap(), diff --git a/counter/pinocchio/src/lib.rs b/counter/pinocchio/src/lib.rs index b745f69..96e079a 100644 --- a/counter/pinocchio/src/lib.rs +++ b/counter/pinocchio/src/lib.rs @@ -18,12 +18,15 @@ use pinocchio::{ account_info::AccountInfo, entrypoint, program_error::ProgramError, pubkey::Pubkey, }; +// Program ID for the counter program pub const ID: Pubkey = pubkey_array!("GRLu2hKaAiMbxpkAM1HeXzks9YeGuz18SEgXEizVvPqX"); +// CPI signer derived from program ID for Light protocol interactions pub const LIGHT_CPI_SIGNER: CpiSigner = derive_light_cpi_signer!("GRLu2hKaAiMbxpkAM1HeXzks9YeGuz18SEgXEizVvPqX"); entrypoint!(process_instruction); +/// Instruction discriminators for the counter program #[repr(u8)] pub enum InstructionType { CreateCounter = 0, @@ -48,26 +51,38 @@ impl TryFrom for InstructionType { } } +/// Compressed account state for the counter +/// Uses Light protocol for state compression #[derive( Debug, Default, Clone, BorshSerialize, BorshDeserialize, LightDiscriminator, LightHasher, )] pub struct CounterAccount { + /// Owner of the counter - used in address derivation and authorization #[hash] pub owner: Pubkey, + /// Current counter value pub value: u64, } +/// Instruction data for creating a new counter #[derive(BorshSerialize, BorshDeserialize)] pub struct CreateCounterInstructionData { + /// Zero-knowledge proof for the transaction pub proof: ValidityProof, + /// Information about the address tree for storing the new address pub address_tree_info: PackedAddressTreeInfo, + /// Index of the state tree where the counter will be stored pub output_state_tree_index: u8, } +/// Instruction data for modifying an existing counter #[derive(BorshSerialize, BorshDeserialize)] pub struct IncrementCounterInstructionData { + /// Zero-knowledge proof for the transaction pub proof: ValidityProof, + /// Current counter value (for verification) pub counter_value: u64, + /// Metadata of the compressed account being modified pub account_meta: CompressedAccountMeta, } @@ -85,13 +100,16 @@ pub struct ResetCounterInstructionData { pub account_meta: CompressedAccountMeta, } +/// Instruction data for closing a counter account #[derive(BorshSerialize, BorshDeserialize)] pub struct CloseCounterInstructionData { pub proof: ValidityProof, pub counter_value: u64, + /// Close-specific metadata that handles account deletion pub account_meta: CompressedAccountMetaClose, } +/// Program-specific error types #[derive(Debug, Clone)] pub enum CounterError { Unauthorized, @@ -109,6 +127,7 @@ impl From for ProgramError { } } +/// Main instruction handler - routes to specific instruction handlers pub fn process_instruction( program_id: &Pubkey, accounts: &[AccountInfo], @@ -121,6 +140,7 @@ pub fn process_instruction( return Err(ProgramError::InvalidInstructionData); } + // First byte is the instruction discriminator let discriminator = InstructionType::try_from(instruction_data[0]) .map_err(|_| ProgramError::InvalidInstructionData)?; @@ -158,6 +178,7 @@ pub fn process_instruction( } } +/// Creates a new counter account with compressed state pub fn create_counter( accounts: &[AccountInfo], instruction_data: CreateCounterInstructionData, @@ -166,6 +187,7 @@ pub fn create_counter( let light_cpi_accounts = CpiAccounts::new(signer, &accounts[1..], LIGHT_CPI_SIGNER); + // Derive deterministic address based on signer and "counter" seed let (address, address_seed) = derive_address( &[b"counter", signer.key().as_ref()], &instruction_data @@ -175,10 +197,12 @@ pub fn create_counter( &ID, ); + // Convert address tree info into parameters for creating new address let new_address_params = instruction_data .address_tree_info .into_new_address_params_packed(address_seed); + // Initialize new compressed account let mut counter = LightAccount::<'_, CounterAccount>::new_init( &ID, Some(address), @@ -188,6 +212,7 @@ pub fn create_counter( counter.owner = *signer.key(); counter.value = 0; + // Create CPI call to Light system program with new address let cpi = CpiInputs::new_with_address( instruction_data.proof, vec![counter.to_account_info().map_err(ProgramError::from)?], @@ -199,12 +224,14 @@ pub fn create_counter( Ok(()) } +/// Increments the counter value by 1 with overflow protection pub fn increment_counter( accounts: &[AccountInfo], instruction_data: IncrementCounterInstructionData, ) -> Result<(), ProgramError> { let signer = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; + // Load existing compressed account for mutation let mut counter = LightAccount::<'_, CounterAccount>::new_mut( &ID, &instruction_data.account_meta, @@ -230,6 +257,7 @@ pub fn increment_counter( Ok(()) } +/// Decrements the counter value by 1 with underflow protection pub fn decrement_counter( accounts: &[AccountInfo], instruction_data: DecrementCounterInstructionData, @@ -265,6 +293,7 @@ pub fn decrement_counter( Ok(()) } +/// Resets the counter value back to 0 pub fn reset_counter( accounts: &[AccountInfo], instruction_data: ResetCounterInstructionData, @@ -296,12 +325,14 @@ pub fn reset_counter( Ok(()) } +/// Closes/deletes the counter account from compressed state pub fn close_counter( accounts: &[AccountInfo], instruction_data: CloseCounterInstructionData, ) -> Result<(), ProgramError> { let signer = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; + // Create account handle for closure (no mutation needed) let counter = LightAccount::<'_, CounterAccount>::new_close( &ID, &instruction_data.account_meta, diff --git a/counter/pinocchio/tests/test.rs b/counter/pinocchio/tests/test.rs index be64c3a..0d21f49 100644 --- a/counter/pinocchio/tests/test.rs +++ b/counter/pinocchio/tests/test.rs @@ -20,6 +20,8 @@ use solana_sdk::{ signature::{Keypair, Signer}, }; +/// Integration test for compressed counter program covering full lifecycle: +/// create, increment, decrement, reset, and close operations #[tokio::test] async fn test_counter() { let config = ProgramTestConfig::new(true, Some(vec![("counter", counter::ID.into())])); @@ -29,7 +31,7 @@ async fn test_counter() { let address_tree_info = rpc.get_address_tree_v1(); let address_tree_pubkey = address_tree_info.tree; - // Create counter + // Derive deterministic address for the counter using program ID and payer pubkey as seeds let (address, _) = derive_address( &[b"counter", payer.pubkey().as_ref()], &address_tree_pubkey, @@ -47,7 +49,7 @@ async fn test_counter() { .await .unwrap(); - // Get the created counter + // Fetch the newly created compressed account and verify address matches let compressed_counter = rpc .get_compressed_account(address, None) .await @@ -55,18 +57,17 @@ async fn test_counter() { .value; assert_eq!(compressed_counter.address.unwrap(), address); - // Test increment increment_counter(&payer, &mut rpc, &compressed_counter) .await .unwrap(); + // Refetch after increment to get updated state let compressed_counter = rpc .get_compressed_account(address, None) .await .unwrap() .value; - // Test decrement decrement_counter(&payer, &mut rpc, &compressed_counter) .await .unwrap(); @@ -77,7 +78,6 @@ async fn test_counter() { .unwrap() .value; - // Test reset reset_counter(&payer, &mut rpc, &compressed_counter) .await .unwrap(); @@ -88,12 +88,12 @@ async fn test_counter() { .unwrap() .value; - // Test close close_counter(&payer, &mut rpc, &compressed_counter) .await .unwrap(); } +/// Creates a new compressed counter account at the specified address pub async fn create_counter( payer: &Keypair, rpc: &mut LightProgramTest, @@ -106,6 +106,7 @@ pub async fn create_counter( accounts.add_pre_accounts_signer(payer.pubkey()); accounts.add_system_accounts(system_account_meta_config); + // Get validity proof for address creation (no input accounts, only new address) let rpc_result = rpc .get_validity_proof( vec![], @@ -118,6 +119,7 @@ pub async fn create_counter( .await? .value; + // Pack tree info for instruction data let output_merkle_tree_index = accounts.insert_or_get(*merkle_tree_pubkey); let packed_address_tree_info = rpc_result.pack_tree_infos(&mut accounts).address_trees[0]; let (accounts, _, _) = accounts.to_account_metas(); @@ -129,6 +131,7 @@ pub async fn create_counter( }; let inputs = instruction_data.try_to_vec().unwrap(); + // Build instruction with discriminator byte + serialized data let instruction = Instruction { program_id: counter::ID.into(), accounts, @@ -144,6 +147,7 @@ pub async fn create_counter( Ok(()) } +/// Increments the counter value by 1, consuming the old account and creating a new one pub async fn increment_counter( payer: &Keypair, rpc: &mut LightProgramTest, @@ -156,6 +160,7 @@ pub async fn increment_counter( let hash = compressed_account.hash; + // Get validity proof for the existing compressed account let rpc_result = rpc .get_validity_proof(vec![hash], vec![], None) .await? @@ -166,6 +171,7 @@ pub async fn increment_counter( .state_trees .unwrap(); + // Deserialize current counter value from compressed account data let counter_account = CounterAccount::deserialize(&mut compressed_account.data.as_ref().unwrap().data.as_slice()) .unwrap(); @@ -199,6 +205,7 @@ pub async fn increment_counter( Ok(()) } +/// Decrements the counter value by 1, consuming the old account and creating a new one pub async fn decrement_counter( payer: &Keypair, rpc: &mut LightProgramTest, @@ -254,6 +261,7 @@ pub async fn decrement_counter( Ok(()) } +/// Resets the counter value to 0, consuming the old account and creating a new one pub async fn reset_counter( payer: &Keypair, rpc: &mut LightProgramTest, @@ -309,6 +317,7 @@ pub async fn reset_counter( Ok(()) } +/// Closes the counter account, consuming it without creating a new one pub async fn close_counter( payer: &Keypair, rpc: &mut LightProgramTest, @@ -335,6 +344,7 @@ pub async fn close_counter( CounterAccount::deserialize(&mut compressed_account.data.as_ref().unwrap().data.as_slice()) .unwrap(); + // Use CompressedAccountMetaClose since we're not creating a new output account let meta_close = CompressedAccountMetaClose { tree_info: packed_accounts.packed_tree_infos[0], address: compressed_account.address.unwrap(), diff --git a/create-and-update/src/lib.rs b/create-and-update/src/lib.rs index 49db1b8..1872f1f 100644 --- a/create-and-update/src/lib.rs +++ b/create-and-update/src/lib.rs @@ -13,9 +13,11 @@ use light_sdk::{ declare_id!("HNqStLMpNuNJqhBF1FbGTKHEFbBLJmq8RdJJmZKWz6jH"); +/// CPI signer derived from the program ID for Light system program interactions pub const LIGHT_CPI_SIGNER: CpiSigner = derive_light_cpi_signer!("HNqStLMpNuNJqhBF1FbGTKHEFbBLJmq8RdJJmZKWz6jH"); +/// Seeds for deterministic address derivation pub const FIRST_SEED: &[u8] = b"first"; pub const SECOND_SEED: &[u8] = b"second"; @@ -32,12 +34,14 @@ pub mod create_and_update { output_state_tree_index: u8, message: String, ) -> Result<()> { + // Setup CPI accounts for Light system program interaction let light_cpi_accounts = CpiAccounts::new( ctx.accounts.signer.as_ref(), ctx.remaining_accounts, crate::LIGHT_CPI_SIGNER, ); + // Derive deterministic address using FIRST_SEED + signer pubkey let (address, address_seed) = derive_address( &[FIRST_SEED, ctx.accounts.signer.key().as_ref()], &address_tree_info @@ -46,6 +50,7 @@ pub mod create_and_update { &crate::ID, ); + // Initialize new compressed account with derived address let mut data_account = LightAccount::<'_, DataAccount>::new_init( &crate::ID, Some(address), @@ -58,12 +63,15 @@ pub mod create_and_update { "Created compressed account with message: {}", data_account.message ); + + // Prepare CPI inputs with new account and address parameters let cpi_inputs = CpiInputs::new_with_address( proof, vec![data_account.to_account_info().map_err(ProgramError::from)?], vec![address_tree_info.into_new_address_params_packed(address_seed)], ); + // Invoke Light system program to create the compressed account cpi_inputs .invoke_light_system_program(light_cpi_accounts) .map_err(ProgramError::from)?; @@ -84,7 +92,7 @@ pub mod create_and_update { crate::LIGHT_CPI_SIGNER, ); - // Create new compressed account + // Derive address for new account using SECOND_SEED let (new_address, new_address_seed) = derive_address( &[SECOND_SEED, ctx.accounts.signer.key().as_ref()], &new_account @@ -94,6 +102,7 @@ pub mod create_and_update { &crate::ID, ); + // Initialize new compressed account let mut new_data_account = LightAccount::<'_, DataAccount>::new_init( &crate::ID, Some(new_address), @@ -102,6 +111,7 @@ pub mod create_and_update { new_data_account.owner = ctx.accounts.signer.key(); new_data_account.message = new_account.message.clone(); + // Create mutable reference to existing account for updates let mut updated_data_account = LightAccount::<'_, DataAccount>::new_mut( &crate::ID, &existing_account.account_meta, @@ -112,9 +122,9 @@ pub mod create_and_update { ) .map_err(ProgramError::from)?; - // Update the message updated_data_account.message = existing_account.update_message.clone(); + // Batch both operations in single CPI call let cpi_inputs = CpiInputs::new_with_address( proof, vec![ @@ -156,7 +166,7 @@ pub mod create_and_update { crate::LIGHT_CPI_SIGNER, ); - // Update first compressed account + // Create mutable reference to first account with current state let mut updated_first_account = LightAccount::<'_, DataAccount>::new_mut( &crate::ID, &first_account.account_meta, @@ -167,10 +177,9 @@ pub mod create_and_update { ) .map_err(ProgramError::from)?; - // Update the message of the first account updated_first_account.message = first_account.update_message.clone(); - // Update second compressed account + // Create mutable reference to second account with current state let mut updated_second_account = LightAccount::<'_, DataAccount>::new_mut( &crate::ID, &second_account.account_meta, @@ -181,9 +190,9 @@ pub mod create_and_update { ) .map_err(ProgramError::from)?; - // Update the message of the second account updated_second_account.message = second_account.update_message.clone(); + // Batch both updates in single CPI call let cpi_inputs = CpiInputs::new( proof, vec![ @@ -216,6 +225,7 @@ pub struct GenericAnchorAccounts<'info> { pub signer: Signer<'info>, } +/// Compressed account data structure with hashable fields for state commitment #[derive( Clone, Debug, Default, BorshSerialize, BorshDeserialize, LightDiscriminator, LightHasher, )] @@ -226,6 +236,7 @@ pub struct DataAccount { pub message: String, } +/// Instruction data for existing compressed account operations #[derive(Clone, Debug, AnchorSerialize, AnchorDeserialize)] pub struct ExistingCompressedAccountIxData { pub account_meta: CompressedAccountMeta, @@ -233,13 +244,13 @@ pub struct ExistingCompressedAccountIxData { pub update_message: String, } +/// Instruction data for new compressed account creation #[derive(Clone, Debug, AnchorSerialize, AnchorDeserialize)] pub struct NewCompressedAccountIxData { pub address_tree_info: PackedAddressTreeInfo, pub message: String, } - #[error_code] pub enum CustomError { #[msg("No authority to perform this action")] diff --git a/create-and-update/tests/test.rs b/create-and-update/tests/test.rs index 656c7c2..3d7cdce 100644 --- a/create-and-update/tests/test.rs +++ b/create-and-update/tests/test.rs @@ -18,6 +18,7 @@ use solana_sdk::{ signature::{Keypair, Signature, Signer}, }; +/// Tests basic compressed account creation functionality #[tokio::test] async fn test_create_compressed_account() { let config = ProgramTestConfig::new( @@ -29,6 +30,7 @@ async fn test_create_compressed_account() { let address_tree_info = rpc.get_address_tree_v1(); + // Derive deterministic address using seed and owner pubkey let (address, _) = derive_address( &[FIRST_SEED, payer.pubkey().as_ref()], &address_tree_info.tree, @@ -46,7 +48,7 @@ async fn test_create_compressed_account() { .await .unwrap(); - // Check that it was created correctly + // Verify account creation and data integrity let compressed_account = rpc .get_compressed_account(address, None) .await @@ -60,6 +62,8 @@ async fn test_create_compressed_account() { assert_eq!(account_data.message, "Hello, World!"); } +/// Tests composite operations: creating a new account while updating an existing one, +/// followed by updating both accounts simultaneously #[tokio::test] async fn test_create_and_update() { let config = ProgramTestConfig::new( @@ -95,7 +99,7 @@ async fn test_create_and_update() { .unwrap() .value; - // Create and update in one instruction + // Execute atomic create-and-update operation create_and_update_accounts( &mut rpc, &payer, @@ -107,7 +111,7 @@ async fn test_create_and_update() { .await .unwrap(); - // Check the new account was created + // Verify new account creation with SECOND_SEED let (new_address, _) = derive_address( &[SECOND_SEED, payer.pubkey().as_ref()], &address_tree_info.tree, @@ -125,7 +129,7 @@ async fn test_create_and_update() { assert_eq!(new_account_data.owner, payer.pubkey()); assert_eq!(new_account_data.message, "New account message"); - // Check the existing account was updated + // Verify existing account was updated let updated_compressed_account = rpc .get_compressed_account(initial_address, None) .await @@ -137,7 +141,7 @@ async fn test_create_and_update() { assert_eq!(updated_account_data.owner, payer.pubkey()); assert_eq!(updated_account_data.message, "Updated message"); - // Now test updating both existing accounts with the third instruction + // Test batch update of both existing accounts update_two_accounts( &mut rpc, &payer, @@ -151,7 +155,7 @@ async fn test_create_and_update() { .await .unwrap(); - // Check both accounts were updated correctly + // Verify both accounts were updated correctly let final_first_account = rpc .get_compressed_account(initial_address, None) .await @@ -179,6 +183,7 @@ async fn test_create_and_update() { ); } +/// Creates a new compressed account at the specified address with the given message async fn create_compressed_account( rpc: &mut R, payer: &Keypair, @@ -193,6 +198,7 @@ where let config = SystemAccountMetaConfig::new(create_and_update::ID); remaining_accounts.add_system_accounts(config); + // Get validity proof for address creation (no existing accounts to prove) let rpc_result = rpc .get_validity_proof( vec![], @@ -205,6 +211,7 @@ where .await? .value; + // Pack tree accounts for CPI and get output state tree for new account let packed_address_tree_accounts = rpc_result .pack_tree_infos(&mut remaining_accounts) .address_trees; @@ -237,6 +244,7 @@ where .await } +/// Atomically creates a new compressed account and updates an existing one in a single transaction async fn create_and_update_accounts( rpc: &mut R, payer: &Keypair, @@ -256,6 +264,7 @@ where let address_tree_info = rpc.get_address_tree_v1(); + // Derive address for the new account using SECOND_SEED let (new_address, _) = derive_address( &[SECOND_SEED, payer.pubkey().as_ref()], &address_tree_info.tree, @@ -264,6 +273,7 @@ where let address_tree_info = rpc.get_address_tree_v1(); + // Get validity proof: existing account hash to prove it exists, new address to prove it doesn't let rpc_result = rpc .get_validity_proof( vec![hash], @@ -279,6 +289,8 @@ where let packed_tree_accounts = rpc_result.pack_tree_infos(&mut remaining_accounts); let packed_state_tree_accounts = packed_tree_accounts.state_trees.unwrap(); let packed_address_tree_accounts = packed_tree_accounts.address_trees; + + // Create metadata for the existing account being updated let account_meta = CompressedAccountMeta { tree_info: packed_state_tree_accounts.packed_tree_infos[0], address: existing_account.address.unwrap(), @@ -316,6 +328,7 @@ where .await } +/// Updates two existing compressed accounts in a single atomic transaction #[allow(clippy::too_many_arguments)] async fn update_two_accounts( rpc: &mut R, @@ -337,6 +350,7 @@ where let first_hash = first_account.hash; let second_hash = second_account.hash; + // Get validity proof for both existing accounts (no new addresses needed) let rpc_result = rpc .get_validity_proof(vec![first_hash, second_hash], vec![], None) .await? @@ -345,6 +359,7 @@ where let packed_tree_accounts = rpc_result.pack_tree_infos(&mut remaining_accounts); let packed_state_tree_accounts = packed_tree_accounts.state_trees.unwrap(); + // Create metadata for both accounts being updated let first_account_meta = CompressedAccountMeta { tree_info: packed_state_tree_accounts.packed_tree_infos[0], address: first_account.address.unwrap(),