sim-cli 0.4.0

CLI tool for running and comparing Solana simulator backtests
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct SimulationOutput {
    pub metadata: SimulationMetadata,
    pub transactions: Vec<Transaction>,
    pub summary: SimulationSummary,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct SimulationMetadata {
    pub start_slot: u64,
    pub end_slot: u64,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub program_ids: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub program_so: Vec<String>,
    pub ran_at_unix_secs: u64,
    pub session_ids: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Transaction {
    pub slot: u64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub timestamp: Option<u64>,
    pub signature: String,
    pub success: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    pub logs: Vec<String>,
    pub sol_changes: Vec<SolChange>,
    pub token_changes: Vec<TokenChange>,
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub account_diffs: Vec<AccountDiff>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountDiff {
    pub account: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pre_state: Option<serde_json::Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub post_state: Option<serde_json::Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SolChange {
    pub pubkey: String,
    pub pre_lamports: u64,
    pub post_lamports: u64,
}

impl SolChange {
    pub fn delta(&self) -> i64 {
        self.post_lamports as i64 - self.pre_lamports as i64
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenChange {
    pub pubkey: String,
    pub mint: String,
    pub owner: String,
    pub pre_amount: u64,
    pub post_amount: u64,
    pub decimals: u8,
}

impl TokenChange {
    pub fn delta(&self) -> i64 {
        self.post_amount as i64 - self.pre_amount as i64
    }
}

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct SimulationSummary {
    pub total_transactions: usize,
    pub successes: usize,
    pub failures: usize,
}

impl SimulationOutput {
    pub fn build(metadata: SimulationMetadata, transactions: Vec<Transaction>) -> Self {
        let summary = SimulationSummary {
            total_transactions: transactions.len(),
            successes: transactions.iter().filter(|t| t.success).count(),
            failures: transactions.iter().filter(|t| !t.success).count(),
        };
        Self {
            metadata,
            transactions,
            summary,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenInfo {
    pub mint: String,
    pub owner: String,
    pub amount: u64,
    pub decimals: u8,
}

/// A single account diff row for NDJSON streaming, mapping 1:1 to a DB row.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountDiffRow {
    pub slot: u64,
    pub tx_hash: String,
    pub account: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pre_lamports: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub post_lamports: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pre_state: Option<serde_json::Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub post_state: Option<serde_json::Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pre_tokens: Option<TokenInfo>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub post_tokens: Option<TokenInfo>,
}

impl AccountDiffRow {
    pub fn from_diff(
        slot: u64,
        tx_hash: &str,
        diff: &AccountDiff,
        sol_changes: &[SolChange],
        token_changes: &[TokenChange],
    ) -> Self {
        let sol = sol_changes.iter().find(|s| s.pubkey == diff.account);
        let token = token_changes.iter().find(|t| t.pubkey == diff.account);

        Self {
            slot,
            tx_hash: tx_hash.to_string(),
            account: diff.account.clone(),
            pre_lamports: sol.map(|s| s.pre_lamports),
            post_lamports: sol.map(|s| s.post_lamports),
            pre_state: diff.pre_state.clone(),
            post_state: diff.post_state.clone(),
            pre_tokens: token.map(|t| TokenInfo {
                mint: t.mint.clone(),
                owner: t.owner.clone(),
                amount: t.pre_amount,
                decimals: t.decimals,
            }),
            post_tokens: token.map(|t| TokenInfo {
                mint: t.mint.clone(),
                owner: t.owner.clone(),
                amount: t.post_amount,
                decimals: t.decimals,
            }),
        }
    }
}

pub(crate) fn format_slot(n: u64) -> String {
    let s = n.to_string();
    let mut out = String::new();
    for (i, c) in s.chars().rev().enumerate() {
        if i > 0 && i % 3 == 0 {
            out.push(',');
        }
        out.push(c);
    }
    out.chars().rev().collect()
}