Skip to main content

equanetwork_math/
price.rs

1//! Combine like-quoted oracle prices (price sources or feeds) into a pair rate.
2//!
3//! Decoding of oracle accounts (Pyth / Switchboard / Chainlink / stake pool)
4//! lives in the on-chain program. This module only does Q64.64 arithmetic.
5
6#[cfg(feature = "wasm")]
7use equanetwork_macros::wasm_expose;
8use ethnum::U256;
9
10use super::consts::{PER_1M_DENOMINATOR, Q64_ONE};
11use super::error::{
12    CoreError, ARITHMETIC_OVERFLOW, DIVISION_BY_ZERO, INVALID_MARGIN, INVALID_PRICE,
13};
14use super::U128;
15
16/// Inclusive Q64.64 interval.
17#[derive(Debug, Clone, Copy, Eq, PartialEq)]
18pub struct PriceBand {
19    pub lo_q64: u128,
20    pub hi_q64: u128,
21}
22
23/// Identity Q64.64 price (1.0) used when no price source or feed is configured.
24#[cfg_attr(feature = "wasm", wasm_expose)]
25pub fn default_price_q64() -> U128 {
26    U128::from(Q64_ONE)
27}
28
29/// Q64.64 ratio `numerator / denominator`.
30///
31/// Same formula for integer amounts (e.g. stake-pool lamports / supply) and
32/// for two Q64.64 prices (`price_in / price_out`).
33#[cfg_attr(feature = "wasm", wasm_expose)]
34pub fn ratio_to_q64(numerator: U128, denominator: U128) -> Result<U128, CoreError> {
35    let numerator: u128 = numerator.into();
36    let denominator: u128 = denominator.into();
37    if denominator == 0 {
38        return Err(DIVISION_BY_ZERO);
39    }
40    if numerator == 0 {
41        return Err(INVALID_PRICE);
42    }
43    let q: U256 = (U256::from(numerator) << 64) / U256::from(denominator);
44    let q = u128::try_from(q).map_err(|_| ARITHMETIC_OVERFLOW)?;
45    if q == 0 {
46        return Err(INVALID_PRICE);
47    }
48    Ok(U128::from(q))
49}
50
51/// Combined pair rate `price_in / price_out` in Q64.64.
52///
53/// Quote currencies cancel when both inputs are denominated the same way
54/// (e.g. two SOL-quoted sources, or two USD-quoted feeds).
55#[cfg_attr(feature = "wasm", wasm_expose)]
56pub fn combine_prices_q64(price_in_q64: U128, price_out_q64: U128) -> Result<U128, CoreError> {
57    ratio_to_q64(price_in_q64, price_out_q64)
58}
59
60/// `[price * (1 − margin), price * (1 + margin)]` in Q64.64.
61pub fn price_band_with_margin(price_q64: U128, margin_per_1m: u32) -> Result<PriceBand, CoreError> {
62    let price: u128 = price_q64.into();
63    if price == 0 {
64        return Err(INVALID_PRICE);
65    }
66    if margin_per_1m > PER_1M_DENOMINATOR as u32 {
67        return Err(INVALID_MARGIN);
68    }
69    let denom = U256::from(PER_1M_DENOMINATOR as u128);
70    let p = U256::from(price);
71    let lo_f = U256::from(PER_1M_DENOMINATOR.saturating_sub(margin_per_1m as u64) as u128);
72    let hi_f = U256::from(PER_1M_DENOMINATOR.saturating_add(margin_per_1m as u64) as u128);
73    let lo = u128::try_from((p * lo_f) / denom).map_err(|_| ARITHMETIC_OVERFLOW)?;
74    let hi = u128::try_from((p * hi_f) / denom).map_err(|_| ARITHMETIC_OVERFLOW)?;
75    if lo == 0 {
76        return Err(INVALID_PRICE);
77    }
78    Ok(PriceBand {
79        lo_q64: lo,
80        hi_q64: hi,
81    })
82}
83
84/// Combined band from two price intervals: `lo = in_lo / out_hi`, `hi = in_hi / out_lo`.
85pub fn combine_price_band(
86    in_lo_q64: U128,
87    in_hi_q64: U128,
88    out_lo_q64: U128,
89    out_hi_q64: U128,
90) -> Result<PriceBand, CoreError> {
91    let lo: u128 = combine_prices_q64(in_lo_q64, out_hi_q64)?.into();
92    let hi: u128 = combine_prices_q64(in_hi_q64, out_lo_q64)?.into();
93    if lo == 0 || hi < lo {
94        return Err(INVALID_PRICE);
95    }
96    Ok(PriceBand {
97        lo_q64: lo,
98        hi_q64: hi,
99    })
100}
101
102/// Combined feed band from two `[mid ± conf]` observations.
103pub fn combine_feed_band(
104    in_mid_q64: U128,
105    in_conf_q64: U128,
106    out_mid_q64: U128,
107    out_conf_q64: U128,
108) -> Result<PriceBand, CoreError> {
109    let in_mid: u128 = in_mid_q64.into();
110    let in_conf: u128 = in_conf_q64.into();
111    let out_mid: u128 = out_mid_q64.into();
112    let out_conf: u128 = out_conf_q64.into();
113    let in_lo = in_mid.saturating_sub(in_conf);
114    let in_hi = in_mid.saturating_add(in_conf);
115    let out_lo = out_mid.saturating_sub(out_conf);
116    let out_hi = out_mid.saturating_add(out_conf);
117    if in_lo == 0 || out_lo == 0 {
118        return Err(INVALID_PRICE);
119    }
120    combine_price_band(
121        U128::from(in_lo),
122        U128::from(in_hi),
123        U128::from(out_lo),
124        U128::from(out_hi),
125    )
126}
127
128/// `num_a / den_a <= num_b / den_b` for positive values (widening mul).
129fn ratio_leq(num_a: u128, den_a: u128, num_b: u128, den_b: u128) -> bool {
130    U256::from(num_a) * U256::from(den_b) <= U256::from(den_a) * U256::from(num_b)
131}
132
133/// Combined synthetic oracle band must lie inside the combined execution-price
134/// band.
135///
136/// Execution is the point `source_in / source_out`, expanded by the stricter of
137/// the two vaults' `price_margin_per_1m`. Oracle is
138/// `(feed_in ± conf_in) / (feed_out ∓ conf_out)`. Quote currencies cancel when
139/// both sources share a denomination and both feeds share a (possibly
140/// different) denomination.
141#[allow(clippy::too_many_arguments)]
142#[cfg_attr(feature = "wasm", wasm_expose)]
143pub fn assert_synthetic_oracle_within_execution(
144    source_in_q64: U128,
145    source_out_q64: U128,
146    feed_in_mid_q64: U128,
147    feed_in_conf_q64: U128,
148    feed_out_mid_q64: U128,
149    feed_out_conf_q64: U128,
150    margin_in_per_1m: u32,
151    margin_out_per_1m: u32,
152) -> Result<(), CoreError> {
153    let source_in: u128 = source_in_q64.into();
154    let source_out: u128 = source_out_q64.into();
155    let feed_in_mid: u128 = feed_in_mid_q64.into();
156    let feed_in_conf: u128 = feed_in_conf_q64.into();
157    let feed_out_mid: u128 = feed_out_mid_q64.into();
158    let feed_out_conf: u128 = feed_out_conf_q64.into();
159
160    if source_in == 0 || source_out == 0 {
161        return Err(INVALID_PRICE);
162    }
163    let margin_per_1m = core::cmp::min(margin_in_per_1m, margin_out_per_1m);
164    let source_band = price_band_with_margin(U128::from(source_in), margin_per_1m)?;
165
166    let feed_in_lo = feed_in_mid.saturating_sub(feed_in_conf);
167    let feed_in_hi = feed_in_mid.saturating_add(feed_in_conf);
168    let feed_out_lo = feed_out_mid.saturating_sub(feed_out_conf);
169    let feed_out_hi = feed_out_mid.saturating_add(feed_out_conf);
170    if feed_in_lo == 0 || feed_out_lo == 0 {
171        return Err(INVALID_PRICE);
172    }
173
174    // exec_lo = source_in_lo / source_out, oracle_lo = feed_in_lo / feed_out_hi
175    // exec_hi = source_in_hi / source_out, oracle_hi = feed_in_hi / feed_out_lo
176    if !ratio_leq(source_band.lo_q64, source_out, feed_in_lo, feed_out_hi)
177        || !ratio_leq(feed_in_hi, feed_out_lo, source_band.hi_q64, source_out)
178    {
179        return Err(INVALID_MARGIN);
180    }
181    Ok(())
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn combine_prices_cancels_quote_currency() {
190        let exec: u128 = combine_prices_q64(U128::from(Q64_ONE * 2), U128::from(Q64_ONE))
191            .unwrap()
192            .into();
193        let oracle: u128 = combine_prices_q64(U128::from(Q64_ONE * 200), U128::from(Q64_ONE * 100))
194            .unwrap()
195            .into();
196        assert_eq!(exec, oracle);
197        assert_eq!(exec, Q64_ONE * 2);
198    }
199
200    #[test]
201    fn ratio_to_q64_from_integer_amounts() {
202        let price: u128 = ratio_to_q64(U128::from(2u128), U128::from(1u128))
203            .unwrap()
204            .into();
205        assert_eq!(price, Q64_ONE * 2);
206    }
207
208    #[test]
209    fn synthetic_oracle_matches_execution() {
210        assert!(assert_synthetic_oracle_within_execution(
211            U128::from(Q64_ONE),
212            U128::from(Q64_ONE),
213            U128::from(Q64_ONE),
214            U128::from(0u128),
215            U128::from(Q64_ONE),
216            U128::from(0u128),
217            5_000,
218            5_000
219        )
220        .is_ok());
221        // 1.04 / 1.00 sits inside execution ± 5%.
222        let near = Q64_ONE + Q64_ONE / 25;
223        assert!(assert_synthetic_oracle_within_execution(
224            U128::from(Q64_ONE),
225            U128::from(Q64_ONE),
226            U128::from(near),
227            U128::from(0u128),
228            U128::from(Q64_ONE),
229            U128::from(0u128),
230            50_000,
231            50_000
232        )
233        .is_ok());
234        // Combined 1.10 / 1.00 is outside execution 1.0 ± 5%.
235        let far = Q64_ONE + Q64_ONE / 10;
236        assert!(assert_synthetic_oracle_within_execution(
237            U128::from(Q64_ONE),
238            U128::from(Q64_ONE),
239            U128::from(far),
240            U128::from(0u128),
241            U128::from(Q64_ONE),
242            U128::from(0u128),
243            50_000,
244            50_000
245        )
246        .is_err());
247        // Wide conf that escapes the combined execution band fails.
248        let conf = Q64_ONE / 50;
249        assert!(assert_synthetic_oracle_within_execution(
250            U128::from(Q64_ONE),
251            U128::from(Q64_ONE),
252            U128::from(Q64_ONE),
253            U128::from(conf),
254            U128::from(Q64_ONE),
255            U128::from(0u128),
256            5_000,
257            5_000
258        )
259        .is_err());
260    }
261
262    #[test]
263    fn mixed_quote_currencies_cancel_in_combined_ratio() {
264        // source_in XYZ/SOL = 2, source_out unset = 1 → execution 2
265        // feed_in XYZ/USD = 200, feed_out ABC/USD = 100 → oracle 2
266        let source_in = Q64_ONE * 2;
267        let feed_in = Q64_ONE * 200;
268        let feed_out = Q64_ONE * 100;
269        assert!(assert_synthetic_oracle_within_execution(
270            U128::from(source_in),
271            U128::from(Q64_ONE),
272            U128::from(feed_in),
273            U128::from(0u128),
274            U128::from(feed_out),
275            U128::from(0u128),
276            5_000,
277            5_000
278        )
279        .is_ok());
280        // Oracle 220/100 = 2.2 vs execution 2 is outside margin.
281        let feed_in_far = Q64_ONE * 220;
282        assert!(assert_synthetic_oracle_within_execution(
283            U128::from(source_in),
284            U128::from(Q64_ONE),
285            U128::from(feed_in_far),
286            U128::from(0u128),
287            U128::from(feed_out),
288            U128::from(0u128),
289            5_000,
290            5_000
291        )
292        .is_err());
293    }
294}