Skip to main content
The Uniswap V3 integration simulates concentrated liquidity positions with tick-based ranges, position minting/burning, and exact V3 swap math.

Configuration

PoolConfig

pub struct PoolConfig {
    pub token0: TokenId,
    pub token1: TokenId,
    pub fee_bps: u16,      // Fee in basis points (e.g., 30 = 0.30%)
    pub tick_spacing: i32, // Tick spacing (e.g., 60 for 0.3% pools)
}

Initialization

use ank_protocol_uniswap_v3::{Uniswapv3, PoolConfig};
use ank_accounting::TokenId;

let uni = Uniswapv3::new(PoolConfig {
    token0: TokenId(1), // ETH
    token1: TokenId(2), // USDC
    fee_bps: 30,        // 0.30% fee
    tick_spacing: 60,
});

Available Actions

Description: Create a concentrated liquidity position in a tick range.Parameters:
  • tick_lower (i32): Lower tick boundary
  • tick_upper (i32): Upper tick boundary
  • amount0_desired (u128): Desired amount of token0
  • amount1_desired (u128): Desired amount of token1
Example:
{
  "kind": "mint_position",
  "tick_lower": -887220,
  "tick_upper": 887220,
  "amount0_desired": "1000000000000000000000",
  "amount1_desired": "2000000000"
}
Mechanics:
  1. Calculate liquidity from desired amounts
  2. Compute actual amounts needed (may use less than desired)
  3. Mint position NFT (tracked by position_id)
  4. Update tick data and global liquidity if in range
Effects:
  • Deducts amount0 and amount1 from wallet (actual used, not desired)
  • Refunds unused amounts
  • Returns position_id
Returns: (position_id, amount0, amount1, liquidity)
Description: Remove liquidity from a position.Parameters:
  • position_id (u64): Position ID to burn
  • liquidity (u128): Amount of liquidity to remove
Example:
{
  "kind": "burn_position",
  "position_id": "1",
  "liquidity": "500000000000000000000"
}
Effects:
  • Reduces position liquidity
  • Credits amount0 and amount1 to wallet
  • Removes position if liquidity reaches zero
Returns: (amount0, amount1)
Description: Legacy action for full-range liquidity (backwards compatibility).Parameters:
  • token0 (u32): Token0 ID
  • token1 (u32): Token1 ID
  • amount0 (u128): Amount of token0
  • amount1 (u128): Amount of token1
Example:
{
  "kind": "add_liquidity",
  "token0": 1,
  "token1": 2,
  "amount0": "1000000000000000000000",
  "amount1": "2000000000"
}
Internally converted to a full-range mint_position call.
Description: Legacy action to remove liquidity (backwards compatibility).Parameters:
  • shares (u128): Amount of liquidity to remove
Example:
{"kind":"remove_liquidity","shares":"500000000000000000000"}
Burns from the user’s largest position.
Description: Swap exact input amount using V3 sqrt price math.Parameters:
  • token_in (u32): Token ID to swap in
  • amount_in (u128): Exact input amount
  • min_out (u128): Minimum output amount (slippage protection)
Example:
{
  "kind": "swap_exact_in",
  "token_in": 1,
  "amount_in": "1000000000000000000",
  "min_out": "1900000000"
}
Mechanics:
  1. Deduct fee: amount_in_after_fee = amount_in * (10000 - fee_bps) / 10000
  2. Compute next sqrt price using V3 formula
  3. Calculate output using get_amount0_delta or get_amount1_delta
  4. Update pool state (sqrt_price, current_tick)
Effects:
  • Deducts amount_in of token_in from wallet
  • Credits amount_out of token_out to wallet
  • Fails if amount_out < min_out

Pool State

Current State

pub struct Pool {
    current_tick: i32,
    sqrt_price_x96: u128,   // Current sqrt(price) in Q96 format
    liquidity: u128,        // Active liquidity at current price
    positions: IndexMap<u64, Position>,
    ticks: IndexMap<i32, i128>, // tick -> net_liquidity_delta
    fee_growth_global_0_x128: u128,
    fee_growth_global_1_x128: u128,
}

Position

pub struct Position {
    pub owner: UserId,
    pub tick_lower: i32,
    pub tick_upper: i32,
    pub liquidity: u128,
    pub fees_owed_0: u128,
    pub fees_owed_1: u128,
}

View Methods

view_user

Returns user positions:
{
  "total_liquidity": "1000000000000000000000",
  "position_count": 2,
  "positions": [
    {
      "id": 1,
      "tick_lower": -887220,
      "tick_upper": 887220,
      "liquidity": "500000000000000000000",
      "fees_owed_0": "0",
      "fees_owed_1": "0"
    },
    {
      "id": 2,
      "tick_lower": -60,
      "tick_upper": 60,
      "liquidity": "500000000000000000000",
      "fees_owed_0": "100000000000000000",
      "fees_owed_1": "200000"
    }
  ]
}

view_market

Returns pool state:
{
  "token0": 1,
  "token1": 2,
  "fee_bps": 30,
  "tick_spacing": 60,
  "current_tick": 0,
  "sqrt_price_x96": "79228162514264337593543950336",
  "liquidity": "1000000000000000000000",
  "position_count": 2,
  "fee_growth_global_0": "0",
  "fee_growth_global_1": "0"
}

Usage Examples

Mint Concentrated Position

use ank_protocol::{Action, Protocol};

// Mint position around current price (±1%)
let result = uni.execute(
    ts,
    user_id,
    Action::Custom(serde_json::json!({
        "kind": "mint_position",
        "tick_lower": -60,
        "tick_upper": 60,
        "amount0_desired": "10000000000000000000",
        "amount1_desired": "20000000000"
    })),
)?;

// Parse position_id from events
let event_msg = result.events[0].to_string();
let position_id = extract_position_id(&event_msg);

Swap with Slippage Protection

// Swap 1 ETH for USDC, expect at least 1900 USDC
let result = uni.execute(
    ts,
    user_id,
    Action::Custom(serde_json::json!({
        "kind": "swap_exact_in",
        "token_in": 1,
        "amount_in": "1000000000000000000",
        "min_out": "1900000000"
    })),
);

match result {
    Ok(out) => {
        let usdc_received = out.delta.0[&TokenId(2)];
        println!("Received {} USDC", usdc_received);
    }
    Err(e) if e.to_string().contains("slippage") => {
        println!("Slippage exceeded, retrying with higher min_out...");
    }
    Err(e) => return Err(e),
}

Full-Range Liquidity

// Provide liquidity across all ticks
uni.execute(
    ts,
    user_id,
    Action::Custom(serde_json::json!({
        "kind": "mint_position",
        "tick_lower": -887220, // MIN_TICK (adjusted for tick_spacing)
        "tick_upper": 887220,  // MAX_TICK (adjusted for tick_spacing)
        "amount0_desired": "100000000000000000000",
        "amount1_desired": "200000000000"
    })),
)?;

Concentrated Liquidity Math

Liquidity Calculation

Given desired amounts and price range:
liquidity = min(
    get_liquidity_for_amount0(sqrt_price_current, sqrt_price_upper, amount0),
    get_liquidity_for_amount1(sqrt_price_lower, sqrt_price_current, amount1)
)

Amount Deltas

// Token0 (when price moves from A to B, B > A)
amount0 = liquidity * (1/sqrt_B - 1/sqrt_A)

// Token1 (when price moves from A to B, B > A)
amount1 = liquidity * (sqrt_B - sqrt_A)

Swap Math

// Zero for one (token0 in, token1 out)
sqrt_price_next = get_next_sqrt_price_from_input(
    sqrt_price_current,
    liquidity,
    amount_in_after_fee,
    zero_for_one
)

amount_out = get_amount1_delta(sqrt_price_next, sqrt_price_current, liquidity)

Tick Spacing

Positions must align to tick spacing:
// Valid ticks for tick_spacing = 60:
// ..., -120, -60, 0, 60, 120, ...

// Invalid: -55, 61, 100
Tick spacing depends on fee tier:
  • 0.01% → tick_spacing = 1
  • 0.05% → tick_spacing = 10
  • 0.30% → tick_spacing = 60
  • 1.00% → tick_spacing = 200

Price from Tick

price = 1.0001^tick
sqrt_price_x96 = sqrt(price) * 2^96
Example:
  • tick = 0 → price = 1.0
  • tick = 6932 → price ≈ 2.0
  • tick = -6932 → price ≈ 0.5

Limitations

The Uniswap V3 simulation does not model:
  • Fee collection (fees_owed tracked but not distributed per swap)
  • Flash swaps
  • Oracle TWAP (no time-weighted average price)
  • Position NFT transfers
  • Multiple fee tiers (single pool only)
  • Protocol fee switch
  • Tick bitmap optimization (iterates all ticks)
  • Price impact across tick ranges (single-step swap)
Use this model for basic swap routing and liquidity provision testing, not for MEV or multi-hop routing strategies.

Advanced: Multi-Hop Swaps

For multi-hop swaps (e.g., ETH → USDC → DAI), execute multiple swap_exact_in actions:
// Pool 1: ETH/USDC
let out1 = uni_eth_usdc.execute(
    ts,
    user_id,
    Action::Custom(json!({
        "kind": "swap_exact_in",
        "token_in": 1,
        "amount_in": "1000000000000000000",
        "min_out": "1900000000"
    })),
)?;

let usdc_out = out1.delta.0[&TokenId(2)];

// Pool 2: USDC/DAI
let out2 = uni_usdc_dai.execute(
    ts,
    user_id,
    Action::Custom(json!({
        "kind": "swap_exact_in",
        "token_in": 2,
        "amount_in": usdc_out.to_string(),
        "min_out": "1850000000000000000000"
    })),
)?;

See Also

Build docs developers (and LLMs) love