equanetwork-math 0.0.4

The Equa Network program math library
Documentation
//! Network swap quotes: decimal-aware pricing + inventory skew + swap fees.
//! Oracles are not used for pricing — pair-rate combination lives in `price`.

#[cfg(feature = "wasm")]
use equanetwork_macros::wasm_expose;

use super::consts::{MAX_TOTAL_SKEW_PER_1M, PER_1M_DENOMINATOR};
use super::error::{
    CoreError, AMOUNT_EXCEEDS_MAX_U64, ARITHMETIC_OVERFLOW, DIVISION_BY_ZERO,
    INSUFFICIENT_LIQUIDITY, INVALID_FEE, INVALID_PRICE, INVALID_SKEW, MAX_SWAP_EXCEEDED,
};
use super::skew::{average_skew_per_m, SkewExponent};
use super::U128;

/// Decimal-aware 1:1 conversion (UI-unit parity).
#[cfg_attr(feature = "wasm", wasm_expose)]
pub fn base_out_1_to_1(
    amount_in: u64,
    in_decimals: u8,
    out_decimals: u8,
) -> Result<u64, CoreError> {
    if amount_in == 0 {
        return Ok(0);
    }
    let in_scale = 10u128
        .checked_pow(in_decimals as u32)
        .ok_or(ARITHMETIC_OVERFLOW)?;
    let out_scale = 10u128
        .checked_pow(out_decimals as u32)
        .ok_or(ARITHMETIC_OVERFLOW)?;
    let numerator = (amount_in as u128)
        .checked_mul(out_scale)
        .ok_or(ARITHMETIC_OVERFLOW)?;
    let out = numerator.checked_div(in_scale).ok_or(DIVISION_BY_ZERO)?;
    out.try_into().map_err(|_| AMOUNT_EXCEEDS_MAX_U64)
}

/// Fair output from Q64.64 prices (no skew, no fee).
///
/// `out = base_1_to_1(amount_in) * price_in / price_out`
#[cfg_attr(feature = "wasm", wasm_expose)]
pub fn fair_out_with_prices(
    amount_in: u64,
    in_decimals: u8,
    out_decimals: u8,
    price_in_q64: U128,
    price_out_q64: U128,
) -> Result<u64, CoreError> {
    let base = base_out_1_to_1(amount_in, in_decimals, out_decimals)?;
    let price_in: u128 = price_in_q64.into();
    let price_out: u128 = price_out_q64.into();
    if price_in == 0 || price_out == 0 {
        return Err(INVALID_PRICE);
    }
    let numerator = (base as u128)
        .checked_mul(price_in)
        .ok_or(ARITHMETIC_OVERFLOW)?;
    let out = numerator.checked_div(price_out).ok_or(DIVISION_BY_ZERO)?;
    out.try_into().map_err(|_| AMOUNT_EXCEEDS_MAX_U64)
}

/// Retain `fee_per_1m` ppm of `amount` in the vault (returns the swapped portion).
#[cfg_attr(feature = "wasm", wasm_expose)]
pub fn apply_swap_fee_per_1m(amount: u64, fee_per_1m: u32) -> Result<u64, CoreError> {
    if fee_per_1m as u64 > PER_1M_DENOMINATOR {
        return Err(INVALID_FEE);
    }
    let keep = PER_1M_DENOMINATOR.saturating_sub(fee_per_1m as u64);
    let product = (amount as u128)
        .checked_mul(keep as u128)
        .ok_or(ARITHMETIC_OVERFLOW)?;
    let out = product
        .checked_div(PER_1M_DENOMINATOR as u128)
        .ok_or(DIVISION_BY_ZERO)?;
    out.try_into().map_err(|_| AMOUNT_EXCEEDS_MAX_U64)
}

/// Inventory skew contribution for one vault (signed ppm): path-average over the fill.
#[allow(clippy::too_many_arguments)]
#[cfg_attr(feature = "wasm", wasm_expose)]
pub fn vault_skew_contribution_per_1m(
    balance_pre: u64,
    balance_post: u64,
    limit_lower: u64,
    limit_upper: u64,
    positive_skew_per_1m: u32,
    negative_skew_per_1m: u32,
    lower_skew_offset_bps: u16,
    upper_skew_offset_bps: u16,
    skew_exponent: SkewExponent,
) -> Result<i64, CoreError> {
    average_skew_per_m(
        balance_pre,
        balance_post,
        limit_lower,
        limit_upper,
        lower_skew_offset_bps,
        upper_skew_offset_bps,
        positive_skew_per_1m,
        negative_skew_per_1m,
        skew_exponent,
    )
}

/// Apply combined skew (ppm) to fair output.
#[cfg_attr(feature = "wasm", wasm_expose)]
pub fn apply_total_skew_per_1m(fair_out: u64, total_skew_per_1m: i64) -> Result<u64, CoreError> {
    let capped = total_skew_per_1m.clamp(-MAX_TOTAL_SKEW_PER_1M, MAX_TOTAL_SKEW_PER_1M);
    let factor = (PER_1M_DENOMINATOR as i128)
        .checked_sub(capped as i128)
        .ok_or(ARITHMETIC_OVERFLOW)?;
    if factor <= 0 {
        return Err(INVALID_SKEW);
    }
    let product = (fair_out as u128)
        .checked_mul(factor as u128)
        .ok_or(ARITHMETIC_OVERFLOW)?;
    let out = product
        .checked_div(PER_1M_DENOMINATOR as u128)
        .ok_or(DIVISION_BY_ZERO)?;
    out.try_into().map_err(|_| AMOUNT_EXCEEDS_MAX_U64)
}

#[derive(Debug, Clone, Copy, Eq, PartialEq)]
#[cfg_attr(feature = "wasm", wasm_expose)]
pub struct SwapQuote {
    pub amount_in: u64,
    pub fair_out: u64,
    pub amount_out: u64,
    pub total_skew_per_1m: i64,
    pub partial: bool,
}

#[derive(Debug, Clone, Copy, Eq, PartialEq)]
#[cfg_attr(feature = "wasm", wasm_expose)]
pub struct VaultSwapLimits {
    pub balance: u64,
    pub limit_lower: u64,
    pub limit_upper: u64,
    pub max_swap_amount: u64,
    pub swap_fee_per_1m: u32,
    pub positive_skew_per_1m: u32,
    pub negative_skew_per_1m: u32,
    pub lower_skew_offset_bps: u16,
    pub upper_skew_offset_bps: u16,
    pub skew_exponent: SkewExponent,
    pub decimals: u8,
}

/// Quote a network swap (prices + fees + skew). No oracle input.
#[cfg_attr(feature = "wasm", wasm_expose)]
pub fn quote_network_swap(
    amount_in: u64,
    input: &VaultSwapLimits,
    output: &VaultSwapLimits,
    price_in_q64: U128,
    price_out_q64: U128,
    allow_partial_fill: bool,
) -> Result<SwapQuote, CoreError> {
    if amount_in == 0 {
        return Err(INSUFFICIENT_LIQUIDITY);
    }

    let price_in: u128 = price_in_q64.into();
    let price_out: u128 = price_out_q64.into();

    let mut fill_in = amount_in;
    if fill_in > input.max_swap_amount {
        if !allow_partial_fill {
            return Err(MAX_SWAP_EXCEEDED);
        }
        fill_in = input.max_swap_amount;
    }

    let input_headroom = input.limit_upper.saturating_sub(input.balance);
    if fill_in > input_headroom {
        if !allow_partial_fill {
            return Err(INSUFFICIENT_LIQUIDITY);
        }
        fill_in = input_headroom;
    }

    // Iterate: compute out, clamp by output spendable + max_swap, maybe reduce in.
    for _ in 0..8 {
        if fill_in == 0 {
            return Err(INSUFFICIENT_LIQUIDITY);
        }
        let in_post = input.balance.saturating_add(fill_in);
        let skew_in = vault_skew_contribution_per_1m(
            input.balance,
            in_post,
            input.limit_lower,
            input.limit_upper,
            input.positive_skew_per_1m,
            input.negative_skew_per_1m,
            input.lower_skew_offset_bps,
            input.upper_skew_offset_bps,
            input.skew_exponent,
        )?;

        // Input fee stays in the input vault; only the net portion is priced.
        let priced_in = apply_swap_fee_per_1m(fill_in, input.swap_fee_per_1m)?;
        if priced_in == 0 {
            return Err(INSUFFICIENT_LIQUIDITY);
        }
        let fair = fair_out_with_prices(
            priced_in,
            input.decimals,
            output.decimals,
            U128::from(price_in),
            U128::from(price_out),
        )?;
        // Provisional output movement uses fair before output fee.
        let out_post_for_skew = output.balance.saturating_sub(fair.min(output.balance));
        let skew_out = vault_skew_contribution_per_1m(
            output.balance,
            out_post_for_skew,
            output.limit_lower,
            output.limit_upper,
            output.positive_skew_per_1m,
            output.negative_skew_per_1m,
            output.lower_skew_offset_bps,
            output.upper_skew_offset_bps,
            output.skew_exponent,
        )?;
        let total_skew = skew_in.saturating_add(skew_out);
        let skewed_out = apply_total_skew_per_1m(fair, total_skew)?;
        // Output fee stays in the output vault.
        let exec_out = apply_swap_fee_per_1m(skewed_out, output.swap_fee_per_1m)?;

        let spendable = output.balance.saturating_sub(output.limit_lower);
        let mut max_out = core::cmp::min(spendable, output.max_swap_amount);
        max_out = core::cmp::min(max_out, output.balance);

        if exec_out > max_out {
            if !allow_partial_fill {
                return Err(if skewed_out > output.max_swap_amount {
                    MAX_SWAP_EXCEEDED
                } else {
                    INSUFFICIENT_LIQUIDITY
                });
            }
            if fair == 0 || exec_out == 0 {
                return Err(INSUFFICIENT_LIQUIDITY);
            }
            let reduced = (fill_in as u128)
                .checked_mul(max_out as u128)
                .ok_or(ARITHMETIC_OVERFLOW)?
                .checked_div(exec_out as u128)
                .ok_or(DIVISION_BY_ZERO)?;
            let reduced: u64 = reduced.try_into().map_err(|_| AMOUNT_EXCEEDS_MAX_U64)?;
            if reduced >= fill_in || reduced == 0 {
                return Err(INSUFFICIENT_LIQUIDITY);
            }
            fill_in = reduced;
            continue;
        }

        // Recompute skew with actual exec_out for output post balance.
        let out_post = output.balance.saturating_sub(exec_out);
        let skew_out = vault_skew_contribution_per_1m(
            output.balance,
            out_post,
            output.limit_lower,
            output.limit_upper,
            output.positive_skew_per_1m,
            output.negative_skew_per_1m,
            output.lower_skew_offset_bps,
            output.upper_skew_offset_bps,
            output.skew_exponent,
        )?;
        let total_skew = skew_in.saturating_add(skew_out);
        let skewed_out = apply_total_skew_per_1m(fair, total_skew)?;
        let exec_out = apply_swap_fee_per_1m(skewed_out, output.swap_fee_per_1m)?;
        if exec_out > max_out {
            if !allow_partial_fill {
                return Err(INSUFFICIENT_LIQUIDITY);
            }
            let reduced = (fill_in as u128)
                .checked_mul(max_out as u128)
                .ok_or(ARITHMETIC_OVERFLOW)?
                .checked_div(exec_out as u128)
                .ok_or(DIVISION_BY_ZERO)?;
            let reduced: u64 = reduced.try_into().map_err(|_| AMOUNT_EXCEEDS_MAX_U64)?;
            if reduced >= fill_in || reduced == 0 {
                return Err(INSUFFICIENT_LIQUIDITY);
            }
            fill_in = reduced;
            continue;
        }

        return Ok(SwapQuote {
            amount_in: fill_in,
            fair_out: fair,
            amount_out: exec_out,
            total_skew_per_1m: total_skew,
            partial: fill_in < amount_in,
        });
    }
    Err(INSUFFICIENT_LIQUIDITY)
}

#[cfg(test)]
mod tests {
    use super::super::Q64_ONE;
    use super::*;

    #[test]
    fn one_to_one_same_decimals() {
        assert_eq!(base_out_1_to_1(1_000_000, 6, 6).unwrap(), 1_000_000);
    }

    #[test]
    fn one_to_one_decimal_conversion() {
        assert_eq!(base_out_1_to_1(1_000_000, 6, 8).unwrap(), 100_000_000);
        assert_eq!(base_out_1_to_1(100_000_000, 8, 6).unwrap(), 1_000_000);
    }

    #[test]
    fn cheaper_output_price_yields_more_out() {
        // output price 0.998 → more out
        let price_out = Q64_ONE * 998 / 1000;
        let out = fair_out_with_prices(1_000_000, 6, 6, U128::from(Q64_ONE), U128::from(price_out))
            .unwrap();
        assert!(out > 1_000_000);
    }

    #[test]
    fn swap_fee_retains_portion() {
        let net = apply_swap_fee_per_1m(1_000_000, 1_000).unwrap();
        assert_eq!(net, 999_000);
    }

    #[test]
    fn skew_rebalance_negative() {
        let skew = vault_skew_contribution_per_1m(
            900,
            600,
            0,
            1000,
            1000,
            1000,
            0,
            0,
            SkewExponent::Linear,
        )
        .unwrap();
        assert!(skew < 0);
    }
}