Skip to main content

simulator_api/
replacement.rs

1//! The parts of a `replacementSubscribe` payload that every kind carries.
2//!
3//! Three mechanisms stand in for a historical swap — metis re-routing a whole transaction, a
4//! probe re-pricing one hop, the original replayed as its own baseline — and they carry different
5//! shapes. What they share is declared once here and flattened into each shape on both sides of
6//! the wire, so a field the server emits cannot be silently dropped by a client that forgot to
7//! restate it.
8
9use serde::{Deserialize, Serialize};
10use serde_with::serde_as;
11use solana_address::Address;
12pub use solana_signature::Signature;
13
14use crate::{EncodedBinary, SwapMode, route_plan::RoutePlan};
15
16/// Basis points per whole unit.
17pub const BPS_SCALE: i128 = 10_000;
18
19/// Clamp bound for a single swap's recorded change, ±1,000% of the base. Losses bound
20/// themselves at −100%, so only gains ever reach this; wide enough that no economically real
21/// improvement truncates (the old ±100% cap flattened every gain past 2×), tight enough that
22/// one dust-base ratio moves a 10k-leg aggregate mean by at most ~10 bps.
23pub const CHANGE_CLAMP_BPS: i128 = 10 * BPS_SCALE;
24
25/// A recorded change, and whether [`CHANGE_CLAMP_BPS`] is what bounded it.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct ChangeBps {
28    pub bps: i64,
29    pub clamped: bool,
30}
31
32/// Change from `base` to `new` in bps, clamped to ±[`CHANGE_CLAMP_BPS`]. Callers guard `base > 0`.
33pub fn clamped_change_bps(new: u64, base: u64) -> ChangeBps {
34    let raw = (i128::from(new) - i128::from(base)) * BPS_SCALE / i128::from(base);
35    ChangeBps {
36        bps: raw.clamp(-CHANGE_CLAMP_BPS, CHANGE_CLAMP_BPS) as i64,
37        clamped: raw.abs() >= CHANGE_CLAMP_BPS,
38    }
39}
40
41/// The slot the payload was produced at, in the shape every other subscription's context takes.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ReplacementContext {
44    pub slot: u64,
45}
46
47/// The identity and ending every replacement payload carries, whatever kind it is.
48#[serde_as]
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub struct ReplacementCore {
52    pub context: ReplacementContext,
53    pub slot: u64,
54    /// Batch within the slot the original sits in; the simulation ran just before it executed.
55    pub batch_index: u32,
56    #[serde_as(as = "serde_with::DisplayFromStr")]
57    pub original_signature: Signature,
58    /// The original failed on L1, so it realised nothing and its route is flow that never landed.
59    #[serde(default)]
60    pub original_failed: bool,
61    /// `filled`, `reverted`, `unattributed` or `unsimulatable` — the closed set the session's own
62    /// outcome columns are typed to, so a consumer tallies all three kinds the same way. Carried
63    /// as a string rather than an enum so a word added server-side reaches an older client.
64    pub outcome: String,
65    /// The failure bucket, absent unless it failed — what keeps one venue declining to trade
66    /// separable from another, and what a breakdown groups by.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub category: Option<String>,
69    /// The concrete error behind [`Self::category`]. Named `err` rather than `error` because that
70    /// is what a re-quote has always called it on this stream.
71    #[serde(rename = "err", default, skip_serializing_if = "Option::is_none")]
72    pub error: Option<String>,
73}
74
75/// What the original filled on L1, and how the shape carrying this scored against it.
76///
77/// One derivation for all three kinds — [`Self::new`] — but the scope differs, so each kind
78/// documents its own: a probe's is the probed HOP's L1 output, a replayed original's is net route
79/// flow across the whole SWAP's pair, a re-quote's is what the original realized across the
80/// transaction. Read within a kind; never sum or average across them.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct L1Score {
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub l1_fill: Option<u64>,
86    /// `filled` against [`Self::l1_fill`] in bps, clamped, so positive means it beat what actually
87    /// traded.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub change_vs_l1_bps: Option<i64>,
90}
91
92impl L1Score {
93    /// Score `measured` against what the original moved on L1 on the same side. ExactIn measures
94    /// output, so more is better; ExactOut measures the input charged, so the sign is flipped and
95    /// positive always means the stand-in beat what traded. An L1 fill of zero is no baseline:
96    /// dividing by it would clamp to a number rather than say nothing.
97    ///
98    /// A measured zero is left unscored for the same reason. Some venues decline by returning
99    /// zero rather than by reverting, which is a refusal to quote and not a quote of nothing;
100    /// scored, it lands at [`CHANGE_CLAMP_BPS`] and a handful of declines sink a venue's mean far
101    /// below what it actually paid.
102    pub fn new(measured: Option<u64>, l1_fill: u64, mode: SwapMode) -> Self {
103        let l1_fill = (l1_fill > 0).then_some(l1_fill);
104        Self {
105            l1_fill,
106            change_vs_l1_bps: measured.filter(|&measured| measured > 0).zip(l1_fill).map(
107                |(measured, l1)| {
108                    let change = clamped_change_bps(measured, l1).bps;
109                    match mode {
110                        SwapMode::ExactIn => change,
111                        SwapMode::ExactOut => -change,
112                    }
113                },
114            ),
115        }
116    }
117}
118
119/// What running one stand-in cost, for the kinds that simulate a whole transaction.
120///
121/// A direct-fill probe carries none: it records neither compute units nor fee, on the wire or in
122/// its table, so it has nothing to report here yet.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124#[serde(rename_all = "camelCase")]
125pub struct SimCost {
126    pub compute_units_consumed: u64,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub fee: Option<u64>,
129}
130
131/// One re-quoted swap within a rerouted transaction's simulation.
132#[serde_as]
133#[derive(Debug, Clone, Serialize, Deserialize)]
134#[serde(rename_all = "camelCase")]
135pub struct RerouteLegNotification {
136    #[serde_as(as = "serde_with::DisplayFromStr")]
137    pub input_mint: Address,
138    #[serde_as(as = "serde_with::DisplayFromStr")]
139    pub output_mint: Address,
140    pub amount: u64,
141    pub swap_mode: String,
142    pub original_quoted_out: u64,
143    pub metis_quoted_out: u64,
144    pub route_summary: String,
145    /// The route metis quoted, when it returned one.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub route_plan: Option<RoutePlan>,
148    /// The route the original took on L1, which the re-quote displaced.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub original_route_plan: Option<RoutePlan>,
151    /// What each hop actually moved. `route_plan` is the quote; this is the fill, and the side
152    /// to compare `original_route_plan` against.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub realized_route_plan: Option<RoutePlan>,
155}
156
157/// One rerouted transaction's simulation result - the `Requote` shape of
158/// [`ReplacementNotification`]. The historical original still executed; this is the
159/// counterfactual, committed nowhere.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161#[serde(rename_all = "camelCase")]
162pub struct RequoteNotification {
163    #[serde(flatten)]
164    pub core: ReplacementCore,
165    /// Scored at whole-transaction scope: what the original realized against what the routed
166    /// stand-in did. Absent for a multi-swap transaction, where no single pair names the fill -
167    /// its per-leg readings are on [`Self::legs`].
168    #[serde(flatten)]
169    pub comparison: L1Score,
170    #[serde(flatten)]
171    pub cost: SimCost,
172    /// One per detected swap in the original, in order.
173    #[serde(default)]
174    pub legs: Vec<RerouteLegNotification>,
175    /// The metis-routed transaction that was simulated. Decode with [`EncodedBinary::decode`]
176    /// (base64 bincode of `TxWithMeta`) to inspect its instructions.
177    pub routed_transaction: EncodedBinary,
178    #[serde(default)]
179    pub logs: Vec<String>,
180    /// What the routed swap actually produced; `None` for a multi-swap transaction, a missing
181    /// output balance record, or a reverted simulation.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub realized_output_amount: Option<u64>,
184    /// What the historical original actually produced, from its recorded balance deltas. `None`
185    /// under the same conditions. Set independently of the simulation's outcome, so it is
186    /// present even when the core carries an error.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub original_realized_output_amount: Option<u64>,
189}
190
191/// The hop a probe re-priced, as L1 and as the replay ran it.
192///
193/// Carried with the probe rather than left to a join against the original's own notification: the
194/// two readings are what the probe is scored against, and a subscriber that had to pair them up
195/// would need every original of the pass in hand before it could write a single row.
196#[serde_as]
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(rename_all = "camelCase")]
199pub struct ProbedHop {
200    /// The block the hop executed in. The probe itself ran at [`ReplacementCore::slot`], which is
201    /// earlier by [`DirectFillNotification::quote_lag_blocks`].
202    pub slot: u64,
203    pub block_time: i64,
204    /// The transaction's position in that block.
205    pub tx_index: u32,
206    /// Which swap of the transaction this hop belongs to, and which hop of that swap it is.
207    pub ix_index: u32,
208    pub leg_index: u32,
209    /// The DEX program that priced the hop on L1. `None` when the route events attribute it to
210    /// none.
211    #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
212    #[serde(default)]
213    pub program: Option<Address>,
214    pub input_amount: u64,
215    pub output_amount: u64,
216    /// The same hop as the replayed original took it. `None` when the replay did not recover the
217    /// hop - it reverted, or its route decoded to fewer legs.
218    #[serde(default)]
219    pub replay_input_amount: Option<u64>,
220    #[serde(default)]
221    pub replay_output_amount: Option<u64>,
222}
223
224/// One direct-fill probe's result - the `DirectFill` shape of [`ReplacementNotification`]: a
225/// single hop re-priced through a direct-fill template, simulated against the state it would
226/// have traded on.
227///
228/// A probe prices ONE hop, so the mints and amount here are the leg's, never the enclosing
229/// swap's - a route's endpoints are not its middle leg's.
230#[serde_as]
231#[derive(Debug, Clone, Serialize, Deserialize)]
232#[serde(rename_all = "camelCase")]
233pub struct DirectFillNotification {
234    #[serde(flatten)]
235    pub core: ReplacementCore,
236    /// Scored at hop scope: the probed hop's own L1 output. A probe prices one hop, so it names
237    /// no route and carries none.
238    #[serde(flatten)]
239    pub comparison: L1Score,
240    /// The template the hop was rebuilt through.
241    pub template: String,
242    /// The program that template's instruction calls: for one built from an aggregator's route,
243    /// the aggregator's, which several venues share.
244    #[serde_as(as = "serde_with::DisplayFromStr")]
245    pub program: Address,
246    /// The hop this priced, as L1 and the replay ran it.
247    pub hop: ProbedHop,
248    /// The transaction of block [`ReplacementCore::slot`] the pass that priced this probe ran
249    /// just before; 0 at the block's start.
250    pub quote_tx_index: u32,
251    /// Blocks between the block the hop executes in and the one it was priced at.
252    pub quote_lag_blocks: u32,
253    #[serde_as(as = "serde_with::DisplayFromStr")]
254    pub input_mint: Address,
255    #[serde_as(as = "serde_with::DisplayFromStr")]
256    pub output_mint: Address,
257    /// The hop's own input amount, in base units.
258    pub amount: u64,
259    pub swap_mode: String,
260    /// What the probed template filled; `None` unless the probe filled attributably.
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub template_fill: Option<u64>,
263}
264
265/// One replayed original - the `Original` shape of [`ReplacementNotification`]: the historical
266/// transaction itself, re-run against the state it was quoted at, so the other two kinds are
267/// scored against a number produced the same way rather than against the chain's.
268#[derive(Debug, Clone, Serialize, Deserialize)]
269#[serde(rename_all = "camelCase")]
270pub struct OriginalNotification {
271    #[serde(flatten)]
272    pub core: ReplacementCore,
273    /// Scored at swap scope: net route flow across the whole swap's pair on L1.
274    #[serde(flatten)]
275    pub comparison: L1Score,
276    #[serde(flatten)]
277    pub cost: SimCost,
278    /// The hops this replay produced.
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub route_plan: Option<RoutePlan>,
281    /// The hops the original took on L1, decoded the same way, so the two are read against each
282    /// other rather than against differently-derived numbers.
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub l1_route_plan: Option<RoutePlan>,
285    /// What the replayed original produced, when a single swap's output could be read off it.
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub simulated_output_amount: Option<u64>,
288}
289
290/// What stood in for a historical swap, streamed over `replacementSubscribe`. Internally tagged
291/// on `kind`: three mechanisms answer different questions - metis re-routing a whole transaction,
292/// a probe re-pricing one hop, and the original replayed as its own baseline - so they carry
293/// different shapes rather than one union.
294#[derive(Debug, Clone, Serialize, Deserialize)]
295#[serde(tag = "kind", rename_all = "camelCase")]
296pub enum ReplacementNotification {
297    Requote(RequoteNotification),
298    DirectFill(DirectFillNotification),
299    Original(OriginalNotification),
300}
301
302impl ReplacementNotification {
303    /// What every kind carries, whichever shape it arrived in.
304    pub const fn core(&self) -> &ReplacementCore {
305        match self {
306            Self::Requote(requote) => &requote.core,
307            Self::DirectFill(probe) => &probe.core,
308            Self::Original(original) => &original.core,
309        }
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use rstest::rstest;
316
317    use super::*;
318
319    #[rstest]
320    #[case::total_loss(0, 1_000, -10_000, false)]
321    #[case::unchanged(1_000, 1_000, 0, false)]
322    #[case::a_real_improvement(1_100, 1_000, 1_000, false)]
323    #[case::ten_x_is_the_last_unclamped(101_000, 10_000, 91_000, false)]
324    #[case::eleven_x_is_the_bound_itself(110_000, 10_000, 100_000, true)]
325    #[case::dust_base(1_000_000, 1, 100_000, true)]
326    fn a_change_reports_whether_the_clamp_bound_it(
327        #[case] new: u64,
328        #[case] base: u64,
329        #[case] bps: i64,
330        #[case] clamped: bool,
331    ) {
332        assert_eq!(clamped_change_bps(new, base), ChangeBps { bps, clamped });
333    }
334
335    #[rstest]
336    #[case::no_baseline(Some(1_000), 0, SwapMode::ExactIn, None, None)]
337    #[case::no_measurement(None, 1_000, SwapMode::ExactIn, Some(1_000), None)]
338    #[case::beat_it(Some(1_100), 1_000, SwapMode::ExactIn, Some(1_000), Some(1_000))]
339    #[case::lost_to_it(Some(900), 1_000, SwapMode::ExactIn, Some(1_000), Some(-1_000))]
340    #[case::tiny_baseline_clamps(
341        Some(u64::MAX),
342        1,
343        SwapMode::ExactIn,
344        Some(1),
345        Some(CHANGE_CLAMP_BPS as i64)
346    )]
347    // ExactOut measures the input charged, so paying less is the win and the sign flips.
348    #[case::exact_out_paid_less(Some(900), 1_000, SwapMode::ExactOut, Some(1_000), Some(1_000))]
349    #[case::exact_out_paid_more(Some(1_100), 1_000, SwapMode::ExactOut, Some(1_000), Some(-1_000))]
350    // A venue declining with a zero is a refusal to quote, not a quote of nothing.
351    #[case::a_declined_quote_is_unscored(Some(0), 1_000, SwapMode::ExactIn, Some(1_000), None)]
352    fn scoring_a_fill_against_l1(
353        #[case] measured: Option<u64>,
354        #[case] l1_fill: u64,
355        #[case] mode: SwapMode,
356        #[case] expected_l1: Option<u64>,
357        #[case] expected_bps: Option<i64>,
358    ) {
359        let scored = L1Score::new(measured, l1_fill, mode);
360        assert_eq!(scored.l1_fill, expected_l1);
361        assert_eq!(scored.change_vs_l1_bps, expected_bps);
362    }
363
364    /// The absent halves have to vanish rather than serialize as nulls: a consumer distinguishes
365    /// "no baseline" from "scored zero", and both sides skip on `None`.
366    #[test]
367    fn an_unscored_comparison_emits_no_keys() {
368        let empty = serde_json::to_value(L1Score::new(None, 0, SwapMode::ExactIn))
369            .expect("a score serializes");
370        assert_eq!(empty, serde_json::json!({}));
371    }
372}