diff --git a/CHANGELOG.md b/CHANGELOG.md index 46f3f645..de28b888 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Honor the configured transaction history capacity independently of its backing allocation, including after resizing, re-enabling, and snapshot restoration. + ## [0.18.1] - 2026-10-19 ### Changed diff --git a/crates/litesvm/src/history.rs b/crates/litesvm/src/history.rs index 6accae96..b246d1e9 100644 --- a/crates/litesvm/src/history.rs +++ b/crates/litesvm/src/history.rs @@ -1,57 +1,62 @@ use {crate::types::TransactionResult, indexmap::IndexMap, solana_signature::Signature}; #[derive(Clone)] -pub struct TransactionHistory(IndexMap); +pub struct TransactionHistory { + entries: IndexMap, + capacity: usize, +} impl TransactionHistory { pub fn new() -> Self { - TransactionHistory(IndexMap::with_capacity(32)) + Self { + entries: IndexMap::with_capacity(32), + capacity: 32, + } } pub fn set_capacity(&mut self, new_cap: usize) { - if new_cap <= self.0.capacity() { - self.0.truncate(new_cap); - self.0.shrink_to_fit(); - } else { - self.0.reserve(new_cap - self.0.capacity()) + if new_cap <= self.capacity { + self.entries.truncate(new_cap); + self.entries.shrink_to_fit(); } + self.capacity = new_cap; } pub fn get_transaction(&self, signature: &Signature) -> Option<&TransactionResult> { - self.0.get(signature) + self.entries.get(signature) } pub fn is_enabled(&self) -> bool { - self.0.capacity() != 0 + self.capacity != 0 } pub fn add_new_transaction(&mut self, signature: Signature, result: TransactionResult) { - let capacity = self.0.capacity(); + let capacity = self.capacity; if capacity != 0 { - if self.0.len() == capacity { - self.0.shift_remove_index(0); + if self.entries.len() == capacity { + self.entries.shift_remove_index(0); } - self.0.insert(signature, result); + self.entries.insert(signature, result); } } pub fn check_transaction(&self, signature: &Signature) -> bool { - self.0.contains_key(signature) + self.entries.contains_key(signature) } #[cfg(feature = "persistence-internal")] pub fn entries(&self) -> &IndexMap { - &self.0 + &self.entries } #[cfg(feature = "persistence-internal")] pub fn capacity(&self) -> usize { - self.0.capacity() + self.capacity } #[cfg(feature = "persistence-internal")] pub fn from_entries(entries: IndexMap, capacity: usize) -> Self { - let mut history = TransactionHistory(entries); + let mut history = Self { entries, capacity }; history.set_capacity(capacity); history } diff --git a/crates/litesvm/tests/history_capacity.rs b/crates/litesvm/tests/history_capacity.rs new file mode 100644 index 00000000..3c5732ae --- /dev/null +++ b/crates/litesvm/tests/history_capacity.rs @@ -0,0 +1,116 @@ +use { + litesvm::LiteSVM, solana_address::Address, solana_keypair::Keypair, solana_signer::Signer, + solana_system_interface::instruction::transfer, solana_transaction::Transaction, + solana_transaction_error::TransactionError, +}; + +fn fixture(capacity: usize) -> (LiteSVM, Keypair, Address) { + let mut svm = LiteSVM::new().with_transaction_history(capacity); + let payer = Keypair::new(); + let recipient = Address::new_unique(); + svm.airdrop(&payer.pubkey(), 10_000_000).unwrap(); + svm.airdrop(&recipient, 1_000_000).unwrap(); + (svm, payer, recipient) +} + +fn transaction(svm: &LiteSVM, payer: &Keypair, recipient: &Address, amount: u64) -> Transaction { + Transaction::new_signed_with_payer( + &[transfer(&payer.pubkey(), recipient, amount)], + Some(&payer.pubkey()), + &[payer], + svm.latest_blockhash(), + ) +} + +#[test] +fn configured_capacity_retains_exactly_the_latest_transactions() { + for capacity in [1, 2, 4, 32, 33] { + let (mut svm, payer, recipient) = fixture(capacity); + let mut signatures = Vec::new(); + for amount in 1..=capacity as u64 + 2 { + let tx = transaction(&svm, &payer, &recipient, amount); + let meta = svm.send_transaction(tx).unwrap(); + signatures.push(meta.signature); + } + for (index, signature) in signatures.iter().enumerate() { + assert_eq!( + svm.get_transaction(signature).is_some(), + index >= 2, + "capacity {capacity}, transaction {index}" + ); + } + let tx = transaction(&svm, &payer, &recipient, capacity as u64 + 2); + let balance = svm.get_balance(&payer.pubkey()); + assert_eq!( + svm.send_transaction(tx).unwrap_err().err, + TransactionError::AlreadyProcessed + ); + assert_eq!(svm.get_balance(&payer.pubkey()), balance); + } +} + +#[test] +fn increasing_capacity_with_few_entries_uses_the_requested_limit() { + let (mut svm, payer, recipient) = fixture(32); + svm = svm.with_transaction_history(33); + let mut signatures = Vec::new(); + for amount in 1..=34 { + let tx = transaction(&svm, &payer, &recipient, amount); + signatures.push(svm.send_transaction(tx).unwrap().signature); + } + assert!(svm.get_transaction(&signatures[0]).is_none()); + for signature in &signatures[1..] { + assert!(svm.get_transaction(signature).is_some()); + } +} + +#[test] +fn setting_capacity_after_partial_use_keeps_the_configured_limit() { + let (mut svm, payer, recipient) = fixture(32); + svm = svm.with_transaction_history(8); + svm = svm.with_transaction_history(8); + let mut signatures = Vec::new(); + for amount in 1..=9 { + let tx = transaction(&svm, &payer, &recipient, amount); + signatures.push(svm.send_transaction(tx).unwrap().signature); + } + assert!(svm.get_transaction(&signatures[0]).is_none()); + for signature in &signatures[1..] { + assert!(svm.get_transaction(signature).is_some()); + } +} + +#[test] +fn history_can_be_disabled_and_reenabled_with_capacity_one() { + let (mut svm, payer, recipient) = fixture(32); + let tx = transaction(&svm, &payer, &recipient, 1); + let signature = tx.signatures[0]; + svm.send_transaction(tx.clone()).unwrap(); + svm = svm.with_transaction_history(0); + assert!(svm.get_transaction(&signature).is_none()); + svm.send_transaction(tx.clone()).unwrap(); + assert!(svm.get_transaction(&signature).is_none()); + svm = svm.with_transaction_history(1); + svm.send_transaction(tx).unwrap(); + assert!(svm.get_transaction(&signature).is_some()); + let next = transaction(&svm, &payer, &recipient, 2); + let next_signature = svm.send_transaction(next).unwrap().signature; + assert!(svm.get_transaction(&signature).is_none()); + assert!(svm.get_transaction(&next_signature).is_some()); +} + +#[test] +fn failed_transactions_use_the_same_retention_limit() { + let (mut svm, payer, recipient) = fixture(1); + let tx = transaction(&svm, &payer, &recipient, u64::MAX); + let signature = tx.signatures[0]; + let failed = svm.send_transaction(tx.clone()).unwrap_err(); + assert_eq!(svm.get_transaction(&signature), Some(&Err(failed))); + assert_eq!( + svm.send_transaction(tx).unwrap_err().err, + TransactionError::AlreadyProcessed + ); + let next = transaction(&svm, &payer, &recipient, 1); + svm.send_transaction(next).unwrap(); + assert!(svm.get_transaction(&signature).is_none()); +} diff --git a/crates/persistence/tests/history_capacity.rs b/crates/persistence/tests/history_capacity.rs new file mode 100644 index 00000000..e1193781 --- /dev/null +++ b/crates/persistence/tests/history_capacity.rs @@ -0,0 +1,44 @@ +use { + litesvm::LiteSVM, + litesvm_persistence::{from_bytes, to_bytes}, + solana_address::Address, + solana_keypair::Keypair, + solana_signer::Signer, + solana_system_interface::instruction::transfer, + solana_transaction::Transaction, +}; + +#[test] +fn configured_capacity_survives_empty_and_populated_snapshots() { + for capacity in [1, 4, 33] { + let payer = Keypair::new(); + let recipient = Address::new_unique(); + let mut svm = LiteSVM::new().with_transaction_history(0); + svm.airdrop(&payer.pubkey(), 10_000_000).unwrap(); + svm.airdrop(&recipient, 1_000_000).unwrap(); + svm = svm.with_transaction_history(capacity); + for populated in [false, true] { + let mut restored = from_bytes(&to_bytes(&svm).unwrap()).unwrap(); + assert_eq!(restored.transaction_history_capacity(), capacity); + let mut signatures = Vec::new(); + for amount in 1..=capacity as u64 + 1 { + let tx = Transaction::new_signed_with_payer( + &[transfer(&payer.pubkey(), &recipient, amount)], + Some(&payer.pubkey()), + &[&payer], + restored.latest_blockhash(), + ); + signatures.push(restored.send_transaction(tx).unwrap().signature); + } + assert!(restored.get_transaction(&signatures[0]).is_none()); + for signature in &signatures[1..] { + assert!(restored.get_transaction(signature).is_some()); + } + assert_eq!(restored.transaction_history_capacity(), capacity); + if !populated { + svm = restored; + svm.expire_blockhash(); + } + } + } +}