//! Pure plan functions for the v4 family.
//!
//! # Scope (Phase 5 MVP)
//!
//! This module ships liquidity operations and swaps:
//! - `add_liquidity` — build MINT_POSITION + SETTLE_PAIR actions,
//! wrap in PositionManager.modifyLiquidities calldata
//! - `remove_liquidity` — BURN_POSITION + TAKE_PAIR
//! - `collect_fees` — build DECREASE_LIQUIDITY(0) + TAKE_PAIR actions,
//! wrap in PositionManager.modifyLiquidities calldata
//! - `swap_exact_in` — SWAP_EXACT_IN_SINGLE + SETTLE_ALL + TAKE_ALL,
//! wrapped in `UniversalRouter.execute(commands=\[0x10\], inputs, deadline)`
//!
//! # Execution path
//!
//! All liquidity operations target the PositionManager contract via
//! `modifyLiquidities(bytes unlockData, uint256 deadline)`. The
//! `unlockData` blob is built by the native [`crate::v4_planner::V4Planner`]
//! (R17-pre Slice B) using the `*Params` structs and `Actions`
//! discriminators from `wp-evm-v4-interfaces`. We use
//! [`crate::v4_planner::encode_modify_liquidities`] to produce the final
//! calldata.
//!
//! # Why V4Planner directly instead of V4PositionPlanner
//!
//! Pre-R17 we used `V4PositionPlanner` from `uniswap-v4-sdk`, which took
//! `&Pool<TP>` — a full SDK entity that requires tick data. Our
//! `AddLiquidityParams` carries only a raw `PoolKey` (sufficient for
//! encoding), so we used the lower-level `V4Planner` directly. R17-pre
//! Slice C replaces that with our own native planner; the encoded output
//! is byte-identical (verified by parity tests below + Slice B's
//! golden-vector tests).
//!
//! # `PoolKey` type identity
//!
//! As of R17-pre Slice E, `data::PoolKey` is a direct re-export of
//! `wp_evm_v4_interfaces::pool::PoolKey` — the same type the native
//! planner's `*Params` structs expect. No conversion at the boundary.
//! Pre-Slice E we ran a `to_iface_pool_key` field-copy helper here
//! because `data::PoolKey` was a re-export from `uniswap-v4-sdk`; that
//! helper is gone now.
use crate::data::{
AddLiquidityParams, CollectFeesParams, ExactInParams, IncreaseLiquidityCallerParams,
PlanFragment, PoolState, Quote, RemoveLiquidityParams,
};
use crate::position_info::apply_slippage_max;
use crate::v4_planner::{encode_modify_liquidities, V4Planner, MSG_SENDER};
use alloy_primitives::{Address, U256};
use alloy_sol_types::{sol, SolCall};
use wp_evm_amm_math::sqrt_price_math::{get_amount_0_delta, get_amount_1_delta};
use wp_evm_amm_math::tick_math::get_sqrt_ratio_at_tick;
use wp_evm_base::types::{Call, SlippageBps, TokenApproval};
use wp_evm_v4_interfaces::periphery::position_manager::{
BurnPositionParams, DecreaseLiquidityParams, MintPositionParams, SettleAllParams,
SettlePairParams, SwapExactInSingleParams, TakeAllParams, TakePairParams,
};
sol! {
/// UniversalRouter.execute — the user-facing entry point for V4 swaps.
/// Command 0x10 = V4_SWAP, which passes inputs[i] directly to
/// PoolManager.unlock(unlockData).
function execute(bytes commands, bytes[] inputs, uint256 deadline)
external payable;
}
/// Build a v4 `add_liquidity` plan fragment with real slippage bounds.
///
/// Uses `V4Planner` to construct MINT_POSITION + SETTLE_PAIR actions,
/// finalizes to the `unlockData` bytes blob, then wraps in
/// `encode_modify_liquidities(unlockData, deadline)` calldata targeting
/// the PositionManager.
///
/// `amount0Max` / `amount1Max` are derived from
/// `(liquidity, tick_lower, tick_upper, state.sqrt_price_x96)` via
/// `get_amount_{0,1}_delta(..., round_up=true)`, then scaled up by
/// `slippage`.
pub fn add_liquidity(
params: &AddLiquidityParams,
state: &PoolState,
slippage: SlippageBps,
deadline: u64,
position_manager: Address,
permit2: Address,
) -> PlanFragment {
let sqrt_lower =
get_sqrt_ratio_at_tick(params.tick_lower).expect("tick_lower within valid range");
let sqrt_upper =
get_sqrt_ratio_at_tick(params.tick_upper).expect("tick_upper within valid range");
let sqrt_current = state.sqrt_price_x96;
let (expected0, expected1) = if sqrt_current <= sqrt_lower {
let a0 = get_amount_0_delta(sqrt_lower, sqrt_upper, params.liquidity, true)
.expect("amount0 delta below range fits in U256");
(a0, U256::ZERO)
} else if sqrt_current >= sqrt_upper {
let a1 = get_amount_1_delta(sqrt_lower, sqrt_upper, params.liquidity, true)
.expect("amount1 delta above range fits in U256");
(U256::ZERO, a1)
} else {
let a0 = get_amount_0_delta(sqrt_current, sqrt_upper, params.liquidity, true)
.expect("amount0 delta in-range fits in U256");
let a1 = get_amount_1_delta(sqrt_lower, sqrt_current, params.liquidity, true)
.expect("amount1 delta in-range fits in U256");
(a0, a1)
};
let amount0_max = apply_slippage_max(expected0, slippage).to::<u128>();
let amount1_max = apply_slippage_max(expected1, slippage).to::<u128>();
let mut planner = V4Planner::new();
planner.add_mint_position(MintPositionParams {
poolKey: params.pool_key.clone(),
tickLower: alloy_primitives::aliases::I24::try_from(params.tick_lower)
.expect("tick_lower fits in i24"),
tickUpper: alloy_primitives::aliases::I24::try_from(params.tick_upper)
.expect("tick_upper fits in i24"),
liquidity: U256::from(params.liquidity),
amount0Max: amount0_max,
amount1Max: amount1_max,
owner: params.recipient,
hookData: alloy_primitives::Bytes::new(),
});
planner.add_settle_pair(SettlePairParams {
currency0: params.pool_key.currency0,
currency1: params.pool_key.currency1,
});
let unlock_data = planner.finalize();
let calldata = encode_modify_liquidities(unlock_data, U256::from(deadline));
let value = if params.pool_key.currency0 == Address::ZERO {
U256::from(amount0_max)
} else if params.pool_key.currency1 == Address::ZERO {
U256::from(amount1_max)
} else {
U256::ZERO
};
let mut approvals = Vec::with_capacity(2);
if params.pool_key.currency0 != Address::ZERO {
approvals.push(TokenApproval {
token: params.pool_key.currency0,
spender: permit2,
min_amount: U256::MAX,
});
}
if params.pool_key.currency1 != Address::ZERO {
approvals.push(TokenApproval {
token: params.pool_key.currency1,
spender: permit2,
min_amount: U256::MAX,
});
}
PlanFragment {
calls: vec![Call { target: position_manager, calldata, value }],
approvals,
value,
}
}
/// Build a v4 `increase_liquidity` plan fragment.
///
/// Uses INCREASE_LIQUIDITY + SETTLE_PAIR to add liquidity to an
/// existing position NFT. Source-verified action sequence: V4 periphery's
/// `_increase` handler does NOT auto-settle deltas; SETTLE_PAIR is
/// required to pay the resulting token debts.
///
/// `amount0_max` / `amount1_max` are caller-supplied base expected amounts
/// scaled up by `slippage` via `apply_slippage_max`.
pub fn increase_liquidity(
params: &IncreaseLiquidityCallerParams,
slippage: SlippageBps,
deadline: u64,
position_manager: Address,
permit2: Address,
) -> PlanFragment {
use wp_evm_v4_interfaces::periphery::position_manager::IncreaseLiquidityParams as V4IncreaseLiquidityParams;
let amount0_max = apply_slippage_max(params.amount0_max, slippage).to::<u128>();
let amount1_max = apply_slippage_max(params.amount1_max, slippage).to::<u128>();
let mut planner = V4Planner::new();
planner.add_increase_liquidity(V4IncreaseLiquidityParams {
tokenId: params.token_id,
liquidity: U256::from(params.liquidity),
amount0Max: amount0_max,
amount1Max: amount1_max,
hookData: alloy_primitives::Bytes::new(),
});
planner.add_settle_pair(SettlePairParams {
currency0: params.pool_key.currency0,
currency1: params.pool_key.currency1,
});
let unlock_data = planner.finalize();
let calldata = encode_modify_liquidities(unlock_data, U256::from(deadline));
let value = if params.pool_key.currency0 == Address::ZERO {
U256::from(amount0_max)
} else if params.pool_key.currency1 == Address::ZERO {
U256::from(amount1_max)
} else {
U256::ZERO
};
let mut approvals = Vec::with_capacity(2);
if params.pool_key.currency0 != Address::ZERO {
approvals.push(TokenApproval {
token: params.pool_key.currency0,
spender: permit2,
min_amount: U256::MAX,
});
}
if params.pool_key.currency1 != Address::ZERO {
approvals.push(TokenApproval {
token: params.pool_key.currency1,
spender: permit2,
min_amount: U256::MAX,
});
}
PlanFragment {
calls: vec![Call { target: position_manager, calldata, value }],
approvals,
value,
}
}
/// Build a v4 `remove_liquidity` plan fragment.
///
/// Uses BURN_POSITION + TAKE_PAIR to burn the caller's NFT position
/// and receive the underlying tokens. Slippage is enforced via the
/// optional `amount0_min` / `amount1_min` hints in params (same
/// `Option<U256>` pattern as v3-core Phase 2 Fix B).
///
/// # Pool key requirement
///
/// `RemoveLiquidityParams` must include the `pool_key` for the position
/// so that TAKE_PAIR can be encoded with the correct `currency0` and
/// `currency1` addresses. Without the real currencies, TAKE_PAIR would
/// reference `address(0)` for both tokens, causing the PositionManager
/// to deliver native ETH instead of the pool's actual ERC-20s.
///
/// # Recipient
///
/// TAKE_PAIR uses `MSG_SENDER` (`address(1)`) as recipient — the
/// PositionManager resolves this to the transaction signer at runtime.
/// This is the canonical V4 pattern; see V4 periphery's
/// [`ActionConstants.sol`][1] for the sentinel definition. A future
/// iteration may add an explicit `recipient` field to
/// `RemoveLiquidityParams` for cases where output should go to a
/// different address.
///
/// [1]: https://github.com/Uniswap/v4-periphery/blob/main/src/libraries/ActionConstants.sol
///
/// # Approvals
///
/// None required. The PositionManager holds the NFT on behalf of its
/// minter; the caller proves ownership via msg.sender / ERC721 ownership.
pub fn remove_liquidity(
params: &RemoveLiquidityParams,
deadline: u64,
position_manager: Address,
) -> PlanFragment {
let amount0_min = params.amount0_min.unwrap_or(U256::ZERO).to::<u128>();
let amount1_min = params.amount1_min.unwrap_or(U256::ZERO).to::<u128>();
let mut planner = V4Planner::new();
// BURN_POSITION: burns the NFT and settles the full liquidity.
// amount0_min / amount1_min enforce minimum-received slippage bounds.
planner.add_burn_position(BurnPositionParams {
tokenId: params.token_id,
amount0Min: amount0_min,
amount1Min: amount1_min,
hookData: alloy_primitives::Bytes::new(),
});
// TAKE_PAIR: forwards the redeemed amounts to the tx signer.
// Uses the real pool currencies from pool_key so the PositionManager
// delivers the correct ERC-20 tokens (not native ETH via address(0)).
planner.add_take_pair(TakePairParams {
currency0: params.pool_key.currency0,
currency1: params.pool_key.currency1,
recipient: MSG_SENDER,
});
let unlock_data = planner.finalize();
let calldata = encode_modify_liquidities(unlock_data, U256::from(deadline));
PlanFragment {
calls: vec![Call { target: position_manager, calldata, value: U256::ZERO }],
approvals: vec![],
value: U256::ZERO,
}
}
/// Build a v4 `collect_fees` plan fragment.
///
/// Encodes the canonical V4 fee-harvest pattern:
/// - `DECREASE_LIQUIDITY(tokenId, liquidity=0)`
/// - `TAKE_PAIR(currency0, currency1, recipient)`
///
/// Uses `u64::MAX` as the deadline sentinel because fee collection is
/// not price-sensitive.
pub fn collect_fees(params: &CollectFeesParams, position_manager: Address) -> PlanFragment {
let mut planner = V4Planner::new();
planner.add_decrease_liquidity(DecreaseLiquidityParams {
tokenId: params.token_id,
liquidity: U256::ZERO,
amount0Min: 0u128,
amount1Min: 0u128,
hookData: alloy_primitives::Bytes::new(),
});
planner.add_take_pair(TakePairParams {
currency0: params.pool_key.currency0,
currency1: params.pool_key.currency1,
recipient: params.recipient,
});
let unlock_data = planner.finalize();
let calldata = encode_modify_liquidities(unlock_data, U256::from(u64::MAX));
PlanFragment {
calls: vec![Call { target: position_manager, calldata, value: U256::ZERO }],
approvals: vec![],
value: U256::ZERO,
}
}
/// Build a V4 swap (exact-in single-hop) plan fragment.
///
/// Constructs a V4Planner sequence (SWAP_EXACT_IN_SINGLE + SETTLE_ALL +
/// TAKE_ALL), wraps it in `UniversalRouter.execute(commands=[0x10],
/// inputs=[unlock_data], deadline)` calldata, and returns a PlanFragment
/// targeting `config.universal_router`.
///
/// # Approvals
///
/// V4 swaps use Permit2 for token transfers. If selling a non-native
/// ERC20, declares a TokenApproval to `config.permit2`. If selling
/// native ETH, no approval is needed and `call.value` carries the amount.
///
/// # Hook restriction
///
/// This function does NOT gate on hooks — the quote function already
/// rejects hooked pools. If called with a hooked pool's PoolKey, the
/// on-chain execution may revert depending on the hook's behavior.
pub fn swap_exact_in(
state: &PoolState,
quote: &Quote,
params: &ExactInParams,
slippage: SlippageBps,
deadline: u64,
universal_router: Address,
permit2: Address,
) -> PlanFragment {
let zero_for_one = params.currency_in == state.pool_key.currency0;
// Compute amount_out_min from the quote with slippage applied.
let bps = U256::from(slippage.as_bps());
let denom = U256::from(10_000u64);
let amount_out_min = quote.amount_out * (denom - bps) / denom;
// Build the V4Planner actions sequence.
let mut planner = V4Planner::new();
planner.add_swap_exact_in_single(SwapExactInSingleParams {
poolKey: state.pool_key.clone(),
zeroForOne: zero_for_one,
amountIn: params.amount_in.to::<u128>(),
amountOutMinimum: amount_out_min.to::<u128>(),
hookData: alloy_primitives::Bytes::new(),
});
planner.add_settle_all(SettleAllParams {
currency: params.currency_in,
maxAmount: params.amount_in,
});
planner
.add_take_all(TakeAllParams { currency: params.currency_out, minAmount: amount_out_min });
let unlock_data = planner.finalize();
// Wrap in UniversalRouter.execute(commands=[0x10], inputs=[unlock_data], deadline)
let commands = alloy_primitives::Bytes::from(vec![0x10u8]); // V4_SWAP command
let inputs = vec![unlock_data];
let calldata: alloy_primitives::Bytes =
executeCall { commands, inputs, deadline: U256::from(deadline) }.abi_encode().into();
// Value: non-zero only if selling native ETH
let is_native_sell = params.currency_in == Address::ZERO;
let value = if is_native_sell { params.amount_in } else { U256::ZERO };
// Approvals: V4 uses Permit2, not direct router approval
let mut approvals = Vec::new();
if !is_native_sell {
approvals.push(TokenApproval {
token: params.currency_in,
spender: permit2,
min_amount: params.amount_in,
});
}
PlanFragment {
calls: vec![Call { target: universal_router, calldata, value }],
approvals,
value,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::{ExactInParams, PoolKey, PoolState, Quote, V4ProtocolConfig};
use alloy_primitives::{address, aliases::I24, aliases::U24, hex, Address, B256};
// Golden-vector hex constants captured from `uniswap_v4_sdk`'s
// `V4Planner` while the SDK was still in the dep tree (PR #142
// SDK-vs-native byte-equality parity tests). R17-pre Slice F
// retired the SDK dep; these constants now serve as the
// regression lock for `plan.rs`'s calldata output. Update only
// when an *intentional* change to encoding ships, in which case
// the captured-mainnet decode test in `wp-evm-v4-interfaces` and
// R2c fork-parity must both still pass.
const TEST_CFG: V4ProtocolConfig = V4ProtocolConfig {
pool_manager: address!("000000000004444c5dc75cB358380D2e3dE08A90"),
position_manager: address!("bd216513d74c8cf14cf4747e6aaa6420ff64ee9e"),
universal_router: address!("66a9893cc07d91d95644aedd05d03f95e1dba8af"),
state_view: address!("7ffe42c4a5deea5b0fec41c94c136cf115597227"),
quoter: address!("52f0e24d1c21c8a0cb1e5a5dd6198556bd9e1203"),
permit2: address!("000000000022D473030F116dDEE9F6B43aC78BA3"),
};
fn test_pool_key() -> PoolKey {
PoolKey {
currency0: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
currency1: address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
fee: U24::from(3000u32),
tickSpacing: I24::try_from(60i32).unwrap(),
hooks: Address::ZERO,
}
}
fn test_pool_state_at_tick_zero(pool_key: PoolKey) -> PoolState {
PoolState {
pool_key,
pool_id: alloy_primitives::b256!(
"0000000000000000000000000000000000000000000000000000000000000099"
),
sqrt_price_x96: get_sqrt_ratio_at_tick(0).unwrap(),
tick: 0,
liquidity: 10_000_000_000_000_000u128,
protocol_fee: 0,
lp_fee: 3000,
ticks: vec![],
}
}
#[test]
fn plan_add_liquidity_targets_position_manager() {
let params = AddLiquidityParams {
pool_key: test_pool_key(),
tick_lower: -60,
tick_upper: 60,
liquidity: 1_000_000_000_000_000u128,
recipient: address!("0000000000000000000000000000000000000099"),
salt: B256::ZERO,
};
let state = test_pool_state_at_tick_zero(params.pool_key.clone());
let frag = add_liquidity(
¶ms,
&state,
SlippageBps::new(50),
9_999_999_999,
TEST_CFG.position_manager,
TEST_CFG.permit2,
);
assert_eq!(frag.calls.len(), 1);
assert_eq!(frag.calls[0].target, TEST_CFG.position_manager);
// Both currencies are non-native ERC-20s, so 2 approvals.
assert_eq!(frag.approvals.len(), 2);
assert_eq!(frag.approvals[0].spender, TEST_CFG.permit2);
assert_eq!(frag.approvals[1].spender, TEST_CFG.permit2);
// Permit2 regression-lock (R23 Slice 4 plan Task 1 Step 8): max-approval-forever
// is the V4 Permit2 convention; a regression that scales the approval down to
// an exact transfer amount would silently break long-lived deployments.
assert_eq!(frag.approvals[0].min_amount, U256::MAX);
assert_eq!(frag.approvals[1].min_amount, U256::MAX);
}
#[test]
fn plan_increase_liquidity_targets_position_manager_with_permit2_approvals() {
let params = IncreaseLiquidityCallerParams {
token_id: U256::from(42u64),
pool_key: test_pool_key(),
liquidity: 1_000_000_000_000u128,
amount0_max: U256::from(1_000_000u64),
amount1_max: U256::from(20_000_000_000_000_000u64),
};
let frag = increase_liquidity(
¶ms,
SlippageBps::new(50),
9_999_999_999,
TEST_CFG.position_manager,
TEST_CFG.permit2,
);
assert_eq!(frag.calls.len(), 1);
assert_eq!(frag.calls[0].target, TEST_CFG.position_manager);
assert_eq!(frag.calls[0].value, U256::ZERO);
assert_eq!(frag.approvals.len(), 2);
assert_eq!(frag.approvals[0].spender, TEST_CFG.permit2);
assert_eq!(frag.approvals[1].spender, TEST_CFG.permit2);
// Permit2 regression-lock (R23 Slice 4 plan Task 1 Step 8): see add_liquidity test.
assert_eq!(frag.approvals[0].min_amount, U256::MAX);
assert_eq!(frag.approvals[1].min_amount, U256::MAX);
assert_eq!(&frag.calls[0].calldata[..4], &[0xdd, 0x46, 0x50, 0x8f]);
}
#[test]
fn plan_increase_liquidity_native_eth_pool_has_one_approval() {
let mut key = test_pool_key();
key.currency0 = Address::ZERO;
let currency1 = key.currency1;
let params = IncreaseLiquidityCallerParams {
token_id: U256::from(42u64),
pool_key: key,
liquidity: 1_000_000_000_000u128,
amount0_max: U256::from(1_000_000u64),
amount1_max: U256::from(20_000_000_000_000_000u64),
};
let frag = increase_liquidity(
¶ms,
SlippageBps::new(50),
9_999_999_999,
TEST_CFG.position_manager,
TEST_CFG.permit2,
);
assert_eq!(frag.approvals.len(), 1);
assert_eq!(frag.approvals[0].token, currency1);
assert_eq!(frag.approvals[0].spender, TEST_CFG.permit2);
assert_eq!(frag.value, U256::from(1_005_000u64));
assert_eq!(frag.calls[0].value, frag.value);
}
#[test]
fn plan_increase_liquidity_golden_hex() {
let params = IncreaseLiquidityCallerParams {
token_id: U256::from(42u64),
pool_key: test_pool_key(),
liquidity: 1_000_000_000_000u128,
amount0_max: U256::from(1_000_000u64),
amount1_max: U256::from(20_000_000_000_000_000u64),
};
let frag = increase_liquidity(
¶ms,
SlippageBps::new(50),
9_999_999_999,
TEST_CFG.position_manager,
TEST_CFG.permit2,
);
assert_eq!(
&frag.calls[0].calldata[..],
&hex!(
"dd46508f000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000002540be3ff0000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000002000d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000e8d4a5100000000000000000000000000000000000000000000000000000000000000f55c8000000000000000000000000000000000000000000000000004768d7effc400000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"
)[..],
"increase_liquidity calldata must remain byte-stable",
);
}
#[test]
fn plan_increase_liquidity_scales_amount_max_by_slippage() {
use alloy_sol_types::SolValue as _;
use wp_evm_v4_interfaces::periphery::position_manager::IPositionManager;
use wp_evm_v4_interfaces::periphery::position_manager::IncreaseLiquidityParams as V4IncreaseLiquidityParams;
let amount0_base = U256::from(1_000_000u64);
let amount1_base = U256::from(2_000_000u64);
let params = IncreaseLiquidityCallerParams {
token_id: U256::from(42u64),
pool_key: test_pool_key(),
liquidity: 1_000_000_000_000u128,
amount0_max: amount0_base,
amount1_max: amount1_base,
};
let frag = increase_liquidity(
¶ms,
SlippageBps::new(100),
9_999_999_999,
TEST_CFG.position_manager,
TEST_CFG.permit2,
);
let decoded = IPositionManager::modifyLiquiditiesCall::abi_decode(&frag.calls[0].calldata)
.expect("modifyLiquidities decode ok");
let (_actions, action_params): (alloy_primitives::Bytes, Vec<alloy_primitives::Bytes>) =
alloy_sol_types::SolValue::abi_decode_params(&decoded.unlockData)
.expect("decode planner tuple");
let inner = V4IncreaseLiquidityParams::abi_decode_params(&action_params[0])
.expect("decode IncreaseLiquidityParams");
assert_eq!(inner.amount0Max, 1_010_000u128);
assert_eq!(inner.amount1Max, 2_020_000u128);
assert_eq!(inner.tokenId, params.token_id);
assert_eq!(inner.liquidity, U256::from(params.liquidity));
}
#[test]
fn plan_add_liquidity_native_eth_pool_has_one_approval() {
let mut key = test_pool_key();
key.currency0 = Address::ZERO; // native ETH
let currency1 = key.currency1;
let params = AddLiquidityParams {
pool_key: key,
tick_lower: -60,
tick_upper: 60,
liquidity: 1_000_000_000_000_000u128,
recipient: address!("0000000000000000000000000000000000000099"),
salt: B256::ZERO,
};
let state = test_pool_state_at_tick_zero(params.pool_key.clone());
let frag = add_liquidity(
¶ms,
&state,
SlippageBps::new(50),
9_999_999_999,
TEST_CFG.position_manager,
TEST_CFG.permit2,
);
// Native ETH currency0 requires no approval; only currency1 does.
assert_eq!(frag.approvals.len(), 1);
assert_eq!(frag.approvals[0].token, currency1);
assert!(frag.value > U256::ZERO, "native side must be funded via msg.value");
assert_eq!(frag.calls[0].value, frag.value);
}
#[test]
fn plan_remove_liquidity_encodes_real_pool_currencies_not_zero() {
// Regression test for C1: TAKE_PAIR must encode the pool's actual
// currencies, not Address::ZERO. A zero address would cause the
// PositionManager to deliver native ETH instead of the real tokens.
//
// The golden hex below was captured from the SDK's V4Planner
// pre-Slice-F (USDC at offset 0x...a0b86991, WETH at
// 0x...c02aaa39), so a regression that re-encodes either
// currency as zero would diverge byte-by-byte.
let pool_key = test_pool_key(); // USDC/WETH 0.3% — both non-zero addresses
let params = RemoveLiquidityParams {
pool_key: pool_key.clone(),
token_id: U256::from(42u64),
liquidity: 1_000_000_000_000u128,
amount0_min: None,
amount1_min: None,
};
let frag = remove_liquidity(¶ms, 9_999_999_999, TEST_CFG.position_manager);
assert_eq!(frag.calls.len(), 1);
assert_eq!(frag.calls[0].target, TEST_CFG.position_manager);
assert!(frag.approvals.is_empty());
assert_eq!(
&frag.calls[0].calldata[..],
&hex!(
"dd46508f00000000000000000000000000000000000000000000000000000000"
"0000004000000000000000000000000000000000000000000000000000000002"
"540be3ff00000000000000000000000000000000000000000000000000000000"
"0000022000000000000000000000000000000000000000000000000000000000"
"0000004000000000000000000000000000000000000000000000000000000000"
"0000008000000000000000000000000000000000000000000000000000000000"
"0000000203110000000000000000000000000000000000000000000000000000"
"0000000000000000000000000000000000000000000000000000000000000000"
"0000000200000000000000000000000000000000000000000000000000000000"
"0000004000000000000000000000000000000000000000000000000000000000"
"0000010000000000000000000000000000000000000000000000000000000000"
"000000a000000000000000000000000000000000000000000000000000000000"
"0000002a00000000000000000000000000000000000000000000000000000000"
"0000000000000000000000000000000000000000000000000000000000000000"
"0000000000000000000000000000000000000000000000000000000000000000"
"0000008000000000000000000000000000000000000000000000000000000000"
"0000000000000000000000000000000000000000000000000000000000000000"
"00000060000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce"
"3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead908"
"3c756cc200000000000000000000000000000000000000000000000000000000"
"00000001"
)[..],
"remove_liquidity calldata must encode real pool currencies in TAKE_PAIR",
);
}
#[test]
fn plan_collect_fees_encodes_decrease_plus_take_pair() {
let recipient = address!("0000000000000000000000000000000000000099");
let pool_key = test_pool_key();
let params = CollectFeesParams {
token_id: U256::from(42u64),
recipient,
pool_key: pool_key.clone(),
};
let frag = collect_fees(¶ms, TEST_CFG.position_manager);
assert_eq!(frag.calls.len(), 1);
assert_eq!(frag.calls[0].target, TEST_CFG.position_manager);
assert_eq!(frag.calls[0].value, U256::ZERO);
assert!(frag.approvals.is_empty());
assert_eq!(
&frag.calls[0].calldata[..],
&hex!(
"dd46508f00000000000000000000000000000000000000000000000000000000"
"00000040000000000000000000000000000000000000000000000000ffffffff"
"ffffffff00000000000000000000000000000000000000000000000000000000"
"0000024000000000000000000000000000000000000000000000000000000000"
"0000004000000000000000000000000000000000000000000000000000000000"
"0000008000000000000000000000000000000000000000000000000000000000"
"0000000201110000000000000000000000000000000000000000000000000000"
"0000000000000000000000000000000000000000000000000000000000000000"
"0000000200000000000000000000000000000000000000000000000000000000"
"0000004000000000000000000000000000000000000000000000000000000000"
"0000012000000000000000000000000000000000000000000000000000000000"
"000000c000000000000000000000000000000000000000000000000000000000"
"0000002a00000000000000000000000000000000000000000000000000000000"
"0000000000000000000000000000000000000000000000000000000000000000"
"0000000000000000000000000000000000000000000000000000000000000000"
"0000000000000000000000000000000000000000000000000000000000000000"
"000000a000000000000000000000000000000000000000000000000000000000"
"0000000000000000000000000000000000000000000000000000000000000000"
"00000060000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce"
"3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead908"
"3c756cc200000000000000000000000000000000000000000000000000000000"
"00000099"
)[..],
);
}
#[test]
fn plan_collect_fees_respects_explicit_recipient() {
let recipient = address!("DEAd000000000000000000000000000000000001");
let pool_key = test_pool_key();
let params =
CollectFeesParams { token_id: U256::from(7u64), recipient, pool_key: pool_key.clone() };
let frag = collect_fees(¶ms, TEST_CFG.position_manager);
assert_eq!(
&frag.calls[0].calldata[..],
&hex!(
"dd46508f00000000000000000000000000000000000000000000000000000000"
"00000040000000000000000000000000000000000000000000000000ffffffff"
"ffffffff00000000000000000000000000000000000000000000000000000000"
"0000024000000000000000000000000000000000000000000000000000000000"
"0000004000000000000000000000000000000000000000000000000000000000"
"0000008000000000000000000000000000000000000000000000000000000000"
"0000000201110000000000000000000000000000000000000000000000000000"
"0000000000000000000000000000000000000000000000000000000000000000"
"0000000200000000000000000000000000000000000000000000000000000000"
"0000004000000000000000000000000000000000000000000000000000000000"
"0000012000000000000000000000000000000000000000000000000000000000"
"000000c000000000000000000000000000000000000000000000000000000000"
"0000000700000000000000000000000000000000000000000000000000000000"
"0000000000000000000000000000000000000000000000000000000000000000"
"0000000000000000000000000000000000000000000000000000000000000000"
"0000000000000000000000000000000000000000000000000000000000000000"
"000000a000000000000000000000000000000000000000000000000000000000"
"0000000000000000000000000000000000000000000000000000000000000000"
"00000060000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce"
"3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead908"
"3c756cc2000000000000000000000000dead0000000000000000000000000000"
"00000001"
)[..],
);
}
#[test]
fn plan_add_liquidity_wires_slippage_into_amount_max_fields() {
let pool_key = test_pool_key();
let state = test_pool_state_at_tick_zero(pool_key.clone());
let liquidity_delta: u128 = 1_000_000_000_000u128;
let params = AddLiquidityParams {
pool_key: pool_key.clone(),
tick_lower: -60,
tick_upper: 60,
liquidity: liquidity_delta,
recipient: address!("0000000000000000000000000000000000000099"),
salt: B256::ZERO,
};
let slippage = SlippageBps::new(100);
let deadline: u64 = 9_999_999_999;
let frag = add_liquidity(
¶ms,
&state,
slippage,
deadline,
TEST_CFG.position_manager,
TEST_CFG.permit2,
);
let sqrt_lower = get_sqrt_ratio_at_tick(-60).unwrap();
let sqrt_upper = get_sqrt_ratio_at_tick(60).unwrap();
let sqrt_current = state.sqrt_price_x96;
let expected0 =
get_amount_0_delta(sqrt_current, sqrt_upper, liquidity_delta, true).unwrap();
let expected1 =
get_amount_1_delta(sqrt_lower, sqrt_current, liquidity_delta, true).unwrap();
let amount0_max =
crate::position_info::apply_slippage_max(expected0, slippage).to::<u128>();
let amount1_max =
crate::position_info::apply_slippage_max(expected1, slippage).to::<u128>();
assert_ne!(amount0_max, u128::MAX);
assert_ne!(amount1_max, u128::MAX);
assert!(amount0_max > 0);
assert!(amount1_max > 0);
// Golden hex captured pre-Slice-F. Encodes:
// - MINT_POSITION (USDC/WETH 0.3% pool, ticks ±60, liquidity
// 1e12, amount{0,1}Max derived from slippage 100 bps)
// - SETTLE_PAIR (currency0=USDC, currency1=WETH)
// - deadline=9_999_999_999 (the 0x...02540be3ff word)
assert_eq!(
&frag.calls[0].calldata[..],
&hex!(
"dd46508f00000000000000000000000000000000000000000000000000000000"
"0000004000000000000000000000000000000000000000000000000000000002"
"540be3ff00000000000000000000000000000000000000000000000000000000"
"0000030000000000000000000000000000000000000000000000000000000000"
"0000004000000000000000000000000000000000000000000000000000000000"
"0000008000000000000000000000000000000000000000000000000000000000"
"00000002020d0000000000000000000000000000000000000000000000000000"
"0000000000000000000000000000000000000000000000000000000000000000"
"0000000200000000000000000000000000000000000000000000000000000000"
"0000004000000000000000000000000000000000000000000000000000000000"
"0000020000000000000000000000000000000000000000000000000000000000"
"000001a0000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce"
"3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead908"
"3c756cc200000000000000000000000000000000000000000000000000000000"
"00000bb800000000000000000000000000000000000000000000000000000000"
"0000003c00000000000000000000000000000000000000000000000000000000"
"00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
"ffffffc400000000000000000000000000000000000000000000000000000000"
"0000003c000000000000000000000000000000000000000000000000000000e8"
"d4a5100000000000000000000000000000000000000000000000000000000000"
"b4528b5900000000000000000000000000000000000000000000000000000000"
"b4528b5900000000000000000000000000000000000000000000000000000000"
"0000009900000000000000000000000000000000000000000000000000000000"
"0000018000000000000000000000000000000000000000000000000000000000"
"0000000000000000000000000000000000000000000000000000000000000000"
"00000040000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce"
"3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead908"
"3c756cc2"
)[..],
);
}
#[test]
fn plan_swap_exact_in_targets_universal_router() {
let pool_key = test_pool_key();
let state = PoolState {
pool_key: pool_key.clone(),
pool_id: alloy_primitives::b256!(
"0000000000000000000000000000000000000000000000000000000000000001"
),
sqrt_price_x96: U256::from(1u64) << 96,
tick: 0,
liquidity: 1_000_000_000_000_000_000u128,
protocol_fee: 0,
lp_fee: 3000,
ticks: vec![],
};
let quote = Quote {
amount_in: U256::from(100_000_000u64),
amount_out: U256::from(40_000_000_000_000_000u64),
sqrt_price_x96_after: state.sqrt_price_x96,
price_impact_bps: 0,
effective_fee_pips: 3000,
};
let params = ExactInParams {
currency_in: pool_key.currency0,
currency_out: pool_key.currency1,
amount_in: quote.amount_in,
recipient: address!("0000000000000000000000000000000000000099"),
};
let frag = swap_exact_in(
&state,
"e,
¶ms,
SlippageBps::new(100),
u64::MAX,
TEST_CFG.universal_router,
TEST_CFG.permit2,
);
assert_eq!(frag.calls.len(), 1);
assert_eq!(frag.calls[0].target, TEST_CFG.universal_router);
assert_eq!(frag.calls[0].value, U256::ZERO); // non-native sell
// Selector for execute(bytes,bytes[],uint256) = 0x3593564c
assert_eq!(&frag.calls[0].calldata[..4], &[0x35, 0x93, 0x56, 0x4c]);
// Approval goes to Permit2, not the router
assert_eq!(frag.approvals.len(), 1);
assert_eq!(frag.approvals[0].spender, TEST_CFG.permit2);
// Permit2 regression-lock (R23 Slice 4 plan Task 1 Step 8): see add_liquidity test.
assert_eq!(frag.approvals[0].min_amount, params.amount_in);
}
#[test]
fn swap_exact_in_execute_wrapper_fields() {
let pool_key = test_pool_key();
let state = PoolState {
pool_key: pool_key.clone(),
pool_id: alloy_primitives::b256!(
"0000000000000000000000000000000000000000000000000000000000000003"
),
sqrt_price_x96: U256::from(1u64) << 96,
tick: 0,
liquidity: 1_000_000_000_000_000_000u128,
protocol_fee: 0,
lp_fee: 3000,
ticks: vec![],
};
let quote = Quote {
amount_in: U256::from(100_000_000u64),
amount_out: U256::from(40_000_000_000_000_000u64),
sqrt_price_x96_after: state.sqrt_price_x96,
price_impact_bps: 0,
effective_fee_pips: 3000,
};
let params = ExactInParams {
currency_in: pool_key.currency0,
currency_out: pool_key.currency1,
amount_in: quote.amount_in,
recipient: address!("0000000000000000000000000000000000000099"),
};
let deadline = 1_700_000_000u64;
let frag = swap_exact_in(
&state,
"e,
¶ms,
SlippageBps::new(50),
deadline,
TEST_CFG.universal_router,
TEST_CFG.permit2,
);
let decoded = executeCall::abi_decode(&frag.calls[0].calldata).expect("execute decode ok");
assert_eq!(decoded.commands.as_ref(), &[0x10u8], "commands must be [V4_SWAP = 0x10]");
assert_eq!(decoded.inputs.len(), 1, "exactly one input blob");
assert_eq!(decoded.deadline, U256::from(deadline));
}
#[test]
fn add_liquidity_modify_liquidities_wrapper_deadline() {
use alloy_sol_types::SolCall as _;
use wp_evm_v4_interfaces::periphery::position_manager::IPositionManager;
let params = AddLiquidityParams {
pool_key: test_pool_key(),
tick_lower: -60,
tick_upper: 60,
liquidity: 1_000_000_000_000_000u128,
recipient: address!("0000000000000000000000000000000000000099"),
salt: alloy_primitives::B256::ZERO,
};
let expected_deadline = 1_700_000_000u64;
let state = test_pool_state_at_tick_zero(params.pool_key.clone());
let frag = add_liquidity(
¶ms,
&state,
SlippageBps::new(50),
expected_deadline,
TEST_CFG.position_manager,
TEST_CFG.permit2,
);
let decoded = IPositionManager::modifyLiquiditiesCall::abi_decode(&frag.calls[0].calldata)
.expect("modifyLiquidities decode ok");
assert_eq!(decoded.deadline, U256::from(expected_deadline));
assert!(!decoded.unlockData.is_empty(), "unlockData must be non-empty");
}
#[test]
fn plan_swap_exact_in_native_eth_has_value_and_no_approval() {
let mut pool_key = test_pool_key();
pool_key.currency0 = Address::ZERO; // native ETH
let state = PoolState {
pool_key: pool_key.clone(),
pool_id: alloy_primitives::b256!(
"0000000000000000000000000000000000000000000000000000000000000002"
),
sqrt_price_x96: U256::from(1u64) << 96,
tick: 0,
liquidity: 1_000_000_000_000_000_000u128,
protocol_fee: 0,
lp_fee: 3000,
ticks: vec![],
};
let eth_amount = U256::from(1_000_000_000_000_000_000u64); // 1 ETH
let quote = Quote {
amount_in: eth_amount,
amount_out: U256::from(2_500_000_000u64), // ~2500 USDC
sqrt_price_x96_after: state.sqrt_price_x96,
price_impact_bps: 0,
effective_fee_pips: 3000,
};
let params = ExactInParams {
currency_in: Address::ZERO,
currency_out: pool_key.currency1,
amount_in: eth_amount,
recipient: address!("0000000000000000000000000000000000000099"),
};
let frag = swap_exact_in(
&state,
"e,
¶ms,
SlippageBps::new(100),
u64::MAX,
TEST_CFG.universal_router,
TEST_CFG.permit2,
);
assert_eq!(frag.calls.len(), 1);
assert_eq!(frag.calls[0].value, eth_amount, "native ETH sell should carry value");
assert_eq!(frag.value, eth_amount);
assert!(frag.approvals.is_empty(), "native ETH needs no approval");
}
}