diff --git a/Cargo.lock b/Cargo.lock index f11428f..ad5e7ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,6 +63,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.15", + "once_cell", + "version_check", +] + [[package]] name = "ahash" version = "0.8.11" @@ -492,7 +503,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115e54d64eb62cdebad391c19efc9dce4981c690c85a33a12199d99bb9546fee" dependencies = [ "borsh-derive 0.10.4", - "hashbrown 0.13.2", + "hashbrown 0.12.3", ] [[package]] @@ -1223,6 +1234,16 @@ dependencies = [ "once_cell", ] +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + [[package]] name = "fastrand" version = "2.1.1" @@ -1460,6 +1481,9 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] [[package]] name = "hashbrown" @@ -1467,7 +1491,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" dependencies = [ - "ahash", + "ahash 0.8.11", ] [[package]] @@ -1671,6 +1695,12 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "indenter" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" + [[package]] name = "indexmap" version = "1.9.3" @@ -1886,7 +1916,7 @@ checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "litesvm" version = "0.3.0" -source = "git+https://github.com/OliverNChalk/litesvm#0e840791846332b6b8feed109a34094e8be9e639" +source = "git+https://github.com/OliverNChalk/litesvm#fe938a2e40da5c01ee011f4502ff36e86aed36b4" dependencies = [ "bincode", "indexmap 2.6.0", @@ -3456,7 +3486,7 @@ version = "2.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00c4128122787a61d8f94fdaa04cb71b3dbb017d9939ac4d632264c55ec345de" dependencies = [ - "ahash", + "ahash 0.8.11", "bincode", "bv", "caps", @@ -4544,6 +4574,7 @@ dependencies = [ "dashmap 6.1.0", "derivative", "expect-test", + "eyre", "faucet", "flate2", "futures", @@ -4556,6 +4587,7 @@ dependencies = [ "solana-client", "solana-logger", "solana-sdk", + "solana-transaction-status", "spl-associated-token-account 5.0.1", "spl-token", "spl-token-2022 5.0.2", diff --git a/Cargo.toml b/Cargo.toml index 573aed3..a9054ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ solana-client = "2.0.13" solana-logger = "2.0.13" solana-program = "2.0.13" solana-sdk = "2.0.13" +solana-transaction-status = "2.0.13" spl-associated-token-account = "5.0.1" spl-token = { version = "6.0.0", features = ["no-entrypoint"] } spl-token-2022 = { version = "5.0.0", features = ["no-entrypoint"] } diff --git a/crates/svm-test/Cargo.toml b/crates/svm-test/Cargo.toml index 6a1c6c0..e7cdf86 100644 --- a/crates/svm-test/Cargo.toml +++ b/crates/svm-test/Cargo.toml @@ -12,6 +12,7 @@ async-trait = "0.1.81" auto_impl.workspace = true dashmap = "6.0.1" derivative = "2.2.0" +eyre = "0.6.12" flate2 = "1.0.32" futures = "0.3.30" itertools = "0.13.0" @@ -23,6 +24,7 @@ solana-account-decoder = { workspace = true } solana-client = { workspace = true } solana-logger = { workspace = true } solana-sdk = { workspace = true } +solana-transaction-status.workspace = true spl-associated-token-account = { workspace = true, optional = true } spl-token = { workspace = true, optional = true } spl-token-2022 = { workspace = true, optional = true } diff --git a/crates/svm-test/src/harness.rs b/crates/svm-test/src/harness.rs index cb4e16f..f9c643f 100644 --- a/crates/svm-test/src/harness.rs +++ b/crates/svm-test/src/harness.rs @@ -4,6 +4,8 @@ use std::sync::{Arc, OnceLock, Weak}; use dashmap::DashMap; use solana_sdk::account::Account; use solana_sdk::pubkey::Pubkey; +use solana_sdk::signature::Signature; +use solana_sdk::transaction::VersionedTransaction; use test_rpc::TestRpc; use super::*; @@ -61,12 +63,6 @@ impl Harness { scenario } - pub fn get_snapshot(&'static self, block: u64) -> Arc { - let rpc = TestRpc::load_snapshot(block); - - Arc::new(Scenario { runtime: &self.runtime, rpc }) - } - fn load_scenario(&'static self, name: &str) -> Arc { let rpc = TestRpc::load_scenario(name); @@ -87,6 +83,10 @@ impl Scenario { ) -> ScenarioWithOverrides { ScenarioWithOverrides { scenario: self.clone(), overrides } } + + pub fn load_tx(&self, sig: &Signature) -> VersionedTransaction { + self.rpc.load_tx_sync(self.runtime, sig) + } } #[async_trait::async_trait] diff --git a/crates/svm-test/src/ser.rs b/crates/svm-test/src/ser.rs index 0c5699c..4f37232 100644 --- a/crates/svm-test/src/ser.rs +++ b/crates/svm-test/src/ser.rs @@ -2,7 +2,14 @@ use serde::{Deserialize, Serialize}; use serde_with::serde_as; use solana_sdk::account::Account; use solana_sdk::clock::Epoch; +use solana_sdk::instruction::CompiledInstruction; +use solana_sdk::message::v0::MessageAddressTableLookup; +use solana_sdk::message::{legacy, v0, VersionedMessage}; use solana_sdk::pubkey::Pubkey; +use solana_sdk::transaction::VersionedTransaction; +use solana_transaction_status::{ + EncodableWithMeta, EncodedTransaction, UiMessage, UiRawMessage, UiTransaction, +}; serde_with::serde_conv!( pub AccountAsJsonAccount, @@ -50,3 +57,238 @@ impl From for JsonAccount { } } } + +serde_with::serde_conv!( + pub TxAsJsonTx, + VersionedTransaction, + to_json_tx, + from_json_tx +); + +fn to_json_tx(tx: &VersionedTransaction) -> UiTransaction { + match tx.json_encode() { + EncodedTransaction::Json(tx) => tx, + _ => unreachable!(), + } +} + +fn from_json_tx(tx: UiTransaction) -> Result { + Ok(VersionedTransaction { + signatures: tx + .signatures + .iter() + .try_fold(Vec::default(), |mut sigs, sig| { + sig.parse().map(|sig| { + sigs.push(sig); + + sigs + }) + })?, + message: match tx.message { + UiMessage::Parsed(_) => unimplemented!(), + UiMessage::Raw(UiRawMessage { + header, + account_keys, + recent_blockhash, + instructions, + address_table_lookups, + }) => { + let account_keys = + account_keys + .iter() + .try_fold(Vec::default(), |mut keys, key| { + key.parse().map(|key| { + keys.push(key); + + keys + }) + })?; + let recent_blockhash = recent_blockhash.parse()?; + let instructions = + instructions + .into_iter() + .try_fold(Vec::default(), |mut ixs, ix| { + solana_sdk::bs58::decode(&ix.data).into_vec().map(|data| { + ixs.push(CompiledInstruction { + program_id_index: ix.program_id_index, + accounts: ix.accounts, + data, + }); + + ixs + }) + })?; + + match address_table_lookups { + Some(address_table_lookups) => VersionedMessage::V0(v0::Message { + header, + account_keys, + recent_blockhash, + instructions, + address_table_lookups: address_table_lookups.into_iter().try_fold( + Vec::default(), + |mut alts, alt| { + alt.account_key.parse().map(|account_key| { + alts.push(MessageAddressTableLookup { + account_key, + writable_indexes: alt.writable_indexes, + readonly_indexes: alt.readonly_indexes, + }); + + alts + }) + }, + )?, + }), + None => VersionedMessage::Legacy(legacy::Message { + header, + account_keys, + recent_blockhash, + instructions, + }), + } + } + }, + }) +} + +#[cfg(test)] +mod tests { + use expect_test::expect; + use solana_sdk::address_lookup_table::AddressLookupTableAccount; + use solana_sdk::hash::Hash; + use solana_sdk::message::VersionedMessage; + use solana_sdk::signer::Signer; + use solana_sdk::system_instruction; + use solana_sdk::transaction::Transaction; + + use super::*; + use crate::utils::test_payer_keypair; + + const DUMMY_PUBKEY: Pubkey = Pubkey::new_from_array([1; 32]); + const DUMMY_HASH: Hash = Hash::new_from_array([2; 32]); + + #[serde_as] + #[derive(Debug, Serialize, Deserialize)] + struct Wrapper(#[serde_as(as = "TxAsJsonTx")] VersionedTransaction); + + #[test] + fn round_trip_legacy_tx() { + let tx = Wrapper( + Transaction::new_signed_with_payer( + &[system_instruction::transfer(&test_payer_keypair().pubkey(), &DUMMY_PUBKEY, 500)], + Some(&test_payer_keypair().pubkey()), + &[test_payer_keypair()], + DUMMY_HASH, + ) + .into(), + ); + + // Act - Serialize. + let serialized = serde_json::to_string_pretty(&tx).unwrap(); + + // Assert. + expect![[r#" + { + "signatures": [ + "Y5KX5txmP8TwgsD2yx43AeUeHnLwPDz3nYhz3xudPMDq5AKmowKk3r3qjsGSp1VFSFhcc5T1dN9x3mqjpRpV1Xi" + ], + "message": { + "header": { + "numRequiredSignatures": 1, + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 1 + }, + "accountKeys": [ + "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9", + "4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi", + "11111111111111111111111111111111" + ], + "recentBlockhash": "8qbHbw2BbbTHBW1sbeqakYXVKRQM8Ne7pLK7m6CVfeR", + "instructions": [ + { + "programIdIndex": 2, + "accounts": [ + 0, + 1 + ], + "data": "3Bxs4hfoaMPsQgGf", + "stackHeight": null + } + ] + } + }"#]] + .assert_eq(&serialized); + + // Act - Recover. + let recovered: Wrapper = serde_json::from_str(&serialized).unwrap(); + + // Assert. + assert_eq!(recovered.0, tx.0); + } + + #[test] + fn round_trip_v0_tx() { + let message = solana_sdk::message::v0::Message::try_compile( + &test_payer_keypair().pubkey(), + &[system_instruction::transfer(&test_payer_keypair().pubkey(), &DUMMY_PUBKEY, 500)], + &vec![AddressLookupTableAccount { key: DUMMY_PUBKEY, addresses: vec![DUMMY_PUBKEY] }], + DUMMY_HASH, + ) + .unwrap(); + let tx = Wrapper( + VersionedTransaction::try_new(VersionedMessage::V0(message), &[test_payer_keypair()]) + .unwrap(), + ); + + // Act - Serialize. + let serialized = serde_json::to_string_pretty(&tx).unwrap(); + + // Assert. + expect![[r#" + { + "signatures": [ + "44z9pb2J9mfT6VSj3hL2KmFUjU7BQub8xV1iyCTduaSVb83db1GL4gfbamAgrP2GB1yk7rkLHmHfsB1mgxouxDRH" + ], + "message": { + "header": { + "numRequiredSignatures": 1, + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 1 + }, + "accountKeys": [ + "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9", + "11111111111111111111111111111111" + ], + "recentBlockhash": "8qbHbw2BbbTHBW1sbeqakYXVKRQM8Ne7pLK7m6CVfeR", + "instructions": [ + { + "programIdIndex": 1, + "accounts": [ + 0, + 2 + ], + "data": "3Bxs4hfoaMPsQgGf", + "stackHeight": null + } + ], + "addressTableLookups": [ + { + "accountKey": "4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi", + "writableIndexes": [ + 0 + ], + "readonlyIndexes": [] + } + ] + } + }"#]] + .assert_eq(&serialized); + + // Act - Recover. + let recovered: Wrapper = serde_json::from_str(&serialized).unwrap(); + + // Assert. + assert_eq!(recovered.0, tx.0); + } +} diff --git a/crates/svm-test/src/svm.rs b/crates/svm-test/src/svm.rs index a894b94..851c355 100644 --- a/crates/svm-test/src/svm.rs +++ b/crates/svm-test/src/svm.rs @@ -22,8 +22,9 @@ pub type DefaultLoader = HashMap { - inner: litesvm::LiteSVM, + pub inner: litesvm::LiteSVM, pub loader: L, reserved_account_keys: ReservedAccountKeys, } @@ -55,7 +56,7 @@ where .with_lamports(1_000_000u64.wrapping_mul(10u64.pow(9))) .with_sysvars() .with_sigverify(true) - .with_blockhash_check(true) + // .with_blockhash_check(true) } pub fn new(loader: L) -> Self { @@ -180,6 +181,8 @@ where } // Programs are a bit special. + let key_s = key.to_string(); + println!("{key_s}"); let account = self.loader.load(key); match (account.executable, account.owner) { (true, bpf_loader::ID | native_loader::ID) => {} diff --git a/crates/svm-test/src/test_rpc.rs b/crates/svm-test/src/test_rpc.rs index bac5850..a6070c7 100644 --- a/crates/svm-test/src/test_rpc.rs +++ b/crates/svm-test/src/test_rpc.rs @@ -9,9 +9,13 @@ use serde::{Deserialize, Serialize}; use serde_with::serde_as; use solana_account_decoder::UiAccountEncoding; use solana_client::nonblocking::rpc_client::RpcClient; -use solana_client::rpc_config::RpcAccountInfoConfig; +use solana_client::rpc_config::{RpcAccountInfoConfig, RpcTransactionConfig}; use solana_sdk::account::Account; +use solana_sdk::commitment_config::CommitmentConfig; use solana_sdk::pubkey::Pubkey; +use solana_sdk::signature::Signature; +use solana_sdk::transaction::VersionedTransaction; +use solana_transaction_status::UiTransactionEncoding; use tokio::runtime::Runtime; use crate::utils::{read_json, read_json_gz, WriteOnDrop}; @@ -40,17 +44,18 @@ pub fn test_static_data_path() -> PathBuf { test_data_path().join("static.json") } -static STATIC_CACHE: OnceLock = OnceLock::new(); +static STATIC_CACHE: OnceLock = OnceLock::new(); -pub fn get_static_cache() -> &'static RpcCache { +pub fn get_static_cache() -> &'static AccountCache { STATIC_CACHE.get_or_init(|| read_json(&test_static_data_path())) } #[derive(Derivative)] #[derivative(Debug)] pub struct TestRpc { - static_cache: &'static RpcCache, - cache: RwLock>, + static_cache: &'static AccountCache, + account_cache: RwLock>, + transaction_cache: RwLock>, /// If the RPC is set the cache file will be ignored & overwritten. #[derivative(Debug = "ignore")] rpc: Option, @@ -59,40 +64,78 @@ pub struct TestRpc { impl TestRpc { fn unwrap_rpc(&self, key: &Pubkey) -> &RpcClient { self.rpc.as_ref().unwrap_or_else(|| { - panic!("Test tried to access an uncached account and TEST_RPC is not set; key={key:?}") + panic!("Test tried to access an uncached account and TEST_RPC is not set; key={key}") }) } -} -impl TestRpc { - pub fn load_snapshot(slot: u64) -> Self { - let static_cache = get_static_cache(); - let cache_path = test_data_path().join(format!("snapshots/{slot}.json.gz")); - let cache = - RwLock::new(WriteOnDrop::new(read_json_gz::(&cache_path), Some(cache_path))); + pub(crate) fn load_tx_sync(&self, runtime: &Runtime, sig: &Signature) -> VersionedTransaction { + // Try load from cache + if let Some(tx) = self.transaction_cache.read().unwrap().get(sig) { + return tx.clone(); + } + + // Load from RPC. + let rpc = self.rpc.as_ref().unwrap_or_else(|| { + panic!("Test tried to access an uncached account and TEST_RPC is not set; tx={sig}") + }); + let tx = runtime + .block_on(rpc.get_transaction_with_config( + sig, + RpcTransactionConfig { + commitment: Some(CommitmentConfig::confirmed()), + encoding: Some(UiTransactionEncoding::Base64), + max_supported_transaction_version: Some(1), + }, + )) + .unwrap() + .transaction + .transaction + .decode() + .unwrap(); + + // Update cache. + self.transaction_cache + .write() + .unwrap() + .insert(*sig, tx.clone()); - TestRpc { static_cache, cache, rpc: None } + tx } +} +impl TestRpc { pub fn load_scenario(name: &str) -> Self { - let cache_path = test_data_path().join(format!("{name}.json.gz")); let rpc = match std::env::var("TEST_RPC") { Ok(url) => Some(RpcClient::new(url)), Err(VarError::NotPresent) => None, Err(VarError::NotUnicode(raw)) => panic!("Non utf8 TEST_RPC; raw={raw:?}"), }; - assert!(rpc.is_some() || cache_path.exists(), "Need either `TEST_RPC` or test cache file"); + // Ensure the scenario directory exists. + let scenario = test_data_path().join(name); + std::fs::create_dir_all(&scenario).unwrap(); + + // Load account cache. + let account_cache_path = scenario.join("accounts.json.gz"); + let account_cache = RwLock::new(WriteOnDrop::new( + match rpc.is_some() { + true => AccountCache(BTreeMap::default()), + false => read_json_gz(&account_cache_path), + }, + Some(account_cache_path), + )); - let cache = RwLock::new(WriteOnDrop::new( + // Load transaction cache. + let transaction_cache_path = scenario.join("transactions.json.gz"); + let transaction_cache = RwLock::new(WriteOnDrop::new( match rpc.is_some() { - true => RpcCache(BTreeMap::default()), - false => read_json_gz(&cache_path), + true => TxCache(BTreeMap::default()), + false => read_json_gz(&transaction_cache_path), }, - Some(cache_path), + Some(transaction_cache_path), )); - TestRpc { static_cache: get_static_cache(), cache, rpc } + TestRpc { static_cache: get_static_cache(), account_cache, transaction_cache, rpc } } pub fn account_sync(&self, runtime: &'static Runtime, key: &Pubkey) -> Account { @@ -105,7 +148,7 @@ impl TestRpc { .static_cache .get(key) .cloned() - .or_else(|| self.cache.read().unwrap().get(key).cloned()) + .or_else(|| self.account_cache.read().unwrap().get(key).cloned()) { return cached; } @@ -126,7 +169,10 @@ impl TestRpc { .unwrap_or_default(); // Update cache. - self.cache.write().unwrap().insert(*key, account.clone()); + self.account_cache + .write() + .unwrap() + .insert(*key, account.clone()); account } @@ -134,12 +180,12 @@ impl TestRpc { #[serde_as] #[derive(Debug, Default, Serialize, Deserialize)] -pub struct RpcCache( +pub struct AccountCache( #[serde_as(as = "BTreeMap")] pub BTreeMap, ); -impl Deref for RpcCache { +impl Deref for AccountCache { type Target = BTreeMap; fn deref(&self) -> &Self::Target { @@ -147,7 +193,28 @@ impl Deref for RpcCache { } } -impl DerefMut for RpcCache { +impl DerefMut for AccountCache { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +#[serde_as] +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct TxCache( + #[serde_as(as = "BTreeMap")] + pub BTreeMap, +); + +impl Deref for TxCache { + type Target = BTreeMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for TxCache { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 }