Skip to main content

equanetwork_math/
swap.rs

1//! Network swap quotes: decimal-aware pricing + inventory skew + swap fees.
2//! Oracles are not used for pricing — pair-rate combination lives in `price`.
3
4#[cfg(feature = "wasm")]
5use equanetwork_macros::wasm_expose;
6
7use super::consts::{MAX_TOTAL_SKEW_PER_1M, PER_1M_DENOMINATOR};
8use super::error::{
9    CoreError, AMOUNT_EXCEEDS_MAX_U64, ARITHMETIC_OVERFLOW, DIVISION_BY_ZERO,
10    INSUFFICIENT_LIQUIDITY, INVALID_FEE, INVALID_PRICE, INVALID_SKEW, MAX_SWAP_EXCEEDED,
11};
12use super::skew::{average_skew_per_m, SkewExponent};
13use super::U128;
14
15/// Decimal-aware 1:1 conversion (UI-unit parity).
16#[cfg_attr(feature = "wasm", wasm_expose)]
17pub fn base_out_1_to_1(
18    amount_in: u64,
19    in_decimals: u8,
20    out_decimals: u8,
21) -> Result<u64, CoreError> {
22    if amount_in == 0 {
23        return Ok(0);
24    }
25    let in_scale = 10u128
26        .checked_pow(in_decimals as u32)
27        .ok_or(ARITHMETIC_OVERFLOW)?;
28    let out_scale = 10u128
29        .checked_pow(out_decimals as u32)
30        .ok_or(ARITHMETIC_OVERFLOW)?;
31    let numerator = (amount_in as u128)
32        .checked_mul(out_scale)
33        .ok_or(ARITHMETIC_OVERFLOW)?;
34    let out = numerator.checked_div(in_scale).ok_or(DIVISION_BY_ZERO)?;
35    out.try_into().map_err(|_| AMOUNT_EXCEEDS_MAX_U64)
36}
37
38/// Fair output from Q64.64 prices (no skew, no fee).
39///
40/// `out = base_1_to_1(amount_in) * price_in / price_out`
41#[cfg_attr(feature = "wasm", wasm_expose)]
42pub fn fair_out_with_prices(
43    amount_in: u64,
44    in_decimals: u8,
45    out_decimals: u8,
46    price_in_q64: U128,
47    price_out_q64: U128,
48) -> Result<u64, CoreError> {
49    let base = base_out_1_to_1(amount_in, in_decimals, out_decimals)?;
50    let price_in: u128 = price_in_q64.into();
51    let price_out: u128 = price_out_q64.into();
52    if price_in == 0 || price_out == 0 {
53        return Err(INVALID_PRICE);
54    }
55    let numerator = (base as u128)
56        .checked_mul(price_in)
57        .ok_or(ARITHMETIC_OVERFLOW)?;
58    let out = numerator.checked_div(price_out).ok_or(DIVISION_BY_ZERO)?;
59    out.try_into().map_err(|_| AMOUNT_EXCEEDS_MAX_U64)
60}
61
62/// Retain `fee_per_1m` ppm of `amount` in the vault (returns the swapped portion).
63#[cfg_attr(feature = "wasm", wasm_expose)]
64pub fn apply_swap_fee_per_1m(amount: u64, fee_per_1m: u32) -> Result<u64, CoreError> {
65    if fee_per_1m as u64 > PER_1M_DENOMINATOR {
66        return Err(INVALID_FEE);
67    }
68    let keep = PER_1M_DENOMINATOR.saturating_sub(fee_per_1m as u64);
69    let product = (amount as u128)
70        .checked_mul(keep as u128)
71        .ok_or(ARITHMETIC_OVERFLOW)?;
72    let out = product
73        .checked_div(PER_1M_DENOMINATOR as u128)
74        .ok_or(DIVISION_BY_ZERO)?;
75    out.try_into().map_err(|_| AMOUNT_EXCEEDS_MAX_U64)
76}
77
78/// Inventory skew contribution for one vault (signed ppm): path-average over the fill.
79#[allow(clippy::too_many_arguments)]
80#[cfg_attr(feature = "wasm", wasm_expose)]
81pub fn vault_skew_contribution_per_1m(
82    balance_pre: u64,
83    balance_post: u64,
84    limit_lower: u64,
85    limit_upper: u64,
86    positive_skew_per_1m: u32,
87    negative_skew_per_1m: u32,
88    lower_skew_offset_bps: u16,
89    upper_skew_offset_bps: u16,
90    skew_exponent: SkewExponent,
91) -> Result<i64, CoreError> {
92    average_skew_per_m(
93        balance_pre,
94        balance_post,
95        limit_lower,
96        limit_upper,
97        lower_skew_offset_bps,
98        upper_skew_offset_bps,
99        positive_skew_per_1m,
100        negative_skew_per_1m,
101        skew_exponent,
102    )
103}
104
105/// Apply combined skew (ppm) to fair output.
106#[cfg_attr(feature = "wasm", wasm_expose)]
107pub fn apply_total_skew_per_1m(fair_out: u64, total_skew_per_1m: i64) -> Result<u64, CoreError> {
108    let capped = total_skew_per_1m.clamp(-MAX_TOTAL_SKEW_PER_1M, MAX_TOTAL_SKEW_PER_1M);
109    let factor = (PER_1M_DENOMINATOR as i128)
110        .checked_sub(capped as i128)
111        .ok_or(ARITHMETIC_OVERFLOW)?;
112    if factor <= 0 {
113        return Err(INVALID_SKEW);
114    }
115    let product = (fair_out as u128)
116        .checked_mul(factor as u128)
117        .ok_or(ARITHMETIC_OVERFLOW)?;
118    let out = product
119        .checked_div(PER_1M_DENOMINATOR as u128)
120        .ok_or(DIVISION_BY_ZERO)?;
121    out.try_into().map_err(|_| AMOUNT_EXCEEDS_MAX_U64)
122}
123
124#[derive(Debug, Clone, Copy, Eq, PartialEq)]
125#[cfg_attr(feature = "wasm", wasm_expose)]
126pub struct SwapQuote {
127    pub amount_in: u64,
128    pub fair_out: u64,
129    pub amount_out: u64,
130    pub total_skew_per_1m: i64,
131    pub partial: bool,
132}
133
134#[derive(Debug, Clone, Copy, Eq, PartialEq)]
135#[cfg_attr(feature = "wasm", wasm_expose)]
136pub struct VaultSwapLimits {
137    pub balance: u64,
138    pub limit_lower: u64,
139    pub limit_upper: u64,
140    pub max_swap_amount: u64,
141    pub swap_fee_per_1m: u32,
142    pub positive_skew_per_1m: u32,
143    pub negative_skew_per_1m: u32,
144    pub lower_skew_offset_bps: u16,
145    pub upper_skew_offset_bps: u16,
146    pub skew_exponent: SkewExponent,
147    pub decimals: u8,
148}
149
150/// Quote a network swap (prices + fees + skew). No oracle input.
151#[cfg_attr(feature = "wasm", wasm_expose)]
152pub fn quote_network_swap(
153    amount_in: u64,
154    input: &VaultSwapLimits,
155    output: &VaultSwapLimits,
156    price_in_q64: U128,
157    price_out_q64: U128,
158    allow_partial_fill: bool,
159) -> Result<SwapQuote, CoreError> {
160    if amount_in == 0 {
161        return Err(INSUFFICIENT_LIQUIDITY);
162    }
163
164    let price_in: u128 = price_in_q64.into();
165    let price_out: u128 = price_out_q64.into();
166
167    let mut fill_in = amount_in;
168    if fill_in > input.max_swap_amount {
169        if !allow_partial_fill {
170            return Err(MAX_SWAP_EXCEEDED);
171        }
172        fill_in = input.max_swap_amount;
173    }
174
175    let input_headroom = input.limit_upper.saturating_sub(input.balance);
176    if fill_in > input_headroom {
177        if !allow_partial_fill {
178            return Err(INSUFFICIENT_LIQUIDITY);
179        }
180        fill_in = input_headroom;
181    }
182
183    // Iterate: compute out, clamp by output spendable + max_swap, maybe reduce in.
184    for _ in 0..8 {
185        if fill_in == 0 {
186            return Err(INSUFFICIENT_LIQUIDITY);
187        }
188        let in_post = input.balance.saturating_add(fill_in);
189        let skew_in = vault_skew_contribution_per_1m(
190            input.balance,
191            in_post,
192            input.limit_lower,
193            input.limit_upper,
194            input.positive_skew_per_1m,
195            input.negative_skew_per_1m,
196            input.lower_skew_offset_bps,
197            input.upper_skew_offset_bps,
198            input.skew_exponent,
199        )?;
200
201        // Input fee stays in the input vault; only the net portion is priced.
202        let priced_in = apply_swap_fee_per_1m(fill_in, input.swap_fee_per_1m)?;
203        if priced_in == 0 {
204            return Err(INSUFFICIENT_LIQUIDITY);
205        }
206        let fair = fair_out_with_prices(
207            priced_in,
208            input.decimals,
209            output.decimals,
210            U128::from(price_in),
211            U128::from(price_out),
212        )?;
213        // Provisional output movement uses fair before output fee.
214        let out_post_for_skew = output.balance.saturating_sub(fair.min(output.balance));
215        let skew_out = vault_skew_contribution_per_1m(
216            output.balance,
217            out_post_for_skew,
218            output.limit_lower,
219            output.limit_upper,
220            output.positive_skew_per_1m,
221            output.negative_skew_per_1m,
222            output.lower_skew_offset_bps,
223            output.upper_skew_offset_bps,
224            output.skew_exponent,
225        )?;
226        let total_skew = skew_in.saturating_add(skew_out);
227        let skewed_out = apply_total_skew_per_1m(fair, total_skew)?;
228        // Output fee stays in the output vault.
229        let exec_out = apply_swap_fee_per_1m(skewed_out, output.swap_fee_per_1m)?;
230
231        let spendable = output.balance.saturating_sub(output.limit_lower);
232        let mut max_out = core::cmp::min(spendable, output.max_swap_amount);
233        max_out = core::cmp::min(max_out, output.balance);
234
235        if exec_out > max_out {
236            if !allow_partial_fill {
237                return Err(if skewed_out > output.max_swap_amount {
238                    MAX_SWAP_EXCEEDED
239                } else {
240                    INSUFFICIENT_LIQUIDITY
241                });
242            }
243            if fair == 0 || exec_out == 0 {
244                return Err(INSUFFICIENT_LIQUIDITY);
245            }
246            let reduced = (fill_in as u128)
247                .checked_mul(max_out as u128)
248                .ok_or(ARITHMETIC_OVERFLOW)?
249                .checked_div(exec_out as u128)
250                .ok_or(DIVISION_BY_ZERO)?;
251            let reduced: u64 = reduced.try_into().map_err(|_| AMOUNT_EXCEEDS_MAX_U64)?;
252            if reduced >= fill_in || reduced == 0 {
253                return Err(INSUFFICIENT_LIQUIDITY);
254            }
255            fill_in = reduced;
256            continue;
257        }
258
259        // Recompute skew with actual exec_out for output post balance.
260        let out_post = output.balance.saturating_sub(exec_out);
261        let skew_out = vault_skew_contribution_per_1m(
262            output.balance,
263            out_post,
264            output.limit_lower,
265            output.limit_upper,
266            output.positive_skew_per_1m,
267            output.negative_skew_per_1m,
268            output.lower_skew_offset_bps,
269            output.upper_skew_offset_bps,
270            output.skew_exponent,
271        )?;
272        let total_skew = skew_in.saturating_add(skew_out);
273        let skewed_out = apply_total_skew_per_1m(fair, total_skew)?;
274        let exec_out = apply_swap_fee_per_1m(skewed_out, output.swap_fee_per_1m)?;
275        if exec_out > max_out {
276            if !allow_partial_fill {
277                return Err(INSUFFICIENT_LIQUIDITY);
278            }
279            let reduced = (fill_in as u128)
280                .checked_mul(max_out as u128)
281                .ok_or(ARITHMETIC_OVERFLOW)?
282                .checked_div(exec_out as u128)
283                .ok_or(DIVISION_BY_ZERO)?;
284            let reduced: u64 = reduced.try_into().map_err(|_| AMOUNT_EXCEEDS_MAX_U64)?;
285            if reduced >= fill_in || reduced == 0 {
286                return Err(INSUFFICIENT_LIQUIDITY);
287            }
288            fill_in = reduced;
289            continue;
290        }
291
292        return Ok(SwapQuote {
293            amount_in: fill_in,
294            fair_out: fair,
295            amount_out: exec_out,
296            total_skew_per_1m: total_skew,
297            partial: fill_in < amount_in,
298        });
299    }
300    Err(INSUFFICIENT_LIQUIDITY)
301}
302
303#[cfg(test)]
304mod tests {
305    use super::super::Q64_ONE;
306    use super::*;
307
308    #[test]
309    fn one_to_one_same_decimals() {
310        assert_eq!(base_out_1_to_1(1_000_000, 6, 6).unwrap(), 1_000_000);
311    }
312
313    #[test]
314    fn one_to_one_decimal_conversion() {
315        assert_eq!(base_out_1_to_1(1_000_000, 6, 8).unwrap(), 100_000_000);
316        assert_eq!(base_out_1_to_1(100_000_000, 8, 6).unwrap(), 1_000_000);
317    }
318
319    #[test]
320    fn cheaper_output_price_yields_more_out() {
321        // output price 0.998 → more out
322        let price_out = Q64_ONE * 998 / 1000;
323        let out = fair_out_with_prices(1_000_000, 6, 6, U128::from(Q64_ONE), U128::from(price_out))
324            .unwrap();
325        assert!(out > 1_000_000);
326    }
327
328    #[test]
329    fn swap_fee_retains_portion() {
330        let net = apply_swap_fee_per_1m(1_000_000, 1_000).unwrap();
331        assert_eq!(net, 999_000);
332    }
333
334    #[test]
335    fn skew_rebalance_negative() {
336        let skew = vault_skew_contribution_per_1m(
337            900,
338            600,
339            0,
340            1000,
341            1000,
342            1000,
343            0,
344            0,
345            SkewExponent::Linear,
346        )
347        .unwrap();
348        assert!(skew < 0);
349    }
350}