From af56afae4725f6ebc9e4c42b11eda9dc1cf53d2c Mon Sep 17 00:00:00 2001 From: ananas-block Date: Tue, 2 Dec 2025 19:48:37 +0000 Subject: [PATCH 1/2] fix: correct epochs_funded_ahead calculation for lagging claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When last_claimed_slot lagged behind current_slot, the epochs_funded_ahead calculation incorrectly counted arrears as "ahead" epochs. This caused top-ups to be skipped when they should have triggered. Example of the bug: - last_claimed_epoch=0, current_epoch=3, available_balance=5*rent_per_epoch - Old: epochs_funded_ahead = (4+1) - 3 = 2 >= max=2 → skip top-up - Fixed: epochs_funded_ahead = 4 - 3 = 1 < max=2 → trigger top-up Changes: - program-libs/compressible: Remove +1 from epochs_funded_ahead calculation - sdk-libs/sdk: Subtract required_epochs from total_epochs_fundable - Add test cases for lagging claim scenarios --- .../compressible/src/compression_info.rs | 12 +---- .../compressible/src/rent/account_rent.rs | 22 ++++++++ .../compressible/tests/compression_info.rs | 50 +++++++++++++++---- .../sdk/src/compressible/compression_info.rs | 11 ++-- 4 files changed, 66 insertions(+), 29 deletions(-) diff --git a/program-libs/compressible/src/compression_info.rs b/program-libs/compressible/src/compression_info.rs index e838c6a44e..1a5a918cf7 100644 --- a/program-libs/compressible/src/compression_info.rs +++ b/program-libs/compressible/src/compression_info.rs @@ -109,18 +109,8 @@ macro_rules! impl_is_compressible { if let Some(rent_deficit) = is_compressible { Ok(lamports_per_write as u64 + rent_deficit) } else { - let last_funded_epoch_number = self.get_last_funded_epoch( - num_bytes, - current_lamports, - rent_exemption_lamports, - )?; - - // 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); + state.epochs_funded_ahead(&self.rent_config, rent_exemption_lamports); // Skip top-up if already funded for max_funded_epochs or more if epochs_funded_ahead >= self.rent_config.max_funded_epochs as u64 { diff --git a/program-libs/compressible/src/rent/account_rent.rs b/program-libs/compressible/src/rent/account_rent.rs index 7152ad07c9..f24a0601ce 100644 --- a/program-libs/compressible/src/rent/account_rent.rs +++ b/program-libs/compressible/src/rent/account_rent.rs @@ -158,6 +158,28 @@ impl AccountRentState { available_balance.saturating_sub(lamports_due) } + + /// Calculate how many epochs AFTER the current epoch are funded. + /// + /// # Parameters + /// - `config`: Rent configuration (provides compression_cost and rent curve) + /// - `rent_exemption_lamports`: Solana's required minimum balance + /// + /// # Returns + /// Number of epochs funded beyond the current epoch (0 if none) + #[inline(always)] + pub fn epochs_funded_ahead( + &self, + config: &impl RentConfigTrait, + rent_exemption_lamports: u64, + ) -> u64 { + let available_balance = + self.get_available_rent_balance(rent_exemption_lamports, config.compression_cost()); + let rent_per_epoch = config.rent_curve_per_epoch(self.num_bytes); + let total_epochs_fundable = available_balance / rent_per_epoch; + let required_epochs = self.get_required_epochs::(); + total_epochs_fundable.saturating_sub(required_epochs) + } } /// Distribution of lamports when closing an account diff --git a/program-libs/compressible/tests/compression_info.rs b/program-libs/compressible/tests/compression_info.rs index 11779fc338..40da5d3058 100644 --- a/program-libs/compressible/tests/compression_info.rs +++ b/program-libs/compressible/tests/compression_info.rs @@ -466,31 +466,31 @@ fn test_calculate_top_up_lamports() { // PATH 3: WELL-FUNDED (0 lamports) // ============================================================ TestCase { - name: "exactly max_funded_epochs (2)", + name: "2 epochs funded - needs top-up (only 1 ahead)", current_slot: 0, 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 0: last_claimed=epoch 0, funded through epoch 1, epochs_funded_ahead=2 >= max=2", + expected_top_up: lamports_per_write as u64, + description: "Epoch 0: funded through epoch 1, epochs_funded_ahead=1 < max=2, needs top-up", }, TestCase { - name: "3 epochs when max is 2", + name: "3 epochs when max is 2 - exactly at target", current_slot: 0, current_lamports: rent_exemption_lamports + FULL_COMPRESSION_COSTS + (RENT_PER_EPOCH * 3), last_claimed_slot: 0, lamports_per_write, expected_top_up: 0, - description: "Epoch 0: not compressible, epochs_funded_ahead=3 > max_funded_epochs=2", + description: "Epoch 0: funded through epoch 2, epochs_funded_ahead=2 >= max_funded_epochs=2", }, TestCase { - name: "2 epochs at epoch 1 boundary", + name: "2 epochs at epoch 1 boundary - needs top-up", current_slot: SLOTS_PER_EPOCH, current_lamports: rent_exemption_lamports + FULL_COMPRESSION_COSTS + (RENT_PER_EPOCH * 2), 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", + expected_top_up: lamports_per_write as u64, + description: "Epoch 1: funded through epoch 2, epochs_funded_ahead=1 < max_funded_epochs=2", }, // ============================================================ // EDGE CASES @@ -505,13 +505,13 @@ fn test_calculate_top_up_lamports() { description: "Zero write fee + compressible state: top_up = 0 + deficit (rent + compression_cost)", }, TestCase { - name: "zero lamports_per_write - well-funded case", + name: "zero lamports_per_write - needs top-up but write fee is 0", current_slot: 0, current_lamports: rent_exemption_lamports + FULL_COMPRESSION_COSTS + (RENT_PER_EPOCH * 2), last_claimed_slot: 0, lamports_per_write: 0, expected_top_up: 0, - description: "Zero write fee + well-funded: epochs_funded_ahead=2 >= max_funded_epochs=2, top_up=0", + description: "Zero write fee: epochs_funded_ahead=1 < max=2, but lamports_per_write=0 so top_up=0", }, TestCase { name: "large lamports_per_write", @@ -531,6 +531,36 @@ fn test_calculate_top_up_lamports() { expected_top_up: lamports_per_write as u64 + RENT_PER_EPOCH + FULL_COMPRESSION_COSTS, description: "Invalid state: current_lamports < rent_exemption+compression_cost, saturating_sub → available_balance=0", }, + // ============================================================ + // LAGGING CLAIMS - verifies arrears don't count as "funded ahead" + // ============================================================ + TestCase { + name: "lagging claim - 5 epochs funded but only 1 ahead due to arrears", + current_slot: SLOTS_PER_EPOCH * 3, // epoch 3 + current_lamports: rent_exemption_lamports + FULL_COMPRESSION_COSTS + (RENT_PER_EPOCH * 5), + last_claimed_slot: 0, // epoch 0 - lagging 3 epochs behind + lamports_per_write, + expected_top_up: lamports_per_write as u64, + description: "Epoch 3: 5 epochs funded from epoch 0 covers through epoch 4. epochs_funded_ahead = 4 - 3 = 1 < max=2, needs top-up", + }, + TestCase { + name: "lagging claim - 6 epochs funded, exactly 2 ahead", + current_slot: SLOTS_PER_EPOCH * 3, // epoch 3 + current_lamports: rent_exemption_lamports + FULL_COMPRESSION_COSTS + (RENT_PER_EPOCH * 6), + last_claimed_slot: 0, // epoch 0 - lagging 3 epochs behind + lamports_per_write, + expected_top_up: 0, + description: "Epoch 3: 6 epochs funded from epoch 0 covers through epoch 5. epochs_funded_ahead = 5 - 3 = 2 >= max=2, no top-up", + }, + TestCase { + name: "lagging claim - just covers arrears plus current+next, no buffer", + current_slot: SLOTS_PER_EPOCH * 3, // epoch 3 + current_lamports: rent_exemption_lamports + FULL_COMPRESSION_COSTS + (RENT_PER_EPOCH * 4), + last_claimed_slot: 0, // epoch 0 + lamports_per_write, + expected_top_up: lamports_per_write as u64, + description: "Epoch 3: 4 epochs funded (0,1,2,3) covers through epoch 3. epochs_funded_ahead = 3 - 3 = 0 < max=2, needs top-up", + }, ]; for test_case in test_cases { diff --git a/sdk-libs/sdk/src/compressible/compression_info.rs b/sdk-libs/sdk/src/compressible/compression_info.rs index 35d9e6f9d3..273eb0ed59 100644 --- a/sdk-libs/sdk/src/compressible/compression_info.rs +++ b/sdk-libs/sdk/src/compressible/compression_info.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use light_compressible::rent::{RentConfig, RentConfigTrait}; +use light_compressible::rent::RentConfig; use light_sdk_types::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress; use solana_account_info::AccountInfo; use solana_clock::Clock; @@ -166,13 +166,8 @@ impl CompressionInfo { return self.lamports_per_write as u64 + rent_deficit; } - // Calculate how many epochs we're funded for - let available_balance = state.get_available_rent_balance( - 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; + let epochs_funded_ahead = + state.epochs_funded_ahead(&self.rent_config, rent_exemption_lamports); // If already at or above target, no top-up needed (cruise control) if epochs_funded_ahead >= self.rent_config.max_funded_epochs as u64 { From 1cfedfb6390e560c2365ff0ce11d43a03b6c83d6 Mon Sep 17 00:00:00 2001 From: ananas-block Date: Wed, 3 Dec 2025 18:20:04 +0000 Subject: [PATCH 2/2] fix: tests --- .../compressed-token-test/tests/ctoken/functional.rs | 7 ++++--- .../compressed-token-test/tests/ctoken/transfer.rs | 12 +++++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/program-tests/compressed-token-test/tests/ctoken/functional.rs b/program-tests/compressed-token-test/tests/ctoken/functional.rs index 15806a069c..5d2ead3955 100644 --- a/program-tests/compressed-token-test/tests/ctoken/functional.rs +++ b/program-tests/compressed-token-test/tests/ctoken/functional.rs @@ -123,7 +123,7 @@ async fn test_compressible_account_with_compression_authority_lifecycle() { .await .unwrap(); - let num_prepaid_epochs = 2; + let num_prepaid_epochs = 3; // 3 epochs for no top-up: epochs_funded_ahead = 3 - 1 = 2 >= 2 let lamports_per_write = Some(100); // Initialize compressible token account @@ -215,10 +215,11 @@ async fn test_compressible_account_with_compression_authority_lifecycle() { // Calculate transaction fee from the transaction result let tx_fee = 10_000; // Standard transaction fee + // With 3 prepaid epochs: compression_cost (11000) + 3 * rent_per_epoch (388) = 12164 assert_eq!( payer_balance_before - payer_balance_after, - 11_776 + tx_fee, - "Payer should have paid exactly 14,830 lamports for additional rent (1 epoch) plus {} tx fee", + 12_164 + tx_fee, + "Payer should have paid 12,164 lamports for additional rent (3 epochs) plus {} tx fee", tx_fee ); diff --git a/program-tests/compressed-token-test/tests/ctoken/transfer.rs b/program-tests/compressed-token-test/tests/ctoken/transfer.rs index c90709525c..a0579f6126 100644 --- a/program-tests/compressed-token-test/tests/ctoken/transfer.rs +++ b/program-tests/compressed-token-test/tests/ctoken/transfer.rs @@ -205,9 +205,10 @@ async fn test_ctoken_transfer_basic_non_compressible() { #[tokio::test] async fn test_ctoken_transfer_compressible_no_topup() { - // Create compressible accounts with 2 prepaid epochs (sufficient, no top-up needed) + // Create compressible accounts with 3 prepaid epochs (sufficient for max_funded_epochs=2, no top-up needed) + // epochs_funded_ahead = total_epochs_fundable - required_epochs = 3 - 1 = 2 >= 2 let (mut context, source, destination, _mint_amount, _source_keypair, _dest_keypair) = - setup_transfer_test(Some(2), 1000).await.unwrap(); + setup_transfer_test(Some(3), 1000).await.unwrap(); // Use the owner keypair as authority (token accounts are owned by context.owner_keypair) let owner_keypair = context.owner_keypair.insecure_clone(); @@ -225,9 +226,10 @@ async fn test_ctoken_transfer_compressible_no_topup() { #[tokio::test] async fn test_ctoken_transfer_compressible_with_topup() { - // Create compressible accounts with 2 prepaid epochs + // Create compressible accounts with 3 prepaid epochs (sufficient for max_funded_epochs=2, no top-up needed) + // epochs_funded_ahead = total_epochs_fundable - required_epochs = 3 - 1 = 2 >= 2 let (mut context, source, destination, _mint_amount, _source_keypair, _dest_keypair) = - setup_transfer_test(Some(2), 1000).await.unwrap(); + setup_transfer_test(Some(3), 1000).await.unwrap(); // For this test, we need to transfer ownership to the payer so it can pay for top-ups // Or we can use a delegate. But the simplest is to use payer as authority for this specific test. // Actually, the owner needs to be the authority for the transfer to work. @@ -451,7 +453,7 @@ async fn test_ctoken_transfer_mixed_compressible_non_compressible() { let compressible_data = CompressibleData { compression_authority: context.compression_authority, rent_sponsor: context.rent_sponsor, - num_prepaid_epochs: 2, + num_prepaid_epochs: 3, // 3 epochs for no top-up: epochs_funded_ahead = 3 - 1 = 2 >= 2 lamports_per_write: Some(100), account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, compress_to_pubkey: false,