Skip to main content

simulator_api/reroute_report/
prices.rs

1//! Pricing a reroute stream in dollars using nothing but the stream.
2//!
3//! No oracle and no external feed. SOL/USD comes from the range's own SOL<->USDC reroutes, the
4//! other majors from their pairings against SOL, USDC and USDT, and everything else from its
5//! major<->exotic legs. A derived price needs [`MIN_SAMPLES`] observations before it is trusted;
6//! the SOL anchor is exempt, since every other price hangs from it.
7
8use std::collections::HashMap;
9
10use solana_address::Address;
11
12use super::record::{LegRecord, SwapMode};
13use crate::mints;
14
15const LAMPORTS_PER_SOL: f64 = 1e9;
16/// Sub-SOL probe swaps realize off-market and drag the median, so the anchor ignores them.
17const ONE_SOL_LAMPORTS: u128 = 1_000_000_000;
18const USDC_UNITS_PER_USD: f64 = 1e6;
19/// A derived price needs at least this many samples to be trusted.
20const MIN_SAMPLES: usize = 3;
21
22/// One ExactIn reroute. ExactOut is excluded: its `amount` is in the output token and would
23/// poison every ratio it entered.
24struct Sample {
25    input_mint: Address,
26    output_mint: Address,
27    amount: u128,
28    original_output: u128,
29}
30
31/// Retained until the range ends, because a median cannot be folded. Two mints and two amounts
32/// against a notification's multi-KB route plans, which is what lets a report skip the stream.
33#[derive(Default)]
34pub struct Samples(Vec<Sample>);
35
36impl Samples {
37    /// Keeps the leg as a pricing sample only if it can price something: ExactIn, a realized and
38    /// nonzero original output, a nonzero input amount, the original didn't fail, and at least
39    /// one side is a major to anchor on. Anything else is silently dropped.
40    pub fn push(&mut self, record: &LegRecord) {
41        let Some(original_output) = record.original_realized_output_amount else {
42            return;
43        };
44        if record.swap_mode != SwapMode::ExactIn
45            || record.amount == 0
46            || original_output == 0
47            || record.original_failed
48        {
49            return;
50        }
51        // Every derivation anchors on a major, so a leg between two exotics is unreadable.
52        if !mints::is_major(&record.input_mint) && !mints::is_major(&record.output_mint) {
53            return;
54        }
55        self.0.push(Sample {
56            input_mint: record.input_mint,
57            output_mint: record.output_mint,
58            amount: u128::from(record.amount),
59            original_output: u128::from(original_output),
60        });
61    }
62
63    /// `None` when the range carries no SOL<->USDC ExactIn reroutes to anchor on.
64    pub fn prices(&self) -> Option<Prices> {
65        let sol_usd = sol_price(&self.0)?;
66        let mut prices = Prices {
67            sol_usd,
68            priced: priced_major_prices(&self.0, sol_usd),
69            exotic: HashMap::new(),
70        };
71        prices.exotic = exotic_unit_prices(&self.0, &prices);
72        Some(prices)
73    }
74}
75
76/// Per-range prices, derived from the range's own reroutes.
77pub struct Prices {
78    sol_usd: f64,
79    /// USD per whole unit for the priced majors.
80    priced: HashMap<Address, f64>,
81    /// USD per *raw* unit for exotics, inferred from their major<->exotic legs.
82    exotic: HashMap<Address, f64>,
83}
84
85impl Prices {
86    pub const fn sol_usd(&self) -> f64 {
87        self.sol_usd
88    }
89
90    /// `None` when the range could not price the mint, which the report counts rather than
91    /// folding in as zero.
92    ///
93    /// Linear in `raw` on every path, so a caller may sum one mint's raw amounts and convert the
94    /// total once, or pass an amount a weight has already scaled out of whole units.
95    pub fn usd(&self, mint: &Address, raw: f64) -> Option<f64> {
96        self.scaled_major(mint, raw)
97            .or_else(|| self.exotic.get(mint).map(|unit| raw * unit))
98    }
99
100    /// [`Self::usd`] for a mint still in its wire form. Unparseable is unpriceable.
101    pub fn usd_str(&self, mint: &str, raw: f64) -> Option<f64> {
102        self.usd(&mint.parse().ok()?, raw)
103    }
104
105    /// The majors alone, which is what [`exotic_unit_prices`] reads while building the exotics.
106    fn scaled_major(&self, mint: &Address, raw: f64) -> Option<f64> {
107        if *mint == mints::SOL {
108            return Some(raw / LAMPORTS_PER_SOL * self.sol_usd);
109        }
110        if let Some(decimals) = mints::stable_decimals(mint) {
111            return Some(raw / 10f64.powi(decimals as i32));
112        }
113        let decimals = mints::priced_decimals(mint)?;
114        let unit = self.priced.get(mint)?;
115        Some(raw / 10f64.powi(decimals as i32) * unit)
116    }
117}
118
119fn median(values: &mut [f64]) -> f64 {
120    values.sort_by(|a, b| a.total_cmp(b));
121    let n = values.len();
122    match n % 2 {
123        1 => values[n / 2],
124        _ => (values[n / 2 - 1] + values[n / 2]) / 2.0,
125    }
126}
127
128fn median_of(mut values: Vec<f64>) -> Option<f64> {
129    (values.len() >= MIN_SAMPLES).then(|| median(&mut values))
130}
131
132fn sol_price(samples: &[Sample]) -> Option<f64> {
133    let mut ratios: Vec<f64> = samples
134        .iter()
135        .filter_map(|sample| {
136            let (input, output) = (sample.input_mint, sample.output_mint);
137            if input == mints::SOL && output == mints::USDC && sample.amount >= ONE_SOL_LAMPORTS {
138                Some(
139                    (sample.original_output as f64 / USDC_UNITS_PER_USD)
140                        / (sample.amount as f64 / LAMPORTS_PER_SOL),
141                )
142            } else if input == mints::USDC
143                && output == mints::SOL
144                && sample.original_output >= ONE_SOL_LAMPORTS
145            {
146                Some(
147                    (sample.amount as f64 / USDC_UNITS_PER_USD)
148                        / (sample.original_output as f64 / LAMPORTS_PER_SOL),
149                )
150            } else {
151                None
152            }
153        })
154        .collect();
155    (!ratios.is_empty()).then(|| median(&mut ratios))
156}
157
158/// Median USD per priced major, from its pairings with USDC/USDT ($1) and SOL, both directions.
159fn priced_major_prices(samples: &[Sample], sol_usd: f64) -> HashMap<Address, f64> {
160    let mut per_mint: HashMap<Address, Vec<f64>> = HashMap::new();
161    for sample in samples {
162        let oriented = [
163            (
164                sample.input_mint,
165                sample.output_mint,
166                sample.amount,
167                sample.original_output,
168            ),
169            (
170                sample.output_mint,
171                sample.input_mint,
172                sample.original_output,
173                sample.amount,
174            ),
175        ];
176        for (mint, other, mint_raw, other_raw) in oriented {
177            let Some(decimals) = mints::priced_decimals(&mint) else {
178                continue;
179            };
180            let other_usd = if other == mints::USDC || other == mints::USDT {
181                other_raw as f64 / USDC_UNITS_PER_USD
182            } else if other == mints::SOL {
183                other_raw as f64 / LAMPORTS_PER_SOL * sol_usd
184            } else {
185                continue;
186            };
187            per_mint
188                .entry(mint)
189                .or_default()
190                .push(other_usd / (mint_raw as f64 / 10f64.powi(decimals as i32)));
191        }
192    }
193    per_mint
194        .into_iter()
195        .filter_map(|(mint, ratios)| Some((mint, median_of(ratios)?)))
196        .collect()
197}
198
199/// Median USD per raw unit for each exotic, from its major<->exotic legs.
200fn exotic_unit_prices(samples: &[Sample], prices: &Prices) -> HashMap<Address, f64> {
201    let mut per_mint: HashMap<Address, Vec<f64>> = HashMap::new();
202    for sample in samples {
203        let (input, output) = (sample.input_mint, sample.output_mint);
204        match (mints::is_major(&input), mints::is_major(&output)) {
205            (true, false) => {
206                if let Some(usd) = prices.scaled_major(&input, sample.amount as f64) {
207                    per_mint
208                        .entry(output)
209                        .or_default()
210                        .push(usd / sample.original_output as f64);
211                }
212            }
213            (false, true) => {
214                if let Some(usd) = prices.scaled_major(&output, sample.original_output as f64) {
215                    per_mint
216                        .entry(input)
217                        .or_default()
218                        .push(usd / sample.amount as f64);
219                }
220            }
221            _ => {}
222        }
223    }
224    per_mint
225        .into_iter()
226        .filter_map(|(mint, ratios)| Some((mint, median_of(ratios)?)))
227        .collect()
228}
229
230#[cfg(test)]
231mod tests {
232    use solana_address::address;
233
234    use super::*;
235
236    /// A longtail mint no table names, so it can only be priced through a major leg.
237    const BONK: Address = address!("DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263");
238    /// A governance blue-chip: parseable, deliberately not a major, and quoted by nobody below.
239    const RAY: Address = address!("4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R");
240    /// What [`anchored`] derives, and what every price hanging off it is stated in.
241    const SOL_USD: f64 = 100.0;
242
243    fn sample_record(
244        input: Address,
245        output: Address,
246        amount: u64,
247        original_output: u64,
248    ) -> LegRecord {
249        LegRecord {
250            original_signature: "sig".to_string(),
251            input_mint: input,
252            output_mint: output,
253            amount,
254            swap_mode: SwapMode::ExactIn,
255            failed: false,
256            original_failed: false,
257            realized_output_amount: None,
258            original_realized_output_amount: Some(original_output),
259            route_plan: None,
260            original_route_plan: None,
261            realized_route_plan: None,
262        }
263    }
264
265    /// Three SOL->USDC reroutes at [`SOL_USD`], which is the only thing a range needs before any
266    /// other price can be derived.
267    fn anchored() -> Samples {
268        let mut samples = Samples::default();
269        for (amount, output) in [
270            (1_000_000_000, 100_000_000),
271            (2_000_000_000, 200_000_000),
272            (3_000_000_000, 300_000_000),
273        ] {
274            samples.push(&sample_record(mints::SOL, mints::USDC, amount, output));
275        }
276        samples
277    }
278
279    /// Sub-SOL swaps realize off-market and would drag the anchor. Two of each, so dropping the
280    /// gate moves the median off `SOL_USD` instead of leaving the real legs a majority.
281    #[test]
282    fn the_sol_anchor_ignores_swaps_under_one_sol() {
283        let mut samples = Samples::default();
284        // Two dust swaps priced at $1000/SOL and two real ones at $100: ungated the median would
285        // be 550.0.
286        for (amount, output) in [
287            (1_000, 1_000),
288            (2_000, 2_000),
289            (1_000_000_000, 100_000_000),
290            (2_000_000_000, 200_000_000),
291        ] {
292            samples.push(&sample_record(mints::SOL, mints::USDC, amount, output));
293        }
294        let prices = samples.prices().expect("anchored");
295        assert_eq!(prices.sol_usd(), SOL_USD);
296    }
297
298    /// The oriented-pair loop reads a priced major from either side of a leg, and the ratio has
299    /// to invert with it — transposed, one cbBTC would be worth 1/60,000th of a dollar.
300    #[test]
301    fn a_priced_major_medians_both_orientations_of_its_legs() {
302        let cbbtc = mints::PRICED_MAJORS[1].address;
303        assert_eq!(mints::priced_decimals(&cbbtc), Some(8));
304        let one_cbbtc: u64 = 1_00000000;
305
306        let mut samples = anchored();
307        // $59,000, $60,000 and — quoted the other way round — $61,000 per cbBTC.
308        for (input, output, amount, original_output) in [
309            (cbbtc, mints::USDC, one_cbbtc, 59_000_000_000),
310            (cbbtc, mints::USDC, 2 * one_cbbtc, 120_000_000_000),
311            (mints::USDC, cbbtc, 61_000_000_000, one_cbbtc),
312        ] {
313            samples.push(&sample_record(input, output, amount, original_output));
314        }
315
316        let prices = samples.prices().expect("anchored");
317        assert_eq!(prices.usd(&cbbtc, one_cbbtc as f64), Some(60_000.0));
318        // Linear in the raw amount, and read at cbBTC's eight decimals rather than USDC's six.
319        assert_eq!(prices.usd(&cbbtc, one_cbbtc as f64 / 2.0), Some(30_000.0));
320    }
321
322    /// USD per raw exotic unit, folded exactly as the derivation folds it: the major side's
323    /// dollars over the raw units they bought.
324    fn unit_price(lamports: f64, exotic_raw: f64) -> f64 {
325        lamports / LAMPORTS_PER_SOL * SOL_USD / exotic_raw
326    }
327
328    /// An exotic has no decimals to read, so it is priced per *raw* unit off its major leg.
329    /// Transposing that division would price the whole longtail at ~1e10 dollars a unit.
330    #[test]
331    fn an_exotic_is_priced_per_raw_unit_off_its_major_leg() {
332        let mut samples = anchored();
333        // 1e12 BONK per SOL, then 2e12 (half the price per unit), then 1e12 again — so the
334        // median unit is the one the first leg quotes, not the cheap outlier in the middle.
335        for (lamports, bonk) in [
336            (1_000_000_000, 1_000_000_000_000),
337            (2_000_000_000, 4_000_000_000_000),
338            (3_000_000_000, 3_000_000_000_000),
339        ] {
340            samples.push(&sample_record(mints::SOL, BONK, lamports, bonk));
341        }
342
343        let prices = samples.prices().expect("anchored");
344        let unit = unit_price(1_000_000_000.0, 1_000_000_000_000.0);
345        assert_eq!(prices.usd(&BONK, 1e12), Some(1e12 * unit));
346        assert_eq!(prices.usd(&BONK, 5e11), Some(5e11 * unit));
347    }
348
349    /// USDe is 9 decimals; treating every stable as 1e6 values its legs 1000x over. Stable
350    /// pricing reads decimals alone, so the USDe<->USDC legs below anchor nothing — the range
351    /// still needs its own SOL<->USDC reroutes to produce a `Prices` at all.
352    #[test]
353    fn a_stable_is_valued_at_its_own_decimals() {
354        let usde_mint = mints::STABLES[4].address;
355        let mut samples = anchored();
356        for _ in 0..3 {
357            samples.push(&sample_record(
358                usde_mint,
359                mints::USDC,
360                1_000_000_000,
361                1_000_000,
362            ));
363        }
364        let prices = samples.prices().expect("anchored");
365        assert_eq!(prices.usd(&usde_mint, 1e6), Some(1e6 / 1e9));
366        assert_eq!(prices.usd(&mints::USDC, 1e6), Some(1.0));
367        // Linear in the raw amount on every path, which is what lets a caller sum first.
368        assert_eq!(prices.usd(&usde_mint, 1e9), Some(1.0));
369        assert_eq!(prices.usd(&mints::SOL, 1e9), Some(SOL_USD));
370    }
371
372    #[test]
373    fn a_price_needs_enough_samples() {
374        assert_eq!(median_of(vec![1.0, 2.0]), None);
375        assert_eq!(median_of(vec![1.0, 2.0, 3.0]), Some(2.0));
376        assert_eq!(median_of(vec![4.0, 1.0, 2.0, 3.0]), Some(2.5));
377    }
378
379    #[test]
380    fn an_unpriceable_mint_is_none_rather_than_zero() {
381        let prices = anchored().prices().expect("anchored");
382        assert_eq!(prices.usd_str("SomeLongtailMintNobodyQuoted", 1e6), None);
383        // A mint the range simply never traded is as unpriceable as an unparseable one.
384        assert_eq!(prices.usd(&RAY, 1e6), None);
385    }
386}