sim-cli 0.7.0

CLI tool for running and comparing Solana simulator backtests
Documentation
use std::collections::{BTreeSet, HashMap};

use solana_transaction_status::{
    EncodedConfirmedTransactionWithStatusMeta, EncodedTransaction, UiMessage,
    UiTransactionTokenBalance, option_serializer::OptionSerializer,
};

use crate::output::{SolChange, TokenChange, Transaction};

/// Index token balances by account index as `(amount, mint, owner, decimals)`.
fn token_index_map(
    balances: &[UiTransactionTokenBalance],
) -> HashMap<u8, (u64, String, String, u8)> {
    balances
        .iter()
        .map(|t| {
            let amount = t.ui_token_amount.amount.parse::<u64>().unwrap_or(0);
            let owner = match &t.owner {
                OptionSerializer::Some(o) => o.clone(),
                _ => String::new(),
            };
            (
                t.account_index,
                (amount, t.mint.clone(), owner, t.ui_token_amount.decimals),
            )
        })
        .collect()
}

pub fn transaction_from_encoded(
    confirmed: EncodedConfirmedTransactionWithStatusMeta,
    signature: &str,
    slot: u64,
) -> Option<Transaction> {
    let block_time = confirmed.block_time.map(|t| t as u64);
    let inner = confirmed.transaction;
    let meta = inner.meta.as_ref()?;
    let error = meta.err.as_ref().map(|e| e.to_string());

    let static_keys: Vec<String> = match &inner.transaction {
        EncodedTransaction::Json(ui_tx) => match &ui_tx.message {
            UiMessage::Raw(raw) => raw.account_keys.clone(),
            UiMessage::Parsed(parsed) => parsed
                .account_keys
                .iter()
                .map(|k| k.pubkey.clone())
                .collect(),
        },
        _ => return None,
    };

    let mut keys = static_keys;
    if let OptionSerializer::Some(addresses) = &meta.loaded_addresses {
        keys.extend(addresses.writable.iter().cloned());
        keys.extend(addresses.readonly.iter().cloned());
    }

    let sol_changes: Vec<SolChange> = keys
        .iter()
        .enumerate()
        .filter_map(|(i, pubkey)| {
            let pre = *meta.pre_balances.get(i)?;
            let post = *meta.post_balances.get(i)?;
            if pre == post {
                return None;
            }
            Some(SolChange {
                pubkey: pubkey.clone(),
                pre_lamports: pre,
                post_lamports: post,
            })
        })
        .collect();

    let empty = Vec::new();
    let pre_tokens = match &meta.pre_token_balances {
        OptionSerializer::Some(v) => v,
        _ => &empty,
    };
    let post_tokens = match &meta.post_token_balances {
        OptionSerializer::Some(v) => v,
        _ => &empty,
    };

    let pre_map = token_index_map(pre_tokens);
    let post_map = token_index_map(post_tokens);

    let mut all_indices: BTreeSet<u8> = BTreeSet::new();
    all_indices.extend(pre_map.keys().copied());
    all_indices.extend(post_map.keys().copied());

    let token_changes: Vec<TokenChange> = all_indices
        .into_iter()
        .filter_map(|idx| {
            let pubkey = keys.get(idx as usize)?.clone();
            let (pre_amount, mint, owner, decimals) = if let Some(info) = pre_map.get(&idx) {
                (info.0, info.1.clone(), info.2.clone(), info.3)
            } else {
                let info = post_map.get(&idx)?;
                (0, info.1.clone(), info.2.clone(), info.3)
            };
            let post_amount = post_map.get(&idx).map(|i| i.0).unwrap_or(0);
            if pre_amount == post_amount {
                return None;
            }
            Some(TokenChange {
                pubkey,
                mint,
                owner,
                pre_amount,
                post_amount,
                decimals,
            })
        })
        .collect();

    Some(Transaction {
        slot,
        timestamp: block_time,
        signature: signature.to_string(),
        success: error.is_none(),
        error,
        sol_changes,
        token_changes,
        logs: Vec::new(),
        account_diffs: Vec::new(),
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Build the `getTransaction`-shaped wire JSON the subscription pushes and
    /// run it through the decoder. Deserializing a literal is sturdier than
    /// hand-constructing the many `OptionSerializer` fields.
    fn decode(transaction: serde_json::Value, meta: serde_json::Value) -> Option<Transaction> {
        let confirmed: EncodedConfirmedTransactionWithStatusMeta =
            serde_json::from_value(serde_json::json!({
                "slot": 42,
                "blockTime": 1_700_000_000,
                "transaction": transaction,
                "meta": meta,
            }))
            .unwrap();
        transaction_from_encoded(confirmed, "sig", 42)
    }

    fn raw_transaction(account_keys: &[&str]) -> serde_json::Value {
        serde_json::json!({
            "signatures": ["sig"],
            "message": {
                "header": {
                    "numRequiredSignatures": 1,
                    "numReadonlySignedAccounts": 0,
                    "numReadonlyUnsignedAccounts": 0,
                },
                "accountKeys": account_keys,
                "recentBlockhash": "hash",
                "instructions": [],
            },
        })
    }

    fn token_balance(account_index: u8, amount: &str) -> serde_json::Value {
        serde_json::json!({
            "accountIndex": account_index,
            "mint": "mint-a",
            "owner": "owner-1",
            "uiTokenAmount": {
                "uiAmount": null,
                "decimals": 6,
                "amount": amount,
                "uiAmountString": amount,
            },
        })
    }

    #[test]
    fn missing_meta_is_none() {
        let result = decode(raw_transaction(&["payer"]), serde_json::Value::Null);
        assert!(result.is_none());
    }

    #[test]
    fn binary_transaction_is_none() {
        let meta = serde_json::json!({
            "err": null, "status": {"Ok": null}, "fee": 0,
            "preBalances": [100], "postBalances": [100],
        });
        assert!(decode(serde_json::json!("3qd..."), meta).is_none());
    }

    #[test]
    fn loaded_addresses_extend_static_keys_and_equal_balances_are_skipped() {
        let meta = serde_json::json!({
            "err": null, "status": {"Ok": null}, "fee": 0,
            // Indices: 0 payer, 1 static-1, 2 loaded-w, 3 loaded-r.
            "preBalances": [100, 50, 10, 7],
            "postBalances": [90, 50, 25, 7],
            "loadedAddresses": {"writable": ["loaded-w"], "readonly": ["loaded-r"]},
        });
        let tx = decode(raw_transaction(&["payer", "static-1"]), meta).unwrap();

        assert!(tx.success);
        assert_eq!(tx.timestamp, Some(1_700_000_000));
        // static-1 and loaded-r are unchanged (pre == post) and must be skipped;
        // the change at index 2 must resolve to the loaded (not static) key.
        assert_eq!(
            tx.sol_changes
                .iter()
                .map(|c| (c.pubkey.as_str(), c.pre_lamports, c.post_lamports))
                .collect::<Vec<_>>(),
            [("payer", 100, 90), ("loaded-w", 10, 25)]
        );
    }

    #[test]
    fn post_only_token_balance_gets_zero_pre_amount() {
        let meta = serde_json::json!({
            "err": null, "status": {"Ok": null}, "fee": 0,
            "preBalances": [100, 10], "postBalances": [100, 10],
            "postTokenBalances": [token_balance(1, "500")],
        });
        let tx = decode(raw_transaction(&["payer", "token-acct"]), meta).unwrap();

        assert_eq!(tx.token_changes.len(), 1);
        let change = &tx.token_changes[0];
        assert_eq!(change.pubkey, "token-acct");
        assert_eq!(change.mint, "mint-a");
        assert_eq!(change.owner, "owner-1");
        assert_eq!((change.pre_amount, change.post_amount), (0, 500));
        assert_eq!(change.decimals, 6);
    }

    #[test]
    fn equal_token_amounts_are_skipped() {
        let meta = serde_json::json!({
            "err": null, "status": {"Ok": null}, "fee": 0,
            "preBalances": [100, 10], "postBalances": [100, 10],
            "preTokenBalances": [token_balance(1, "500")],
            "postTokenBalances": [token_balance(1, "500")],
        });
        let tx = decode(raw_transaction(&["payer", "token-acct"]), meta).unwrap();
        assert!(tx.token_changes.is_empty());
    }
}