simulator-api 0.20.0

Wire-protocol types for the Solana simulator backtest WebSocket API
Documentation
//! One route, hop by hop: what the router quotes, what a decoded transaction reports, and what
//! both are recorded and streamed as. Only the router names a pool; only a transaction's route
//! events name the program that ran a hop.
//!
//! Addresses and amounts stay as strings — nothing here does address arithmetic, and a raw token
//! amount exceeds what a JSON number holds exactly.
//!
//! Every field is concrete and always written. A plan is recorded through bincode, whose decoder
//! reads fields positionally: `#[serde(flatten)]` and [`serde_json::Value`] cannot cross it, and
//! `skip_serializing_if` desynchronizes it. An unset value therefore travels as an explicit null,
//! which costs a JSON reader nothing — `json_extract` answers null for a missing key either way.

use serde::{Deserialize, Serialize};

/// A whole route in the order its hops run. Deserialization bypasses [`RoutePlan::new`], so a
/// plan read off the wire can still be empty.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct RoutePlan(Vec<RouteHop>);

impl RoutePlan {
    /// `None` for no hops: an empty route is an absence, not a route that filled nothing.
    pub fn new(hops: Vec<RouteHop>) -> Option<Self> {
        (!hops.is_empty()).then_some(Self(hops))
    }

    pub fn hops(&self) -> &[RouteHop] {
        &self.0
    }

    pub fn is_empty(&self) -> bool {
        self.0.is_empty()
    }

    pub fn is_split(&self) -> bool {
        is_split(&self.0)
    }
}

/// The trade was split across pools rather than routed as one path, so the hops are branches and
/// cannot line up positionally against a sequential original.
pub fn is_split(hops: &[RouteHop]) -> bool {
    hops.iter().any(|hop| hop.percent != FULL_PERCENT)
}

/// A route's identity for deduplication: two routes over the same pools fill the same way
/// whatever order the router lists them in. Skips hops the router gave no pool for.
pub fn pool_keys(hops: &[RouteHop]) -> impl Iterator<Item = &str> {
    hops.iter().filter_map(RouteHop::amm_key)
}

pub fn labels(hops: &[RouteHop]) -> impl Iterator<Item = &str> {
    hops.iter().filter_map(RouteHop::label)
}

/// The whole trade through one hop; anything less is a split.
pub const FULL_PERCENT: u64 = 100;

const fn full_percent() -> u64 {
    FULL_PERCENT
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RouteHop {
    /// Execution order, from a decoded transaction; the router's plan is ordered but unnumbered.
    #[serde(default)]
    pub leg: Option<u32>,
    /// Share of the trade this hop carries.
    #[serde(default = "full_percent")]
    pub percent: u64,
    /// Set by a decoded transaction, where the router sets [`RouteHopSwapInfo::amm_key`].
    #[serde(default)]
    pub program: Option<String>,
    /// Sent by the router, null in everything observed.
    #[serde(default)]
    pub bps: Option<u64>,
    pub swap_info: RouteHopSwapInfo,
}

/// `percent` starts at [`FULL_PERCENT`]: a hop is the whole trade unless a split says otherwise.
impl Default for RouteHop {
    fn default() -> Self {
        Self {
            leg: None,
            percent: FULL_PERCENT,
            program: None,
            bps: None,
            swap_info: RouteHopSwapInfo::default(),
        }
    }
}

impl RouteHop {
    pub fn label(&self) -> Option<&str> {
        self.swap_info.label.as_deref()
    }

    pub fn amm_key(&self) -> Option<&str> {
        self.swap_info.amm_key.as_deref()
    }

    pub fn input_mint(&self) -> Option<&str> {
        self.swap_info.input_mint.as_deref()
    }

    pub fn output_mint(&self) -> Option<&str> {
        self.swap_info.output_mint.as_deref()
    }
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RouteHopSwapInfo {
    /// The DEX's name, e.g. `Raydium`. `None` where the hop's program has no known label, which
    /// is a hop that cannot be compared by venue rather than a hop with no venue.
    #[serde(default)]
    pub label: Option<String>,
    /// The pool crossed. Only the router names it; route events repeat the program id in `amm`.
    #[serde(default)]
    pub amm_key: Option<String>,
    #[serde(default)]
    pub input_mint: Option<String>,
    #[serde(default)]
    pub in_amount: Option<String>,
    #[serde(default)]
    pub output_mint: Option<String>,
    #[serde(default)]
    pub out_amount: Option<String>,
}

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

    fn hop(percent: u64) -> RouteHop {
        RouteHop {
            percent,
            ..Default::default()
        }
    }

    #[test]
    fn no_hops_is_absent_rather_than_empty() {
        assert_eq!(RoutePlan::new(Vec::new()), None);
        assert_eq!(
            RoutePlan::new(vec![hop(FULL_PERCENT)]).map(|plan| plan.hops().len()),
            Some(1)
        );
    }

    #[test]
    fn a_route_is_split_when_any_hop_carries_less_than_the_whole_trade() {
        assert!(
            !RoutePlan::new(vec![hop(100), hop(100)])
                .expect("a plan")
                .is_split()
        );
        assert!(
            RoutePlan::new(vec![hop(60), hop(40)])
                .expect("a plan")
                .is_split()
        );
    }

    /// Both sources land on the same paths, so one reader covers every column.
    #[test]
    fn both_sources_serialize_onto_one_shape() {
        let quoted = RouteHop {
            swap_info: RouteHopSwapInfo {
                label: Some("SolFi".to_string()),
                amm_key: Some("pool".to_string()),
                input_mint: Some("sol".to_string()),
                in_amount: Some("1000".to_string()),
                output_mint: Some("usdc".to_string()),
                out_amount: Some("1010".to_string()),
            },
            ..Default::default()
        };
        let executed = RouteHop {
            leg: Some(0),
            program: Some("prog".to_string()),
            swap_info: RouteHopSwapInfo {
                amm_key: None,
                ..quoted.swap_info.clone()
            },
            ..quoted.clone()
        };

        let json = |hop: &RouteHop| serde_json::to_value(hop).expect("json");
        for value in [json(&quoted), json(&executed)] {
            assert_eq!(value["percent"], 100);
            assert_eq!(value["swapInfo"]["label"], "SolFi");
            assert_eq!(value["swapInfo"]["inputMint"], "sol");
            // Amounts are strings: a raw token amount exceeds what a JSON number holds exactly.
            assert_eq!(value["swapInfo"]["inAmount"], "1000");
            assert_eq!(value["swapInfo"]["outAmount"], "1010");
        }
        assert_eq!(json(&quoted)["swapInfo"]["ammKey"], "pool");
        assert_eq!(json(&executed)["program"], "prog");
    }

    /// A stored column comes back out on the paths a reader queries it by.
    #[test]
    fn a_recorded_plan_reads_back_onto_the_paths_it_was_written_with() {
        let recorded = r#"[{"percent":100,"swapInfo":{"label":"Aquifer","ammKey":"CNC5Tae","inputMint":"EPjFWdd","outputMint":"So11111","inAmount":"385638781","outAmount":"4999931316"},"bps":null}]"#;

        let plan: RoutePlan = serde_json::from_str(recorded).expect("a recorded plan decodes");
        let rewritten = serde_json::to_value(&plan).expect("json");

        assert_eq!(rewritten[0]["percent"], 100);
        assert_eq!(rewritten[0]["swapInfo"]["label"], "Aquifer");
        assert_eq!(rewritten[0]["swapInfo"]["ammKey"], "CNC5Tae");
        assert_eq!(rewritten[0]["swapInfo"]["inputMint"], "EPjFWdd");
        assert_eq!(rewritten[0]["swapInfo"]["inAmount"], "385638781");
        assert_eq!(rewritten[0]["swapInfo"]["outputMint"], "So11111");
        assert_eq!(rewritten[0]["swapInfo"]["outAmount"], "4999931316");
        assert_eq!(rewritten[0]["bps"], serde_json::Value::Null);
    }

    /// The positional decoder reads back exactly what was written, and a JSON reader resolves an
    /// explicit null and an absent key alike.
    #[test]
    fn an_unset_field_is_written_as_null_rather_than_omitted() {
        let value = serde_json::to_value(hop(FULL_PERCENT)).expect("json");

        assert!(value.get("program").is_some(), "written, not skipped");
        assert_eq!(value["program"], serde_json::Value::Null);
        assert_eq!(value["swapInfo"]["ammKey"], serde_json::Value::Null);
        assert_eq!(value["program"], value["neverWritten"]);
    }
}