diff --git a/Cargo.lock b/Cargo.lock index 450aafe7..643cbf03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2539,6 +2539,35 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +[[package]] +name = "lite-coverage" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "bincode", + "borsh 1.5.7", + "bs58", + "jsonrpc-core", + "lazy_static", + "libloading", + "log", + "serde", + "solana-account", + "solana-instruction", + "solana-keypair", + "solana-program-entrypoint", + "solana-program-error", + "solana-program-runtime", + "solana-program-stubs", + "solana-program-test", + "solana-pubkey", + "solana-sdk-ids", + "solana-signer", + "solana-sysvar", + "solana-transaction", + "tokio", +] + [[package]] name = "litemap" version = "0.8.0" @@ -2559,6 +2588,7 @@ dependencies = [ "indexmap", "itertools 0.14.0", "libsecp256k1", + "lite-coverage", "log", "qualifier_attr", "serde", @@ -2589,6 +2619,7 @@ dependencies = [ "solana-nonce", "solana-nonce-account", "solana-precompile-error", + "solana-program-entrypoint", "solana-program-error", "solana-program-option", "solana-program-pack", @@ -2657,6 +2688,7 @@ dependencies = [ "solana-epoch-schedule", "solana-hash", "solana-instruction", + "solana-keypair", "solana-last-restart-slot", "solana-message", "solana-pubkey", @@ -5781,6 +5813,15 @@ dependencies = [ "thiserror 2.0.14", ] +[[package]] +name = "solana-program-stubs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92919225911e3dcfe67af6d012aa567ac812945db97b15141859bf4e28fa117c" +dependencies = [ + "lazy_static", +] + [[package]] name = "solana-program-test" version = "2.3.7" diff --git a/Cargo.toml b/Cargo.toml index 18c3ad27..e6a7a644 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,7 @@ solana-nonce = "2.2" solana-nonce-account = "2.2" solana-precompile-error = "2.2" solana-program-error = "2.2" +solana-program-entrypoint = "2.3" solana-program-option = "2.2" solana-program-pack = "2.2" solana-program-runtime = "2.3" @@ -89,6 +90,8 @@ spl-token-2022 = "8.0" test-log = "0.2" thiserror = "2.0" tokio = "1.35" +lite-coverage = { path = "crates/lite-coverage" } +solana-program-stubs = { version = "0.1.0", features = [ "loader_stubs" ] } [profile.bench] debug = true diff --git a/crates/lite-coverage/Cargo.toml b/crates/lite-coverage/Cargo.toml new file mode 100644 index 00000000..2a357e9b --- /dev/null +++ b/crates/lite-coverage/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "lite-coverage" +version = "0.1.0" +edition = "2021" +[lib] +path = "src/lib.rs" + +[dependencies] +libloading = "0.8.8" +base64 = "0.22.1" +bincode = { workspace = true } +serde = { workspace = true } +jsonrpc-core = "18.0.0" +bs58 = { version = "0.5.1", default-features = false } +borsh = "1.5.7" +solana-program-runtime = { workspace = true } +solana-program-test = { workspace = true } +solana-account = { workspace = true } +solana-keypair = { workspace = true } +solana-signer = { workspace = true } +solana-transaction = { workspace = true } +solana-instruction = { workspace = true } +solana-pubkey = { workspace = true } +solana-program-error = { workspace = true } +solana-program-entrypoint = { workspace = true } +solana-sysvar = { workspace = true } +solana-sdk-ids = { workspace = true } +tokio = { workspace = true } +lazy_static = "1.5.0" +solana-program-stubs = { workspace = true } +log.workspace = true diff --git a/crates/lite-coverage/src/lib.rs b/crates/lite-coverage/src/lib.rs new file mode 100644 index 00000000..2839cbfa --- /dev/null +++ b/crates/lite-coverage/src/lib.rs @@ -0,0 +1,7 @@ +mod lite_coverage; +mod loader; +mod sbf; +mod stubs; +mod types; +pub use lite_coverage::*; +pub use types::*; diff --git a/crates/lite-coverage/src/lite_coverage.rs b/crates/lite-coverage/src/lite_coverage.rs new file mode 100644 index 00000000..039b51e3 --- /dev/null +++ b/crates/lite-coverage/src/lite_coverage.rs @@ -0,0 +1,181 @@ +use std::{io::Write, rc::Rc}; + +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; + +use crate::{ + loader::{entrypoint, Loader}, + types::{LiteCoverageError, NativeProgram}, + AdditionalProgram, ProgramTestContextHandle, +}; +use { + solana_account::AccountSharedData, + solana_program_test::{processor, ProgramTest, ProgramTestContext}, + solana_transaction::versioned::VersionedTransaction, + std::{cell::RefCell, sync::Arc}, + tokio::runtime::Runtime, +}; + +/// Main object to look after code coverage. +#[derive(Clone)] +pub struct LiteCoverage { + pub programs: Vec, + pub pt_context: Rc>>, + rt: Arc, + #[allow(dead_code)] + loader: Rc, +} + +impl LiteCoverage { + /// Get a singleton instance to the main code coverage object. + pub fn new( + programs: Vec, + additional_programs: Vec, + payer: Keypair, + ) -> LiteCoverageError { + { + use std::sync::Once; + static SINGLETON: Once = Once::new(); + + if !SINGLETON.is_completed() { + SINGLETON.call_once(|| {}); + } else { + return Err(Box::::from( + "LiteCoverage is singleton, please use only one instance of LiteSVM", + )); + } + } + + let static_programs = Box::leak(Box::new(programs.clone())); + let mut program_test = ProgramTest::default(); + program_test.prefer_bpf(false); + program_test.set_payer(payer); + + for (pubkey, name) in additional_programs.clone().into_iter() { + let name = Box::leak(Box::new(name)); + program_test.add_upgradeable_program_to_genesis(name, &pubkey); + } + + let mut loader = Loader::new(); + for (program_id, program_name) in static_programs.iter() { + log::info!( + "Adding native program (SBF avatar) '{}' with program id: {}", + program_name, + program_id + ); + loader.add_program(program_name, program_id)?; + program_test.add_program(program_name, *program_id, processor!(entrypoint)); + } + log::info!("Loaded: {:?}", loader); + + let rt = tokio::runtime::Runtime::new()?; + let pt_context = rt.block_on(async move { program_test.start_with_context().await }); + loader.adjust_stubs()?; + + LiteCoverage::log_anchor_test_event_artifacts(programs.clone(), additional_programs)?; + + Ok(Self { + pt_context: Rc::new(RefCell::new(Some(pt_context))), + programs, + rt: Arc::new(rt), + loader: Rc::new(loader), + }) + } + + /// Get a handle to the ProgramTestContext. + pub fn get_program_test_context(&self) -> ProgramTestContextHandle { + assert!( + self.pt_context.borrow().is_some(), + "ProgramTestContext is already acquired!" + ); + ProgramTestContextHandle::new(Rc::clone(&self.pt_context)) + } + + /// Add an account to the ProgramTestContext. + pub fn add_account(&self, account_pubkey: &Pubkey, account_data: &AccountSharedData) { + let mut pt_context = self.get_program_test_context(); + if let Some(ctx) = &mut *pt_context { + ctx.set_account(account_pubkey, account_data); + } + } + + /// Register the transaction's recent blockhash into ProgramTestContext. + /// This avoids the need to pre-sign with payer. + async fn register_recent_blockhash_from_transaction( + &self, + tx: &VersionedTransaction, + ) -> LiteCoverageError<()> { + let pt_context = self.get_program_test_context(); + let ctx = pt_context + .as_ref() + .ok_or(Box::::from( + "Missing ProgramTestContext", + ))?; + + let trans = tx.clone().into_legacy_transaction().unwrap(); + ctx.register_recent_blockhash(&trans.message.recent_blockhash, None); + Ok(()) + } + + /// Send the transaction to the natively loaded SBF avatars already prepared for + /// obtaining code coverage. + pub fn send_transaction( + &self, + tx: VersionedTransaction, + accounts: &[(Pubkey, AccountSharedData)], + ) -> LiteCoverageError<()> { + let _: LiteCoverageError<()> = self.rt.block_on(async { + for (account_pubkey, account_data) in accounts { + self.add_account(account_pubkey, account_data); + } + self.register_recent_blockhash_from_transaction(&tx).await?; + + let pt_context = self.get_program_test_context(); + let ctx = + pt_context + .as_ref() + .ok_or(Box::::from( + "Missing ProgramTestContext", + ))?; + let res = ctx + .banks_client + .process_transaction_with_metadata(tx) + .await?; + + log::info!("LiteCoverage transaction result: {:#?}", res); + Ok(()) + }); + Ok(()) + } + + /// Log some events provided that some anchor envvars are globally set. + /// This is useful for anchor to know that it's litesvm that's actually used. + /// With this information anchor can go further visualizing the code coverage results + /// or bail out. + fn log_anchor_test_event_artifacts( + progs: Vec, + additional_progs: Vec, + ) -> LiteCoverageError<()> { + if let Ok(report_events) = std::env::var("ANCHOR_TEST_CODE_COVERAGE_REPORT_EVENTS") { + if report_events == "true" { + if let Ok(event_file) = + std::env::var("ANCHOR_TEST_CODE_COVERAGE_ARTIFACTS_EVENT_FILE") + { + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .write(true) + .open(format!("{}.{}.log", event_file, std::process::id()))?; + file.write_all("litesvm=true\n".as_bytes())?; + for (pubkey, name) in &progs { + file.write_all(format!("{}={}\n", name, pubkey).as_bytes())?; + } + for (pubkey, name) in &additional_progs { + file.write_all(format!("{}={}\n", name, pubkey).as_bytes())?; + } + } + } + } + Ok(()) + } +} diff --git a/crates/lite-coverage/src/loader.rs b/crates/lite-coverage/src/loader.rs new file mode 100644 index 00000000..60fdfb08 --- /dev/null +++ b/crates/lite-coverage/src/loader.rs @@ -0,0 +1,241 @@ +use crate::{ + sbf, + stubs::{StubsManager, SyscallStubsApi, UnimplementedSyscallStubs, WrapperSyscallStubs}, + types::LiteCoverageError, +}; +use core::str; +use libloading::{Library, Symbol}; +use solana_program_error::{ProgramError, ProgramResult}; +use solana_pubkey::Pubkey; +use solana_sysvar::{program_stubs::set_syscall_stubs, slot_history::AccountInfo}; +use std::{ + collections::HashMap, + path::PathBuf, + sync::{atomic::AtomicPtr, Mutex}, +}; + +type ProgramEntrypoint = unsafe extern "C" fn(input: *mut u8) -> u64; +type ProgramSetSyscallStubsApi = unsafe extern "C" fn(stubs_api: SyscallStubsApi); + +lazy_static::lazy_static! ( + pub static ref PROGRAMS_MAP: Mutex>> = Mutex::new(HashMap::new()); +); + +/// ProgramTest invokes this entrypoint to dispatch to the right entrypoint +/// of the natively loaded SBF avatars. +pub fn entrypoint(program_id: &Pubkey, accounts: &[AccountInfo], _data: &[u8]) -> ProgramResult { + let map = PROGRAMS_MAP.lock().unwrap(); + let entry = map + .get(program_id) + .unwrap() + .load(std::sync::atomic::Ordering::Relaxed); + let fn_ptr = entry as *const (); + let entry: ProgramEntrypoint = unsafe { std::mem::transmute(fn_ptr) }; + + // Serialize entrypoint parameters with SBF ABI + let invoke_context: &solana_program_test::InvokeContext<'_> = + solana_program_test::get_invoke_context() as &_; + let transaction_context = &invoke_context.transaction_context; + let instruction_context = transaction_context + .get_current_instruction_context() + .map_err(|_| ProgramError::InvalidArgument)?; + + let mask_out_rent_epoch_in_vm_serialization = invoke_context + .get_feature_set() + .mask_out_rent_epoch_in_vm_serialization; + + let (mut parameter_bytes, _mem_region, serialized_account_meta_data) = + solana_program_runtime::serialization::serialize_parameters( + transaction_context, + instruction_context, + true, // copy_account_data // There is no VM so direct mapping can not be implemented here + mask_out_rent_epoch_in_vm_serialization, + ) + .map_err(|_| ProgramError::InvalidArgument)?; + let original_data_lens: Vec<_> = serialized_account_meta_data + .iter() + .map(|sam| sam.original_data_len) + .collect(); + + let res = unsafe { entry(parameter_bytes.as_slice().as_ptr() as *mut _) }; + if res == 0 { + // Deserialize data back into instruction params. + let (_, updated_account_infos, _) = unsafe { + sbf::deserialize_updated_account_infos( + &mut parameter_bytes.as_slice_mut()[0] as *mut u8, + &original_data_lens, + ) + }; + + let accounts_len = accounts.len(); + for i in 0..accounts_len { + if accounts[i].lamports() != updated_account_infos[i].lamports() { + // Lamports have changed - update. + (**accounts[i].lamports.borrow_mut()) = updated_account_infos[i].lamports(); + } + if *accounts[i].data.borrow() != *updated_account_infos[i].data.borrow() { + // Account data has changed - update. + let new_data = updated_account_infos[i].data.borrow_mut().to_vec(); + let boxed: Box<[u8]> = new_data.into_boxed_slice(); + let leaked = Box::leak(Box::new(boxed)); + *accounts[i].data.borrow_mut() = leaked; + } + + // Account key has changed - update. + let key_mut_ptr = accounts[i].key.as_array().as_ptr() as *mut u8; + updated_account_infos[i] + .key + .as_array() + .iter() + .enumerate() + .for_each(|(i, b)| { + unsafe { *key_mut_ptr.add(i) = *b }; + }); + + // Account owner has changed - update. + let owner_mut_ptr = accounts[i].owner.as_array().as_ptr() as *mut u8; + updated_account_infos[i] + .owner + .as_array() + .iter() + .enumerate() + .for_each(|(i, b)| { + unsafe { *owner_mut_ptr.add(i) = *b }; + }); + } + + Ok(()) + } else { + Err(ProgramError::Custom(res as _)) + } +} + +/// A loader object to track loaded SBF avatars. +#[derive(Debug, Default)] +pub(crate) struct Loader { + libs: HashMap, +} + +impl Loader { + #[cfg(target_os = "macos")] + pub(crate) const SBF_AVATAR_EXT: &str = "dylib"; + #[cfg(target_os = "linux")] + pub(crate) const SBF_AVATAR_EXT: &str = "so"; + + // Get an instance to a loader. + pub(crate) fn new() -> Self { + Self { + libs: HashMap::new(), + } + } + + /// Adjust stubs for the natively loaded SBF avatars. + /// NB: This function must be called after ProgramTest .start/start_context() method! + /// Only after starting we have the appropriate SYSCALL_STUBS initialized. + pub(crate) fn adjust_stubs(&self) -> LiteCoverageError<()> { + // So in ProgramTest's start() ...: + // setup_bank() has passed and we have the appropriate stubs! + // First to get them put there unimplemented stubs for a moment. + let program_test_stubs = set_syscall_stubs(Box::new(UnimplementedSyscallStubs {})); + // Store the good ones in our global variable! + StubsManager::my_set_syscall_stubs(program_test_stubs); + // Now create an instance so that program_test's stubs are backed with ours - the wrapper uses our global variable! + set_syscall_stubs(Box::new(WrapperSyscallStubs {})); + + // Now for each program set the appropriate stubs + for (program_id, _) in self.libs.iter() { + // Now create the C interface so that the solana programs can reach our SYSCALL_STUBS! + let stubs_api = SyscallStubsApi::new(); + // Pass it to the loaded smart contract! + self.set_syscall_stubs_api(program_id, stubs_api)?; + } + Ok(()) + } + + fn default_sbf_avatar_dirs() -> Vec { + let search_path = vec![ + PathBuf::from("target/debug"), + PathBuf::from("tests/coverage_fixtures"), + ]; + log::info!(r#"SBF avatars .so|dylib search path: {:?}"#, search_path); + search_path + } + + fn find_sbf_avatar_file(program_name: &str) -> Option { + for dir in Loader::default_sbf_avatar_dirs() { + let candidate = dir.join(format!("lib{program_name}.{}", Loader::SBF_AVATAR_EXT)); + if candidate.exists() { + let sbf_avatar_path = candidate.to_string_lossy().to_string(); + log::info!("SBF avatar found @ {}", sbf_avatar_path); + return Some(sbf_avatar_path); + } + } + None + } + + /// Load natively a SBF avatar. + pub(crate) fn add_program( + &mut self, + program_name: &str, + program_id: &Pubkey, + ) -> LiteCoverageError<()> { + // Come up with the so_path of the SBF avatar based on the program_name. + // For example: target/debug/libcounter.dylib if program_name is 'counter'. + let sbf_avatar_path = + Loader::find_sbf_avatar_file(program_name).ok_or(Box::< + dyn std::error::Error + Send + Sync, + >::from(format!( + r#"LiteCoverage Loader: Can't find any SBF avatar for program '{program_name}'. +For example - looking for 'target/debug/libcounter.so|dylib' or +'tests/coverage_fixtures/libcounter.so|dylib' if program_name is 'counter' where +the extension .so would be on Linux or .dylib would be on MacOS. Mind that +symbolic links are also fine as with the case of using some programs built externally. +"# + )))?; + let lib = unsafe { Library::new(sbf_avatar_path)? }; + self.libs + .insert(*program_id, (program_name.to_string(), lib)); + + let entrypoint = self.get_entrypoint(program_id)?; + let mut programs_map = PROGRAMS_MAP.lock().unwrap(); + programs_map.insert(*program_id, AtomicPtr::new(entrypoint as *mut _)); + Ok(()) + } + + /// Set stubs at the natively loaded SBF avatar. + pub(crate) fn set_syscall_stubs_api( + &self, + program_id: &Pubkey, + stubs_api: SyscallStubsApi, + ) -> LiteCoverageError<()> { + let res: Result, libloading::Error> = unsafe { + self.libs + .get(program_id) + .ok_or("No such program_id".to_string())? + .1 + .get(b"set_stubs") + }; + match res { + Ok(func) => unsafe { func(stubs_api) }, + Err(e) => { + // REVISIT: The idea behind is this to try working with stubs + // and if not neccesary (as is the case with pinocchio as it + // seems for now) to continue + log::warn!("Can't set stubs, error: {}! Proceed forward..", e); + } + } + Ok(()) + } + + /// Obtain the entrypoint for this program_id. + fn get_entrypoint(&self, program_id: &Pubkey) -> LiteCoverageError { + let entrypoint: Symbol = unsafe { + self.libs + .get(program_id) + .ok_or("No such program_id".to_string())? + .1 + .get(b"entrypoint")? + }; + Ok(*entrypoint) + } +} diff --git a/crates/lite-coverage/src/sbf.rs b/crates/lite-coverage/src/sbf.rs new file mode 100644 index 00000000..2e80e149 --- /dev/null +++ b/crates/lite-coverage/src/sbf.rs @@ -0,0 +1,132 @@ +// Adapted from solana-program-entrypoint +// https://solana.com/docs/programs/faq#input-parameter-serialization + +use solana_program_entrypoint::{BPF_ALIGN_OF_U128, MAX_PERMITTED_DATA_INCREASE, NON_DUP_MARKER}; +use solana_pubkey::Pubkey; +use solana_sysvar::slot_history::AccountInfo; + +#[allow(clippy::arithmetic_side_effects)] +#[inline(always)] // this reduces CU usage by half! +unsafe fn deserialize_account_info<'a>( + mut offset: usize, + new_input: *mut u8, + original_data_len: usize, +) -> (AccountInfo<'a>, usize) { + #[allow(clippy::cast_ptr_alignment)] + let is_signer = *(new_input.add(offset) as *const u8) != 0; + offset += size_of::(); + + #[allow(clippy::cast_ptr_alignment)] + let is_writable = *(new_input.add(offset) as *const u8) != 0; + offset += size_of::(); + + #[allow(clippy::cast_ptr_alignment)] + let executable = *(new_input.add(offset) as *const u8) != 0; + offset += size_of::(); + + // padding or original_data_len + let _original_data_len_from_new_input = *(new_input.add(offset) as *const u32); + // TODO: Put this assert? Does pinocchio update the padding to the original data len? + // assert!(original_data_len == _original_data_len_from_new_input as usize); + offset += size_of::(); + + let key: &Pubkey = &*(new_input.add(offset) as *const Pubkey); + offset += size_of::(); + + let owner: &Pubkey = &*(new_input.add(offset) as *const Pubkey); + offset += size_of::(); + + #[allow(clippy::cast_ptr_alignment)] + let lamports = std::rc::Rc::new(std::cell::RefCell::new( + &mut *(new_input.add(offset) as *mut u64), + )); + offset += size_of::(); + + #[allow(clippy::cast_ptr_alignment)] + let data_len = *(new_input.add(offset) as *const u64) as usize; + offset += size_of::(); + + let data = std::rc::Rc::new(std::cell::RefCell::new({ + std::slice::from_raw_parts_mut(new_input.add(offset), data_len) + })); + // use original_data_len when advancing as done at deserialize_parameters_aligned + offset += original_data_len + MAX_PERMITTED_DATA_INCREASE; + offset += (offset as *const u8).align_offset(BPF_ALIGN_OF_U128); // padding + + #[allow(clippy::cast_ptr_alignment)] + let rent_epoch = *(new_input.add(offset) as *const u64); + offset += size_of::(); + + ( + AccountInfo { + key, + is_signer, + is_writable, + lamports, + data, + owner, + executable, + rent_epoch, + }, + offset, + ) +} + +#[allow(clippy::arithmetic_side_effects)] +#[inline(always)] // this reduces CU usage +unsafe fn deserialize_instruction_data<'a>(input: *mut u8, mut offset: usize) -> (&'a [u8], usize) { + #[allow(clippy::cast_ptr_alignment)] + let instruction_data_len = *(input.add(offset) as *const u64) as usize; + offset += size_of::(); + + let instruction_data = { std::slice::from_raw_parts(input.add(offset), instruction_data_len) }; + offset += instruction_data_len; + + (instruction_data, offset) +} + +#[allow(clippy::arithmetic_side_effects)] +pub(crate) unsafe fn deserialize_updated_account_infos<'a>( + new_input: *mut u8, + original_data_lens: &[usize], +) -> (&'a Pubkey, Vec>, &'a [u8]) { + let mut offset: usize = 0; + + // Number of accounts present + + #[allow(clippy::cast_ptr_alignment)] + let num_accounts = *(new_input.add(offset) as *const u64) as usize; + offset += size_of::(); + + // Account Infos + + let mut accounts = Vec::with_capacity(num_accounts); + #[allow(clippy::needless_range_loop)] + for i in 0..num_accounts { + let dup_info = *(new_input.add(offset)); + offset += size_of::(); + if dup_info == NON_DUP_MARKER { + let original_data_len = original_data_lens[i]; + let (account_info, new_offset) = + deserialize_account_info(offset, new_input, original_data_len); + offset = new_offset; + accounts.push(account_info); + } else { + offset += 7; // padding + + // Duplicate account, clone the original + accounts.push(accounts[dup_info as usize].clone()); + } + } + + // Instruction data + + let (instruction_data, new_offset) = deserialize_instruction_data(new_input, offset); + offset = new_offset; + + // Program Id + + let program_id: &Pubkey = &*(new_input.add(offset) as *const Pubkey); + + (program_id, accounts, instruction_data) +} diff --git a/crates/lite-coverage/src/stubs.rs b/crates/lite-coverage/src/stubs.rs new file mode 100644 index 00000000..795b3c38 --- /dev/null +++ b/crates/lite-coverage/src/stubs.rs @@ -0,0 +1,102 @@ +use std::sync::{Arc, RwLock}; + +use solana_program_error::ProgramResult; +use solana_program_stubs::declare_sol_loader_stubs; +use solana_sysvar::program_stubs::SyscallStubs; + +use solana_instruction::{AccountMeta, Instruction}; +use solana_pubkey::Pubkey; +use solana_sysvar::slot_history::AccountInfo; + +declare_sol_loader_stubs!(); + +/// Main logic behind the StubsManager is explained in loader::adjust_stubs. +pub struct StubsManager; +impl StubsManager { + pub(crate) fn my_set_syscall_stubs( + syscall_stubs: Box, + ) -> Box { + std::mem::replace(&mut SYSCALL_STUBS.write().unwrap(), syscall_stubs) + } +} + +pub struct WrapperSyscallStubs {} +impl SyscallStubs for WrapperSyscallStubs { + fn sol_get_clock_sysvar(&self, var_addr: *mut u8) -> u64 { + SYSCALL_STUBS.read().unwrap().sol_get_clock_sysvar(var_addr) + } + fn sol_get_epoch_rewards_sysvar(&self, var_addr: *mut u8) -> u64 { + SYSCALL_STUBS + .read() + .unwrap() + .sol_get_epoch_rewards_sysvar(var_addr) + } + fn sol_get_epoch_schedule_sysvar(&self, var_addr: *mut u8) -> u64 { + SYSCALL_STUBS + .read() + .unwrap() + .sol_get_epoch_schedule_sysvar(var_addr) + } + fn sol_get_fees_sysvar(&self, var_addr: *mut u8) -> u64 { + SYSCALL_STUBS.read().unwrap().sol_get_fees_sysvar(var_addr) + } + fn sol_get_last_restart_slot(&self, var_addr: *mut u8) -> u64 { + SYSCALL_STUBS + .read() + .unwrap() + .sol_get_last_restart_slot(var_addr) + } + fn sol_get_processed_sibling_instruction(&self, index: usize) -> Option { + SYSCALL_STUBS + .read() + .unwrap() + .sol_get_processed_sibling_instruction(index) + } + fn sol_get_rent_sysvar(&self, var_addr: *mut u8) -> u64 { + SYSCALL_STUBS.read().unwrap().sol_get_rent_sysvar(var_addr) + } + fn sol_get_return_data(&self) -> Option<(Pubkey, Vec)> { + SYSCALL_STUBS.read().unwrap().sol_get_return_data() + } + fn sol_get_stack_height(&self) -> u64 { + SYSCALL_STUBS.read().unwrap().sol_get_stack_height() + } + fn sol_invoke_signed( + &self, + instruction: &Instruction, + account_infos: &[AccountInfo], + signers_seeds: &[&[&[u8]]], + ) -> ProgramResult { + SYSCALL_STUBS + .read() + .unwrap() + .sol_invoke_signed(instruction, account_infos, signers_seeds) + } + fn sol_log(&self, message: &str) { + SYSCALL_STUBS.read().unwrap().sol_log(message); + } + fn sol_log_compute_units(&self) { + SYSCALL_STUBS.read().unwrap().sol_log_compute_units(); + } + fn sol_log_data(&self, fields: &[&[u8]]) { + SYSCALL_STUBS.read().unwrap().sol_log_data(fields); + } + unsafe fn sol_memcmp(&self, s1: *const u8, s2: *const u8, n: usize, result: *mut i32) { + unsafe { SYSCALL_STUBS.read().unwrap().sol_memcmp(s1, s2, n, result) }; + } + unsafe fn sol_memcpy(&self, dst: *mut u8, src: *const u8, n: usize) { + unsafe { SYSCALL_STUBS.read().unwrap().sol_memcpy(dst, src, n) }; + } + unsafe fn sol_memmove(&self, dst: *mut u8, src: *const u8, n: usize) { + unsafe { SYSCALL_STUBS.read().unwrap().sol_memmove(dst, src, n) }; + } + unsafe fn sol_memset(&self, s: *mut u8, c: u8, n: usize) { + unsafe { SYSCALL_STUBS.read().unwrap().sol_memset(s, c, n) }; + } + fn sol_remaining_compute_units(&self) -> u64 { + SYSCALL_STUBS.read().unwrap().sol_remaining_compute_units() + } + fn sol_set_return_data(&self, data: &[u8]) { + SYSCALL_STUBS.read().unwrap().sol_set_return_data(data); + } +} diff --git a/crates/lite-coverage/src/types.rs b/crates/lite-coverage/src/types.rs new file mode 100644 index 00000000..13fcf621 --- /dev/null +++ b/crates/lite-coverage/src/types.rs @@ -0,0 +1,48 @@ +use solana_program_test::ProgramTestContext; +use solana_pubkey::Pubkey; +use std::{ + cell::RefCell, + error::Error, + ops::{Deref, DerefMut}, + rc::Rc, +}; + +pub type LiteCoverageError = Result>; +pub type ProgramName = String; +pub type NativeProgram = (Pubkey, ProgramName); +pub type AdditionalProgram = (Pubkey, ProgramName); + +/// ProgramTestContextHandle to reconcile sync with async in terms of RefCell borrows. +pub struct ProgramTestContextHandle { + ctx: Option, + owner: Rc>>, +} + +impl ProgramTestContextHandle { + pub fn new(owner: Rc>>) -> Self { + // Take the context from the owner + let ctx = owner.take(); + Self { ctx, owner } + } +} + +impl Drop for ProgramTestContextHandle { + fn drop(&mut self) { + // Return the context back to the owner + *self.owner.borrow_mut() = self.ctx.take(); + } +} + +impl Deref for ProgramTestContextHandle { + type Target = Option; + + fn deref(&self) -> &Self::Target { + &self.ctx + } +} + +impl DerefMut for ProgramTestContextHandle { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.ctx + } +} diff --git a/crates/litesvm/Cargo.toml b/crates/litesvm/Cargo.toml index 23bf6960..8f650896 100644 --- a/crates/litesvm/Cargo.toml +++ b/crates/litesvm/Cargo.toml @@ -48,6 +48,7 @@ solana-nonce.workspace = true solana-nonce-account.workspace = true solana-precompile-error.workspace = true solana-program-error.workspace = true +solana-program-entrypoint.workspace = true solana-program-runtime.workspace = true solana-pubkey.workspace = true solana-rent.workspace = true @@ -70,6 +71,9 @@ solana-transaction-context.workspace = true solana-transaction-error.workspace = true solana-vote-program.workspace = true thiserror.workspace = true +lite-coverage = { workspace = true } +solana-program-test.workspace = true +tokio.workspace = true [dev-dependencies] criterion.workspace = true diff --git a/crates/litesvm/src/lib.rs b/crates/litesvm/src/lib.rs index ad0b38eb..5958fc52 100644 --- a/crates/litesvm/src/lib.rs +++ b/crates/litesvm/src/lib.rs @@ -253,12 +253,15 @@ much easier. */ +use lite_coverage::AdditionalProgram; +pub use lite_coverage::{LiteCoverage, LiteCoverageError, NativeProgram}; #[cfg(feature = "nodejs-internal")] use qualifier_attr::qualifiers; #[allow(deprecated)] -use solana_sysvar::recent_blockhashes::IterItem; -#[allow(deprecated)] -use solana_sysvar::{fees::Fees, recent_blockhashes::RecentBlockhashes}; +use solana_sysvar::{ + fees::Fees, recent_blockhashes::IterItem, recent_blockhashes::RecentBlockhashes, +}; + use { crate::{ accounts_db::AccountsDb, @@ -358,6 +361,7 @@ pub struct LiteSVM { blockhash_check: bool, fee_structure: FeeStructure, log_bytes_limit: Option, + lite_coverage: Option, } impl Default for LiteSVM { @@ -373,6 +377,7 @@ impl Default for LiteSVM { blockhash_check: false, fee_structure: FeeStructure::default(), log_bytes_limit: Some(10_000), + lite_coverage: None, } } } @@ -927,6 +932,15 @@ impl LiteSVM { match maybe_program_indices { Ok(program_indices) => { let mut context = self.create_transaction_context(compute_budget, accounts); + + let tx_lite_coverage = || { + if self.lite_coverage.is_some() { + Some(tx.clone()) + } else { + None + } + }; + let feature_set = self.feature_set.runtime_features(); let mut invoke_context = InvokeContext::new( &mut context, @@ -942,6 +956,7 @@ impl LiteSVM { compute_budget.to_budget(), SVMTransactionExecutionCost::default(), ); + let mut tx_result = process_message( tx.message(), &program_indices, @@ -951,6 +966,10 @@ impl LiteSVM { ) .map(|_| ()); + if let Some(tx_copy) = tx_lite_coverage() { + self.send_transaction_lite_coverage(tx_copy); + } + if let Err(err) = self.check_accounts_rent(tx, &context) { tx_result = Err(err); }; @@ -967,6 +986,73 @@ impl LiteSVM { } } + fn send_transaction_lite_coverage(&self, tx: SanitizedTransaction) { + if let Some(lite_coverage) = self.lite_coverage.as_ref() { + // Sync Sysvars + self.sync_sysvars_with_lite_coverage(); + // Sync Accounts + let tx_accounts = self.collect_accounts_for_lite_coverage(&tx); + + let _ = lite_coverage.send_transaction(tx.to_versioned_transaction(), &tx_accounts); + } + } + + fn sync_sysvars_with_lite_coverage(&self) { + if let Some(lite_coverage) = self.lite_coverage.as_ref() { + let pt_context = lite_coverage.get_program_test_context(); + if let Some(ctx) = &*pt_context { + let clock_sysvar = self.get_sysvar::(); + ctx.set_sysvar(&clock_sysvar); + + let epoch_schedule_sysvar = self.get_sysvar::(); + ctx.set_sysvar(&epoch_schedule_sysvar); + + #[allow(deprecated)] + let fees_sysvar = self.get_sysvar::(); + ctx.set_sysvar(&fees_sysvar); + + let rent_sysvar = self.get_sysvar::(); + ctx.set_sysvar(&rent_sysvar); + + let epoch_rewards_sysvar = self.get_sysvar::(); + ctx.set_sysvar(&epoch_rewards_sysvar); + + #[allow(deprecated)] + let recent_blockhashes_sysvar = self.get_sysvar::(); + ctx.set_sysvar(&recent_blockhashes_sysvar); + + let slot_hashed_sysvar = self.get_sysvar::(); + ctx.set_sysvar(&slot_hashed_sysvar); + + let slot_history_sysvar = self.get_sysvar::(); + ctx.set_sysvar(&slot_history_sysvar); + + let stake_history_sysvar = self.get_sysvar::(); + ctx.set_sysvar(&stake_history_sysvar); + + let last_restart_slot_sysvar = self.get_sysvar::(); + ctx.set_sysvar(&last_restart_slot_sysvar); + } + } + } + + fn collect_accounts_for_lite_coverage( + &self, + tx: &SanitizedTransaction, + ) -> Vec<(Pubkey, AccountSharedData)> { + let account_keys = tx.message().static_account_keys(); + let mut tx_accounts: Vec<(Pubkey, AccountSharedData)> = + Vec::with_capacity(account_keys.len()); + for account_key in account_keys { + let account = self.get_account(account_key); + if let Some(account) = account { + if !account.executable() { + tx_accounts.push((*account_key, account.into())); + } + } + } + tx_accounts + } fn check_accounts_rent( &self, tx: &SanitizedTransaction, @@ -1155,6 +1241,17 @@ impl LiteSVM { }) } + /// Attach programs for obtaining code coverage. + pub fn with_coverage( + &mut self, + programs: Vec, + additional_programs: Vec, + payer: Keypair, + ) -> LiteCoverageError<()> { + self.lite_coverage = Some(LiteCoverage::new(programs, additional_programs, payer)?); + Ok(()) + } + /// Submits a signed transaction. pub fn send_transaction(&mut self, tx: impl Into) -> TransactionResult { let log_collector = LogCollector { diff --git a/crates/node-litesvm/Cargo.toml b/crates/node-litesvm/Cargo.toml index a716e906..2d22c8bf 100644 --- a/crates/node-litesvm/Cargo.toml +++ b/crates/node-litesvm/Cargo.toml @@ -36,6 +36,6 @@ solana-sysvar = { workspace = true } solana-transaction = { workspace = true } solana-transaction-context = { workspace = true } solana-transaction-error = { workspace = true } - +solana-keypair = { workspace = true } [build-dependencies] napi-build = "2.2.3" diff --git a/crates/node-litesvm/litesvm/index.ts b/crates/node-litesvm/litesvm/index.ts index 907a97b1..324e1532 100644 --- a/crates/node-litesvm/litesvm/index.ts +++ b/crates/node-litesvm/litesvm/index.ts @@ -1,87 +1,87 @@ import { - Account, - AddressAndAccount, - Clock, - ComputeBudget, - EpochRewards, - EpochSchedule, - FailedTransactionMetadata, - FeatureSet, - SimulatedTransactionInfo as SimulatedTransactionInfoInner, - LiteSvm as LiteSVMInner, - Rent, - SlotHash, - SlotHistory, - StakeHistory, - TransactionMetadata, + Account, + AddressAndAccount, + Clock, + ComputeBudget, + EpochRewards, + EpochSchedule, + FailedTransactionMetadata, + FeatureSet, + SimulatedTransactionInfo as SimulatedTransactionInfoInner, + LiteSvm as LiteSVMInner, + Rent, + SlotHash, + SlotHistory, + StakeHistory, + TransactionMetadata, } from "./internal"; export { - Account, - Clock, - ComputeBudget, - EpochRewards, - EpochSchedule, - FailedTransactionMetadata, - FeatureSet, - InnerInstruction, - Rent, - SlotHash, - SlotHistory, - SlotHistoryCheck, - StakeHistory, - StakeHistoryEntry, - TransactionMetadata, - TransactionReturnData, + Account, + Clock, + ComputeBudget, + EpochRewards, + EpochSchedule, + FailedTransactionMetadata, + FeatureSet, + InnerInstruction, + Rent, + SlotHash, + SlotHistory, + SlotHistoryCheck, + StakeHistory, + StakeHistoryEntry, + TransactionMetadata, + TransactionReturnData, } from "./internal"; import { - AccountInfo, - PublicKey, - Transaction, - VersionedTransaction, + AccountInfo, + PublicKey, + Transaction, + VersionedTransaction, } from "@solana/web3.js"; export type AccountInfoBytes = AccountInfo; function toAccountInfo(acc: Account): AccountInfoBytes { - const owner = new PublicKey(acc.owner()); - return { - executable: acc.executable(), - owner, - lamports: Number(acc.lamports()), - data: acc.data(), - rentEpoch: Number(acc.rentEpoch()), - }; + const owner = new PublicKey(acc.owner()); + return { + executable: acc.executable(), + owner, + lamports: Number(acc.lamports()), + data: acc.data(), + rentEpoch: Number(acc.rentEpoch()), + }; } function fromAccountInfo(acc: AccountInfoBytes): Account { - const maybeRentEpoch = acc.rentEpoch; - const rentEpoch = maybeRentEpoch || 0; - return new Account( - BigInt(acc.lamports), - acc.data, - acc.owner.toBytes(), - acc.executable, - BigInt(rentEpoch), - ); + const maybeRentEpoch = acc.rentEpoch; + const rentEpoch = maybeRentEpoch || 0; + return new Account( + BigInt(acc.lamports), + acc.data, + acc.owner.toBytes(), + acc.executable, + BigInt(rentEpoch) + ); } function convertAddressAndAccount( - val: AddressAndAccount, + val: AddressAndAccount ): [PublicKey, Account] { - return [new PublicKey(val.address), val.account()]; + return [new PublicKey(val.address), val.account()]; } export class SimulatedTransactionInfo { - constructor(inner: SimulatedTransactionInfoInner) { - this.inner = inner; - } - private inner: SimulatedTransactionInfoInner; - meta(): TransactionMetadata { - return this.inner.meta(); - } - postAccounts(): [PublicKey, Account][] { - return this.inner.postAccounts().map(convertAddressAndAccount); - } + constructor(inner: SimulatedTransactionInfoInner) { + this.inner = inner; + } + private inner: SimulatedTransactionInfoInner; + meta(): TransactionMetadata { + return this.inner.meta(); + } + postAccounts(): [PublicKey, Account][] { + return this.inner.postAccounts().map(convertAddressAndAccount); + } } /** @@ -288,6 +288,20 @@ export class LiteSVM { ): TransactionMetadata | FailedTransactionMetadata | null { return this.inner.airdrop(address.toBytes(), lamports); } + + /** + * Adds a SBF avatar (i.e native program) necessary for generating code coverage. + * @param programs - an array of program names. + * @param additionalPrograms - an array of additional SBF programs to load. + * @param payer - payer for the transactions, should be the same as used with the LiteSVM object. + */ + withCoverage( + programs: Array<[string, Uint8Array]>, + additionalPrograms: Array<[string, Uint8Array]>, + payer: Uint8Array + ) { + return this.inner.withCoverage(programs, additionalPrograms, payer); + } /** * Adds an SBF program to the test environment from the file specified. diff --git a/crates/node-litesvm/litesvm/internal.d.ts b/crates/node-litesvm/litesvm/internal.d.ts index b6cc323b..dc7e57d3 100644 --- a/crates/node-litesvm/litesvm/internal.d.ts +++ b/crates/node-litesvm/litesvm/internal.d.ts @@ -315,6 +315,11 @@ export declare class LiteSvm { airdrop(pubkey: Uint8Array, lamports: bigint): TransactionMetadata | FailedTransactionMetadata | null /** Adds am SBF program to the test environment from the file specified. */ addProgramFromFile(programId: Uint8Array, path: string): void + /** + * Load native programs as well as additional SBF programs in order to + * provide code coverage. + */ + withCoverage(programs: Array<[string, Uint8Array]>, additionalPrograms: Array<[string, Uint8Array]>, payer: Uint8Array): void /** Adds am SBF program to the test environment. */ addProgram(programId: Uint8Array, programBytes: Uint8Array): void sendLegacyTransaction(txBytes: Uint8Array): TransactionMetadata | FailedTransactionMetadata diff --git a/crates/node-litesvm/src/lib.rs b/crates/node-litesvm/src/lib.rs index 8768cdc2..d417a54f 100644 --- a/crates/node-litesvm/src/lib.rs +++ b/crates/node-litesvm/src/lib.rs @@ -28,7 +28,9 @@ use { solana_clock::Clock as ClockOriginal, solana_epoch_rewards::EpochRewards as EpochRewardsOriginal, solana_epoch_schedule::EpochSchedule as EpochScheduleOriginal, + solana_keypair::Keypair, solana_last_restart_slot::LastRestartSlot, + solana_pubkey::Pubkey, solana_rent::Rent as RentOriginal, solana_signature::Signature, solana_slot_hashes::SlotHashes, @@ -234,6 +236,54 @@ impl LiteSvm { }) } + #[napi] + /// Load native programs as well as additional SBF programs in order to + /// provide code coverage. + pub fn with_coverage( + &mut self, + programs: Vec<(String, Uint8Array)>, + additional_programs: Vec<(String, Uint8Array)>, + payer: Uint8Array, + ) -> Result<()> { + let mut progs: Vec<(Pubkey, String)> = vec![]; + for p in programs { + progs.push(( + Pubkey::new_from_array( + p.1.to_vec().try_into().map_err(|_| { + Error::new(Status::InvalidArg, "Program ID must be 32 bytes") + })?, + ), + p.0.clone(), + )); + } + let mut additional_progs: Vec<(Pubkey, String)> = vec![]; + for ap in additional_programs { + additional_progs.push(( + Pubkey::new_from_array( + ap.1.to_vec().try_into().map_err(|_| { + Error::new(Status::InvalidArg, "Program ID must be 32 bytes") + })?, + ), + ap.0.clone(), + )); + } + + self.0 + .with_coverage( + progs, + additional_progs, + Keypair::try_from(&payer[..]) + .map_err(|_| Error::new(Status::InvalidArg, "Invalid Payer Keypair bytes"))?, + ) + .map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to set programs for coverage: {e}"), + ) + })?; + Ok(()) + } + #[napi] /// Adds am SBF program to the test environment. pub fn add_program(&mut self, program_id: Uint8Array, program_bytes: &[u8]) -> Result<()> {