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 wincode 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, its route decoded without that hop, or it split the hop differently. Its
218 /// input can differ from `input_amount` when an earlier hop filled differently, so compare
219 /// output per unit of input.
220 #[serde(default)]
221 pub replay_input_amount: Option<u64>,
222 #[serde(default)]
223 pub replay_output_amount: Option<u64>,
224}
225
226/// One direct-fill probe's result - the `DirectFill` shape of [`ReplacementNotification`]: a
227/// single hop re-priced through a direct-fill template, simulated against the state it would
228/// have traded on.
229///
230/// A probe prices ONE hop, so the mints and amount here are the leg's, never the enclosing
231/// swap's - a route's endpoints are not its middle leg's.
232#[serde_as]
233#[derive(Debug, Clone, Serialize, Deserialize)]
234#[serde(rename_all = "camelCase")]
235pub struct DirectFillNotification {
236 #[serde(flatten)]
237 pub core: ReplacementCore,
238 /// Scored at hop scope: the probed hop's own L1 output. A probe prices one hop, so it names
239 /// no route and carries none.
240 #[serde(flatten)]
241 pub comparison: L1Score,
242 /// The template the hop was rebuilt through.
243 pub template: String,
244 /// The program that template's instruction calls: for one built from an aggregator's route,
245 /// the aggregator's, which several venues share.
246 #[serde_as(as = "serde_with::DisplayFromStr")]
247 pub program: Address,
248 /// The hop this priced, as L1 and the replay ran it.
249 pub hop: ProbedHop,
250 /// The transaction of block [`ReplacementCore::slot`] the pass that priced this probe ran
251 /// just before; 0 at the block's start.
252 pub quote_tx_index: u32,
253 /// Blocks between the block the hop executes in and the one it was priced at.
254 pub quote_lag_blocks: u32,
255 #[serde_as(as = "serde_with::DisplayFromStr")]
256 pub input_mint: Address,
257 #[serde_as(as = "serde_with::DisplayFromStr")]
258 pub output_mint: Address,
259 /// The hop's own input amount, in base units.
260 pub amount: u64,
261 pub swap_mode: String,
262 /// What the probed template filled; `None` unless the probe filled attributably.
263 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub template_fill: Option<u64>,
265}
266
267/// One replayed original - the `Original` shape of [`ReplacementNotification`]: the historical
268/// transaction itself, re-run against the state it was quoted at, so the other two kinds are
269/// scored against a number produced the same way rather than against the chain's.
270#[derive(Debug, Clone, Serialize, Deserialize)]
271#[serde(rename_all = "camelCase")]
272pub struct OriginalNotification {
273 #[serde(flatten)]
274 pub core: ReplacementCore,
275 /// Scored at swap scope: net route flow across the whole swap's pair on L1.
276 #[serde(flatten)]
277 pub comparison: L1Score,
278 #[serde(flatten)]
279 pub cost: SimCost,
280 /// The hops this replay produced.
281 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub route_plan: Option<RoutePlan>,
283 /// The hops the original took on L1, decoded the same way, so the two are read against each
284 /// other rather than against differently-derived numbers.
285 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub l1_route_plan: Option<RoutePlan>,
287 /// What the replayed original produced, when a single swap's output could be read off it.
288 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub simulated_output_amount: Option<u64>,
290}
291
292/// What stood in for a historical swap, streamed over `replacementSubscribe`. Internally tagged
293/// on `kind`: three mechanisms answer different questions - metis re-routing a whole transaction,
294/// a probe re-pricing one hop, and the original replayed as its own baseline - so they carry
295/// different shapes rather than one union.
296#[derive(Debug, Clone, Serialize, Deserialize)]
297#[serde(tag = "kind", rename_all = "camelCase")]
298pub enum ReplacementNotification {
299 Requote(RequoteNotification),
300 DirectFill(DirectFillNotification),
301 Original(OriginalNotification),
302}
303
304impl ReplacementNotification {
305 /// What every kind carries, whichever shape it arrived in.
306 pub const fn core(&self) -> &ReplacementCore {
307 match self {
308 Self::Requote(requote) => &requote.core,
309 Self::DirectFill(probe) => &probe.core,
310 Self::Original(original) => &original.core,
311 }
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use rstest::rstest;
318
319 use super::*;
320
321 #[rstest]
322 #[case::total_loss(0, 1_000, -10_000, false)]
323 #[case::unchanged(1_000, 1_000, 0, false)]
324 #[case::a_real_improvement(1_100, 1_000, 1_000, false)]
325 #[case::ten_x_is_the_last_unclamped(101_000, 10_000, 91_000, false)]
326 #[case::eleven_x_is_the_bound_itself(110_000, 10_000, 100_000, true)]
327 #[case::dust_base(1_000_000, 1, 100_000, true)]
328 fn a_change_reports_whether_the_clamp_bound_it(
329 #[case] new: u64,
330 #[case] base: u64,
331 #[case] bps: i64,
332 #[case] clamped: bool,
333 ) {
334 assert_eq!(clamped_change_bps(new, base), ChangeBps { bps, clamped });
335 }
336
337 #[rstest]
338 #[case::no_baseline(Some(1_000), 0, SwapMode::ExactIn, None, None)]
339 #[case::no_measurement(None, 1_000, SwapMode::ExactIn, Some(1_000), None)]
340 #[case::beat_it(Some(1_100), 1_000, SwapMode::ExactIn, Some(1_000), Some(1_000))]
341 #[case::lost_to_it(Some(900), 1_000, SwapMode::ExactIn, Some(1_000), Some(-1_000))]
342 #[case::tiny_baseline_clamps(
343 Some(u64::MAX),
344 1,
345 SwapMode::ExactIn,
346 Some(1),
347 Some(CHANGE_CLAMP_BPS as i64)
348 )]
349 // ExactOut measures the input charged, so paying less is the win and the sign flips.
350 #[case::exact_out_paid_less(Some(900), 1_000, SwapMode::ExactOut, Some(1_000), Some(1_000))]
351 #[case::exact_out_paid_more(Some(1_100), 1_000, SwapMode::ExactOut, Some(1_000), Some(-1_000))]
352 // A venue declining with a zero is a refusal to quote, not a quote of nothing.
353 #[case::a_declined_quote_is_unscored(Some(0), 1_000, SwapMode::ExactIn, Some(1_000), None)]
354 fn scoring_a_fill_against_l1(
355 #[case] measured: Option<u64>,
356 #[case] l1_fill: u64,
357 #[case] mode: SwapMode,
358 #[case] expected_l1: Option<u64>,
359 #[case] expected_bps: Option<i64>,
360 ) {
361 let scored = L1Score::new(measured, l1_fill, mode);
362 assert_eq!(scored.l1_fill, expected_l1);
363 assert_eq!(scored.change_vs_l1_bps, expected_bps);
364 }
365
366 /// The absent halves have to vanish rather than serialize as nulls: a consumer distinguishes
367 /// "no baseline" from "scored zero", and both sides skip on `None`.
368 #[test]
369 fn an_unscored_comparison_emits_no_keys() {
370 let empty = serde_json::to_value(L1Score::new(None, 0, SwapMode::ExactIn))
371 .expect("a score serializes");
372 assert_eq!(empty, serde_json::json!({}));
373 }
374}