simulator-api 0.23.0

Wire-protocol types for the Solana simulator backtest WebSocket API
Documentation
//! The parts of a `replacementSubscribe` payload that every kind carries.
//!
//! Three mechanisms stand in for a historical swap — metis re-routing a whole transaction, a
//! probe re-pricing one hop, the original replayed as its own baseline — and they carry different
//! shapes. What they share is declared once here and flattened into each shape on both sides of
//! the wire, so a field the server emits cannot be silently dropped by a client that forgot to
//! restate it.

use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use solana_address::Address;
pub use solana_signature::Signature;

use crate::{EncodedBinary, SwapMode, route_plan::RoutePlan};

/// Basis points per whole unit.
pub const BPS_SCALE: i128 = 10_000;

/// Clamp bound for a single swap's recorded change, ±1,000% of the base. Losses bound
/// themselves at −100%, so only gains ever reach this; wide enough that no economically real
/// improvement truncates (the old ±100% cap flattened every gain past 2×), tight enough that
/// one dust-base ratio moves a 10k-leg aggregate mean by at most ~10 bps.
pub const CHANGE_CLAMP_BPS: i128 = 10 * BPS_SCALE;

/// A recorded change, and whether [`CHANGE_CLAMP_BPS`] is what bounded it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ChangeBps {
    pub bps: i64,
    pub clamped: bool,
}

/// Change from `base` to `new` in bps, clamped to ±[`CHANGE_CLAMP_BPS`]. Callers guard `base > 0`.
pub fn clamped_change_bps(new: u64, base: u64) -> ChangeBps {
    let raw = (i128::from(new) - i128::from(base)) * BPS_SCALE / i128::from(base);
    ChangeBps {
        bps: raw.clamp(-CHANGE_CLAMP_BPS, CHANGE_CLAMP_BPS) as i64,
        clamped: raw.abs() >= CHANGE_CLAMP_BPS,
    }
}

/// The slot the payload was produced at, in the shape every other subscription's context takes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReplacementContext {
    pub slot: u64,
}

/// The identity and ending every replacement payload carries, whatever kind it is.
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReplacementCore {
    pub context: ReplacementContext,
    pub slot: u64,
    /// Batch within the slot the original sits in; the simulation ran just before it executed.
    pub batch_index: u32,
    #[serde_as(as = "serde_with::DisplayFromStr")]
    pub original_signature: Signature,
    /// The original failed on L1, so it realised nothing and its route is flow that never landed.
    #[serde(default)]
    pub original_failed: bool,
    /// `filled`, `reverted`, `unattributed` or `unsimulatable` — the closed set the session's own
    /// outcome columns are typed to, so a consumer tallies all three kinds the same way. Carried
    /// as a string rather than an enum so a word added server-side reaches an older client.
    pub outcome: String,
    /// The failure bucket, absent unless it failed — what keeps one venue declining to trade
    /// separable from another, and what a breakdown groups by.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub category: Option<String>,
    /// The concrete error behind [`Self::category`]. Named `err` rather than `error` because that
    /// is what a re-quote has always called it on this stream.
    #[serde(rename = "err", default, skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

/// What the original filled on L1, and how the shape carrying this scored against it.
///
/// One derivation for all three kinds — [`Self::new`] — but the scope differs, so each kind
/// documents its own: a probe's is the probed HOP's L1 output, a replayed original's is net route
/// flow across the whole SWAP's pair, a re-quote's is what the original realized across the
/// transaction. Read within a kind; never sum or average across them.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct L1Score {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub l1_fill: Option<u64>,
    /// `filled` against [`Self::l1_fill`] in bps, clamped, so positive means it beat what actually
    /// traded.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub change_vs_l1_bps: Option<i64>,
}

impl L1Score {
    /// Score `measured` against what the original moved on L1 on the same side. ExactIn measures
    /// output, so more is better; ExactOut measures the input charged, so the sign is flipped and
    /// positive always means the stand-in beat what traded. An L1 fill of zero is no baseline:
    /// dividing by it would clamp to a number rather than say nothing.
    ///
    /// A measured zero is left unscored for the same reason. Some venues decline by returning
    /// zero rather than by reverting, which is a refusal to quote and not a quote of nothing;
    /// scored, it lands at [`CHANGE_CLAMP_BPS`] and a handful of declines sink a venue's mean far
    /// below what it actually paid.
    pub fn new(measured: Option<u64>, l1_fill: u64, mode: SwapMode) -> Self {
        let l1_fill = (l1_fill > 0).then_some(l1_fill);
        Self {
            l1_fill,
            change_vs_l1_bps: measured.filter(|&measured| measured > 0).zip(l1_fill).map(
                |(measured, l1)| {
                    let change = clamped_change_bps(measured, l1).bps;
                    match mode {
                        SwapMode::ExactIn => change,
                        SwapMode::ExactOut => -change,
                    }
                },
            ),
        }
    }
}

/// What running one stand-in cost, for the kinds that simulate a whole transaction.
///
/// A direct-fill probe carries none: it records neither compute units nor fee, on the wire or in
/// its table, so it has nothing to report here yet.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SimCost {
    pub compute_units_consumed: u64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fee: Option<u64>,
}

/// One re-quoted swap within a rerouted transaction's simulation.
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RerouteLegNotification {
    #[serde_as(as = "serde_with::DisplayFromStr")]
    pub input_mint: Address,
    #[serde_as(as = "serde_with::DisplayFromStr")]
    pub output_mint: Address,
    pub amount: u64,
    pub swap_mode: String,
    pub original_quoted_out: u64,
    pub metis_quoted_out: u64,
    pub route_summary: String,
    /// The route metis quoted, when it returned one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub route_plan: Option<RoutePlan>,
    /// The route the original took on L1, which the re-quote displaced.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub original_route_plan: Option<RoutePlan>,
    /// What each hop actually moved. `route_plan` is the quote; this is the fill, and the side
    /// to compare `original_route_plan` against.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub realized_route_plan: Option<RoutePlan>,
}

/// One rerouted transaction's simulation result - the `Requote` shape of
/// [`ReplacementNotification`]. The historical original still executed; this is the
/// counterfactual, committed nowhere.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RequoteNotification {
    #[serde(flatten)]
    pub core: ReplacementCore,
    /// Scored at whole-transaction scope: what the original realized against what the routed
    /// stand-in did. Absent for a multi-swap transaction, where no single pair names the fill -
    /// its per-leg readings are on [`Self::legs`].
    #[serde(flatten)]
    pub comparison: L1Score,
    #[serde(flatten)]
    pub cost: SimCost,
    /// One per detected swap in the original, in order.
    #[serde(default)]
    pub legs: Vec<RerouteLegNotification>,
    /// The metis-routed transaction that was simulated. Decode with [`EncodedBinary::decode`]
    /// (base64 bincode of `TxWithMeta`) to inspect its instructions.
    pub routed_transaction: EncodedBinary,
    #[serde(default)]
    pub logs: Vec<String>,
    /// What the routed swap actually produced; `None` for a multi-swap transaction, a missing
    /// output balance record, or a reverted simulation.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub realized_output_amount: Option<u64>,
    /// What the historical original actually produced, from its recorded balance deltas. `None`
    /// under the same conditions. Set independently of the simulation's outcome, so it is
    /// present even when the core carries an error.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub original_realized_output_amount: Option<u64>,
}

/// One direct-fill probe's result - the `DirectFill` shape of [`ReplacementNotification`]: a
/// single hop re-priced through a direct-fill template, simulated against the state it would
/// have traded on.
///
/// A probe prices ONE hop, so the mints and amount here are the leg's, never the enclosing
/// swap's - a route's endpoints are not its middle leg's.
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DirectFillNotification {
    #[serde(flatten)]
    pub core: ReplacementCore,
    /// Scored at hop scope: the probed hop's own L1 output. A probe prices one hop, so it names
    /// no route and carries none.
    #[serde(flatten)]
    pub comparison: L1Score,
    /// The template the hop was rebuilt through.
    pub template: String,
    #[serde_as(as = "serde_with::DisplayFromStr")]
    pub input_mint: Address,
    #[serde_as(as = "serde_with::DisplayFromStr")]
    pub output_mint: Address,
    /// The hop's own input amount, in base units.
    pub amount: u64,
    pub swap_mode: String,
    /// What the probed template filled; `None` unless the probe filled attributably.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub template_fill: Option<u64>,
}

/// One replayed original - the `Original` shape of [`ReplacementNotification`]: the historical
/// transaction itself, re-run against the state it was quoted at, so the other two kinds are
/// scored against a number produced the same way rather than against the chain's.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OriginalNotification {
    #[serde(flatten)]
    pub core: ReplacementCore,
    /// Scored at swap scope: net route flow across the whole swap's pair on L1.
    #[serde(flatten)]
    pub comparison: L1Score,
    #[serde(flatten)]
    pub cost: SimCost,
    /// The hops this replay produced.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub route_plan: Option<RoutePlan>,
    /// The hops the original took on L1, decoded the same way, so the two are read against each
    /// other rather than against differently-derived numbers.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub l1_route_plan: Option<RoutePlan>,
    /// What the replayed original produced, when a single swap's output could be read off it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub simulated_output_amount: Option<u64>,
}

/// What stood in for a historical swap, streamed over `replacementSubscribe`. Internally tagged
/// on `kind`: three mechanisms answer different questions - metis re-routing a whole transaction,
/// a probe re-pricing one hop, and the original replayed as its own baseline - so they carry
/// different shapes rather than one union.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum ReplacementNotification {
    Requote(RequoteNotification),
    DirectFill(DirectFillNotification),
    Original(OriginalNotification),
}

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

    use super::*;

    #[rstest]
    #[case::total_loss(0, 1_000, -10_000, false)]
    #[case::unchanged(1_000, 1_000, 0, false)]
    #[case::a_real_improvement(1_100, 1_000, 1_000, false)]
    #[case::ten_x_is_the_last_unclamped(101_000, 10_000, 91_000, false)]
    #[case::eleven_x_is_the_bound_itself(110_000, 10_000, 100_000, true)]
    #[case::dust_base(1_000_000, 1, 100_000, true)]
    fn a_change_reports_whether_the_clamp_bound_it(
        #[case] new: u64,
        #[case] base: u64,
        #[case] bps: i64,
        #[case] clamped: bool,
    ) {
        assert_eq!(clamped_change_bps(new, base), ChangeBps { bps, clamped });
    }

    #[rstest]
    #[case::no_baseline(Some(1_000), 0, SwapMode::ExactIn, None, None)]
    #[case::no_measurement(None, 1_000, SwapMode::ExactIn, Some(1_000), None)]
    #[case::beat_it(Some(1_100), 1_000, SwapMode::ExactIn, Some(1_000), Some(1_000))]
    #[case::lost_to_it(Some(900), 1_000, SwapMode::ExactIn, Some(1_000), Some(-1_000))]
    #[case::tiny_baseline_clamps(
        Some(u64::MAX),
        1,
        SwapMode::ExactIn,
        Some(1),
        Some(CHANGE_CLAMP_BPS as i64)
    )]
    // ExactOut measures the input charged, so paying less is the win and the sign flips.
    #[case::exact_out_paid_less(Some(900), 1_000, SwapMode::ExactOut, Some(1_000), Some(1_000))]
    #[case::exact_out_paid_more(Some(1_100), 1_000, SwapMode::ExactOut, Some(1_000), Some(-1_000))]
    // A venue declining with a zero is a refusal to quote, not a quote of nothing.
    #[case::a_declined_quote_is_unscored(Some(0), 1_000, SwapMode::ExactIn, Some(1_000), None)]
    fn scoring_a_fill_against_l1(
        #[case] measured: Option<u64>,
        #[case] l1_fill: u64,
        #[case] mode: SwapMode,
        #[case] expected_l1: Option<u64>,
        #[case] expected_bps: Option<i64>,
    ) {
        let scored = L1Score::new(measured, l1_fill, mode);
        assert_eq!(scored.l1_fill, expected_l1);
        assert_eq!(scored.change_vs_l1_bps, expected_bps);
    }

    /// The absent halves have to vanish rather than serialize as nulls: a consumer distinguishes
    /// "no baseline" from "scored zero", and both sides skip on `None`.
    #[test]
    fn an_unscored_comparison_emits_no_keys() {
        let empty = serde_json::to_value(L1Score::new(None, 0, SwapMode::ExactIn))
            .expect("a score serializes");
        assert_eq!(empty, serde_json::json!({}));
    }
}