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}