diff --git a/Cargo.lock b/Cargo.lock index e47dbc7b..332ee740 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1268,6 +1268,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "feature-probe" version = "0.1.1" @@ -1592,6 +1608,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -1778,6 +1796,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litesvm" version = "0.11.0" @@ -1905,6 +1929,33 @@ dependencies = [ "solana-transaction-error", ] +[[package]] +name = "litesvm-persistence" +version = "0.11.0" +dependencies = [ + "agave-feature-set", + "bincode", + "indexmap", + "litesvm", + "serde", + "solana-account", + "solana-address 2.5.0", + "solana-clock", + "solana-compute-budget", + "solana-fee-structure", + "solana-hash 3.1.0", + "solana-instruction", + "solana-keypair", + "solana-message", + "solana-native-token", + "solana-signature", + "solana-signer", + "solana-system-interface 2.0.0", + "solana-transaction", + "tempfile", + "thiserror 2.0.18", +] + [[package]] name = "litesvm-token" version = "0.11.0" @@ -2614,6 +2665,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -4538,6 +4602,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "test-log" version = "0.2.19" diff --git a/Cargo.toml b/Cargo.toml index d56abc79..b852e134 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ indexmap = "2.12" itertools = "0.14" libsecp256k1 = "0.6.0" litesvm = { path = "crates/litesvm", version = "0.11" } +litesvm-persistence = { path = "crates/persistence", version = "0.11" } log = "0.4" napi = { version = "3.8.3", default-features = false } napi-build = "2.3.1" @@ -90,6 +91,7 @@ solana-vote-interface = "5.0.0" spl-associated-token-account-interface = "2.0.0" spl-token-2022-interface = "2.0.0" spl-token-interface = "2.0.0" +tempfile = "3" test-log = "0.2" thiserror = "2.0" diff --git a/crates/litesvm/Cargo.toml b/crates/litesvm/Cargo.toml index 4af9adc2..490237e3 100644 --- a/crates/litesvm/Cargo.toml +++ b/crates/litesvm/Cargo.toml @@ -14,6 +14,7 @@ invocation-inspect-callback = [] nodejs-internal = ["dep:qualifier_attr"] hashbrown = ["dep:hashbrown"] serde = [] +persistence-internal = ["serde"] precompiles = ["dep:agave-precompiles"] register-tracing = ["invocation-inspect-callback", "dep:hex", "dep:sha2"] diff --git a/crates/litesvm/src/accounts_db.rs b/crates/litesvm/src/accounts_db.rs index 4dad8daf..a2152422 100644 --- a/crates/litesvm/src/accounts_db.rs +++ b/crates/litesvm/src/accounts_db.rs @@ -213,6 +213,39 @@ impl AccountsDb { self.inner.insert(address, data); } + /// Rebuilds the sysvar cache from account data already present in `self.inner`. + #[cfg(feature = "persistence-internal")] + pub(crate) fn rebuild_sysvar_cache(&mut self) { + self.sysvar_cache.reset(); + let accounts = &self.inner; + self.sysvar_cache.fill_missing_entries(|pubkey, set_sysvar| { + if let Some(acc) = accounts.get(pubkey) { + set_sysvar(acc.data()) + } + }); + if let Ok(clock) = self.sysvar_cache.get_clock() { + self.programs_cache.set_slot_for_tests(clock.slot); + } + } + + /// Scans all accounts for executable BPF programs and loads them into the program cache. + #[cfg(feature = "persistence-internal")] + pub(crate) fn load_all_existing_programs(&mut self) -> Result<(), LiteSVMError> { + let executable_keys: Vec
= self + .inner + .iter() + .filter(|(_, acc)| acc.executable() && acc.owner() != &native_loader::ID) + .map(|(k, _)| *k) + .collect(); + + for key in executable_keys { + let account = self.inner.get(&key).unwrap().clone(); + let loaded = self.load_program(&account)?; + self.programs_cache.replenish(key, Arc::new(loaded)); + } + Ok(()) + } + pub(crate) fn sync_accounts( &mut self, mut accounts: Vec<(Address, AccountSharedData)>, @@ -236,7 +269,11 @@ impl AccountsDb { let owner = program_account.owner(); let program_runtime_v1 = self.environments.program_runtime_v1.clone(); - let slot = self.sysvar_cache.get_clock().unwrap().slot; + let slot = self + .sysvar_cache + .get_clock() + .map(|c| c.slot) + .unwrap_or(0); if bpf_loader::check_id(owner) || bpf_loader_deprecated::check_id(owner) { ProgramCacheEntry::new( diff --git a/crates/litesvm/src/history.rs b/crates/litesvm/src/history.rs index 13596fe3..551c36a9 100644 --- a/crates/litesvm/src/history.rs +++ b/crates/litesvm/src/history.rs @@ -34,4 +34,21 @@ impl TransactionHistory { pub fn check_transaction(&self, signature: &Signature) -> bool { self.0.contains_key(signature) } + + #[cfg(feature = "persistence-internal")] + pub fn entries(&self) -> &IndexMap { + &self.0 + } + + #[cfg(feature = "persistence-internal")] + pub fn capacity(&self) -> usize { + self.0.capacity() + } + + #[cfg(feature = "persistence-internal")] + pub fn from_entries(entries: IndexMap, capacity: usize) -> Self { + let mut history = TransactionHistory(entries); + history.set_capacity(capacity); + history + } } diff --git a/crates/litesvm/src/lib.rs b/crates/litesvm/src/lib.rs index c199d450..d95a0ba4 100644 --- a/crates/litesvm/src/lib.rs +++ b/crates/litesvm/src/lib.rs @@ -374,6 +374,9 @@ use { }, }; +#[cfg(feature = "persistence-internal")] +use indexmap::IndexMap; + pub mod error; pub mod types; @@ -1678,6 +1681,90 @@ impl LiteSVM { self } + + // ── persistence-internal: getters ────────────────────────────────── + + #[cfg(feature = "persistence-internal")] + pub fn airdrop_keypair_bytes(&self) -> &[u8; 64] { + &self.airdrop_kp + } + + #[cfg(feature = "persistence-internal")] + pub fn get_blockhash_check(&self) -> bool { + self.blockhash_check + } + + #[cfg(feature = "persistence-internal")] + pub fn get_fee_structure(&self) -> &FeeStructure { + &self.fee_structure + } + + #[cfg(feature = "persistence-internal")] + pub fn get_log_bytes_limit(&self) -> Option { + self.log_bytes_limit + } + + #[cfg(feature = "persistence-internal")] + pub fn get_feature_set_ref(&self) -> &FeatureSet { + &self.feature_set + } + + #[cfg(feature = "persistence-internal")] + pub fn transaction_history_entries(&self) -> &IndexMap { + self.history.entries() + } + + #[cfg(feature = "persistence-internal")] + pub fn transaction_history_capacity(&self) -> usize { + self.history.capacity() + } + + // ── persistence-internal: setters ────────────────────────────────── + + #[cfg(feature = "persistence-internal")] + pub fn set_latest_blockhash(&mut self, hash: Hash) { + self.latest_blockhash = hash; + } + + #[cfg(feature = "persistence-internal")] + pub fn set_airdrop_keypair(&mut self, kp: [u8; 64]) { + self.airdrop_kp = kp; + } + + #[cfg(feature = "persistence-internal")] + pub fn set_account_no_checks(&mut self, pubkey: Address, account: AccountSharedData) { + self.accounts.add_account_no_checks(pubkey, account); + } + + #[cfg(feature = "persistence-internal")] + pub fn restore_transaction_history( + &mut self, + entries: IndexMap, + capacity: usize, + ) { + self.history = TransactionHistory::from_entries(entries, capacity); + } + + #[cfg(feature = "persistence-internal")] + pub fn set_fee_structure(&mut self, fee_structure: FeeStructure) { + self.fee_structure = fee_structure; + } + + // ── persistence-internal: cache rebuild ──────────────────────────── + + /// Rebuilds all derived caches after bulk account insertion. + /// + /// Must be called after restoring accounts via `set_account_no_checks`. + /// Order matters: environments first, then sysvars, then BPF programs. + #[cfg(feature = "persistence-internal")] + pub fn rebuild_caches(&mut self) -> Result<(), LiteSVMError> { + self.reserved_account_keys = + Self::reserved_account_keys_for_feature_set(&self.feature_set); + self.set_builtins(); + self.accounts.rebuild_sysvar_cache(); + self.accounts.load_all_existing_programs()?; + Ok(()) + } } struct CheckAndProcessTransactionSuccessCore<'ix_data> { diff --git a/crates/persistence/Cargo.toml b/crates/persistence/Cargo.toml new file mode 100644 index 00000000..3e74ce6f --- /dev/null +++ b/crates/persistence/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "litesvm-persistence" +description = "Save and load LiteSVM state snapshots" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +litesvm = { workspace = true, features = ["persistence-internal"] } +bincode.workspace = true +serde = { workspace = true, features = ["derive"] } +indexmap = { workspace = true, features = ["serde"] } +solana-account.workspace = true +solana-address.workspace = true +solana-compute-budget.workspace = true +solana-fee-structure.workspace = true +solana-hash.workspace = true +solana-signature.workspace = true +agave-feature-set = { workspace = true, features = ["agave-unstable-api"] } +thiserror.workspace = true + +[dev-dependencies] +solana-instruction.workspace = true +solana-keypair.workspace = true +solana-signer.workspace = true +solana-system-interface = { workspace = true, features = ["bincode"] } +solana-native-token.workspace = true +solana-message.workspace = true +solana-transaction = { workspace = true, features = ["verify"] } +solana-clock.workspace = true +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/persistence/src/error.rs b/crates/persistence/src/error.rs new file mode 100644 index 00000000..8e32a974 --- /dev/null +++ b/crates/persistence/src/error.rs @@ -0,0 +1,15 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum PersistenceError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("serialization error: {0}")] + Serialize(#[from] bincode::Error), + #[error("unsupported snapshot version: {0}")] + UnsupportedVersion(u8), + #[error("failed to rebuild caches: {0}")] + CacheRebuild(#[from] litesvm::error::LiteSVMError), + #[error("serialization thread panicked")] + ThreadPanic, +} diff --git a/crates/persistence/src/lib.rs b/crates/persistence/src/lib.rs new file mode 100644 index 00000000..b5dae152 --- /dev/null +++ b/crates/persistence/src/lib.rs @@ -0,0 +1,165 @@ +mod error; +mod types; + +pub use error::PersistenceError; + +use { + litesvm::LiteSVM, + std::{ + fs::File, + io::{BufReader, BufWriter, Read, Write}, + path::Path, + }, + types::{FeatureSetSnapshot, LiteSvmSnapshot}, +}; + +const STATE_VERSION: u8 = 1; +const LARGE_STACK_SIZE: usize = 64 * 1024 * 1024; // 64 MB + +fn extract_snapshot(svm: &LiteSVM) -> LiteSvmSnapshot { + LiteSvmSnapshot { + accounts: svm + .accounts_db() + .inner + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect(), + airdrop_kp: svm.airdrop_keypair_bytes().to_vec(), + feature_set: FeatureSetSnapshot::from_feature_set(svm.get_feature_set_ref()), + latest_blockhash: svm.latest_blockhash(), + history: svm + .transaction_history_entries() + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect(), + history_capacity: svm.transaction_history_capacity(), + compute_budget: svm.get_compute_budget(), + sigverify: svm.get_sigverify(), + blockhash_check: svm.get_blockhash_check(), + fee_structure: svm.get_fee_structure().clone(), + log_bytes_limit: svm.get_log_bytes_limit(), + } +} + +fn restore_from_snapshot(snapshot: LiteSvmSnapshot) -> Result { + let feature_set = snapshot.feature_set.into_feature_set(); + let mut svm = LiteSVM::default().with_feature_set(feature_set); + + // Set scalar config + svm = svm + .with_sigverify(snapshot.sigverify) + .with_blockhash_check(snapshot.blockhash_check) + .with_log_bytes_limit(snapshot.log_bytes_limit); + + if let Some(cb) = snapshot.compute_budget { + svm = svm.with_compute_budget(cb); + } + + svm.set_fee_structure(snapshot.fee_structure); + svm.set_latest_blockhash(snapshot.latest_blockhash); + + let airdrop_kp: [u8; 64] = snapshot + .airdrop_kp + .try_into() + .map_err(|_| PersistenceError::Serialize( + Box::new(bincode::ErrorKind::Custom("invalid airdrop keypair length".into())).into(), + ))?; + svm.set_airdrop_keypair(airdrop_kp); + + // Pass 1: insert all accounts without triggering cache updates + for (address, account) in snapshot.accounts { + svm.set_account_no_checks(address, account); + } + + // Restore transaction history with original capacity + svm.restore_transaction_history( + snapshot.history.into_iter().collect(), + snapshot.history_capacity, + ); + + // Pass 2: rebuild all derived caches + svm.rebuild_caches()?; + + Ok(svm) +} + +fn serialize_snapshot(snapshot: &LiteSvmSnapshot) -> Result, PersistenceError> { + let mut buf = vec![STATE_VERSION]; + bincode::serialize_into(&mut buf, snapshot)?; + Ok(buf) +} + +fn deserialize_snapshot(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err(PersistenceError::Serialize(Box::new(bincode::ErrorKind::Custom( + "empty input".into(), + )).into())); + } + let version = bytes[0]; + if version != STATE_VERSION { + return Err(PersistenceError::UnsupportedVersion(version)); + } + let snapshot: LiteSvmSnapshot = bincode::deserialize(&bytes[1..])?; + Ok(snapshot) +} + +/// Runs `f` on a thread with a large stack to prevent stack overflow +/// during bincode serialization/deserialization of large account maps. +fn on_large_stack(f: F) -> Result +where + F: FnOnce() -> Result + Send + 'static, + T: Send + 'static, +{ + let handle = std::thread::Builder::new() + .stack_size(LARGE_STACK_SIZE) + .name("litesvm-persistence".into()) + .spawn(f) + .map_err(PersistenceError::Io)?; + + handle.join().map_err(|_| PersistenceError::ThreadPanic)? +} + +/// Saves the full LiteSVM state to a file. +pub fn save_to_file(svm: &LiteSVM, path: impl AsRef) -> Result<(), PersistenceError> { + let snapshot = extract_snapshot(svm); + let path = path.as_ref().to_path_buf(); + on_large_stack(move || { + let file = File::create(&path)?; + let mut writer = BufWriter::new(file); + writer.write_all(&[STATE_VERSION])?; + bincode::serialize_into(&mut writer, &snapshot)?; + writer.flush()?; + Ok(()) + }) +} + +/// Loads a full LiteSVM state from a file. +pub fn load_from_file(path: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); + on_large_stack(move || { + let file = File::open(&path)?; + let mut reader = BufReader::new(file); + let mut version = [0u8; 1]; + reader.read_exact(&mut version)?; + if version[0] != STATE_VERSION { + return Err(PersistenceError::UnsupportedVersion(version[0])); + } + let snapshot: LiteSvmSnapshot = bincode::deserialize_from(&mut reader)?; + restore_from_snapshot(snapshot) + }) +} + +/// Serializes the full LiteSVM state to bytes. +pub fn to_bytes(svm: &LiteSVM) -> Result, PersistenceError> { + let snapshot = extract_snapshot(svm); + on_large_stack(move || serialize_snapshot(&snapshot)) +} + +/// Deserializes the full LiteSVM state from bytes. +pub fn from_bytes(bytes: &[u8]) -> Result { + let bytes = bytes.to_vec(); + on_large_stack(move || { + let snapshot = deserialize_snapshot(&bytes)?; + restore_from_snapshot(snapshot) + }) +} diff --git a/crates/persistence/src/types.rs b/crates/persistence/src/types.rs new file mode 100644 index 00000000..68fd5147 --- /dev/null +++ b/crates/persistence/src/types.rs @@ -0,0 +1,161 @@ +use { + agave_feature_set::FeatureSet, + litesvm::types::TransactionResult, + serde::{Deserialize, Serialize}, + solana_account::AccountSharedData, + solana_address::Address, + solana_compute_budget::compute_budget::ComputeBudget, + solana_fee_structure::{FeeBin, FeeStructure}, + solana_hash::Hash, + solana_signature::Signature, +}; + +// ── Serde remote definitions for upstream types without serde ────────── + +#[derive(Serialize, Deserialize)] +#[serde(remote = "FeeBin")] +struct FeeBinDef { + pub limit: u64, + pub fee: u64, +} + +#[derive(Serialize, Deserialize)] +#[serde(remote = "FeeStructure")] +struct FeeStructureDef { + pub lamports_per_signature: u64, + pub lamports_per_write_lock: u64, + #[serde(with = "fee_bin_vec")] + pub compute_fee_bins: Vec, +} + +/// Helper module to serialize `Vec` using the remote definition. +mod fee_bin_vec { + use super::*; + use serde::{Deserializer, Serializer}; + + #[derive(Serialize, Deserialize)] + struct FeeBinWrapper(#[serde(with = "FeeBinDef")] FeeBin); + + pub fn serialize(v: &[FeeBin], s: S) -> Result { + let wrappers: Vec = v.iter().map(|b| FeeBinWrapper(b.clone())).collect(); + wrappers.serialize(s) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + let wrappers = Vec::::deserialize(d)?; + Ok(wrappers.into_iter().map(|w| w.0).collect()) + } +} + +#[derive(Serialize, Deserialize)] +#[serde(remote = "ComputeBudget")] +struct ComputeBudgetDef { + pub compute_unit_limit: u64, + pub log_64_units: u64, + pub create_program_address_units: u64, + pub invoke_units: u64, + pub max_instruction_stack_depth: usize, + pub max_instruction_trace_length: usize, + pub sha256_base_cost: u64, + pub sha256_byte_cost: u64, + pub sha256_max_slices: u64, + pub max_call_depth: usize, + pub stack_frame_size: usize, + pub log_pubkey_units: u64, + pub cpi_bytes_per_unit: u64, + pub sysvar_base_cost: u64, + pub secp256k1_recover_cost: u64, + pub syscall_base_cost: u64, + pub curve25519_edwards_validate_point_cost: u64, + pub curve25519_edwards_add_cost: u64, + pub curve25519_edwards_subtract_cost: u64, + pub curve25519_edwards_multiply_cost: u64, + pub curve25519_edwards_msm_base_cost: u64, + pub curve25519_edwards_msm_incremental_cost: u64, + pub curve25519_ristretto_validate_point_cost: u64, + pub curve25519_ristretto_add_cost: u64, + pub curve25519_ristretto_subtract_cost: u64, + pub curve25519_ristretto_multiply_cost: u64, + pub curve25519_ristretto_msm_base_cost: u64, + pub curve25519_ristretto_msm_incremental_cost: u64, + pub heap_size: u32, + pub heap_cost: u64, + pub mem_op_base_cost: u64, + pub alt_bn128_addition_cost: u64, + pub alt_bn128_multiplication_cost: u64, + pub alt_bn128_pairing_one_pair_cost_first: u64, + pub alt_bn128_pairing_one_pair_cost_other: u64, + pub big_modular_exponentiation_base_cost: u64, + pub big_modular_exponentiation_cost_divisor: u64, + pub poseidon_cost_coefficient_a: u64, + pub poseidon_cost_coefficient_c: u64, + pub get_remaining_compute_units_cost: u64, + pub alt_bn128_g1_compress: u64, + pub alt_bn128_g1_decompress: u64, + pub alt_bn128_g2_compress: u64, + pub alt_bn128_g2_decompress: u64, +} + +/// Helper module to serialize `Option` using the remote definition. +mod compute_budget_option { + use super::*; + use serde::{Deserializer, Serializer}; + + #[derive(Serialize, Deserialize)] + struct Wrapper(#[serde(with = "ComputeBudgetDef")] ComputeBudget); + + pub fn serialize(v: &Option, s: S) -> Result { + v.as_ref().map(|cb| Wrapper(*cb)).serialize(s) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + d: D, + ) -> Result, D::Error> { + let wrapper = Option::::deserialize(d)?; + Ok(wrapper.map(|w| w.0)) + } +} + +// ── FeatureSet snapshot (uses AHashMap/AHashSet, can't use serde remote) ── + +#[derive(Serialize, Deserialize)] +pub(crate) struct FeatureSetSnapshot { + pub active: Vec<(Address, u64)>, + pub inactive: Vec
, +} + +impl FeatureSetSnapshot { + pub fn from_feature_set(fs: &FeatureSet) -> Self { + let mut active: Vec<(Address, u64)> = fs.active().iter().map(|(k, v)| (*k, *v)).collect(); + active.sort_by_key(|(k, _)| *k); + let mut inactive: Vec
= fs.inactive().iter().copied().collect(); + inactive.sort(); + Self { active, inactive } + } + + pub fn into_feature_set(self) -> FeatureSet { + FeatureSet::new( + self.active.into_iter().collect(), + self.inactive.into_iter().collect(), + ) + } +} + +// ── Top-level snapshot ───────────────────────────────────────────────── + +#[derive(Serialize, Deserialize)] +pub(crate) struct LiteSvmSnapshot { + pub accounts: Vec<(Address, AccountSharedData)>, + pub airdrop_kp: Vec, + pub feature_set: FeatureSetSnapshot, + pub latest_blockhash: Hash, + pub history: Vec<(Signature, TransactionResult)>, + pub history_capacity: usize, + #[serde(with = "compute_budget_option")] + pub compute_budget: Option, + pub sigverify: bool, + pub blockhash_check: bool, + #[serde(with = "FeeStructureDef")] + pub fee_structure: FeeStructure, + pub log_bytes_limit: Option, +} diff --git a/crates/persistence/tests/roundtrip.rs b/crates/persistence/tests/roundtrip.rs new file mode 100644 index 00000000..762d37d7 --- /dev/null +++ b/crates/persistence/tests/roundtrip.rs @@ -0,0 +1,312 @@ +use { + litesvm::LiteSVM, + litesvm_persistence::{from_bytes, load_from_file, save_to_file, to_bytes, PersistenceError}, + solana_account::Account, + solana_address::Address, + solana_clock::Clock, + solana_instruction::{account_meta::AccountMeta, Instruction}, + solana_keypair::Keypair, + solana_message::Message, + solana_native_token::LAMPORTS_PER_SOL, + solana_signer::Signer, + solana_system_interface::instruction::transfer, + solana_transaction::Transaction, + std::path::PathBuf, +}; + +/// Helper: create a seeded LiteSVM with builtins, sysvars, and an airdropped account. +fn seeded_svm() -> (LiteSVM, Keypair) { + let mut svm = LiteSVM::new(); + let kp = Keypair::new(); + svm.airdrop(&kp.pubkey(), 10 * LAMPORTS_PER_SOL).unwrap(); + (svm, kp) +} + +#[test] +fn basic_account_round_trip() { + let (svm, kp) = seeded_svm(); + let balance_before = svm.get_balance(&kp.pubkey()).unwrap(); + + let bytes = to_bytes(&svm).unwrap(); + let restored = from_bytes(&bytes).unwrap(); + + assert_eq!(restored.get_balance(&kp.pubkey()).unwrap(), balance_before); +} + +#[test] +fn multiple_accounts_round_trip() { + let mut svm = LiteSVM::new(); + let mut addresses = Vec::new(); + for i in 0..10 { + let addr = Address::new_unique(); + svm.airdrop(&addr, (i + 1) * LAMPORTS_PER_SOL).unwrap(); + addresses.push(addr); + } + + let bytes = to_bytes(&svm).unwrap(); + let restored = from_bytes(&bytes).unwrap(); + + for (i, addr) in addresses.iter().enumerate() { + assert_eq!( + restored.get_balance(addr).unwrap(), + (i as u64 + 1) * LAMPORTS_PER_SOL + ); + } +} + +#[test] +fn sysvar_round_trip() { + let mut svm = LiteSVM::new(); + svm.warp_to_slot(42); + + let clock_before: Clock = svm.get_sysvar(); + + let bytes = to_bytes(&svm).unwrap(); + let restored = from_bytes(&bytes).unwrap(); + + let clock_after: Clock = restored.get_sysvar(); + assert_eq!(clock_before.slot, clock_after.slot); + assert_eq!(42, clock_after.slot); +} + +#[test] +fn config_round_trip() { + let svm = LiteSVM::new() + .with_sigverify(true) + .with_blockhash_check(true) + .with_log_bytes_limit(Some(5000)); + + let bytes = to_bytes(&svm).unwrap(); + let restored = from_bytes(&bytes).unwrap(); + + assert_eq!(restored.get_sigverify(), true); + assert_eq!(restored.get_blockhash_check(), true); + assert_eq!(restored.get_log_bytes_limit(), Some(5000)); +} + +#[test] +fn blockhash_round_trip() { + let mut svm = LiteSVM::new(); + svm.expire_blockhash(); + let bh = svm.latest_blockhash(); + + let bytes = to_bytes(&svm).unwrap(); + let restored = from_bytes(&bytes).unwrap(); + + assert_eq!(restored.latest_blockhash(), bh); +} + +#[test] +fn airdrop_keypair_round_trip() { + let svm = LiteSVM::new(); + let airdrop_pk = svm.airdrop_pubkey(); + + let bytes = to_bytes(&svm).unwrap(); + let restored = from_bytes(&bytes).unwrap(); + + assert_eq!(restored.airdrop_pubkey(), airdrop_pk); +} + +#[test] +fn transaction_history_round_trip() { + let (mut svm, kp) = seeded_svm(); + let to = Address::new_unique(); + svm.airdrop(&to, LAMPORTS_PER_SOL).unwrap(); + + let ix = transfer(&kp.pubkey(), &to, 1_000); + let tx = Transaction::new( + &[&kp], + Message::new(&[ix], Some(&kp.pubkey())), + svm.latest_blockhash(), + ); + let result = svm.send_transaction(tx).unwrap(); + let sig = result.signature; + + let bytes = to_bytes(&svm).unwrap(); + let restored = from_bytes(&bytes).unwrap(); + + assert!(restored.get_transaction(&sig).is_some()); +} + +#[test] +fn bytes_round_trip() { + let (svm, kp) = seeded_svm(); + + let bytes = to_bytes(&svm).unwrap(); + let restored = from_bytes(&bytes).unwrap(); + + assert_eq!( + restored.get_balance(&kp.pubkey()).unwrap(), + svm.get_balance(&kp.pubkey()).unwrap() + ); +} + +#[test] +fn file_round_trip() { + let (svm, kp) = seeded_svm(); + let balance = svm.get_balance(&kp.pubkey()).unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snapshot.bin"); + + save_to_file(&svm, &path).unwrap(); + let restored = load_from_file(&path).unwrap(); + + assert_eq!(restored.get_balance(&kp.pubkey()).unwrap(), balance); +} + +#[test] +fn airdrop_works_after_restore() { + let (svm, _kp) = seeded_svm(); + let bytes = to_bytes(&svm).unwrap(); + let mut restored = from_bytes(&bytes).unwrap(); + + let new_addr = Address::new_unique(); + restored.airdrop(&new_addr, 5 * LAMPORTS_PER_SOL).unwrap(); + assert_eq!( + restored.get_balance(&new_addr).unwrap(), + 5 * LAMPORTS_PER_SOL + ); +} + +#[test] +fn send_transaction_after_restore() { + let (svm, kp) = seeded_svm(); + let bytes = to_bytes(&svm).unwrap(); + let mut restored = from_bytes(&bytes).unwrap(); + + // Need a fresh blockhash for the restored instance + restored.expire_blockhash(); + + let to = Address::new_unique(); + restored.airdrop(&to, LAMPORTS_PER_SOL).unwrap(); + + let ix = transfer(&kp.pubkey(), &to, 500_000); + let tx = Transaction::new( + &[&kp], + Message::new(&[ix], Some(&kp.pubkey())), + restored.latest_blockhash(), + ); + let result = restored.send_transaction(tx); + assert!(result.is_ok()); +} + +#[test] +fn load_nonexistent_file() { + let result = load_from_file("/tmp/nonexistent_litesvm_snapshot_abc123.bin"); + assert!(matches!(result, Err(PersistenceError::Io(_)))); +} + +#[test] +fn load_corrupted_data() { + let result = from_bytes(&[1, 0, 0, 0, 0xff, 0xff]); // version 1 + garbage + assert!(matches!(result, Err(PersistenceError::Serialize(_)))); +} + +#[test] +fn version_check() { + let result = from_bytes(&[255, 0, 0, 0]); // invalid version + assert!(matches!(result, Err(PersistenceError::UnsupportedVersion(255)))); +} + +#[test] +fn bpf_program_round_trip() { + // Load the counter program + let mut so_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + so_path.push("../litesvm/test_programs/target/deploy/counter.so"); + let program_bytes = std::fs::read(&so_path).expect("counter.so not found — run `cd crates/litesvm/test_programs && cargo build-sbf`"); + + let program_id = + Address::from_str_const("GtdambwDgHWrDJdVPBkEHGhCwokqgAoch162teUjJse2"); + let counter_address = + Address::from_str_const("J39wvrFY2AkoAUCke5347RMNk3ditxZfVidoZ7U6Fguf"); + + // Set up SVM with a deployed BPF program and counter account + let mut svm = LiteSVM::new(); + svm.add_program(program_id, &program_bytes).unwrap(); + + let payer = Keypair::new(); + svm.airdrop(&payer.pubkey(), 10 * LAMPORTS_PER_SOL).unwrap(); + svm.set_account( + counter_address, + Account { + lamports: 5, + data: vec![0u8; std::mem::size_of::()], + owner: program_id, + ..Default::default() + }, + ) + .unwrap(); + + // Increment counter once before snapshot + let ix = Instruction { + program_id, + accounts: vec![AccountMeta::new(counter_address, false)], + data: vec![0, 0], + }; + let tx = Transaction::new( + &[&payer], + Message::new(&[ix], Some(&payer.pubkey())), + svm.latest_blockhash(), + ); + svm.send_transaction(tx).unwrap(); + assert_eq!( + svm.get_account(&counter_address).unwrap().data, + 1u32.to_le_bytes().to_vec() + ); + + // Save and restore + let bytes = to_bytes(&svm).unwrap(); + let mut restored = from_bytes(&bytes).unwrap(); + + // Verify counter value preserved + assert_eq!( + restored.get_account(&counter_address).unwrap().data, + 1u32.to_le_bytes().to_vec() + ); + + // Increment counter on restored instance — proves program cache was rebuilt + restored.expire_blockhash(); + let ix = Instruction { + program_id, + accounts: vec![AccountMeta::new(counter_address, false)], + data: vec![0, 1], + }; + let tx = Transaction::new( + &[&payer], + Message::new(&[ix], Some(&payer.pubkey())), + restored.latest_blockhash(), + ); + restored.send_transaction(tx).unwrap(); + assert_eq!( + restored.get_account(&counter_address).unwrap().data, + 2u32.to_le_bytes().to_vec() + ); +} + +#[test] +fn account_with_data_round_trip() { + let mut svm = LiteSVM::new(); + let addr = Address::new_unique(); + let owner = Address::new_unique(); + let data = vec![1, 2, 3, 4, 5, 6, 7, 8]; + svm.set_account( + addr, + Account { + lamports: 1_000_000, + data: data.clone(), + owner, + executable: false, + rent_epoch: 0, + }, + ) + .unwrap(); + + let bytes = to_bytes(&svm).unwrap(); + let restored = from_bytes(&bytes).unwrap(); + + let account = restored.get_account(&addr).unwrap(); + assert_eq!(account.data, data); + assert_eq!(account.owner, owner); + assert_eq!(account.lamports, 1_000_000); +}