Adding and Removing Liquidity
Introduction
This guide will cover:
- Setting up liquidity operations – Preparing to add/remove liquidity from v4 positions, including fetching position details, handling native ETH vs ERC20 tokens, and configuring Permit2 for ERC20 token approvals.
 - Adding liquidity to existing positions – Using the v4 SDK to increase liquidity with 
addCallParameters, handling native ETH positions, and executing transactions via PositionManager multicall. - Removing liquidity from positions – Using 
removeCallParametersto decrease or fully exit positions, handling proportional withdrawals, and token collection. 
For this guide, the following Uniswap packages are used:
v4 Architecture and Key Changes
Native ETH Handling
Unlike v3, Uniswap v4 has native support for ETH without wrapping to WETH. This requires special handling in the SDK:
// ✅ Correct: Using Ether.onChain() for native ETH
const token0 = Ether.onChain(chainId)
Position Manager Multicall
All v4 position operations use the PositionManager contract's multicall function with encoded action sequences:
const { calldata, value } = V4PositionManager.addCallParameters(position, options)
await walletClient.writeContract({
  address: POSITION_MANAGER_ADDRESS,
  functionName: 'multicall',
  args: [[calldata]],
  value: BigInt(value),
})
Adding Liquidity to Existing Positions
Theory: IncreaseLiquidityOptions
When adding liquidity to existing positions, we use IncreaseLiquidityOptions which combines:
CommonOptions: slippage, deadline, hookDataModifyPositionSpecificOptions: tokenIdCommonAddLiquidityOptions: useNative, batchPermit
Step 1: Fetch Position Details
interface PositionDetails {
  tokenId: bigint
  tickLower: number
  tickUpper: number
  liquidity: bigint
  poolKey: {
    currency0: Address
    currency1: Address
    fee: number
    tickSpacing: number
    hooks: Address
  }
  token0: Currency // Can be Ether or Token
  token1: Token // Always Token in current implementation
  currentTick: number
  sqrtPriceX96: string
  poolLiquidity: string
}
async function getPositionDetails(tokenId: bigint): Promise<PositionDetails> {
  // Fetch position info from PositionManager
  const [poolKey, infoValue] = await publicClient.readContract({
    address: POSITION_MANAGER_ADDRESS,
    abi: POSITION_MANAGER_ABI,
    functionName: 'getPoolAndPositionInfo',
    args: [tokenId],
  })
  // Create proper Currency instances
  let token0: Currency
  if (isNativeETH(poolKey.currency0)) {
    token0 = Ether.onChain(chainId)
  } else {
    const decimals0 = await fetchTokenDecimals(poolKey.currency0)
    const symbol0 = await getTokenSymbol(poolKey.currency0)
    token0 = new Token(chainId, poolKey.currency0, decimals0, symbol0)
  }
  const token1 = new Token(chainId, poolKey.currency1, decimals1, symbol1)
  return {
    tokenId,
    tickLower: infoValue.tickLower,
    tickUpper: infoValue.tickUpper,
    liquidity: infoValue.liquidity,
    poolKey,
    token0,
    token1,
    // ... other fields
  }
}
Step 2: Configure Permit2 (Recommended)
const PERMIT2_TYPES = {
  PermitDetails: [
    { name: 'token', type: 'address' },
    { name: 'amount', type: 'uint160' },
    { name: 'expiration', type: 'uint48' },
    { name: 'nonce', type: 'uint48' },
  ],
  PermitBatch: [
    { name: 'details', type: 'PermitDetails[]' },
    { name: 'spender', type: 'address' },
    { name: 'sigDeadline', type: 'uint256' },
  ],
}
async function configurePermit2(positionDetails: EnhancedPositionDetails, deadline: number) {
  const permitDetails = []
  // Add token1 (always ERC20)
  const [, , nonce1] = await publicClient.readContract({
    address: PERMIT2_ADDRESS,
    abi: PERMIT2_ABI,
    functionName: 'allowance',
    args: [userAddress, positionDetails.token1.address, POSITION_MANAGER_ADDRESS],
  })
  permitDetails.push({
    token: positionDetails.token1.address,
    amount: (2n ** 160n - 1n).toString(),
    expiration: deadline.toString(),
    nonce: nonce1.toString(),
  })
  // Add token0 only if it's not native ETH
  if (!positionDetails.token0.isNative) {
    const [, , nonce0] = await publicClient.readContract({
      address: PERMIT2_ADDRESS,
      abi: PERMIT2_ABI,
      functionName: 'allowance',
      args: [userAddress, (positionDetails.token0 as Token).address, POSITION_MANAGER_ADDRESS],
    })
    permitDetails.push({
      token: (positionDetails.token0 as Token).address,
      amount: (2n ** 160n - 1n).toString(),
      expiration: deadline.toString(),
      nonce: nonce0.toString(),
    })
  }
  const permitData = {
    details: permitDetails,
    spender: POSITION_MANAGER_ADDRESS,
    sigDeadline: deadline.toString(),
  }
  // Sign Permit2 data
  const signature = await walletClient.signTypedData({
    account,
    domain: {
      name: 'Permit2',
      chainId,
      verifyingContract: PERMIT2_ADDRESS,
    },
    types: PERMIT2_TYPES,
    primaryType: 'PermitBatch',
    message: permitData,
  })
  return {
    owner: userAddress,
    permitBatch: permitData,
    signature,
  }
}
Step 3: Create Position and Add Liquidity
async function addLiquidityToPosition(
  positionDetails: EnhancedPositionDetails,
  amount0: string,
  amount1: string,
  slippageTolerance: number = 0.05
) {
  // Create Pool instance
  const pool = new Pool(
    positionDetails.token0,
    positionDetails.token1,
    positionDetails.poolKey.fee,
    positionDetails.poolKey.tickSpacing,
    positionDetails.poolKey.hooks,
    positionDetails.sqrtPriceX96,
    positionDetails.poolLiquidity,
    positionDetails.currentTick
  )
  // Create currency amounts
  const amount0Currency = CurrencyAmount.fromRawAmount(positionDetails.token0, amount0)
  const amount1Currency = CurrencyAmount.fromRawAmount(positionDetails.token1, amount1)
  // Create Position from amounts
  const position = Position.fromAmounts({
    pool,
    tickLower: positionDetails.tickLower,
    tickUpper: positionDetails.tickUpper,
    amount0: amount0Currency.quotient,
    amount1: amount1Currency.quotient,
    useFullPrecision: true,
  })
  // Configure options
  const slippagePct = new Percent(Math.floor(slippageTolerance * 100), 10_000)
  const deadline = Math.floor(Date.now() / 1000) + 1200 // 20 minutes
  const addOptions: AddLiquidityOptions = {
    // CommonOptions
    slippageTolerance: slippagePct,
    deadline: deadline.toString(),
    hookData: '0x',
    // ModifyPositionSpecificOptions
    tokenId: positionDetails.tokenId.toString(),
    // CommonAddLiquidityOptions
    ...(positionDetails.token0.isNative && { useNative: Ether.onChain(chainId) }),
    batchPermit: await configurePermit2(positionDetails, deadline),
  }
  // Generate calldata and execute
  const { calldata, value } = V4PositionManager.addCallParameters(position, addOptions)
  const txHash = await walletClient.writeContract({
    account,
    address: POSITION_MANAGER_ADDRESS,
    chain: unichain,
    abi: POSITION_MANAGER_ABI,
    functionName: 'multicall',
    args: [[calldata]],
    value: BigInt(value.toString()),
  })
  return { txHash, addedAmounts: { amount0, amount1 } }
}
Removing Liquidity from Positions
Theory: RemoveLiquidityOptions
When removing liquidity, we use RemoveLiquidityOptions which includes:
CommonOptions: slippage, deadline, hookDataModifyPositionSpecificOptions: tokenIdRemoveLiquiditySpecificOptions: liquidityPercentage, burnToken, permit
Step 1: Calculate Liquidity to Remove
function calculateLiquidityToRemove(
  currentLiquidity: bigint,
  percentageToRemove: number // 0.25 = 25%, 1.0 = 100%
): {
  liquidityToRemove: bigint
  liquidityPercentage: Percent
} {
  const liquidityToRemove = (currentLiquidity * BigInt(Math.floor(percentageToRemove * 10000))) / 10000n
  const liquidityPercentage = new Percent(Math.floor(percentageToRemove * 100), 100)
  return { liquidityToRemove, liquidityPercentage }
}
Step 2: Remove Liquidity Implementation
async function removeLiquidityFromPosition(
  positionDetails: EnhancedPositionDetails,
  percentageToRemove: number, // 0.25 = 25%, 1.0 = 100%
  slippageTolerance: number = 0.05,
  burnTokenIfEmpty: boolean = false
) {
  const { liquidityToRemove, liquidityPercentage } = calculateLiquidityToRemove(
    positionDetails.liquidity,
    percentageToRemove
  )
  // Create Pool instance
  const pool = new Pool(
    positionDetails.token0,
    positionDetails.token1,
    positionDetails.poolKey.fee,
    positionDetails.poolKey.tickSpacing,
    positionDetails.poolKey.hooks,
    positionDetails.sqrtPriceX96,
    positionDetails.poolLiquidity,
    positionDetails.currentTick
  )
  // Create Position instance with current liquidity
  const position = new Position({
    pool,
    tickLower: positionDetails.tickLower,
    tickUpper: positionDetails.tickUpper,
    liquidity: positionDetails.liquidity.toString(),
  })
  // Configure remove options
  const slippagePct = new Percent(Math.floor(slippageTolerance * 100), 10_000)
  const deadline = Math.floor(Date.now() / 1000) + 1200
  const removeOptions: RemoveLiquidityOptions = {
    // CommonOptions
    slippageTolerance: slippagePct,
    deadline: deadline.toString(),
    hookData: '0x',
    // ModifyPositionSpecificOptions
    tokenId: positionDetails.tokenId.toString(),
    // RemoveLiquiditySpecificOptions
    liquidityPercentage,
    burnToken: burnTokenIfEmpty && percentageToRemove === 1.0,
    // permit: optional NFT permit if transaction sender doesn't own the NFT
  }
  // Generate calldata and execute
  const { calldata, value } = V4PositionManager.removeCallParameters(position, removeOptions)
  const txHash = await walletClient.writeContract({
    account,
    address: POSITION_MANAGER_ADDRESS,
    chain: unichain,
    abi: POSITION_MANAGER_ABI,
    functionName: 'multicall',
    args: [[calldata]],
    value: BigInt(value.toString()),
  })
  return {
    txHash,
    removedLiquidity: liquidityToRemove,
    percentageRemoved: percentageToRemove,
    tokenBurned: burnTokenIfEmpty && percentageToRemove === 1.0,
  }
}
Complete Example: Add/Remove Workflow
async function completeAddRemoveWorkflow() {
  const tokenId = 123456n
  // 1. Fetch position details
  const positionDetails = await getPositionDetails(tokenId)
  console.log(`Position: ${positionDetails.token0.symbol}/${positionDetails.token1.symbol}`)
  // 2. Add liquidity
  const addResult = await addLiquidityToPosition(
    positionDetails,
    '1000000000000000', // 0.001 ETH
    '1000000', // 1 USDC
    0.05 // 5% slippage
  )
  console.log(`Added liquidity: ${addResult.txHash}`)
  // 3. Wait and verify
  await new Promise((resolve) => setTimeout(resolve, 5000))
  const updatedPosition = await getPositionDetails(tokenId)
  // 4. Remove 50% of liquidity
  const removeResult = await removeLiquidityFromPosition(
    updatedPosition,
    0.5, // 50%
    0.05, // 5% slippage
    false // don't burn token
  )
  console.log(`Removed 50% liquidity: ${removeResult.txHash}`)
  return { addResult, removeResult }
}