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/// One direct-fill probe's result - the `DirectFill` shape of [`ReplacementNotification`]: a
192/// single hop re-priced through a direct-fill template, simulated against the state it would
193/// have traded on.
194///
195/// A probe prices ONE hop, so the mints and amount here are the leg's, never the enclosing
196/// swap's - a route's endpoints are not its middle leg's.
197#[serde_as]
198#[derive(Debug, Clone, Serialize, Deserialize)]
199#[serde(rename_all = "camelCase")]
200pub struct DirectFillNotification {
201    #[serde(flatten)]
202    pub core: ReplacementCore,
203    /// Scored at hop scope: the probed hop's own L1 output. A probe prices one hop, so it names
204    /// no route and carries none.
205    #[serde(flatten)]
206    pub comparison: L1Score,
207    /// The template the hop was rebuilt through.
208    pub template: String,
209    #[serde_as(as = "serde_with::DisplayFromStr")]
210    pub input_mint: Address,
211    #[serde_as(as = "serde_with::DisplayFromStr")]
212    pub output_mint: Address,
213    /// The hop's own input amount, in base units.
214    pub amount: u64,
215    pub swap_mode: String,
216    /// What the probed template filled; `None` unless the probe filled attributably.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub template_fill: Option<u64>,
219}
220
221/// One replayed original - the `Original` shape of [`ReplacementNotification`]: the historical
222/// transaction itself, re-run against the state it was quoted at, so the other two kinds are
223/// scored against a number produced the same way rather than against the chain's.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225#[serde(rename_all = "camelCase")]
226pub struct OriginalNotification {
227    #[serde(flatten)]
228    pub core: ReplacementCore,
229    /// Scored at swap scope: net route flow across the whole swap's pair on L1.
230    #[serde(flatten)]
231    pub comparison: L1Score,
232    #[serde(flatten)]
233    pub cost: SimCost,
234    /// The hops this replay produced.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub route_plan: Option<RoutePlan>,
237    /// The hops the original took on L1, decoded the same way, so the two are read against each
238    /// other rather than against differently-derived numbers.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub l1_route_plan: Option<RoutePlan>,
241    /// What the replayed original produced, when a single swap's output could be read off it.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub simulated_output_amount: Option<u64>,
244}
245
246/// What stood in for a historical swap, streamed over `replacementSubscribe`. Internally tagged
247/// on `kind`: three mechanisms answer different questions - metis re-routing a whole transaction,
248/// a probe re-pricing one hop, and the original replayed as its own baseline - so they carry
249/// different shapes rather than one union.
250#[derive(Debug, Clone, Serialize, Deserialize)]
251#[serde(tag = "kind", rename_all = "camelCase")]
252pub enum ReplacementNotification {
253    Requote(RequoteNotification),
254    DirectFill(DirectFillNotification),
255    Original(OriginalNotification),
256}
257
258#[cfg(test)]
259mod tests {
260    use rstest::rstest;
261
262    use super::*;
263
264    #[rstest]
265    #[case::total_loss(0, 1_000, -10_000, false)]
266    #[case::unchanged(1_000, 1_000, 0, false)]
267    #[case::a_real_improvement(1_100, 1_000, 1_000, false)]
268    #[case::ten_x_is_the_last_unclamped(101_000, 10_000, 91_000, false)]
269    #[case::eleven_x_is_the_bound_itself(110_000, 10_000, 100_000, true)]
270    #[case::dust_base(1_000_000, 1, 100_000, true)]
271    fn a_change_reports_whether_the_clamp_bound_it(
272        #[case] new: u64,
273        #[case] base: u64,
274        #[case] bps: i64,
275        #[case] clamped: bool,
276    ) {
277        assert_eq!(clamped_change_bps(new, base), ChangeBps { bps, clamped });
278    }
279
280    #[rstest]
281    #[case::no_baseline(Some(1_000), 0, SwapMode::ExactIn, None, None)]
282    #[case::no_measurement(None, 1_000, SwapMode::ExactIn, Some(1_000), None)]
283    #[case::beat_it(Some(1_100), 1_000, SwapMode::ExactIn, Some(1_000), Some(1_000))]
284    #[case::lost_to_it(Some(900), 1_000, SwapMode::ExactIn, Some(1_000), Some(-1_000))]
285    #[case::tiny_baseline_clamps(
286        Some(u64::MAX),
287        1,
288        SwapMode::ExactIn,
289        Some(1),
290        Some(CHANGE_CLAMP_BPS as i64)
291    )]
292    // ExactOut measures the input charged, so paying less is the win and the sign flips.
293    #[case::exact_out_paid_less(Some(900), 1_000, SwapMode::ExactOut, Some(1_000), Some(1_000))]
294    #[case::exact_out_paid_more(Some(1_100), 1_000, SwapMode::ExactOut, Some(1_000), Some(-1_000))]
295    // A venue declining with a zero is a refusal to quote, not a quote of nothing.
296    #[case::a_declined_quote_is_unscored(Some(0), 1_000, SwapMode::ExactIn, Some(1_000), None)]
297    fn scoring_a_fill_against_l1(
298        #[case] measured: Option<u64>,
299        #[case] l1_fill: u64,
300        #[case] mode: SwapMode,
301        #[case] expected_l1: Option<u64>,
302        #[case] expected_bps: Option<i64>,
303    ) {
304        let scored = L1Score::new(measured, l1_fill, mode);
305        assert_eq!(scored.l1_fill, expected_l1);
306        assert_eq!(scored.change_vs_l1_bps, expected_bps);
307    }
308
309    /// The absent halves have to vanish rather than serialize as nulls: a consumer distinguishes
310    /// "no baseline" from "scored zero", and both sides skip on `None`.
311    #[test]
312    fn an_unscored_comparison_emits_no_keys() {
313        let empty = serde_json::to_value(L1Score::new(None, 0, SwapMode::ExactIn))
314            .expect("a score serializes");
315        assert_eq!(empty, serde_json::json!({}));
316    }
317}