wp-evm-v4-core 0.1.14

Pure data + quote + plan for v4-family DEXes — no async dependencies
Documentation
//! Pure quote functions for the v4 family.
//!
//! # Strategy
//!
//! Uniswap V4 swap math is byte-for-byte identical to Uniswap V3's for
//! hookless pools (the V4 PoolManager's `Pool.sol` uses the same
//! TickMath/SwapMath/SqrtPriceMath libraries, and `uniswap-v4-sdk-rs`
//! delegates to v3's `v3_swap` function directly). We exploit this by
//! converting our v4 `PoolState` to v3's `PoolState` and delegating to
//! `wp-evm-v3-core::quote::exact_in_with_fee_fn`.
//!
//! # Hook gating
//!
//! Pools with hooks that have swap-related permissions (`BEFORE_SWAP`,
//! `AFTER_SWAP`, `BEFORE_SWAP_RETURNS_DELTA`, or `AFTER_SWAP_RETURNS_DELTA`
//! flags set in the hook address) cannot be quoted with standard CL math —
//! their behavior depends on arbitrary hook code. We return
//! `QuoteError::HookedPoolUnsupported` for such pools. This matches the
//! broader `0xCC` mask described in Hooks.sol.
//!
//! Pools with no swap-permission hooks (including pools with hooks that
//! only do `BEFORE_INITIALIZE`, `AFTER_ADD_LIQUIDITY`, etc.) can be
//! quoted normally.

use crate::data::{ExactInParams, ExactOutParams, PoolState, Quote};
use crate::hook_options::has_swap_permissions as native_has_swap_permissions;
use alloy_primitives::Address;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum QuoteError {
    #[error("input currency does not match pool currencies")]
    UnknownCurrency,
    #[error("pool has swap-permission hooks at {hooks}; unsupported by standard CL quote math")]
    HookedPoolUnsupported { hooks: Address },
    #[error("pool uses dynamic fee flag; unsupported (dynamic-fee pools require hooks)")]
    DynamicFeeUnsupported,
    #[error("v3 family quote error: {0}")]
    Delegate(String),
}

/// The Uniswap V4 sentinel indicating a dynamic-fee pool.
///
/// When `pool_key.fee == 0x800000`, the pool's actual fee is computed
/// by its hook contract on each swap. These pools require hooked-quote
/// support (future work).
const DYNAMIC_FEE_FLAG: u32 = 0x800000;

pub fn exact_in(state: &PoolState, params: &ExactInParams) -> Result<Quote, QuoteError> {
    // Gate 1: reject hooked pools.
    let hooks = state.pool_key.hooks;
    if has_swap_permissions(hooks) {
        return Err(QuoteError::HookedPoolUnsupported { hooks });
    }

    // Gate 2: reject dynamic-fee pools (should be unreachable after gate 1
    // since dynamic fees require hooks, but defensive).
    let fee: u32 = state.pool_key.fee.to::<u32>();
    if fee == DYNAMIC_FEE_FLAG {
        return Err(QuoteError::DynamicFeeUnsupported);
    }

    // Convert v4 PoolState → v3 PoolState and delegate.
    let v3_state = as_v3_pool_state(state, fee);
    let v3_params = as_v3_exact_in_params(params);

    // Call v3-core's with-fee-fn variant so we can inject the v4 fee
    // (which lives in pool_key.fee, not in a state.fee field of its own).
    let v3_quote = wp_evm_v3_core::quote::exact_in_with_fee_fn(&v3_state, &v3_params, move |_| fee)
        .map_err(|e| QuoteError::Delegate(format!("{e:?}")))?;

    Ok(Quote {
        amount_in: v3_quote.amount_in,
        amount_out: v3_quote.amount_out,
        sqrt_price_x96_after: v3_quote.sqrt_price_x96_after,
        price_impact_bps: v3_quote.price_impact_bps,
        effective_fee_pips: fee,
    })
}

pub fn exact_out(state: &PoolState, params: &ExactOutParams) -> Result<Quote, QuoteError> {
    let hooks = state.pool_key.hooks;
    if has_swap_permissions(hooks) {
        return Err(QuoteError::HookedPoolUnsupported { hooks });
    }

    let fee: u32 = state.pool_key.fee.to::<u32>();
    if fee == DYNAMIC_FEE_FLAG {
        return Err(QuoteError::DynamicFeeUnsupported);
    }

    let v3_state = as_v3_pool_state(state, fee);
    let v3_params = wp_evm_v3_core::data::ExactOutParams {
        token_in: params.currency_in,
        token_out: params.currency_out,
        amount_out: params.amount_out,
        recipient: params.recipient,
    };

    let v3_quote =
        wp_evm_v3_core::quote::exact_out_with_fee_fn(&v3_state, &v3_params, move |_| fee)
            .map_err(|e| QuoteError::Delegate(format!("{e:?}")))?;

    Ok(Quote {
        amount_in: v3_quote.amount_in,
        amount_out: v3_quote.amount_out,
        sqrt_price_x96_after: v3_quote.sqrt_price_x96_after,
        price_impact_bps: v3_quote.price_impact_bps,
        effective_fee_pips: fee,
    })
}

/// Convert a v4 `PoolState` to a v3 `PoolState` for delegation.
///
/// The v4 `pool_key.currency0` / `pool_key.currency1` map to v3's
/// `token0` / `token1`. The fee is passed explicitly (see caller) because
/// v4 stores fee in `pool_key`, not directly on `PoolState`. Tick data
/// structure is identical.
fn as_v3_pool_state(state: &PoolState, fee: u32) -> wp_evm_v3_core::data::PoolState {
    // tick_spacing: v4 stores it as I24 (alloy alias); v3 stores as i32.
    let tick_spacing_i32: i32 = state.pool_key.tickSpacing.as_i32();

    wp_evm_v3_core::data::PoolState {
        token0: state.pool_key.currency0,
        token1: state.pool_key.currency1,
        fee,
        tick_spacing: tick_spacing_i32,
        sqrt_price_x96: state.sqrt_price_x96,
        liquidity: state.liquidity,
        tick: state.tick,
        ticks: state
            .ticks
            .iter()
            .map(|t| wp_evm_v3_core::data::TickInfo {
                tick: t.tick,
                liquidity_net: t.liquidity_net,
                liquidity_gross: t.liquidity_gross,
            })
            .collect(),
    }
}

fn as_v3_exact_in_params(p: &ExactInParams) -> wp_evm_v3_core::data::ExactInParams {
    wp_evm_v3_core::data::ExactInParams {
        token_in: p.currency_in,
        token_out: p.currency_out,
        amount_in: p.amount_in,
        recipient: p.recipient,
    }
}

/// Reject pools whose hook address sets any of the four swap-permission
/// flags (`BEFORE_SWAP | AFTER_SWAP | BEFORE_SWAP_RETURNS_DELTA |
/// AFTER_SWAP_RETURNS_DELTA`). The bit layout, the `0xCC` mask, and the
/// rationale for the stricter-than-SDK check live in
/// [`crate::hook_options`].
fn has_swap_permissions(hooks: Address) -> bool {
    native_has_swap_permissions(hooks)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::data::{PoolKey, TickInfo};
    use alloy_primitives::{address, aliases::I24, aliases::U24, b256, U256};

    fn hookless_usdc_weth_fixture() -> PoolState {
        // Same sqrt_price_x96 / tick / ticks setup as the v3-core
        // fixture proven to actually cross ticks.
        let sqrt_price_x96 = U256::from_str_radix("3543191142285914205922034323214", 10).unwrap();
        let pool_key = PoolKey {
            currency0: address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), // USDC
            currency1: address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"), // WETH
            fee: U24::from(3000u32),
            tickSpacing: I24::try_from(60i32).unwrap(),
            hooks: Address::ZERO, // no hooks
        };
        PoolState {
            pool_key,
            pool_id: b256!("0000000000000000000000000000000000000000000000000000000000000001"),
            sqrt_price_x96,
            tick: 76012,
            liquidity: 2_000_000_000_000_000_000_000u128,
            protocol_fee: 0,
            lp_fee: 3000,
            ticks: vec![
                TickInfo {
                    tick: 74940,
                    liquidity_net: 1_000_000_000_000_000_000_000i128,
                    liquidity_gross: 1_000_000_000_000_000_000_000u128,
                },
                TickInfo {
                    tick: 75960,
                    liquidity_net: 1_000_000_000_000_000_000_000i128,
                    liquidity_gross: 1_000_000_000_000_000_000_000u128,
                },
                TickInfo {
                    tick: 76020,
                    liquidity_net: -2_000_000_000_000_000_000_000i128,
                    liquidity_gross: 2_000_000_000_000_000_000_000u128,
                },
            ],
        }
    }

    fn hooked_pool_fixture() -> PoolState {
        let mut state = hookless_usdc_weth_fixture();
        // A hook address with the BEFORE_SWAP flag (bit 7 of the last byte) set.
        state.pool_key.hooks = address!("0000000000000000000000000000000000000080");
        state
    }

    #[test]
    fn exact_in_hookless_pool_delegates_successfully() {
        let s = hookless_usdc_weth_fixture();
        let p = ExactInParams {
            currency_in: s.pool_key.currency0,
            currency_out: s.pool_key.currency1,
            amount_in: U256::from(1_000_000u64),
            recipient: address!("0000000000000000000000000000000000000099"),
        };
        let q = exact_in(&s, &p).expect("hookless quote ok");
        assert!(q.amount_out > U256::ZERO);
        assert_eq!(q.amount_in, p.amount_in);
        assert_eq!(q.effective_fee_pips, 3000);
    }

    #[test]
    fn exact_in_hooked_pool_rejected() {
        let s = hooked_pool_fixture();
        let p = ExactInParams {
            currency_in: s.pool_key.currency0,
            currency_out: s.pool_key.currency1,
            amount_in: U256::from(1_000_000u64),
            recipient: address!("0000000000000000000000000000000000000099"),
        };
        let err = exact_in(&s, &p).expect_err("hooked pool should be rejected");
        assert!(matches!(err, QuoteError::HookedPoolUnsupported { .. }));
    }

    #[test]
    fn has_swap_permissions_detects_swap_flags() {
        assert!(!has_swap_permissions(Address::ZERO));
        // address with no swap flags (bit 0 only, AfterRemoveLiquidityReturnsDelta)
        assert!(!has_swap_permissions(address!("0000000000000000000000000000000000000001")));
        // BEFORE_SWAP = bit 7 → last byte = 0x80
        assert!(has_swap_permissions(address!("0000000000000000000000000000000000000080")));
        // AFTER_SWAP = bit 6 → last byte = 0x40
        assert!(has_swap_permissions(address!("0000000000000000000000000000000000000040")));
        // BEFORE_SWAP_RETURNS_DELTA = bit 3 → last byte = 0x08
        assert!(has_swap_permissions(address!("0000000000000000000000000000000000000008")));
        // AFTER_SWAP_RETURNS_DELTA = bit 2 → last byte = 0x04
        assert!(has_swap_permissions(address!("0000000000000000000000000000000000000004")));
        // All four set → last byte = 0xCC
        assert!(has_swap_permissions(address!("00000000000000000000000000000000000000CC")));
    }

    // ── Issue #4: v4 edge case tests ─────────────────────────────────────────

    #[test]
    fn exact_in_dynamic_fee_pool_rejected() {
        // Gate 2 in exact_in: fee == DYNAMIC_FEE_FLAG (0x800000) must return
        // DynamicFeeUnsupported. Dynamic-fee pools require a hook to compute
        // the fee, so they can't be quoted with standard CL math.
        let mut s = hookless_usdc_weth_fixture();
        s.pool_key.fee = U24::from(0x800000u32);
        let p = ExactInParams {
            currency_in: s.pool_key.currency0,
            currency_out: s.pool_key.currency1,
            amount_in: U256::from(1_000_000u64),
            recipient: address!("0000000000000000000000000000000000000099"),
        };
        let err = exact_in(&s, &p).expect_err("dynamic fee pool should be rejected");
        assert!(matches!(err, QuoteError::DynamicFeeUnsupported));
    }

    #[test]
    fn exact_out_dynamic_fee_pool_rejected() {
        // Mirror of exact_in variant for exact_out.
        let mut s = hookless_usdc_weth_fixture();
        s.pool_key.fee = U24::from(0x800000u32);
        let p = ExactOutParams {
            currency_in: s.pool_key.currency0,
            currency_out: s.pool_key.currency1,
            amount_out: U256::from(500_000_000_000_000u64),
            recipient: address!("0000000000000000000000000000000000000099"),
        };
        let err = exact_out(&s, &p).expect_err("dynamic fee pool should be rejected");
        assert!(matches!(err, QuoteError::DynamicFeeUnsupported));
    }

    #[test]
    fn exact_in_unknown_currency_delegates_to_v3_error() {
        // A currency not in the pool produces a Delegate error (wrapping
        // the v3-core UnknownToken). This verifies the delegation path
        // propagates errors correctly.
        let s = hookless_usdc_weth_fixture();
        let bogus = address!("000000000000000000000000000000000000dead");
        let p = ExactInParams {
            currency_in: bogus,
            currency_out: s.pool_key.currency1,
            amount_in: U256::from(1_000_000u64),
            recipient: address!("0000000000000000000000000000000000000099"),
        };
        let err = exact_in(&s, &p).expect_err("unknown currency should be rejected");
        assert!(matches!(err, QuoteError::Delegate(_)), "expected Delegate error, got {:?}", err);
    }

    #[test]
    fn exact_out_hooked_pool_rejected() {
        let mut s = hookless_usdc_weth_fixture();
        s.pool_key.hooks = address!("0000000000000000000000000000000000000080");
        let p = ExactOutParams {
            currency_in: s.pool_key.currency0,
            currency_out: s.pool_key.currency1,
            amount_out: U256::from(500_000_000_000_000u64),
            recipient: address!("0000000000000000000000000000000000000099"),
        };
        let err = exact_out(&s, &p).expect_err("hooked pool should be rejected");
        assert!(matches!(err, QuoteError::HookedPoolUnsupported { .. }));
    }

    #[test]
    fn exact_in_before_swap_returns_delta_only_hook_rejected() {
        let mut s = hookless_usdc_weth_fixture();
        s.pool_key.hooks = address!("0000000000000000000000000000000000000008");
        let p = ExactInParams {
            currency_in: s.pool_key.currency0,
            currency_out: s.pool_key.currency1,
            amount_in: U256::from(1_000_000u64),
            recipient: address!("0000000000000000000000000000000000000099"),
        };
        let err = exact_in(&s, &p).expect_err("delta-returning hook should be rejected");
        assert!(matches!(err, QuoteError::HookedPoolUnsupported { .. }));
    }

    #[test]
    fn exact_out_unknown_currency_delegates_to_v3_error() {
        let s = hookless_usdc_weth_fixture();
        let bogus = address!("000000000000000000000000000000000000dead");
        let p = ExactOutParams {
            currency_in: bogus,
            currency_out: s.pool_key.currency1,
            amount_out: U256::from(500_000_000_000_000u64),
            recipient: address!("0000000000000000000000000000000000000099"),
        };
        let err = exact_out(&s, &p).expect_err("unknown currency should be rejected");
        assert!(matches!(err, QuoteError::Delegate(_)), "expected Delegate error, got {:?}", err);
    }
}