Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 1 addition & 11 deletions program-libs/compressible/src/compression_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions program-libs/compressible/src/rent/account_rent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<true>();
total_epochs_fundable.saturating_sub(required_epochs)
}
}

/// Distribution of lamports when closing an account
Expand Down
50 changes: 40 additions & 10 deletions program-libs/compressible/tests/compression_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
);

Expand Down
12 changes: 7 additions & 5 deletions program-tests/compressed-token-test/tests/ctoken/transfer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 3 additions & 8 deletions sdk-libs/sdk/src/compressible/compression_info.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Loading