From 00aed01d51a3c8684172850f6e969f1110e56bdc Mon Sep 17 00:00:00 2001 From: ananas Date: Mon, 17 Nov 2025 22:50:18 +0000 Subject: [PATCH 1/2] fix: calculate_top_up_lamports --- .../compressible/src/compression_info.rs | 20 ++++++++++++------- .../compressible/tests/compression_info.rs | 10 +++++----- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/program-libs/compressible/src/compression_info.rs b/program-libs/compressible/src/compression_info.rs index 5371065817..e838c6a44e 100644 --- a/program-libs/compressible/src/compression_info.rs +++ b/program-libs/compressible/src/compression_info.rs @@ -10,7 +10,7 @@ use crate::{ error::CompressibleError, rent::{ get_last_funded_epoch, get_rent_exemption_lamports, AccountRentState, RentConfig, - RentConfigTrait, SLOTS_PER_EPOCH, + SLOTS_PER_EPOCH, }, AnchorDeserialize, AnchorSerialize, }; @@ -109,13 +109,19 @@ macro_rules! impl_is_compressible { if let Some(rent_deficit) = is_compressible { Ok(lamports_per_write as u64 + rent_deficit) } else { - // Calculate epochs funded ahead using available balance - let available_balance = state.get_available_rent_balance( + let last_funded_epoch_number = self.get_last_funded_epoch( + num_bytes, + current_lamports, rent_exemption_lamports, - self.rent_config.compression_cost(), - ); - let rent_per_epoch = self.rent_config.rent_curve_per_epoch(num_bytes); - let epochs_funded_ahead = available_balance / rent_per_epoch; + )?; + + // Calculate how many epochs ahead of current epoch the account is funded + // last_funded_epoch_number is the epoch number (e.g., 1), so we add 1 to get count + // (epochs 0 and 1 = 2 epochs funded) + let current_epoch = crate::rent::slot_to_epoch(current_slot); + let epochs_funded_ahead = + (last_funded_epoch_number.saturating_add(1)).saturating_sub(current_epoch); + // Skip top-up if already funded for max_funded_epochs or more if epochs_funded_ahead >= self.rent_config.max_funded_epochs as u64 { Ok(0) diff --git a/program-libs/compressible/tests/compression_info.rs b/program-libs/compressible/tests/compression_info.rs index ae755c4e5b..ff6913a53f 100644 --- a/program-libs/compressible/tests/compression_info.rs +++ b/program-libs/compressible/tests/compression_info.rs @@ -406,13 +406,13 @@ fn test_calculate_top_up_lamports() { description: "Epoch 1: available_balance=775 (1.997 epochs), required=776 (2 epochs), compressible with 1 lamport deficit", }, TestCase { - name: "exact boundary - not compressible by exact match", + name: "exact boundary - lagging claim requires top-up", current_slot: SLOTS_PER_EPOCH, current_lamports: rent_exemption_lamports + FULL_COMPRESSION_COSTS + (RENT_PER_EPOCH * 2), last_claimed_slot: 0, lamports_per_write, - expected_top_up: 0, - description: "Epoch 1: available_balance=776 == required=776 (2 epochs), not compressible, epochs_funded_ahead=2", + expected_top_up: lamports_per_write as u64, + description: "Epoch 1: last_claimed=epoch 0, funded through epoch 1, epochs_funded_ahead=1 < max=2", }, // ============================================================ // PATH 2: NOT COMPRESSIBLE, NEEDS TOP-UP (lamports_per_write) @@ -472,7 +472,7 @@ fn test_calculate_top_up_lamports() { last_claimed_slot: 0, lamports_per_write, expected_top_up: 0, - description: "Epoch 0: not compressible, epochs_funded_ahead=2 >= max_funded_epochs=2, no top-up needed", + description: "Epoch 0: last_claimed=epoch 0, funded through epoch 1, epochs_funded_ahead=2 >= max=2", }, TestCase { name: "3 epochs when max is 2", @@ -487,7 +487,7 @@ fn test_calculate_top_up_lamports() { name: "2 epochs at epoch 1 boundary", current_slot: SLOTS_PER_EPOCH, current_lamports: rent_exemption_lamports + FULL_COMPRESSION_COSTS + (RENT_PER_EPOCH * 2), - last_claimed_slot: 0, + last_claimed_slot: SLOTS_PER_EPOCH, lamports_per_write, expected_top_up: 0, description: "Epoch 1: not compressible (has 776 for required 776), epochs_funded_ahead=2 >= max_funded_epochs=2", From 926e776e3bbadae4f05c87d6071eb60366c762d8 Mon Sep 17 00:00:00 2001 From: ananas Date: Mon, 17 Nov 2025 23:30:02 +0000 Subject: [PATCH 2/2] add test --- Cargo.lock | 3 + program-tests/registry-test/Cargo.toml | 3 + .../registry-test/tests/compressible.rs | 287 ++++++++++++++++++ sdk-libs/program-test/src/compressible.rs | 85 ++++-- 4 files changed, 349 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bdae838091..41327eec4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5587,6 +5587,8 @@ version = "1.1.0" dependencies = [ "account-compression", "anchor-lang", + "anchor-spl", + "borsh 0.10.4", "forester-utils", "light-account-checks", "light-batched-merkle-tree", @@ -5601,6 +5603,7 @@ dependencies = [ "light-registry", "light-test-utils", "light-token-client", + "light-zero-copy", "serial_test", "solana-sdk", "tokio", diff --git a/program-tests/registry-test/Cargo.toml b/program-tests/registry-test/Cargo.toml index 93ce8cdd95..5f9d6506b1 100644 --- a/program-tests/registry-test/Cargo.toml +++ b/program-tests/registry-test/Cargo.toml @@ -27,6 +27,7 @@ tokio = { workspace = true } light-prover-client = { workspace = true, features = ["devenv"] } light-client = { workspace = true, features = ["devenv"] } anchor-lang = { workspace = true } +anchor-spl = { workspace = true } forester-utils = { workspace = true } light-registry = { workspace = true } account-compression = { workspace = true } @@ -40,3 +41,5 @@ light-compressed-token-sdk = { workspace = true } light-compressible = { workspace = true } light-token-client = { workspace = true } light-ctoken-types = { workspace = true } +light-zero-copy = { workspace = true } +borsh = { workspace = true } diff --git a/program-tests/registry-test/tests/compressible.rs b/program-tests/registry-test/tests/compressible.rs index 71393130c5..327aba5d4d 100644 --- a/program-tests/registry-test/tests/compressible.rs +++ b/program-tests/registry-test/tests/compressible.rs @@ -7,6 +7,7 @@ use light_compressed_token_sdk::instructions::derive_ctoken_ata; use light_compressible::{ config::CompressibleConfig, error::CompressibleError, rent::SLOTS_PER_EPOCH, }; +use light_ctoken_types::state::{CToken, ExtensionStruct}; use light_program_test::{ forester::claim_forester, program_test::TestRpc, utils::assert::assert_rpc_error, LightProgramTest, ProgramTestConfig, @@ -1059,3 +1060,289 @@ async fn test_update_compressible_config_invalid_authority() -> Result<(), RpcEr Ok(()) } + +/// Helper function to assert that a compressible account is NOT compressible (well-funded) +async fn assert_not_compressible( + rpc: &mut R, + account_pubkey: Pubkey, + name: &str, +) -> Result<(), RpcError> { + use borsh::BorshDeserialize; + use light_ctoken_types::state::{CToken, ExtensionStruct}; + + let account = rpc + .get_account(account_pubkey) + .await? + .ok_or_else(|| RpcError::AssertRpcError(format!("{} account not found", name)))?; + + let ctoken = CToken::deserialize(&mut account.data.as_slice()) + .map_err(|e| RpcError::AssertRpcError(format!("Failed to deserialize CToken: {:?}", e)))?; + + if let Some(extensions) = ctoken.extensions.as_ref() { + for ext in extensions.iter() { + if let ExtensionStruct::Compressible(compressible_ext) = ext { + let current_slot = rpc.get_slot().await?; + + // Check if account is compressible using AccountRentState + let state = light_compressible::rent::AccountRentState { + num_bytes: account.data.len() as u64, + current_slot, + current_lamports: account.lamports, + last_claimed_slot: compressible_ext.last_claimed_slot, + }; + let is_compressible = state.is_compressible( + &compressible_ext.rent_config, + light_ctoken_types::COMPRESSIBLE_TOKEN_RENT_EXEMPTION, + ); + + assert!( + is_compressible.is_none(), + "{} should NOT be compressible (well-funded), but has deficit: {:?}", + name, + is_compressible + ); + + // Also verify last_funded_epoch is ahead of current + let last_funded_epoch = compressible_ext + .get_last_funded_epoch( + account.data.len() as u64, + account.lamports, + light_ctoken_types::COMPRESSIBLE_TOKEN_RENT_EXEMPTION, + ) + .map_err(|e| { + RpcError::AssertRpcError(format!( + "Failed to get last funded epoch: {:?}", + e + )) + })?; + + let current_epoch = light_compressible::rent::slot_to_epoch(current_slot); + + assert!( + last_funded_epoch >= current_epoch, + "{} last_funded_epoch ({}) should be >= current_epoch ({})", + name, + last_funded_epoch, + current_epoch + ); + + return Ok(()); + } + } + } + + Err(RpcError::AssertRpcError(format!( + "{} does not have compressible extension", + name + ))) +} + +#[tokio::test] +async fn test_compressible_account_infinite_funding() -> Result<(), RpcError> { + use light_test_utils::assert_ctoken_transfer::assert_ctoken_transfer; + use light_token_client::actions::ctoken_transfer; + + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + let payer = rpc.get_payer().insecure_clone(); + let mint = create_mint_helper(&mut rpc, &payer).await; + + // Create owner for both accounts + let owner_keypair = Keypair::new(); + let owner_pubkey = owner_keypair.pubkey(); + airdrop_lamports(&mut rpc, &owner_pubkey, 100_000_000_000) + .await + .unwrap(); + + // Fund rent sponsor with sufficient lamports + let rent_sponsor = rpc.test_accounts.funding_pool_config.rent_sponsor_pda; + airdrop_lamports(&mut rpc, &rent_sponsor, 100_000_000_000) + .await + .unwrap(); + + // Create Account A (will hold tokens initially) + let account_a = create_compressible_token_account( + &mut rpc, + CreateCompressibleTokenAccountInputs { + owner: owner_pubkey, + mint, + num_prepaid_epochs: 2, + payer: &payer, + token_account_keypair: None, + lamports_per_write: Some(100), + token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + }, + ) + .await + .unwrap(); + + // Create Account B (initially empty) + let account_b = create_compressible_token_account( + &mut rpc, + CreateCompressibleTokenAccountInputs { + owner: owner_pubkey, + mint, + num_prepaid_epochs: 2, + payer: &payer, + token_account_keypair: None, + lamports_per_write: Some(100), + token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + }, + ) + .await + .unwrap(); + + // Mint 1,000,000 tokens to Account A + let transfer_amount = 1_000_000u64; + { + use light_ctoken_types::state::CToken; + use light_zero_copy::traits::ZeroCopyAtMut; + + let mut account_data = rpc.get_account(account_a).await?.unwrap(); + let (mut ctoken, _) = CToken::zero_copy_at_mut(&mut account_data.data) + .map_err(|e| RpcError::AssertRpcError(format!("Failed to parse CToken: {:?}", e)))?; + *ctoken.amount = transfer_amount.into(); + rpc.set_account(account_a, account_data); + } + + let account_a_data = rpc.get_account(account_a).await?.unwrap(); + let ctoken_a = CToken::deserialize(&mut account_a_data.data.as_slice()) + .map_err(|e| RpcError::AssertRpcError(format!("Failed to deserialize CToken: {:?}", e)))?; + + let rent_config = ctoken_a + .extensions + .as_ref() + .and_then(|exts| { + exts.iter().find_map(|ext| { + if let ExtensionStruct::Compressible(comp) = ext { + Some(comp.rent_config) + } else { + None + } + }) + }) + .ok_or_else(|| RpcError::AssertRpcError("No compressible extension found".to_string()))?; + + let account_size = account_a_data.data.len() as u64; + let rent_per_epoch = rent_config.rent_curve_per_epoch(account_size); + + println!("Starting infinite funding test: 1000 iterations over 100 epochs"); + println!("Rent per epoch: {} lamports", rent_per_epoch); + println!("Account size: {} bytes", account_size); + + // Track rent sponsor balance before starting + let initial_rent_sponsor_balance = rpc.get_account(rent_sponsor).await?.unwrap().lamports; + + // Get initial slot and last_claimed_slot from both accounts + let initial_slot = rpc.get_slot().await?; + + let get_last_claimed_slot = |account_data: &[u8]| -> Result { + let ctoken = CToken::deserialize(&mut &account_data[..]).map_err(|e| { + RpcError::AssertRpcError(format!("Failed to deserialize CToken: {:?}", e)) + })?; + + if let Some(extensions) = ctoken.extensions.as_ref() { + for ext in extensions.iter() { + if let ExtensionStruct::Compressible(comp) = ext { + return Ok(comp.last_claimed_slot); + } + } + } + Err(RpcError::AssertRpcError( + "No compressible extension".to_string(), + )) + }; + + let initial_last_claimed_a = + get_last_claimed_slot(&rpc.get_account(account_a).await?.unwrap().data)?; + let initial_last_claimed_b = + get_last_claimed_slot(&rpc.get_account(account_b).await?.unwrap().data)?; + + println!("Initial slot: {}", initial_slot); + println!( + "Account A initial last_claimed_slot: {}", + initial_last_claimed_a + ); + println!( + "Account B initial last_claimed_slot: {}", + initial_last_claimed_b + ); + + // Main loop: 1000 iterations = 100 epochs * 10 iterations per epoch + for i in 0..1000 { + let epoch = i / 10; + + // Determine transfer direction (alternate each iteration) + let (source, dest, source_name, dest_name) = if i % 2 == 0 { + (account_a, account_b, "Account A", "Account B") + } else { + (account_b, account_a, "Account B", "Account A") + }; + + // Transfer all tokens from source to dest + ctoken_transfer( + &mut rpc, + source, + dest, + transfer_amount, + &owner_keypair, + &payer, + ) + .await + .map_err(|e| { + RpcError::AssertRpcError(format!("Transfer failed at iteration {}: {:?}", i, e)) + })?; + + // Assert the transfer succeeded + assert_ctoken_transfer(&mut rpc, source, dest, transfer_amount).await; + + // Assert both accounts are still well-funded (NOT compressible) + assert_not_compressible(&mut rpc, source, source_name).await?; + assert_not_compressible(&mut rpc, dest, dest_name).await?; + + // Advance by 1/10 of an epoch (630 slots) + let advance_slots = SLOTS_PER_EPOCH / 10; // 630 slots + rpc.warp_slot_forward(advance_slots).await.unwrap(); + + // Log progress every 100 iterations + if i % 100 == 0 && i > 0 { + println!("Completed iteration {}/1000 (epoch {})", i, epoch); + } + } + + println!("Test completed successfully!"); + println!("Both accounts remained well-funded through 100 epochs of continuous transfers"); + + // Final verification + assert_not_compressible(&mut rpc, account_a, "Account A (final)").await?; + assert_not_compressible(&mut rpc, account_b, "Account B (final)").await?; + + // Verify total rent claimed + let final_rent_sponsor_balance = rpc.get_account(rent_sponsor).await?.unwrap().lamports; + let total_rent_claimed = final_rent_sponsor_balance - initial_rent_sponsor_balance; + + // Get final last_claimed_slot from both accounts + let final_last_claimed_a = + get_last_claimed_slot(&rpc.get_account(account_a).await?.unwrap().data)?; + let final_last_claimed_b = + get_last_claimed_slot(&rpc.get_account(account_b).await?.unwrap().data)?; + + // Calculate exact number of completed epochs that were claimed for each account + use light_compressible::rent::SLOTS_PER_EPOCH; + let completed_epochs_a = (final_last_claimed_a - initial_last_claimed_a) / SLOTS_PER_EPOCH; + let completed_epochs_b = (final_last_claimed_b - initial_last_claimed_b) / SLOTS_PER_EPOCH; + + // Calculate exact expected rent using RentConfig's rent_curve_per_epoch + let expected_rent_a = rent_config.get_rent(account_size, completed_epochs_a); + let expected_rent_b = rent_config.get_rent(account_size, completed_epochs_b); + let expected_total_rent = expected_rent_a + expected_rent_b; + + // Assert exact match + assert_eq!( + total_rent_claimed, expected_total_rent, + "Rent claimed should exactly match expected rent" + ); + + Ok(()) +} diff --git a/sdk-libs/program-test/src/compressible.rs b/sdk-libs/program-test/src/compressible.rs index 65a6dceaca..e914e28a34 100644 --- a/sdk-libs/program-test/src/compressible.rs +++ b/sdk-libs/program-test/src/compressible.rs @@ -124,45 +124,72 @@ pub async fn claim_and_compress( } let current_slot = rpc.get_slot().await?; - let compressible_accounts = { - stored_compressible_accounts - .iter() - .filter(|a| a.1.last_paid_slot < current_slot) - .map(|e| e.1) - .collect::>() - }; - - let claim_able_accounts = stored_compressible_accounts - .iter() - .filter(|a| a.1.last_paid_slot >= current_slot) - .map(|e| *e.0) - .collect::>(); + let rent_exemption = rpc + .get_minimum_balance_for_rent_exemption(COMPRESSIBLE_TOKEN_ACCOUNT_SIZE as usize) + .await?; + + let mut compress_accounts = Vec::new(); + let mut claim_accounts = Vec::new(); + + // For each stored account, determine action using AccountRentState + for (pubkey, stored_account) in stored_compressible_accounts.iter() { + let account = rpc.get_account(*pubkey).await?.unwrap(); + + // Get compressible extension + if let Some(extensions) = stored_account.account.extensions.as_ref() { + for extension in extensions.iter() { + if let ExtensionStruct::Compressible(comp_ext) = extension { + use light_compressible::rent::AccountRentState; + + // Create state for rent calculation + let state = AccountRentState { + num_bytes: account.data.len() as u64, + current_slot, + current_lamports: account.lamports, + last_claimed_slot: comp_ext.last_claimed_slot, + }; + + // Check what action is needed + match state.calculate_claimable_rent(&comp_ext.rent_config, rent_exemption) { + None => { + // Account is compressible (has rent deficit) + compress_accounts.push(*pubkey); + } + Some(claimable_amount) if claimable_amount > 0 => { + // Has rent to claim from completed epochs + claim_accounts.push(*pubkey); + } + Some(_) => { + // Well-funded, nothing to claim (0 completed epochs) + // Do nothing - skip this account + } + } + } + } + } + } // Process claimable accounts in batches - for token_accounts in claim_able_accounts.as_slice().chunks(20) { - println!("Claim from : {:?}", token_accounts); - // Use the new claim_forester function to claim via registry program + for token_accounts in claim_accounts.as_slice().chunks(20) { + println!( + "Claim from {} accounts: {:?}", + token_accounts.len(), + token_accounts + ); claim_forester(rpc, token_accounts, &forester_keypair, &payer).await?; } // Process compressible accounts in batches - const BATCH_SIZE: usize = 10; // Process up to 10 accounts at a time - let mut pubkeys = Vec::with_capacity(compressible_accounts.len()); - for chunk in compressible_accounts.chunks(BATCH_SIZE) { - let chunk_pubkeys: Vec = chunk.iter().map(|e| e.pubkey).collect(); - println!("Compress and close: {:?}", chunk_pubkeys); - - // Use the new compress_and_close_forester function via registry program - compress_and_close_forester(rpc, &chunk_pubkeys, &forester_keypair, &payer, None).await?; + const BATCH_SIZE: usize = 10; + for chunk in compress_accounts.chunks(BATCH_SIZE) { + println!("Compress and close {} accounts: {:?}", chunk.len(), chunk); + compress_and_close_forester(rpc, chunk, &forester_keypair, &payer, None).await?; - // Remove processed accounts from the HashMap + // Remove compressed accounts from HashMap for account_pubkey in chunk { - pubkeys.push(account_pubkey.pubkey); + stored_compressible_accounts.remove(account_pubkey); } } - for pubkey in pubkeys { - stored_compressible_accounts.remove(&pubkey); - } Ok(()) }