use crate::error::{Error, Result};
use crate::fixture::{Fixture, FixtureEntry};
use base64::Engine;
use litesvm::types::TransactionResult;
use litesvm::LiteSVM;
use serde_json::json;
use solana_account::Account;
use solana_address::Address;
use solana_client::rpc_client::RpcClient;
use solana_client::rpc_request::RpcRequest;
use solana_clock::Clock;
use solana_transaction::versioned::VersionedTransaction;
use std::collections::HashMap;
use std::str::FromStr;
const NATIVE_LOADER: &str = "NativeLoader1111111111111111111111111111111";
const BPF_LOADER_2: &str = "BPFLoader2111111111111111111111111111111111";
const BPF_LOADER_UPGRADEABLE: &str = "BPFLoaderUpgradeab1e11111111111111111111111";
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, Default)]
pub struct ReplayResult {
pub success: bool,
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_name: Option<String>,
pub logs: Vec<String>,
pub compute_units: u64,
}
fn b64_decode(s: &str) -> Vec<u8> {
base64::engine::general_purpose::STANDARD
.decode(s)
.unwrap_or_default()
}
type ElfCache = HashMap<(String, u64), std::sync::Arc<Vec<u8>>>;
static ELF_CACHE: std::sync::LazyLock<std::sync::Mutex<ElfCache>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
const ELF_CACHE_MAX_ENTRIES: usize = 256;
fn fetch_programdata_elf_cached(client: &RpcClient, pd_addr: &str) -> Option<Vec<u8>> {
let header: serde_json::Value = client
.send(
RpcRequest::GetAccountInfo,
json!([pd_addr, { "encoding": "base64", "commitment": "confirmed", "dataSlice": { "offset": 0, "length": 45 } }]),
)
.ok()?;
let head = b64_decode(header["value"]["data"][0].as_str()?);
let slot = head
.get(4..12)
.and_then(|b| b.try_into().ok())
.map(u64::from_le_bytes)?;
let key = (pd_addr.to_string(), slot);
if let Ok(cache) = ELF_CACHE.lock() {
if let Some(elf) = cache.get(&key) {
return Some(elf.as_ref().clone());
}
}
let elf = fetch_account_data(client, pd_addr)
.filter(|d| d.len() > 45)
.map(|d| d[45..].to_vec())?;
if let Ok(mut cache) = ELF_CACHE.lock() {
if cache.len() >= ELF_CACHE_MAX_ENTRIES {
cache.clear();
}
cache.insert(key, std::sync::Arc::new(elf.clone()));
}
Some(elf)
}
fn fetch_account_data(client: &RpcClient, address: &str) -> Option<Vec<u8>> {
let resp: serde_json::Value = client
.send(
RpcRequest::GetAccountInfo,
json!([address, { "encoding": "base64", "commitment": "confirmed" }]),
)
.ok()?;
let data_b64 = resp["value"]["data"][0].as_str()?;
Some(b64_decode(data_b64))
}
fn fetch_account_at_slot(
archive: &RpcClient,
address: &str,
slot: u64,
) -> Option<serde_json::Value> {
let resp: serde_json::Value = archive
.send(
RpcRequest::GetAccountInfo,
json!([address, { "encoding": "base64", "slot": slot }]),
)
.ok()?;
let v = &resp["value"];
if v.is_null() {
None
} else {
Some(v.clone())
}
}
const ARCHIVE_PROBE_LAG: u64 = 10_000;
pub(crate) fn archive_honors_slot(archive: &RpcClient, slot: u64) -> bool {
let probe = slot.saturating_sub(ARCHIVE_PROBE_LAG);
let resp: serde_json::Value = match archive.send(
RpcRequest::GetAccountInfo,
json!([SPL_TOKEN_PROGRAM, { "encoding": "base64", "slot": probe }]),
) {
Ok(v) => v,
Err(_) => return false,
};
match resp["context"]["slot"].as_u64() {
Some(ctx_slot) => ctx_slot <= probe,
None => false,
}
}
fn fetch_account_data_at_slot(archive: &RpcClient, address: &str, slot: u64) -> Option<Vec<u8>> {
Some(b64_decode(
fetch_account_at_slot(archive, address, slot)?["data"][0].as_str()?,
))
}
fn fetch_loaded_at_slot(
archive: &RpcClient,
account_keys: &[String],
slot: u64,
) -> Result<LoadedAccounts> {
let mut out: Vec<(Address, Loaded)> = Vec::new();
let mut existing: HashMap<String, ()> = HashMap::new();
for key in account_keys {
let Some(acc) = fetch_account_at_slot(archive, key, slot) else {
continue;
};
existing.insert(key.clone(), ());
let Ok(address) = Address::from_str(key) else {
continue;
};
let owner = acc["owner"].as_str().unwrap_or_default();
let executable = acc["executable"].as_bool().unwrap_or(false);
if executable {
let elf: Option<Vec<u8>> = if owner == NATIVE_LOADER {
None
} else if owner == BPF_LOADER_2 {
Some(b64_decode(acc["data"][0].as_str().unwrap_or_default()))
} else if owner == BPF_LOADER_UPGRADEABLE {
let prog = b64_decode(acc["data"][0].as_str().unwrap_or_default());
if prog.len() >= 36 {
let pd_bytes: [u8; 32] = prog[4..36].try_into().unwrap();
let pd_addr = Address::from(pd_bytes);
fetch_account_data_at_slot(archive, &pd_addr.to_string(), slot)
.filter(|d| d.len() > 45)
.map(|d| d[45..].to_vec())
} else {
None
}
} else {
None
};
if let Some(elf) = elf {
out.push((address, Loaded::Program(elf)));
}
continue;
}
let Ok(owner_addr) = Address::from_str(owner) else {
continue;
};
out.push((
address,
Loaded::Data(Account {
lamports: acc["lamports"].as_u64().unwrap_or(0),
data: b64_decode(acc["data"][0].as_str().unwrap_or_default()),
owner: owner_addr,
executable: false,
rent_epoch: 0,
}),
));
}
Ok((out, existing))
}
enum Loaded {
Data(Account),
Program(Vec<u8>),
}
type LoadedAccounts = (Vec<(Address, Loaded)>, HashMap<String, ()>);
fn fetch_loaded(client: &RpcClient, account_keys: &[String]) -> Result<LoadedAccounts> {
let resp: serde_json::Value = client
.send(
RpcRequest::GetMultipleAccounts,
json!([
account_keys,
{ "encoding": "base64", "commitment": "confirmed" }
]),
)
.map_err(Error::rpc)?;
let accounts = resp["value"]
.as_array()
.ok_or_else(|| Error::MalformedRpcResponse("getMultipleAccounts: no value array".into()))?;
let mut out: Vec<(Address, Loaded)> = Vec::new();
let mut existing: HashMap<String, ()> = HashMap::new();
for (i, acc) in accounts.iter().enumerate() {
if acc.is_null() {
continue; }
existing.insert(account_keys[i].clone(), ());
let Ok(address) = Address::from_str(&account_keys[i]) else {
continue;
};
let owner = acc["owner"].as_str().unwrap_or_default();
let executable = acc["executable"].as_bool().unwrap_or(false);
if executable {
let elf: Option<Vec<u8>> = if owner == NATIVE_LOADER {
None } else if owner == BPF_LOADER_2 {
Some(b64_decode(acc["data"][0].as_str().unwrap_or_default()))
} else if owner == BPF_LOADER_UPGRADEABLE {
let prog = b64_decode(acc["data"][0].as_str().unwrap_or_default());
if prog.len() >= 36 {
let pd_bytes: [u8; 32] = prog[4..36].try_into().unwrap();
let pd_addr = Address::from(pd_bytes);
fetch_programdata_elf_cached(client, &pd_addr.to_string())
} else {
None
}
} else {
None
};
if let Some(elf) = elf {
out.push((address, Loaded::Program(elf)));
}
continue;
}
let Ok(owner_addr) = Address::from_str(owner) else {
continue;
};
out.push((
address,
Loaded::Data(Account {
lamports: acc["lamports"].as_u64().unwrap_or(0),
data: b64_decode(acc["data"][0].as_str().unwrap_or_default()),
owner: owner_addr,
executable: false,
rent_epoch: 0,
}),
));
}
Ok((out, existing))
}
#[derive(Clone, Debug)]
pub struct FeatureToggle {
pub id: Address,
pub active: bool,
}
fn svm_from_loaded(loaded: &[(Address, Loaded)], features: &[FeatureToggle], slot: u64) -> LiteSVM {
let mut svm = LiteSVM::new()
.with_sigverify(false)
.with_blockhash_check(false)
.with_log_bytes_limit(None);
if !features.is_empty() {
let mut fs = LiteSVM::mainnet_feature_set();
for t in features {
let pk = solana_pubkey::Pubkey::new_from_array(t.id.to_bytes());
if t.active {
fs.activate(&pk, slot);
} else {
fs.deactivate(&pk);
}
}
svm = svm
.with_feature_set(fs)
.with_builtins()
.with_feature_accounts();
}
for (addr, l) in loaded {
match l {
Loaded::Data(a) => {
let _ = svm.set_account(*addr, a.clone());
}
Loaded::Program(elf) => {
let _ = svm.add_program(*addr, elf);
}
}
}
svm
}
pub(crate) struct LoadedInfo {
pub address: String,
pub is_program: bool,
pub owner_is_system: bool,
pub data_len: usize,
pub hash: String,
}
pub(crate) struct ReplayContext {
signature: String,
tx: VersionedTransaction,
loaded: Vec<(Address, Loaded)>,
slot: Option<u64>,
block_time: Option<i64>,
time_travel: TimeTravel,
idls: HashMap<String, serde_json::Value>,
feature_toggles: Vec<FeatureToggle>,
}
pub(crate) struct RawAccountDiff {
pub(crate) address: String,
pub(crate) owner: String,
pub(crate) lamports_before: u64,
pub(crate) lamports_after: u64,
pub(crate) data_before: Vec<u8>,
pub(crate) data_after: Vec<u8>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TimeTravel {
#[serde(default)]
pub epochs: Option<i64>,
#[serde(default)]
pub slots: Option<i64>,
#[serde(default)]
pub seconds: Option<i64>,
#[serde(default)]
pub at_unix_timestamp: Option<i64>,
#[serde(default)]
pub at_slot: Option<u64>,
#[serde(default)]
pub at_epoch: Option<u64>,
}
const SLOTS_PER_EPOCH: i64 = 432_000;
const SECS_PER_SLOT: f64 = 0.4;
impl TimeTravel {
pub(crate) fn is_noop(&self) -> bool {
self.epochs.is_none()
&& self.slots.is_none()
&& self.seconds.is_none()
&& self.at_unix_timestamp.is_none()
&& self.at_slot.is_none()
&& self.at_epoch.is_none()
}
fn apply(&self, clock: &mut Clock) {
let mut slot_delta: i64 = 0;
if let Some(e) = self.epochs {
slot_delta = slot_delta.saturating_add(e.saturating_mul(SLOTS_PER_EPOCH));
}
if let Some(s) = self.slots {
slot_delta = slot_delta.saturating_add(s);
}
if slot_delta != 0 {
clock.slot = clock.slot.saturating_add_signed(slot_delta);
clock.epoch = clock
.epoch
.saturating_add_signed(slot_delta / SLOTS_PER_EPOCH);
clock.unix_timestamp = clock
.unix_timestamp
.saturating_add((slot_delta as f64 * SECS_PER_SLOT) as i64);
}
if let Some(secs) = self.seconds {
clock.unix_timestamp = clock.unix_timestamp.saturating_add(secs);
let by = (secs as f64 / SECS_PER_SLOT) as i64;
clock.slot = clock.slot.saturating_add_signed(by);
clock.epoch = clock.epoch.saturating_add_signed(by / SLOTS_PER_EPOCH);
}
if let Some(t) = self.at_unix_timestamp {
clock.unix_timestamp = t;
}
if let Some(s) = self.at_slot {
clock.slot = s;
}
if let Some(e) = self.at_epoch {
clock.epoch = e;
}
clock.leader_schedule_epoch = clock.epoch + 1;
clock.epoch_start_timestamp = clock.epoch_start_timestamp.min(clock.unix_timestamp);
}
pub(crate) fn describe(&self, clock: &Clock) -> String {
format!(
"slot {} · epoch {} · {}",
clock.slot,
clock.epoch,
chrono_like(clock.unix_timestamp)
)
}
}
fn chrono_like(ts: i64) -> String {
let days = ts.div_euclid(86_400);
let secs = ts.rem_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{y:04}-{m:02}-{d:02} {:02}:{:02} UTC",
secs / 3600,
(secs % 3600) / 60
)
}
impl ReplayContext {
pub(crate) fn set_time_travel(&mut self, tt: TimeTravel) {
self.time_travel = tt;
}
pub(crate) fn replace_program(&mut self, program_id: &str, elf: Vec<u8>) -> Option<Vec<u8>> {
for (addr, l) in self.loaded.iter_mut() {
if addr.to_string() == program_id {
if let Loaded::Program(existing) = l {
return Some(std::mem::replace(existing, elf));
}
}
}
None
}
pub(crate) fn loaded_info(&self) -> Vec<LoadedInfo> {
self.loaded
.iter()
.map(|(addr, l)| match l {
Loaded::Data(a) => LoadedInfo {
address: addr.to_string(),
is_program: false,
owner_is_system: a.owner == Address::default(),
data_len: a.data.len(),
hash: solana_blake3_hasher::hash(&a.data).to_string(),
},
Loaded::Program(elf) => LoadedInfo {
address: addr.to_string(),
is_program: true,
owner_is_system: false,
data_len: elf.len(),
hash: solana_blake3_hasher::hash(elf).to_string(),
},
})
.collect()
}
pub(crate) fn set_feature_toggles(&mut self, toggles: Vec<FeatureToggle>) {
self.feature_toggles = toggles;
}
fn base_clock(&self, clock: &mut Clock) {
let Some(slot) = self.slot else { return };
clock.slot = slot;
clock.epoch = slot / SLOTS_PER_EPOCH as u64;
const GENESIS_UNIX: i64 = 1_584_368_940; clock.unix_timestamp = self
.block_time
.unwrap_or_else(|| GENESIS_UNIX + (slot as f64 * SECS_PER_SLOT) as i64);
clock.epoch_start_timestamp =
clock.unix_timestamp - ((slot % SLOTS_PER_EPOCH as u64) as f64 * SECS_PER_SLOT) as i64;
clock.leader_schedule_epoch = clock.epoch + 1;
}
pub(crate) fn describe_clock(&self) -> String {
let svm = LiteSVM::new();
let mut clock = svm.get_sysvar::<Clock>();
self.base_clock(&mut clock);
self.time_travel.apply(&mut clock);
self.time_travel.describe(&clock)
}
fn fresh_svm(&self) -> LiteSVM {
let mut svm = svm_from_loaded(&self.loaded, &self.feature_toggles, self.slot.unwrap_or(0));
let mut clock = svm.get_sysvar::<Clock>();
self.base_clock(&mut clock);
if !self.time_travel.is_noop() {
self.time_travel.apply(&mut clock);
}
svm.set_sysvar::<Clock>(&clock);
svm
}
pub(crate) fn run_with_diff(
&self,
mutations: &[Mutation],
) -> Result<(ReplayResult, Vec<RawAccountDiff>)> {
let (result, svm) = self.run_full(mutations)?;
let mut diffs = Vec::new();
for (addr, l) in &self.loaded {
let Loaded::Data(before) = l else { continue };
let Some(after) = svm.get_account(addr) else {
continue;
};
if after.lamports == before.lamports && after.data == before.data {
continue; }
diffs.push(RawAccountDiff {
address: addr.to_string(),
owner: after.owner.to_string(),
lamports_before: before.lamports,
lamports_after: after.lamports,
data_before: before.data.clone(),
data_after: after.data.clone(),
});
}
Ok((result, diffs))
}
pub(crate) fn run_and_read_account(
&self,
mutations: &[Mutation],
address: &str,
) -> Result<(ReplayResult, Option<Account>)> {
let (result, svm) = self.run_full(mutations)?;
let acc = Address::from_str(address)
.ok()
.and_then(|a| svm.get_account(&a));
Ok((result, acc))
}
fn pre_account(&self, address: &str) -> Option<&Account> {
let addr = Address::from_str(address).ok()?;
self.loaded.iter().find_map(|(a, l)| match l {
Loaded::Data(acc) if *a == addr => Some(acc),
_ => None,
})
}
fn is_known(&self, address: &str) -> bool {
match Address::from_str(address) {
Ok(addr) => self.loaded.iter().any(|(a, _)| *a == addr),
Err(_) => false,
}
}
pub(crate) fn add_idl(&mut self, owner: String, idl: serde_json::Value) {
self.idls.insert(owner, idl);
}
pub(crate) fn idl_map(&self) -> &HashMap<String, serde_json::Value> {
&self.idls
}
pub(crate) fn interesting_programs(&self) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
for (addr, l) in &self.loaded {
match l {
Loaded::Program(_) => {
seen.insert(addr.to_string());
}
Loaded::Data(acc) => {
seen.insert(acc.owner.to_string());
}
}
}
seen.into_iter().collect()
}
pub(crate) fn push_feature_toggle(&mut self, t: FeatureToggle) {
self.feature_toggles.push(t);
}
fn decode_pre(&self, address: &str) -> Result<(crate::decode::DecodedAccount, &Account)> {
let pre = self
.pre_account(address)
.ok_or_else(|| Error::AccountNotFound(format!("{address} (no pre-state)")))?;
let owner = pre.owner.to_string();
crate::decode::decode_bytes(&owner, &pre.data)
.or_else(|| {
self.idls
.get(&owner)
.and_then(|i| crate::idl::decode_with_idl(i, &pre.data))
})
.map(|dec| (dec, pre))
.ok_or_else(|| Error::UndecodableAccount {
address: address.to_string(),
owner,
})
}
}
const SPL_TOKEN_PROGRAM: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
struct TokenInfo {
mint: String,
owner: String, amount: u64,
}
#[derive(Default)]
pub(crate) struct PreState {
token_amounts: HashMap<String, u64>,
lamports: HashMap<String, u64>,
token_info: HashMap<String, TokenInfo>,
}
impl PreState {
pub(crate) fn from_meta(tx: &serde_json::Value, account_keys: &[String]) -> PreState {
let mut ps = PreState::default();
if let Some(pre) = tx["meta"]["preBalances"].as_array() {
for (i, v) in pre.iter().enumerate() {
if let (Some(addr), Some(l)) = (account_keys.get(i), v.as_u64()) {
ps.lamports.insert(addr.clone(), l);
}
}
}
if let Some(pre) = tx["meta"]["preTokenBalances"].as_array() {
for e in pre {
let idx = e["accountIndex"].as_u64().unwrap_or(u64::MAX) as usize;
let amt = e["uiTokenAmount"]["amount"]
.as_str()
.and_then(|s| s.parse::<u64>().ok());
let (Some(addr), Some(amt)) = (account_keys.get(idx), amt) else {
continue;
};
ps.token_amounts.insert(addr.clone(), amt);
if let (Some(mint), Some(owner)) = (e["mint"].as_str(), e["owner"].as_str()) {
ps.token_info.insert(
addr.clone(),
TokenInfo {
mint: mint.to_string(),
owner: owner.to_string(),
amount: amt,
},
);
}
}
}
ps
}
fn reconstruct(&self, address: &str) -> Option<Account> {
if let Some(info) = self.token_info.get(address) {
let mut data = vec![0u8; 165];
let mint = Address::from_str(&info.mint).ok()?;
let owner = Address::from_str(&info.owner).ok()?;
data[0..32].copy_from_slice(mint.as_array());
data[32..64].copy_from_slice(owner.as_array());
data[64..72].copy_from_slice(&info.amount.to_le_bytes());
data[108] = 1; return Some(Account {
lamports: self.lamports.get(address).copied().unwrap_or(2_039_280),
data,
owner: Address::from_str(SPL_TOKEN_PROGRAM).ok()?,
executable: false,
rent_epoch: 0,
});
}
match self.lamports.get(address) {
Some(&l) if l > 0 => Some(Account {
lamports: l,
data: vec![],
owner: Address::default(), executable: false,
rent_epoch: 0,
}),
_ => None,
}
}
fn is_empty(&self) -> bool {
self.token_amounts.is_empty() && self.lamports.is_empty()
}
}
pub(crate) fn build_context(
client: &RpcClient,
signature: &str,
account_keys: &[String],
_tx_slot: Option<u64>,
pre_state: &PreState,
) -> Result<ReplayContext> {
let slot = client.get_slot().ok().or(_tx_slot);
let block_time = slot.and_then(|s| client.get_block_time(s).ok());
let tx = fetch_transaction(client, signature)?;
let mut all_keys = account_keys.to_vec();
if let Some(lookups) = tx.message.address_table_lookups() {
for l in lookups {
all_keys.push(l.account_key.to_string());
}
}
let (mut loaded, existing) = fetch_loaded(client, &all_keys)?;
if !pre_state.is_empty() {
for key in account_keys {
if existing.contains_key(key) {
continue;
}
if let (Some(acc), Ok(addr)) = (pre_state.reconstruct(key), Address::from_str(key)) {
loaded.push((addr, Loaded::Data(acc)));
}
}
for (addr, l) in loaded.iter_mut() {
if let Loaded::Data(acc) = l {
let key = addr.to_string();
if let Some(&lamports) = pre_state.lamports.get(&key) {
acc.lamports = lamports;
}
if let Some(&amt) = pre_state.token_amounts.get(&key) {
if acc.data.len() >= 72 {
acc.data[64..72].copy_from_slice(&amt.to_le_bytes());
}
}
}
}
}
Ok(ReplayContext {
signature: signature.to_string(),
tx,
loaded,
slot,
block_time,
time_travel: TimeTravel::default(),
idls: HashMap::new(),
feature_toggles: Vec::new(),
})
}
pub(crate) fn build_context_at_slot(
archive: &RpcClient,
signature: &str,
account_keys: &[String],
state_slot: u64,
clock_slot: u64,
tx_block_time: Option<i64>,
pre_state: &PreState,
) -> Result<ReplayContext> {
let tx = fetch_transaction(archive, signature)?;
let mut all_keys = account_keys.to_vec();
if let Some(lookups) = tx.message.address_table_lookups() {
for l in lookups {
all_keys.push(l.account_key.to_string());
}
};
let (mut loaded, existing) = fetch_loaded_at_slot(archive, &all_keys, state_slot)?;
if !pre_state.is_empty() {
for key in account_keys {
if existing.contains_key(key) {
continue;
}
if let (Some(acc), Ok(addr)) = (pre_state.reconstruct(key), Address::from_str(key)) {
loaded.push((addr, Loaded::Data(acc)));
}
}
for (addr, l) in loaded.iter_mut() {
if let Loaded::Data(acc) = l {
let key = addr.to_string();
if let Some(&lamports) = pre_state.lamports.get(&key) {
acc.lamports = lamports;
}
if let Some(&amt) = pre_state.token_amounts.get(&key) {
if acc.data.len() >= 72 {
acc.data[64..72].copy_from_slice(&amt.to_le_bytes());
}
}
}
}
}
Ok(ReplayContext {
signature: signature.to_string(),
tx,
loaded,
slot: Some(clock_slot),
block_time: tx_block_time,
time_travel: TimeTravel::default(),
idls: HashMap::new(),
feature_toggles: Vec::new(),
})
}
fn b64_encode(bytes: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(bytes)
}
pub(crate) fn resolve_alt_addresses(
client: &RpcClient,
tx: &VersionedTransaction,
) -> (Vec<String>, Vec<String>) {
let mut writable = Vec::new();
let mut readonly = Vec::new();
if let Some(lookups) = tx.message.address_table_lookups() {
for l in lookups {
let Some(data) = fetch_account_data(client, &l.account_key.to_string()) else {
continue;
};
let read = |idx: u8| -> Option<String> {
let off = 56 + idx as usize * 32;
let bytes: [u8; 32] = data.get(off..off + 32)?.try_into().ok()?;
Some(Address::from(bytes).to_string())
};
for &idx in &l.writable_indexes {
if let Some(a) = read(idx) {
writable.push(a);
}
}
for &idx in &l.readonly_indexes {
if let Some(a) = read(idx) {
readonly.push(a);
}
}
}
}
(writable, readonly)
}
pub(crate) fn preflight_context(
client: &RpcClient,
tx: VersionedTransaction,
) -> Result<ReplayContext> {
let mut keys: Vec<String> = tx
.message
.static_account_keys()
.iter()
.map(|k| k.to_string())
.collect();
let (writable, readonly) = resolve_alt_addresses(client, &tx);
keys.extend(writable);
keys.extend(readonly);
let mut all_keys = keys.clone();
if let Some(lookups) = tx.message.address_table_lookups() {
for l in lookups {
all_keys.push(l.account_key.to_string());
}
}
let (loaded, _existing) = fetch_loaded(client, &all_keys)?;
let slot = client.get_slot().ok();
let block_time = slot.and_then(|s| client.get_block_time(s).ok());
let signature = tx
.signatures
.first()
.map(|s| s.to_string())
.unwrap_or_default();
Ok(ReplayContext {
signature,
tx,
loaded,
slot,
block_time,
time_travel: TimeTravel::default(),
idls: HashMap::new(),
feature_toggles: Vec::new(),
})
}
impl ReplayContext {
pub(crate) fn to_fixture(&self) -> Result<Fixture> {
let tx_bytes = bincode::serialize(&self.tx)
.map_err(|e| Error::Fixture(format!("serialize transaction: {e}")))?;
let entries = self
.loaded
.iter()
.map(|(addr, l)| match l {
Loaded::Data(a) => FixtureEntry::Data {
address: addr.to_string(),
owner: a.owner.to_string(),
lamports: a.lamports,
data_b64: b64_encode(&a.data),
},
Loaded::Program(elf) => FixtureEntry::Program {
address: addr.to_string(),
elf_b64: b64_encode(elf),
},
})
.collect();
Ok(Fixture {
version: crate::fixture::FIXTURE_VERSION,
signature: self.signature.clone(),
captured_slot: self.slot,
captured_block_time: self.block_time,
tx_b64: b64_encode(&tx_bytes),
entries,
idls: self
.idls
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
recorded: None, })
}
pub(crate) fn from_fixture(fx: &Fixture) -> Result<ReplayContext> {
let tx_bytes = base64::engine::general_purpose::STANDARD
.decode(&fx.tx_b64)
.map_err(|e| Error::Fixture(format!("bad tx base64: {e}")))?;
let tx: VersionedTransaction = bincode::deserialize(&tx_bytes)
.map_err(|e| Error::Fixture(format!("deserialize tx: {e}")))?;
let mut loaded = Vec::with_capacity(fx.entries.len());
for e in &fx.entries {
match e {
FixtureEntry::Data {
address,
owner,
lamports,
data_b64,
} => {
let addr = Address::from_str(address)
.map_err(|_| Error::Fixture(format!("bad address {address}")))?;
let owner_addr = Address::from_str(owner)
.map_err(|_| Error::Fixture(format!("bad owner {owner}")))?;
let data = base64::engine::general_purpose::STANDARD
.decode(data_b64)
.map_err(|e| {
Error::Fixture(format!("bad data base64 for {address}: {e}"))
})?;
loaded.push((
addr,
Loaded::Data(Account {
lamports: *lamports,
data,
owner: owner_addr,
executable: false,
rent_epoch: 0,
}),
));
}
FixtureEntry::Program { address, elf_b64 } => {
let addr = Address::from_str(address)
.map_err(|_| Error::Fixture(format!("bad address {address}")))?;
let elf = base64::engine::general_purpose::STANDARD
.decode(elf_b64)
.map_err(|e| {
Error::Fixture(format!("bad elf base64 for {address}: {e}"))
})?;
loaded.push((addr, Loaded::Program(elf)));
}
}
}
Ok(ReplayContext {
signature: fx.signature.clone(),
tx,
loaded,
slot: fx.captured_slot,
block_time: fx.captured_block_time,
time_travel: TimeTravel::default(),
idls: fx
.idls
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
feature_toggles: Vec::new(),
})
}
}
fn fetch_transaction(client: &RpcClient, signature: &str) -> Result<VersionedTransaction> {
let resp: serde_json::Value = client
.send(
RpcRequest::GetTransaction,
json!([signature, { "encoding": "base64", "maxSupportedTransactionVersion": 0 }]),
)
.map_err(Error::rpc)?;
if resp.is_null() {
return Err(Error::TransactionNotFound(signature.to_string()));
}
let tx_b64 = resp["transaction"][0].as_str().ok_or_else(|| {
Error::MalformedRpcResponse(format!("no base64 transaction in response for {signature}"))
})?;
let bytes = b64_decode(tx_b64);
bincode::deserialize::<VersionedTransaction>(&bytes)
.map_err(|e| Error::TxDecode(format!("{signature}: {e}")))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Mutation {
Lamports {
address: String,
value: u64,
},
Data {
address: String,
bytes: Vec<u8>,
},
DataPatch {
address: String,
offset: usize,
bytes: Vec<u8>,
},
IxArg {
index: usize,
arg: String,
value: serde_json::Value,
},
IxData {
index: usize,
offset: usize,
bytes: Vec<u8>,
},
Field {
address: String,
field: String,
value: i128,
},
Owner {
address: String,
owner: String,
},
}
impl Mutation {
pub fn lamports(address: impl Into<String>, value: u64) -> Mutation {
Mutation::Lamports {
address: address.into(),
value,
}
}
pub fn data(address: impl Into<String>, bytes: Vec<u8>) -> Mutation {
Mutation::Data {
address: address.into(),
bytes,
}
}
pub fn owner(address: impl Into<String>, owner: impl Into<String>) -> Mutation {
Mutation::Owner {
address: address.into(),
owner: owner.into(),
}
}
pub fn patch(address: impl Into<String>, offset: usize, bytes: Vec<u8>) -> Mutation {
Mutation::DataPatch {
address: address.into(),
offset,
bytes,
}
}
pub fn field(
address: impl Into<String>,
field: impl Into<String>,
value: impl Into<i128>,
) -> Mutation {
Mutation::Field {
address: address.into(),
field: field.into(),
value: value.into(),
}
}
pub fn ix_arg(index: usize, arg: impl Into<String>, value: serde_json::Value) -> Mutation {
Mutation::IxArg {
index,
arg: arg.into(),
value,
}
}
fn address(&self) -> &str {
match self {
Mutation::Lamports { address, .. }
| Mutation::Data { address, .. }
| Mutation::DataPatch { address, .. }
| Mutation::Field { address, .. }
| Mutation::Owner { address, .. } => address,
Mutation::IxArg { .. } | Mutation::IxData { .. } => "",
}
}
}
fn encode_field_value(f: &crate::decode::Field, value: i128) -> Result<Vec<u8>> {
let out_of_range = || Error::FieldValueOutOfRange {
field: f.name.clone(),
ty: f.ty.clone(),
value,
};
match (f.ty.as_str(), f.size) {
("bool", _) => match value {
0 | 1 => Ok(vec![value as u8]),
_ => Err(out_of_range()),
},
("u8" | "u16" | "u32" | "u64", n @ 1..=8) => {
let v = u64::try_from(value).map_err(|_| out_of_range())?;
if n < 8 && (v >> (8 * n)) != 0 {
return Err(out_of_range());
}
Ok(v.to_le_bytes()[..n].to_vec())
}
("i8" | "i16" | "i32" | "i64", n @ 1..=8) => {
let v = i64::try_from(value).map_err(|_| out_of_range())?;
if n < 8 {
let bound = 1i64 << (8 * n - 1);
if v < -bound || v >= bound {
return Err(out_of_range());
}
}
Ok((v as u64).to_le_bytes()[..n].to_vec())
}
(ty, _) => Err(Error::NonNumericField {
field: f.name.clone(),
ty: ty.to_string(),
}),
}
}
fn apply_mutation(svm: &mut LiteSVM, m: &Mutation) -> Result<()> {
if matches!(m, Mutation::IxArg { .. } | Mutation::IxData { .. }) {
return Ok(());
}
let address = m.address();
let addr =
Address::from_str(address).map_err(|_| Error::InvalidAddress(address.to_string()))?;
let mut account = svm
.get_account(&addr)
.ok_or_else(|| Error::MutationTargetMissing(address.to_string()))?;
match m {
Mutation::Lamports { value, .. } => account.lamports = *value,
Mutation::Data { bytes, .. } => account.data = bytes.clone(),
Mutation::DataPatch { offset, bytes, .. } => {
let end = offset
.checked_add(bytes.len())
.filter(|&e| e <= account.data.len())
.ok_or_else(|| Error::PatchOutOfRange {
address: address.to_string(),
offset: *offset,
len: bytes.len(),
size: account.data.len(),
})?;
account.data[*offset..end].copy_from_slice(bytes);
}
Mutation::Field { field, .. } => {
return Err(Error::InvalidSpec(format!(
"field mutation \"{field}\" was not resolved before application"
)));
}
Mutation::Owner { owner, .. } => {
account.owner =
Address::from_str(owner).map_err(|_| Error::InvalidAddress(owner.to_string()))?;
}
Mutation::IxArg { .. } | Mutation::IxData { .. } => return Ok(()),
}
svm.set_account(addr, account).map_err(|e| {
Error::MalformedRpcResponse(format!("set_account failed for {address}: {e:?}"))
})
}
fn to_replay_result(result: TransactionResult) -> ReplayResult {
match result {
Ok(meta) => ReplayResult {
success: true,
logs: meta.logs,
compute_units: meta.compute_units_consumed,
..Default::default()
},
Err(failed) => ReplayResult {
success: false,
error: Some(format!("{:?}", failed.err)),
logs: failed.meta.logs,
compute_units: failed.meta.compute_units_consumed,
..Default::default()
},
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Expect {
Success,
Revert,
RevertContains(String),
Any,
}
impl Expect {
pub(crate) fn matches(&self, r: &ReplayResult) -> bool {
match self {
Expect::Success => r.success,
Expect::Revert => !r.success,
Expect::RevertContains(s) => {
!r.success
&& (r.error.as_deref().is_some_and(|e| e.contains(s))
|| r.logs.iter().any(|l| l.contains(s)))
}
Expect::Any => true,
}
}
pub(crate) fn describe(&self) -> String {
match self {
Expect::Success => "succeeds".into(),
Expect::Revert => "reverts".into(),
Expect::RevertContains(s) => format!("reverts with \"{s}\""),
Expect::Any => "any outcome".into(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CmpOp {
Eq,
Ne,
Lt,
Le,
Gt,
Ge,
}
impl CmpOp {
pub(crate) fn test_i128(self, a: i128, b: i128) -> bool {
match self {
CmpOp::Eq => a == b,
CmpOp::Ne => a != b,
CmpOp::Lt => a < b,
CmpOp::Le => a <= b,
CmpOp::Gt => a > b,
CmpOp::Ge => a >= b,
}
}
pub(crate) fn symbol(self) -> &'static str {
match self {
CmpOp::Eq => "==",
CmpOp::Ne => "!=",
CmpOp::Lt => "<",
CmpOp::Le => "<=",
CmpOp::Gt => ">",
CmpOp::Ge => ">=",
}
}
}
#[derive(Debug, Clone)]
pub(crate) enum StateCheck {
Lamports { op: CmpOp, value: i128 },
U64At {
offset: usize,
op: CmpOp,
value: i128,
},
LamportsDelta { op: CmpOp, value: i128 },
TokenDelta { op: CmpOp, value: i128 },
Field {
name: String,
op: CmpOp,
value: i128,
},
FieldDelta {
name: String,
op: CmpOp,
value: i128,
},
FieldUnchanged { name: String },
}
#[derive(Debug, Clone)]
pub(crate) struct AccountAssert {
pub(crate) address: String,
pub(crate) check: StateCheck,
}
fn read_u64_at(data: &[u8], offset: usize) -> u64 {
match data.get(offset..offset + 8) {
Some(s) => u64::from_le_bytes(s.try_into().unwrap()),
None => 0,
}
}
pub(crate) fn find_field<'a>(
dec: &'a crate::decode::DecodedAccount,
name: &'a str,
) -> Result<&'a crate::decode::Field> {
if let Some(f) = dec.fields.iter().find(|f| f.name == name) {
return Ok(f);
}
let last = name.rsplit('.').next().unwrap_or(name);
let hits: Vec<&crate::decode::Field> = dec
.fields
.iter()
.filter(|f| f.name.rsplit('.').next().unwrap_or(&f.name) == last)
.collect();
match hits.len() {
1 => Ok(hits[0]),
0 => Err(Error::UnknownField {
field: name.to_string(),
type_name: dec.type_name.clone(),
available: dec.fields.iter().map(|f| f.name.clone()).take(12).collect(),
}),
_ => Err(Error::AmbiguousField {
field: name.to_string(),
candidates: hits.iter().map(|f| f.name.clone()).collect(),
}),
}
}
pub(crate) fn read_field_int(data: &[u8], f: &crate::decode::Field) -> Result<i128> {
let bytes = f
.offset
.checked_add(f.size)
.and_then(|end| data.get(f.offset..end))
.ok_or_else(|| Error::OutOfRange {
what: format!("{} @{}", f.name, f.offset),
len: data.len(),
})?;
let le = |b: &[u8]| -> u128 { b.iter().rev().fold(0u128, |acc, &x| (acc << 8) | x as u128) };
match (f.ty.as_str(), f.size) {
("bool", _) => Ok(bytes.first().is_some_and(|&b| b != 0) as i128),
("u8" | "u16" | "u32" | "u64", _) => Ok(le(bytes) as i128),
("i8" | "i16" | "i32" | "i64", n) => {
let raw = le(bytes);
let shift = 128 - 8 * n as u32;
Ok(((raw as i128) << shift) >> shift) }
(ty, _) => Err(Error::NonNumericField {
field: f.name.clone(),
ty: ty.to_string(),
}),
}
}
impl AccountAssert {
fn describe(&self) -> String {
let a = short(&self.address);
match &self.check {
StateCheck::Lamports { op, value } => format!("{a} lamports {} {value}", op.symbol()),
StateCheck::U64At { offset, op, value } => {
format!("{a} u64@{offset} {} {value}", op.symbol())
}
StateCheck::LamportsDelta { op, value } => {
format!("{a} lamports Δ {} {value}", op.symbol())
}
StateCheck::TokenDelta { op, value } => format!("{a} token Δ {} {value}", op.symbol()),
StateCheck::Field { name, op, value } => format!("{a} {name} {} {value}", op.symbol()),
StateCheck::FieldDelta { name, op, value } => {
format!("{a} {name} Δ {} {value}", op.symbol())
}
StateCheck::FieldUnchanged { name } => format!("{a} {name} unchanged"),
}
}
fn eval(&self, svm: &LiteSVM, ctx: &ReplayContext) -> Result<bool> {
let addr = Address::from_str(&self.address)
.map_err(|_| Error::InvalidAddress(self.address.clone()))?;
let acc = svm.get_account(&addr);
if acc.is_none() && !ctx.is_known(&self.address) {
return Err(Error::AccountNotFound(self.address.clone()));
}
match &self.check {
StateCheck::Lamports { op, value } => {
Ok(op.test_i128(acc.map(|a| a.lamports).unwrap_or(0) as i128, *value))
}
StateCheck::U64At { offset, op, value } => {
let acc = acc.ok_or_else(|| Error::AccountNotFound(self.address.clone()))?;
if offset
.checked_add(8)
.filter(|&e| e <= acc.data.len())
.is_none()
{
return Err(Error::OutOfRange {
what: format!("u64@{offset}"),
len: acc.data.len(),
});
}
Ok(op.test_i128(read_u64_at(&acc.data, *offset) as i128, *value))
}
StateCheck::LamportsDelta { op, value } => {
let pre = ctx
.pre_account(&self.address)
.map(|a| a.lamports)
.unwrap_or(0) as i128;
let post = acc.map(|a| a.lamports).unwrap_or(0) as i128;
Ok(op.test_i128(post - pre, *value))
}
StateCheck::TokenDelta { op, value } => {
let pre = ctx
.pre_account(&self.address)
.map(|a| read_u64_at(&a.data, 64))
.unwrap_or(0) as i128;
let post = acc.map(|a| read_u64_at(&a.data, 64)).unwrap_or(0) as i128;
Ok(op.test_i128(post - pre, *value))
}
StateCheck::Field { name, op, value } => {
let (dec, _) = ctx.decode_pre(&self.address)?;
let f = find_field(&dec, name)?;
let acc = acc.ok_or_else(|| Error::AccountNotFound(self.address.clone()))?;
Ok(op.test_i128(read_field_int(&acc.data, f)?, *value))
}
StateCheck::FieldDelta { name, op, value } => {
let (dec, pre) = ctx.decode_pre(&self.address)?;
let f = find_field(&dec, name)?;
let acc = acc.ok_or_else(|| Error::AccountNotFound(self.address.clone()))?;
let delta = read_field_int(&acc.data, f)? - read_field_int(&pre.data, f)?;
Ok(op.test_i128(delta, *value))
}
StateCheck::FieldUnchanged { name } => {
let (dec, pre) = ctx.decode_pre(&self.address)?;
let f = find_field(&dec, name)?;
let acc = acc.ok_or_else(|| Error::AccountNotFound(self.address.clone()))?;
Ok(field_bytes(&acc.data, f)? == field_bytes(&pre.data, f)?)
}
}
}
}
pub(crate) fn field_bytes<'a>(data: &'a [u8], f: &crate::decode::Field) -> Result<&'a [u8]> {
let span = if f.ty.starts_with("coption-") {
f.size + 4
} else {
f.size
};
f.offset
.checked_add(span)
.and_then(|end| data.get(f.offset..end))
.ok_or_else(|| Error::OutOfRange {
what: format!("{} @{}", f.name, f.offset),
len: data.len(),
})
}
fn short(s: &str) -> String {
let count = s.chars().count();
if count > 12 {
let head: String = s.chars().take(4).collect();
let tail: String = s.chars().skip(count - 4).collect();
format!("{head}…{tail}")
} else {
s.to_string()
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct AssertOutcome {
pub description: String,
pub pass: bool,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct ScenarioOutcome {
pub name: String,
pub expect: String,
pub pass: bool,
pub actual: ReplayResult,
pub asserts: Vec<AssertOutcome>,
}
impl ReplayContext {
fn resolve_mutation(&self, m: &Mutation) -> Result<Mutation> {
if let Mutation::IxArg { index, arg, value } = m {
let ixs = self.tx.message.instructions();
let ix = ixs.get(*index).ok_or_else(|| {
Error::InvalidSpec(format!(
"instruction index {index} out of range ({} instructions)",
ixs.len()
))
})?;
let keys = self.tx.message.static_account_keys();
let program = keys
.get(ix.program_id_index as usize)
.map(|p| p.to_string())
.unwrap_or_default();
let idl = self.idls.get(&program).ok_or_else(|| {
Error::InvalidSpec(format!(
"instruction {index}: program {program} has no IDL, so arguments cannot be re-encoded"
))
})?;
let def = crate::idl::find_ix(idl, &ix.data).ok_or_else(|| {
Error::InvalidSpec(format!(
"instruction {index}: data does not match any IDL instruction"
))
})?;
let (offset, size, label) = crate::idl::ix_arg_span(&def, arg).ok_or_else(|| {
Error::InvalidSpec(format!(
"instruction {index}: argument \"{arg}\" is not a fixed-size scalar at a known offset"
))
})?;
let bytes = crate::idl::encode_fixed(&label, size, value).ok_or_else(|| {
Error::InvalidSpec(format!(
"instruction {index}: value {value} does not fit argument \"{arg}\" ({label})"
))
})?;
return Ok(Mutation::IxData {
index: *index,
offset,
bytes,
});
}
let Mutation::Field {
address,
field,
value,
} = m
else {
return Ok(m.clone());
};
let (decoded, _pre) = self.decode_pre(address)?;
let f = find_field(&decoded, field)?;
let bytes = encode_field_value(f, *value)?;
Ok(Mutation::DataPatch {
address: address.clone(),
offset: f.offset,
bytes,
})
}
fn tx_with_mutations(&self, resolved: &[Mutation]) -> Result<VersionedTransaction> {
use solana_message::VersionedMessage;
let mut tx = self.tx.clone();
for m in resolved {
let Mutation::IxData {
index,
offset,
bytes,
} = m
else {
continue;
};
let ixs: &mut Vec<_> = match &mut tx.message {
VersionedMessage::Legacy(m) => &mut m.instructions,
VersionedMessage::V0(m) => &mut m.instructions,
VersionedMessage::V1(m) => &mut m.instructions,
};
let ix = ixs.get_mut(*index).ok_or_else(|| {
Error::InvalidSpec(format!("instruction index {index} out of range"))
})?;
let end = offset
.checked_add(bytes.len())
.filter(|&e| e <= ix.data.len())
.ok_or_else(|| {
Error::InvalidSpec(format!(
"instruction {index}: patch at {offset}+{} exceeds data length {}",
bytes.len(),
ix.data.len()
))
})?;
ix.data[*offset..end].copy_from_slice(bytes);
}
Ok(tx)
}
fn run_full(&self, mutations: &[Mutation]) -> Result<(ReplayResult, LiteSVM)> {
let mut svm = self.fresh_svm();
let resolved: Vec<Mutation> = mutations
.iter()
.map(|m| self.resolve_mutation(m))
.collect::<Result<_>>()?;
for m in &resolved {
apply_mutation(&mut svm, m)?;
}
let tx = self.tx_with_mutations(&resolved)?;
let result = to_replay_result(svm.send_transaction(tx));
Ok((result, svm))
}
pub(crate) fn validate_mutations(&self, mutations: &[Mutation]) -> Result<()> {
for m in mutations {
if matches!(m, Mutation::IxArg { .. } | Mutation::IxData { .. }) {
let r = self.resolve_mutation(m)?;
self.tx_with_mutations(&[r])?;
continue;
}
let address = m.address();
let addr = Address::from_str(address)
.map_err(|_| Error::InvalidAddress(address.to_string()))?;
let data_len = self
.loaded
.iter()
.find_map(|(a, l)| match l {
Loaded::Data(acc) if *a == addr => Some(acc.data.len()),
Loaded::Program(_) if *a == addr => Some(0),
_ => None,
})
.ok_or_else(|| Error::MutationTargetMissing(address.to_string()))?;
let m = self.resolve_mutation(m)?;
if let Mutation::DataPatch { offset, bytes, .. } = &m {
if offset
.checked_add(bytes.len())
.filter(|&e| e <= data_len)
.is_none()
{
return Err(Error::PatchOutOfRange {
address: address.to_string(),
offset: *offset,
len: bytes.len(),
size: data_len,
});
}
}
}
Ok(())
}
}
pub(crate) fn run_suite(
ctx: &ReplayContext,
recorded: Option<&crate::scope::OnchainRecord>,
scenarios: &[crate::check::Scenario],
) -> Result<Vec<ScenarioOutcome>> {
use crate::check::CheckKind;
for s in scenarios {
ctx.validate_mutations(&s.mutations)?;
}
scenarios
.iter()
.map(|s| {
let (actual, svm) = ctx.run_full(&s.mutations)?;
let outcomes: Vec<&Expect> = s
.checks
.iter()
.filter_map(|c| match &c.0 {
CheckKind::Outcome(e) => Some(e),
_ => None,
})
.collect();
let has_matches_onchain = s
.checks
.iter()
.any(|c| matches!(&c.0, CheckKind::MatchesOnchain));
let implicit = [if has_matches_onchain {
Expect::Any
} else {
Expect::Success
}];
let effective: &[&Expect] = if outcomes.is_empty() {
&[&implicit[0]]
} else {
&outcomes
};
let outcome_pass = effective.iter().all(|e| e.matches(&actual));
let expect = effective
.iter()
.map(|e| e.describe())
.collect::<Vec<_>>()
.join(" & ");
let mut asserts: Vec<AssertOutcome> = Vec::new();
for c in &s.checks {
match &c.0 {
CheckKind::Outcome(_) => {}
CheckKind::LogContains(t) => asserts.push(AssertOutcome {
description: format!("logs contain \"{t}\""),
pass: actual.error.as_deref().is_some_and(|e| e.contains(t))
|| actual.logs.iter().any(|l| l.contains(t)),
}),
CheckKind::ComputeUnits(c) => asserts.push(AssertOutcome {
description: format!("compute units {} {}", c.op.symbol(), c.value),
pass: c.op.test_i128(actual.compute_units as i128, c.value),
}),
CheckKind::MatchesOnchain => asserts.push(match recorded {
Some(r) => AssertOutcome {
description: format!(
"matches the on-chain outcome ({})",
if r.success { "success" } else { "failure" }
),
pass: actual.success == r.success,
},
None => AssertOutcome {
description:
"matches the on-chain outcome — no recorded outcome available \
(pre-flight tx, or a v1 fixture; re-capture to record it)"
.into(),
pass: false,
},
}),
CheckKind::Account(list) => {
for a in list {
asserts.push(match a.eval(&svm, ctx) {
Ok(pass) => AssertOutcome {
description: a.describe(),
pass,
},
Err(e) => AssertOutcome {
description: format!("{} — {e}", a.describe()),
pass: false,
},
});
}
}
}
}
let pass = outcome_pass && asserts.iter().all(|a| a.pass);
Ok(ScenarioOutcome {
name: s.name.clone(),
expect,
pass,
actual,
asserts,
})
})
.collect()
}
pub(crate) struct RawStepRun {
pub(crate) keep: Vec<usize>,
pub(crate) result: litesvm::types::TransactionResult,
pub(crate) post: Option<Vec<(Address, Account)>>,
}
const COMPUTE_BUDGET_ID: &str = "ComputeBudget111111111111111111111111111111";
impl ReplayContext {
pub(crate) fn signature(&self) -> &str {
&self.signature
}
pub(crate) fn transaction(&self) -> &VersionedTransaction {
&self.tx
}
pub(crate) fn message_account_keys(&self) -> Vec<String> {
let mut keys: Vec<String> = self
.tx
.message
.static_account_keys()
.iter()
.map(|a| a.to_string())
.collect();
let Some(lookups) = self.tx.message.address_table_lookups() else {
return keys;
};
let (mut writable, mut readonly) = (Vec::new(), Vec::new());
for l in lookups {
let data = self.loaded.iter().find_map(|(a, ld)| match ld {
Loaded::Data(acc) if *a == l.account_key => Some(&acc.data),
_ => None,
});
let Some(data) = data else { continue };
let read = |idx: u8| -> Option<String> {
let off = 56 + idx as usize * 32;
let bytes: [u8; 32] = data.get(off..off + 32)?.try_into().ok()?;
Some(Address::from(bytes).to_string())
};
writable.extend(l.writable_indexes.iter().filter_map(|&i| read(i)));
readonly.extend(l.readonly_indexes.iter().filter_map(|&i| read(i)));
}
keys.extend(writable);
keys.extend(readonly);
keys
}
pub(crate) fn pre_account_owned(&self, address: &str) -> Option<Account> {
self.pre_account(address).cloned()
}
fn tx_with_instructions(
&self,
base: &VersionedTransaction,
keep: &[usize],
) -> VersionedTransaction {
use solana_message::VersionedMessage;
let all = base.message.instructions();
let subset: Vec<_> = keep.iter().filter_map(|&i| all.get(i).cloned()).collect();
let mut tx = base.clone();
match &mut tx.message {
VersionedMessage::Legacy(m) => m.instructions = subset,
VersionedMessage::V0(m) => m.instructions = subset,
VersionedMessage::V1(m) => m.instructions = subset,
}
tx
}
pub(crate) fn trace_raw(&self, mutations: &[Mutation]) -> Result<Vec<RawStepRun>> {
let mut svm = self.fresh_svm();
let resolved: Vec<Mutation> = mutations
.iter()
.map(|m| self.resolve_mutation(m))
.collect::<Result<_>>()?;
for m in &resolved {
apply_mutation(&mut svm, m)?;
}
let base = self.tx_with_mutations(&resolved)?;
let keys = base.message.static_account_keys();
let n = base.message.instructions().len();
let is_cb: Vec<bool> = base
.message
.instructions()
.iter()
.map(|ix| {
keys.get(ix.program_id_index as usize)
.map(|p| p.to_string() == COMPUTE_BUDGET_ID)
.unwrap_or(false)
})
.collect();
let mut runs = Vec::with_capacity(n);
for k in 0..n {
let keep: Vec<usize> = (0..n).filter(|&i| i <= k || is_cb[i]).collect();
let tx = self.tx_with_instructions(&base, &keep);
let (result, post) = match svm.simulate_transaction(tx) {
Ok(info) => (
Ok(info.meta),
Some(
info.post_accounts
.into_iter()
.map(|(a, acc)| (a, Account::from(acc)))
.collect(),
),
),
Err(failed) => (Err(failed), None),
};
runs.push(RawStepRun { keep, result, post });
}
Ok(runs)
}
}
pub(crate) fn replay_result_of(result: &litesvm::types::TransactionResult) -> ReplayResult {
to_replay_result(result.clone())
}
#[cfg(test)]
mod tests {
use super::*;
fn f(name: &str, ty: &str, size: usize) -> crate::decode::Field {
crate::decode::Field {
name: name.into(),
offset: 0,
ty: ty.into(),
size,
value: String::new(),
editable: true,
note: None,
}
}
#[test]
fn field_values_encode_le_at_exact_width() {
assert_eq!(
encode_field_value(&f("c", "u8", 1), 255).unwrap(),
vec![255]
);
assert_eq!(
encode_field_value(&f("c", "u64", 8), u64::MAX as i128).unwrap(),
u64::MAX.to_le_bytes().to_vec()
);
assert_eq!(
encode_field_value(&f("c", "i32", 4), -5).unwrap(),
(-5i32).to_le_bytes().to_vec()
);
assert_eq!(
encode_field_value(&f("c", "i64", 8), i64::MIN as i128).unwrap(),
i64::MIN.to_le_bytes().to_vec()
);
assert_eq!(encode_field_value(&f("c", "bool", 1), 1).unwrap(), vec![1]);
}
#[test]
fn field_values_out_of_range_are_hard_errors_not_truncations() {
for (ty, size, value) in [
("u8", 1usize, 256i128),
("u8", 1, -1),
("u16", 2, 65_536),
("u64", 8, -1),
("u64", 8, u64::MAX as i128 + 1),
("i8", 1, 128),
("i8", 1, -129),
("i64", 8, i64::MAX as i128 + 1),
("bool", 1, 2),
] {
assert!(
matches!(
encode_field_value(&f("c", ty, size), value),
Err(Error::FieldValueOutOfRange { .. })
),
"{ty} should reject {value}"
);
}
}
#[test]
fn non_numeric_field_mutations_are_rejected() {
assert!(matches!(
encode_field_value(&f("owner", "pubkey", 32), 1),
Err(Error::NonNumericField { .. })
));
assert!(matches!(
encode_field_value(&f("x", "coption-u64", 8), 1),
Err(Error::NonNumericField { .. })
));
}
fn token_decoded() -> (crate::decode::DecodedAccount, Vec<u8>) {
let mut data = vec![0u8; 165];
data[64..72].copy_from_slice(&1234u64.to_le_bytes());
let dec = crate::decode::decode_bytes("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", &data)
.expect("token account decodes");
(dec, data)
}
#[test]
fn field_resolves_by_name_and_dot_segment() {
let (dec, data) = token_decoded();
let f = find_field(&dec, "amount").unwrap();
assert_eq!(f.offset, 64);
assert_eq!(read_field_int(&data, f).unwrap(), 1234);
assert_eq!(find_field(&dec, "vault.amount").unwrap().offset, 64);
}
#[test]
fn feature_toggle_changes_the_effective_feature_set() {
let feat = Address::from_str("CLCoTADvV64PSrnR6QXty6Fwrt9Xc6EdxSJE4wLRePjq").unwrap();
let pk = solana_pubkey::Pubkey::new_from_array(feat.to_bytes());
let base = svm_from_loaded(&[], &[], 0);
assert!(
base.get_feature_set().is_active(&pk),
"gate should be active by default"
);
let off = svm_from_loaded(
&[],
&[FeatureToggle {
id: feat,
active: false,
}],
0,
);
assert!(
!off.get_feature_set().is_active(&pk),
"toggle should deactivate the gate"
);
}
#[test]
fn unknown_field_errors_and_lists_candidates() {
let (dec, _) = token_decoded();
let e = find_field(&dec, "reserveA").unwrap_err().to_string();
assert!(e.contains("no field \"reserveA\""), "{e}");
assert!(e.contains("amount"), "should list available fields: {e}");
}
#[test]
fn non_integer_field_refuses_comparison() {
let (dec, data) = token_decoded();
let owner = find_field(&dec, "owner").unwrap();
assert!(read_field_int(&data, owner)
.unwrap_err()
.to_string()
.contains("pubkey"));
}
#[test]
fn signed_fields_sign_extend() {
let f = crate::decode::Field {
name: "start_ts".into(),
offset: 0,
ty: "i64".into(),
size: 8,
value: String::new(),
editable: true,
note: None,
};
assert_eq!(read_field_int(&(-42i64).to_le_bytes(), &f).unwrap(), -42);
assert_eq!(read_field_int(&7i64.to_le_bytes(), &f).unwrap(), 7);
}
#[test]
fn typoed_mutation_is_a_hard_error_not_a_passing_revert() {
let ctx = ReplayContext {
signature: String::new(),
tx: VersionedTransaction::default(),
loaded: Vec::new(),
slot: None,
block_time: None,
time_travel: TimeTravel::default(),
idls: HashMap::new(),
feature_toggles: Vec::new(),
};
let typo = Mutation::lamports(Address::from([7u8; 32]).to_string(), 0);
assert!(matches!(
ctx.run_full(std::slice::from_ref(&typo)),
Err(Error::MutationTargetMissing(_))
));
let suite = [crate::check::Scenario::new("drain")
.mutate(typo)
.check(crate::check::Check::revert())];
assert!(matches!(
run_suite(&ctx, None, &suite),
Err(Error::MutationTargetMissing(_))
));
}
#[test]
fn patch_bounds_are_validated_up_front() {
let addr = Address::from_str("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA").unwrap();
let ctx = ReplayContext {
signature: String::new(),
tx: VersionedTransaction::default(),
loaded: vec![(
addr,
Loaded::Data(Account {
lamports: 1,
data: vec![0u8; 8],
owner: Address::default(),
executable: false,
rent_epoch: 0,
}),
)],
slot: None,
block_time: None,
time_travel: TimeTravel::default(),
idls: HashMap::new(),
feature_toggles: Vec::new(),
};
assert!(ctx
.validate_mutations(&[Mutation::patch(addr.to_string(), 0, vec![0; 8])])
.is_ok());
assert!(matches!(
ctx.validate_mutations(&[Mutation::patch(addr.to_string(), 4, vec![0; 8])]),
Err(Error::PatchOutOfRange {
offset: 4,
len: 8,
size: 8,
..
})
));
assert!(ctx
.validate_mutations(&[Mutation::patch(addr.to_string(), usize::MAX, vec![0; 8])])
.is_err());
}
#[test]
fn fixture_roundtrip_preserves_slot_and_clock() {
let ctx = ReplayContext {
signature: "SIG".into(),
tx: VersionedTransaction::default(),
loaded: Vec::new(),
slot: Some(295_000_123),
block_time: Some(1_787_600_325),
time_travel: TimeTravel::default(),
idls: HashMap::new(),
feature_toggles: Vec::new(),
};
let fx = ctx.to_fixture().unwrap();
assert_eq!(fx.captured_slot, Some(295_000_123));
assert_eq!(fx.captured_block_time, Some(1_787_600_325));
let json = fx.to_json().unwrap();
let fx2 = Fixture::from_json(&json).unwrap();
let restored = ReplayContext::from_fixture(&fx2).unwrap();
assert_eq!(restored.slot, Some(295_000_123));
assert_eq!(restored.block_time, Some(1_787_600_325));
}
#[test]
fn chrono_like_formats_utc() {
assert_eq!(chrono_like(0), "1970-01-01 00:00 UTC");
assert_eq!(chrono_like(1_584_368_940), "2020-03-16 14:29 UTC");
assert_eq!(chrono_like(-1), "1969-12-31 23:59 UTC");
}
fn clock() -> Clock {
Clock {
slot: 1_000 * SLOTS_PER_EPOCH as u64,
epoch: 1_000,
epoch_start_timestamp: 2_000_000_000,
unix_timestamp: 2_000_000_000,
leader_schedule_epoch: 1_001,
}
}
#[test]
fn noop_time_travel_changes_nothing() {
let tt = TimeTravel::default();
assert!(tt.is_noop());
let mut c = clock();
tt.apply(&mut c);
assert_eq!(
(c.slot, c.epoch, c.unix_timestamp),
(clock().slot, 1_000, 2_000_000_000)
);
}
#[test]
fn epoch_jump_advances_slot_epoch_and_time_together() {
let tt = TimeTravel {
epochs: Some(2),
..Default::default()
};
let mut c = clock();
tt.apply(&mut c);
assert_eq!(c.epoch, 1_002);
assert_eq!(c.slot, clock().slot + 2 * SLOTS_PER_EPOCH as u64);
let expected_secs = (2.0 * SLOTS_PER_EPOCH as f64 * SECS_PER_SLOT) as i64;
assert_eq!(c.unix_timestamp, 2_000_000_000 + expected_secs);
assert_eq!(c.leader_schedule_epoch, c.epoch + 1);
}
#[test]
fn seconds_jump_moves_slots_in_step() {
let tt = TimeTravel {
seconds: Some(40),
..Default::default()
};
let mut c = clock();
tt.apply(&mut c);
assert_eq!(c.unix_timestamp, 2_000_000_040);
assert_eq!(c.slot, clock().slot + 100); }
#[test]
fn extreme_warp_saturates_instead_of_overflowing() {
let tt = TimeTravel {
epochs: Some(i64::MAX),
seconds: Some(i64::MAX),
slots: Some(i64::MAX),
..Default::default()
};
let mut c = clock();
tt.apply(&mut c); assert!(c.unix_timestamp >= clock().unix_timestamp);
}
#[test]
fn absolute_settings_override_relative_jumps() {
let tt = TimeTravel {
seconds: Some(1_000_000),
at_unix_timestamp: Some(3_000_000_000),
at_epoch: Some(7),
..Default::default()
};
let mut c = clock();
tt.apply(&mut c);
assert_eq!(c.unix_timestamp, 3_000_000_000);
assert_eq!(c.epoch, 7);
assert!(c.epoch_start_timestamp <= c.unix_timestamp);
}
#[test]
fn read_u64_at_handles_bounds() {
let mut data = vec![0u8; 72];
data[64..72].copy_from_slice(&42u64.to_le_bytes());
assert_eq!(read_u64_at(&data, 64), 42);
assert_eq!(read_u64_at(&data, 70), 0); }
#[test]
fn reconstructs_closed_token_account_from_meta() {
let tx = serde_json::json!({
"meta": {
"preBalances": [2_039_280],
"preTokenBalances": [{
"accountIndex": 0,
"mint": "So11111111111111111111111111111111111111112",
"owner": "Vote111111111111111111111111111111111111111",
"uiTokenAmount": { "amount": "12345", "decimals": 9 }
}]
}
});
let keys = vec!["4Nd1mBQtrMJVYVfKf2PJy9NZUZdTAsp7D4xWLs4gDB4T".to_string()];
let pre = PreState::from_meta(&tx, &keys);
let acc = pre
.reconstruct(&keys[0])
.expect("should rebuild the SPL account");
assert_eq!(acc.data.len(), 165);
assert_eq!(read_u64_at(&acc.data, 64), 12345);
assert_eq!(acc.data[108], 1); assert_eq!(acc.owner.to_string(), SPL_TOKEN_PROGRAM);
}
#[test]
fn never_resurrects_an_account_the_tx_creates() {
let pre = PreState::default();
assert!(pre.reconstruct("SomeAddr").is_none());
}
#[test]
fn captures_pre_transaction_lamports_per_account() {
let tx = serde_json::json!({
"meta": { "preBalances": [5_000_000_000u64, 2_039_280u64] }
});
let keys = vec![
"4Nd1mBQtrMJVYVfKf2PJy9NZUZdTAsp7D4xWLs4gDB4T".to_string(),
"So11111111111111111111111111111111111111112".to_string(),
];
let pre = PreState::from_meta(&tx, &keys);
assert_eq!(pre.lamports.get(&keys[0]).copied(), Some(5_000_000_000));
assert_eq!(pre.lamports.get(&keys[1]).copied(), Some(2_039_280));
assert!(!pre.is_empty());
}
}