Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2462290
feat: add L2 revm swap integration test and update pool data contracts
Will-Smith11 Mar 5, 2026
0d2bef2
fix: correct L2 swap fee application to match AngstromL2 beforeSwap l…
Will-Smith11 Mar 5, 2026
b8f226d
debug: add pool key and quoter error detail prints to l2 swap test
Will-Smith11 Mar 5, 2026
b6fc950
fix: set gas_price=basefee on quoter calls to prevent uint256 underflow
Will-Smith11 Mar 5, 2026
3ea4ea2
fix: store pool LP fee in L2FeeConfiguration so local swap sim applie…
Will-Smith11 Mar 5, 2026
5810dae
fix: advance tick batch cursor by actual last tick instead of raw num…
Will-Smith11 Mar 5, 2026
e14f4b2
test: expand swap tests to multiple sizes and accept out-of-range as …
Will-Smith11 Mar 5, 2026
426d576
fix: separate L1 and L2 fee logic in pool swap to prevent cross-conta…
Will-Smith11 Mar 5, 2026
7436bd0
test: add L1 swap replay integration test comparing local sim vs on-c…
Will-Smith11 Mar 6, 2026
1b72751
fix: increase L2 log fetch batch size from 2k to 50k blocks
Will-Smith11 Mar 6, 2026
ad496cc
fix: reduce L2 log fetch batch to 9,999 blocks to stay within RPC limit
Will-Smith11 Mar 6, 2026
e39ba17
fix: unlock Angstrom hook before each on-chain quote in L1 swap test
Will-Smith11 Mar 6, 2026
f89a205
fix: correct off-by-one in hook unlock block number for L1 swap test
Will-Smith11 Mar 6, 2026
924d084
fix: reduce L2 swap test tick range and batch size to avoid RPC rate …
Will-Smith11 Mar 6, 2026
7d52ba2
fix: reduce ticks_per_batch to 100 to avoid EVM CreateContractSizeLimit
Will-Smith11 Mar 6, 2026
523a0cc
fix: add selector validation to SwapQuoter to prevent garbage values …
Will-Smith11 Mar 6, 2026
e34573d
fix: reduce L2 MEV tax priority fee and handle on-chain revert gracef…
Will-Smith11 Mar 6, 2026
4298d8e
w
Will-Smith11 Mar 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion abi-v4/GetUniswapV4PoolData.sol/GetUniswapV4PoolData.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion abi-v4/GetUniswapV4TickData.sol/GetUniswapV4TickData.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions contracts/cache/solidity-files-cache.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions contracts/src/GetUniswapV4PoolData.sol
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ contract GetUniswapV4PoolData {
slot0.tick()
);

poolData.token0Decimals = IERC20(asset0).decimals();
poolData.token1Decimals = IERC20(asset1).decimals();
poolData.token0Decimals = asset0 == address(0) ? 18 : IERC20(asset0).decimals();
poolData.token1Decimals = asset1 == address(0) ? 18 : IERC20(asset1).decimals();

poolData.liquidity = liquidity;
poolData.sqrtPrice = slot0.sqrtPriceX96();
Expand Down
50 changes: 50 additions & 0 deletions contracts/src/test/SwapQuoter.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;

import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {SwapParams} from "v4-core/src/types/PoolOperation.sol";
import {BalanceDelta} from "v4-core/src/types/BalanceDelta.sol";
import {IUnlockCallback} from "v4-core/src/interfaces/callback/IUnlockCallback.sol";

contract SwapQuoter is IUnlockCallback {
IPoolManager public immutable manager;

error QuoteResult(int128 amount0, int128 amount1);
error UnexpectedRevert(bytes reason);

constructor(IPoolManager _manager) {
manager = _manager;
}

function quote(
PoolKey memory key,
SwapParams memory params,
bytes memory hookData
) external returns (int128 amount0, int128 amount1) {
try manager.unlock(abi.encode(key, params, hookData)) {
revert("Expected revert");
} catch (bytes memory reason) {
// Verify the revert is our QuoteResult error before decoding
bytes4 selector;
assembly {
selector := mload(add(reason, 32))
}
if (selector != QuoteResult.selector || reason.length < 68) {
revert UnexpectedRevert(reason);
}
assembly {
amount0 := mload(add(reason, 36))
amount1 := mload(add(reason, 68))
}
}
}

function unlockCallback(bytes calldata data) external returns (bytes memory) {
require(msg.sender == address(manager));
(PoolKey memory key, SwapParams memory params, bytes memory hookData) =
abi.decode(data, (PoolKey, SwapParams, bytes));
BalanceDelta delta = manager.swap(key, params, hookData);
revert QuoteResult(delta.amount0(), delta.amount1());
}
}
38 changes: 25 additions & 13 deletions crates/uni-v4-structure/src/fee_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};

/// L2 MEV tax constants from AngstromL2.sol
/// The `SWAP_TAXED_GAS` is the abstract estimated gas cost for a swap.
pub const L2_SWAP_TAXED_GAS: u128 = 100_000;
pub const L2_SWAP_TAXED_GAS: u128 = 120_000;
/// MEV tax charged is `priority_fee * SWAP_MEV_TAX_FACTOR` meaning the tax rate
/// is `SWAP_MEV_TAX_FACTOR / (SWAP_MEV_TAX_FACTOR + 1)`
pub const L2_SWAP_MEV_TAX_FACTOR: u128 = 99;
Expand All @@ -20,6 +20,7 @@ pub struct L1FeeConfiguration {
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct L2FeeConfiguration {
pub is_initialized: bool,
pub lp_fee: u32,
pub creator_tax_fee_e6: u32,
pub protocol_tax_fee_e6: u32,
pub creator_swap_fee_e6: u32,
Expand All @@ -36,7 +37,7 @@ pub trait FeeConfig:

/// Returns the swap fee applied during the swap (in compute_swap_step).
/// - L1: LP fee charged during swap
/// - L2: 0 (no LP fee during swap, all fees applied after)
/// - L2: Pool's static LP fee from PoolKey.fee (charged by V4 AMM)
fn swap_fee(&self) -> u32;
/// Returns the protocol fee applied after the swap on the output token.
/// - L1: protocol_fee applied after swap
Expand All @@ -48,7 +49,7 @@ pub trait FeeConfig:
/// Returns the total fee for a swap.
/// - L1 bundle mode: uses bundle_fee
/// - L1 unlocked mode: swap_fee + protocol_fee
/// - L2: swap_fee (0) + protocol_fee (creator + protocol swap fees)
/// - L2: lp_fee + protocol_fee (creator + protocol swap fees)
fn fee(&self, bundle: bool) -> u32;

fn priority_fee_tax_floor(&self) -> u128 {
Expand All @@ -57,10 +58,12 @@ pub trait FeeConfig:

fn update_fees(&mut self, update: Self::Update);

/// Whether this fee config uses L2-style fees (BeforeSwapDelta + MEV tax).
fn l2_fees(&self) -> bool;

/// Calculate MEV tax given a priority fee in wei.
/// Default returns 0 (no MEV tax, used by L1).
/// L2 implements: SWAP_MEV_TAX_FACTOR * SWAP_TAXED_GAS * (priority_fee -
/// floor)
/// L1 returns 0. L2 implements: SWAP_MEV_TAX_FACTOR * SWAP_TAXED_GAS *
/// (priority_fee - floor)
fn mev_tax(&self, _priority_fee_wei: u128) -> u128 {
0
}
Expand All @@ -69,6 +72,10 @@ pub trait FeeConfig:
impl FeeConfig for L1FeeConfiguration {
type Update = L1FeeUpdate;

fn l2_fees(&self) -> bool {
false
}

fn protocol_fee(&self) -> u32 {
self.protocol_fee
}
Expand Down Expand Up @@ -105,7 +112,7 @@ impl FeeConfig for L2FeeConfiguration {
}

fn swap_fee(&self) -> u32 {
0
self.lp_fee
}

fn bundle_fee(&self) -> Option<u32> {
Expand All @@ -116,6 +123,10 @@ impl FeeConfig for L2FeeConfiguration {
self.swap_fee() + self.protocol_fee()
}

fn l2_fees(&self) -> bool {
true
}

fn priority_fee_tax_floor(&self) -> u128 {
self.priority_fee_tax_floor
}
Expand Down Expand Up @@ -171,6 +182,7 @@ mod tests {
fn l2_fee_config(floor: u128) -> L2FeeConfiguration {
L2FeeConfiguration {
is_initialized: true,
lp_fee: 0,
creator_tax_fee_e6: 1000,
protocol_tax_fee_e6: 2000,
creator_swap_fee_e6: 3000,
Expand All @@ -192,10 +204,10 @@ mod tests {
#[test]
fn l2_mev_tax_zero_floor() {
let cfg = l2_fee_config(0);
// 99 * 100_000 * 1 = 9_900_000
assert_eq!(cfg.mev_tax(1), 9_900_000);
// 99 * 100_000 * 1_000_000_000 (1 gwei) = 9_900_000_000_000_000
assert_eq!(cfg.mev_tax(1_000_000_000), 9_900_000_000_000_000);
// 99 * 120_000 * 1 = 11_880_000
assert_eq!(cfg.mev_tax(1), 11_880_000);
// 99 * 120_000 * 1_000_000_000 (1 gwei) = 11_880_000_000_000_000
assert_eq!(cfg.mev_tax(1_000_000_000), 11_880_000_000_000_000);
}

#[test]
Expand All @@ -215,8 +227,8 @@ mod tests {
fn l2_mev_tax_subtracts_floor() {
let cfg = l2_fee_config(100);
// priority_fee=150, effective=50
// 99 * 100_000 * 50 = 495_000_000
assert_eq!(cfg.mev_tax(150), 99 * 100_000 * 50);
// 99 * 120_000 * 50 = 594_000_000
assert_eq!(cfg.mev_tax(150), 99 * 120_000 * 50);
}

#[test]
Expand Down
132 changes: 86 additions & 46 deletions crates/uni-v4-structure/src/pool_swap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,55 @@ impl<'a, T: V4Network> PoolSwap<'a, T> {
if self.direction { MIN_SQRT_RATIO + U256_1 } else { MAX_SQRT_RATIO - U256_1 }
});

let mut amount_remaining = self.target_amount;
// L2 BeforeSwapDelta: deduct protocol fee + MEV tax from input BEFORE AMM.
// This mirrors AngstromL2.sol's beforeSwap which returns a BeforeSwapDelta
// that reduces amountSpecified before the pool swap runs.
// L1 does not use BeforeSwapDelta — its protocol fee is applied after the swap.
let (before_swap_input_deduction, before_swap_output_deduction) =
if self.fee_config.l2_fees() && !self.is_bundle && exact_input {
let protocol_fee_rate = self.fee_config.protocol_fee();
let mev_tax = self.mev_tax_amount.unwrap_or(0);
let ether_is_input = self.direction; // zeroForOne = selling ETH

if protocol_fee_rate > 0 {
let input_amount = self.target_amount.unsigned_abs();

// MEV tax is on ETH. If ETH is the input token, subtract it first
// (matches Solidity: `if (etherIsInput) inputAmount -= swapTax`)
let taxable_input = if ether_is_input {
input_amount.saturating_sub(U256::from(mev_tax))
} else {
input_amount
};

let fee_amount =
taxable_input * U256::from(protocol_fee_rate) / U256::from(1_000_000u32);

if ether_is_input {
// ETH→CBBTC: both mev_tax and fee deducted from input (specified)
(mev_tax + fee_amount.saturating_to::<u128>(), 0u128)
} else {
// CBBTC→ETH: fee from input (specified), mev_tax from output
// (unspecified/ETH)
(fee_amount.saturating_to::<u128>(), mev_tax)
}
} else if mev_tax > 0 {
// No protocol fee but MEV tax applies
if ether_is_input { (mev_tax, 0u128) } else { (0u128, mev_tax) }
} else {
(0u128, 0u128)
}
} else {
(0u128, 0u128)
};

// Reduce input amount by beforeSwap deduction (fees taken before AMM)
let mut amount_remaining = if before_swap_input_deduction > 0 && exact_input {
self.target_amount
.saturating_sub(I256::from_raw(U256::from(before_swap_input_deduction)))
} else {
self.target_amount
};
let mut sqrt_price_x96: U256 = self.liquidity.current_sqrt_price.into();

let mut steps = Vec::new();
Expand Down Expand Up @@ -125,74 +173,66 @@ impl<'a, T: V4Network> PoolSwap<'a, T> {
(t0, t1)
});

// Calculate and apply protocol fee directly to deltas for unlocked mode
let (final_d_t0, final_d_t1) = if !self.is_bundle {
let (final_d_t0, final_d_t1) = if self.fee_config.l2_fees() {
// L2: add back beforeSwap deductions to final deltas.
// The AMM ran on reduced input, so we re-add fees to the input side
// and subtract MEV tax from output if applicable.
if before_swap_input_deduction > 0 || before_swap_output_deduction > 0 {
if self.direction {
// zeroForOne: token0 is input, token1 is output
let adj_t0 = total_d_t0.saturating_add(before_swap_input_deduction);
(adj_t0, total_d_t1)
} else {
// oneForZero: token1 is input, token0 is output
let adj_t1 = total_d_t1.saturating_add(before_swap_input_deduction);
let adj_t0 = total_d_t0.saturating_sub(before_swap_output_deduction);
(adj_t0, adj_t1)
}
} else {
(total_d_t0, total_d_t1)
}
} else if !self.is_bundle {
// L1: protocol fee applied AFTER the AMM on the output token.
let fee_rate_e6 = U256::from(self.fee_config.protocol_fee());
let one_e6 = U256::from(1_000_000);
let one_e6 = U256::from(1_000_000u32);

// Determine which token gets the protocol fee based on swap direction
// exact_input != self.direction determines the target token
// exact_input != direction determines which token is the output
let target_is_token0 = exact_input != self.direction;

if target_is_token0 {
// Protocol fee applies to token0
let p_target_amount_u256 = U256::from(total_d_t0);

let amount = U256::from(total_d_t0);
let fee = if exact_input {
p_target_amount_u256 * fee_rate_e6 / one_e6
amount * fee_rate_e6 / one_e6
} else {
p_target_amount_u256 * one_e6 / (one_e6 - fee_rate_e6) - p_target_amount_u256
amount * one_e6 / (one_e6 - fee_rate_e6) - amount
};

// based on direction here, if this is the input token, we add,
// if output, we subtract.
if self.direction {
let adjusted_d_t0 = total_d_t0.saturating_add(fee.saturating_to::<u128>());
(adjusted_d_t0, total_d_t1)
// token0 is input: add fee
(total_d_t0.saturating_add(fee.saturating_to::<u128>()), total_d_t1)
} else {
let adjusted_d_t0 = total_d_t0.saturating_sub(fee.saturating_to::<u128>());
(adjusted_d_t0, total_d_t1)
// token0 is output: subtract fee
(total_d_t0.saturating_sub(fee.saturating_to::<u128>()), total_d_t1)
}
} else {
// Protocol fee applies to token1
let p_target_amount_u256 = U256::from(total_d_t1);

let amount = U256::from(total_d_t1);
let fee = if exact_input {
p_target_amount_u256 * fee_rate_e6 / one_e6
amount * fee_rate_e6 / one_e6
} else {
p_target_amount_u256 * one_e6 / (one_e6 - fee_rate_e6) - p_target_amount_u256
amount * one_e6 / (one_e6 - fee_rate_e6) - amount
};

if self.direction {
let adjusted_d_t1 = total_d_t1.saturating_sub(fee.saturating_to::<u128>());
(total_d_t0, adjusted_d_t1)
// token1 is output: subtract fee
(total_d_t0, total_d_t1.saturating_sub(fee.saturating_to::<u128>()))
} else {
let adjusted_d_t1 = total_d_t1.saturating_add(fee.saturating_to::<u128>());
(total_d_t0, adjusted_d_t1)
// token1 is input: add fee
(total_d_t0, total_d_t1.saturating_add(fee.saturating_to::<u128>()))
}
}
} else {
// In bundle mode, no protocol fee is applied during swap
// Bundle mode: no protocol fee
(total_d_t0, total_d_t1)
};

// Apply L2 MEV tax to token0 (ETH) delta if applicable.
// In L2 pools, token0 is always native ETH.
// - zeroForOne=true (selling ETH): user pays more ETH (add tax to input)
// - zeroForOne=false (buying ETH): user receives less ETH (subtract tax from
// output)
let (final_d_t0, final_d_t1) = if let Some(mev_tax) = self.mev_tax_amount {
if self.direction {
// Selling ETH: add MEV tax to token0 input
(final_d_t0.saturating_add(mev_tax), final_d_t1)
} else {
// Buying ETH: subtract MEV tax from token0 output
(final_d_t0.saturating_sub(mev_tax), final_d_t1)
}
} else {
(final_d_t0, final_d_t1)
};

Ok(PoolSwapResult {
fee_config: self.fee_config,
start_price: range_start,
Expand Down
Loading