Skip to content

Instantly share code, notes, and snippets.

@0xdeployer
Created May 26, 2026 22:30
Show Gist options
  • Select an option

  • Save 0xdeployer/a1202aedd8374a539a831d91516a7d0e to your computer and use it in GitHub Desktop.

Select an option

Save 0xdeployer/a1202aedd8374a539a831d91516a7d0e to your computer and use it in GitHub Desktop.
---
name: uniswap-v4-single-sided-lp
description: Mint, unwind, or reposition a single-sided Uniswap v4 LP position (one currency only) using raw PositionManager.modifyLiquidities calldata. Covers tick math for token0/token1 ordering, dynamic-fee Doppler pools, Permit2 approval chain, MINT_POSITION + SETTLE_PAIR (mint) and BURN_POSITION + TAKE_PAIR (unwind) action sequences on Base.
visibility: private
---
# Uniswap v4 single-sided LP (raw calldata)
How to mint, unwind, or reposition a single-sided concentrated liquidity position on a Uniswap v4 pool when no dedicated tool exists. Battle-tested on Base against Doppler-hooked WETH/ERC20 pools.
## Key contracts (Base)
- **PositionManager (v4)**: `0x7C5f5A4bBd8fD63184577525326123B519429bDc`
- **PoolManager (v4)**: `0x498581fF718922c3f8e6A244956aF099B2652b2b`
- **Permit2**: `0x000000000022D473030F116dDEE9F6B43aC78BA3`
- **StateView (read pool state)**: `0xA3c0c9b65baD0b08107Aa264b0f3dB444b867A71`
- **WETH (currency0 when paired with ERC20)**: `0x4200000000000000000000000000000000000006`
For other chains, look up the v4 deployment addresses — the calldata shape below is identical.
## Action codes (from periphery `Actions.sol`)
Liquidity actions (PositionManager):
- `0x00` INCREASE_LIQUIDITY
- `0x01` DECREASE_LIQUIDITY
- `0x02` MINT_POSITION
- `0x03` BURN_POSITION
- `0x04` INCREASE_LIQUIDITY_FROM_DELTAS
- `0x05` MINT_POSITION_FROM_DELTAS
Settle/Take actions:
- `0x0b` SETTLE
- `0x0c` SETTLE_ALL
- `0x0d` SETTLE_PAIR
- `0x0e` TAKE
- `0x0f` TAKE_ALL
- `0x10` TAKE_PORTION
- `0x11` TAKE_PAIR
- `0x12` CLOSE_CURRENCY
- `0x13` CLEAR_OR_TAKE
- `0x14` SWEEP
Common sequences:
- **Mint single-sided**: `0x020d` (MINT_POSITION + SETTLE_PAIR)
- **Unwind in one tx**: `0x0311` (BURN_POSITION + TAKE_PAIR)
- **Decrease only (keep NFT)**: `0x0111` (DECREASE_LIQUIDITY + TAKE_PAIR)
## Concepts you MUST get right
### 1. Currency ordering
v4 pools sort `currency0 < currency1` by address. WETH on Base (`0x4200…0006`) is almost always `currency0` when paired with a higher-address ERC20.
The pool's **tick** represents `price = currency1 / currency0`, i.e. how much currency1 you get per 1 currency0.
### 2. Single-sided side selection
- Position holds **only currency0** when `currentTick < tickLower` (entire range above current).
- Position holds **only currency1** when `currentTick > tickUpper` (entire range below current).
- Mixed when in range.
### 3. Tick math
```
priceRatio = 1.0001 ^ tick // currency1 per currency0 (raw)
```
For human pricing of currency1 in USD:
```
usdPrice(c1) = usdPrice(c0) / priceRatio
```
So **higher tick → cheaper currency1** (more currency1 per WETH).
To move currency1's USD price UP by x%, tick must go DOWN by `ln(1+x)/ln(1.0001) ≈ x*10000` ticks (for small x). "1% above current price" → `tickUpper ≈ currentTick - 100`.
### 4. tickSpacing rounding
Both `tickLower` and `tickUpper` must be multiples of the pool's `tickSpacing`. Round toward the side that keeps the position correctly single-sided (i.e. keep tickUpper strictly below currentTick for single-sided currency1).
### 5. Liquidity from a target amount
For a position holding only currency1 (below current):
```
L = amount1 * 2^96 / (sqrtP(tickUpper) - sqrtP(tickLower))
```
For only currency0 (above current):
```
L = amount0 * sqrtP(tickLower) * sqrtP(tickUpper) / (2^96 * (sqrtP(tickUpper) - sqrtP(tickLower)))
```
where `sqrtP(t) = sqrt(1.0001^t) * 2^96` (Q64.96). Use the full v3 TickMath.getSqrtRatioAtTick BigInt implementation — the polynomial constants matter at large |tick|. The inline `compute-liquidity.js` script below includes a copy-paste implementation.
## Step-by-step workflow (MINT)
### Step 0 — gather pool data
Read pool state from StateView using the poolId (a `bytes32` hash of the PoolKey):
```
StateView.getSlot0(poolId) → (sqrtPriceX96, tick, protocolFee, lpFee)
```
You also need the PoolKey fields (`currency0`, `currency1`, `fee`, `tickSpacing`, `hooks`). For pools you didn't create, decode the PoolKey from the pool's deployment tx, a subgraph, or — if you already have a position there — `PositionManager.getPoolAndPositionInfo(tokenId)` returns the full PoolKey tuple. For Doppler pools, `fee = 0x800000 = 8388608` (dynamic-fee sentinel — hook controls the actual fee).
### Step 1 — plan ticks and L
1. Compute current human price and current tick.
2. Translate user's price/mcap targets into target ticks.
3. Round to `tickSpacing`, picking the rounding direction that keeps the position single-sided (`tickUpper < currentTick` for currency1-only, `tickLower > currentTick` for currency0-only).
4. Compute L from the target amount using the formula above (or run `compute-liquidity.js`).
5. **Sanity-check**: re-derive amount needed from L and confirm it matches the target within rounding.
### Step 2 — approval chain (TWO approvals required)
v4 uses Permit2 indirection. For each ERC20 you're depositing:
**Approval A — ERC20 → Permit2** (one-time per token):
```
ERC20.approve(Permit2, max uint256)
```
**Approval B — Permit2 → PositionManager** (one-time per token+spender):
```
Permit2.approve(token, PositionManager, max uint160, max uint48)
```
Use `max uint160 = 2^160 - 1` and `max uint48 = 2^48 - 1` for "infinite".
**Check before approving**:
- `ERC20.allowance(owner, Permit2)` — skip if already max.
- `Permit2.allowance(owner, token, PositionManager)` returns `(uint160 amount, uint48 expiration, uint48 nonce)` — skip if amount is high and not expired.
**CRITICAL — both sides matter even for single-sided positions.** If your range is right next to the current tick, the v4 math may compute a 1-wei delta on the "empty" currency. Two options:
- (a) Approve a small amount of the other currency too (safer for tight ranges).
- (b) Set `amount0Max = 0` (or `amount1Max = 0`), accepting the tx will revert if any dust is owed. Safe with a healthy tick gap from current.
A mint will revert with `TRANSFER_FROM_FAILED` if `amount0Max = 1` triggers a 1-wei pull on the "empty" currency with no approval in place. Setting that side's `amountMax = 0` avoids the dust pull entirely.
### Step 3 — encode and submit modifyLiquidities
`PositionManager.modifyLiquidities(bytes unlockData, uint256 deadline)` (selector `0xdd46508f`)
Where `unlockData = abi.encode(bytes actions, bytes[] params)`.
**actions** = `0x020d` (MINT_POSITION + SETTLE_PAIR)
**params[0] — MINT_POSITION** (8 fields, abi-encoded):
1. `PoolKey` tuple: `(address currency0, address currency1, uint24 fee, int24 tickSpacing, address hooks)`
2. `int24 tickLower`
3. `int24 tickUpper`
4. `uint256 liquidity` — the L you computed
5. `uint128 amount0Max` — max currency0 you'll spend (use 0 for single-sided c1 with healthy tick gap)
6. `uint128 amount1Max` — max currency1 you'll spend (target + ~1% buffer)
7. `address recipient` — usually `msg.sender`
8. `bytes hookData` — `0x` for most pools (Doppler ignores it)
**params[1] — SETTLE_PAIR** (2 fields): `address currency0`, `address currency1`. Pulls owed currencies from `msg.sender` via Permit2.
Use the inline `build-mint-calldata.js` script below to encode all of this with viem.
### Step 4 — verify and save
After tx success, PositionManager mints an LP NFT to the recipient. The new tokenId is in the ERC721 `Transfer` log from `address(0)` (topic1) to the recipient — read the receipt with `getTransactionReceipt` and filter logs by PositionManager address and `Transfer` topic `0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef`.
Save position details (tokenId, poolId, ticks, L, hook, recipient, tx hash) to `/.memory/project_<protocol>_<pool>.md`.
## Repositioning workflow
To shift an existing position to a new range, do it in TWO transactions: unwind, then re-mint. (A multicall combining both in one tx is possible but trickier; for reliability prefer two txs.)
### Step R0 — read current position
```
PositionManager.getPoolAndPositionInfo(tokenId) → (PoolKey poolKey, uint256 info)
PositionManager.getPositionLiquidity(tokenId) → uint128 L
StateView.getSlot0(poolId) → (sqrtPriceX96, tick, ...)
```
Confirm the position is still where you think it is and that the user owns the NFT (`ownerOf(tokenId)`).
### Step R1 — unwind: BURN_POSITION + TAKE_PAIR (one tx)
`BURN_POSITION` decreases ALL remaining liquidity AND burns the NFT in one action — no separate decrease step required when fully exiting. `TAKE_PAIR` then sweeps both currency credits to the recipient.
**actions** = `0x0311`
**params[0] — BURN_POSITION** (4 fields):
1. `uint256 tokenId`
2. `uint128 amount0Min` (slippage floor for currency0 returned; `0` if you're OK with anything)
3. `uint128 amount1Min` (same for currency1)
4. `bytes hookData` — `0x`
**params[1] — TAKE_PAIR** (3 fields):
1. `address currency0`
2. `address currency1`
3. `address recipient`
After this tx, both currencies land in the recipient's wallet (no Permit2 needed for receiving) and `getPositionLiquidity(tokenId)` reverts/returns 0 since the NFT is burned.
Use the inline `build-unwind-calldata.js` script below to encode the unwind.
### Step R2 — re-read pool state, compute new ticks
The tick may have moved during/after the unwind tx. Re-call `StateView.getSlot0(poolId)` to get the freshest `currentTick`, then plan the new `tickLower`/`tickUpper` per Step 1 above.
If the position was in-range when unwound, the wallet now holds BOTH currencies. To re-mint single-sided in the same currency, you may need to swap the other side first OR choose a new range that's still single-sided on the side you have more of.
### Step R3 — re-mint per Steps 2–4 above
Approvals from the original mint are still live (Permit2 approvals don't expire for ~80 years), so this is just `modifyLiquidities(0x020d, …)` again with the new ticks and updated L. The new tokenId comes from the receipt's `Transfer` log.
**Repositioning gotcha**: if the price moved while you were in-range, the unwind returns BOTH currencies. Example: a single-sided currency1 position with `tickUpper` below the price at mint can end up straddling `tickUpper` if the price drops into the range — when you unwind you'll receive a mix of currency0 + currency1, not just currency1. Account for this before re-minting single-sided (swap the unwanted side, or pick a new range that's single-sided on whichever side you have more of).
## Common failure modes
- **`TRANSFER_FROM_FAILED`** — Permit2 approval missing on a currency the position needs (often the "empty" side due to dust). Approve a small buffer of the other side OR set its `amountMax = 0`.
- **`InvalidTickSpacing` / `TickMisaligned`** — `tickLower` or `tickUpper` not a multiple of `tickSpacing`.
- **Position has tokens on the wrong side** — you crossed the current tick. For single-sided c1, ensure `tickUpper < currentTick`. For single-sided c0, ensure `tickLower > currentTick`.
- **Position got both currencies returned on unwind even though you minted single-sided** — current tick moved into your range while you held it. Expected; account for the extra currency before re-minting.
- **Hook reverts** — Doppler hooks have schedules and may reject liquidity mints outside certain windows. Check the hook's `beforeAddLiquidity` requirements.
## Inline scripts
All three scripts use viem (`bun add viem` or `npm i viem`). Run with `node <file>.js`. Edit the `CONFIG` block at the top of each before running.
### compute-liquidity.js
Given target amount, ticks, and current tick, output L and the back-derived amount. Self-contained BigInt port of Uniswap v3 TickMath.getSqrtRatioAtTick — no dependencies.
```js
// Compute Uniswap v4 liquidity L from a target amount and tick range.
// Usage: node compute-liquidity.js
// Edit the CONFIG section below.
const CONFIG = {
tickLower: 0, // multiple of pool tickSpacing
tickUpper: 0, // multiple of pool tickSpacing
currentTick: 0, // from StateView.getSlot0
side: 'currency1', // 'currency0' or 'currency1'
amount: 0n, // target amount in atomic units (e.g. 1B * 1e18 = 10n ** 9n * 10n ** 18n)
};
// --- TickMath: sqrtPriceX96 = sqrt(1.0001^tick) * 2^96 ---
// Port of Uniswap v3/v4 TickMath.sol getSqrtRatioAtTick.
function getSqrtRatioAtTick(tick) {
const absTick = BigInt(tick < 0 ? -tick : tick);
let ratio = (absTick & 0x1n) !== 0n
? 0xfffcb933bd6fad37aa2d162d1a594001n
: 0x100000000000000000000000000000000n;
const m = (x) => { ratio = (ratio * x) >> 128n; };
if ((absTick & 0x2n) !== 0n) m(0xfff97272373d413259a46990580e213an);
if ((absTick & 0x4n) !== 0n) m(0xfff2e50f5f656932ef12357cf3c7fdccn);
if ((absTick & 0x8n) !== 0n) m(0xffe5caca7e10e4e61c3624eaa0941cd0n);
if ((absTick & 0x10n) !== 0n) m(0xffcb9843d60f6159c9db58835c926644n);
if ((absTick & 0x20n) !== 0n) m(0xff973b41fa98c081472e6896dfb254c0n);
if ((absTick & 0x40n) !== 0n) m(0xff2ea16466c96a3843ec78b326b52861n);
if ((absTick & 0x80n) !== 0n) m(0xfe5dee046a99a2a811c461f1969c3053n);
if ((absTick & 0x100n) !== 0n) m(0xfcbe86c7900a88aedcffc83b479aa3a4n);
if ((absTick & 0x200n) !== 0n) m(0xf987a7253ac413176f2b074cf7815e54n);
if ((absTick & 0x400n) !== 0n) m(0xf3392b0822b70005940c7a398e4b70f3n);
if ((absTick & 0x800n) !== 0n) m(0xe7159475a2c29b7443b29c7fa6e889d9n);
if ((absTick & 0x1000n) !== 0n) m(0xd097f3bdfd2022b8845ad8f792aa5825n);
if ((absTick & 0x2000n) !== 0n) m(0xa9f746462d870fdf8a65dc1f90e061e5n);
if ((absTick & 0x4000n) !== 0n) m(0x70d869a156d2a1b890bb3df62baf32f7n);
if ((absTick & 0x8000n) !== 0n) m(0x31be135f97d08fd981231505542fcfa6n);
if ((absTick & 0x10000n) !== 0n) m(0x9aa508b5b7a84e1c677de54f3e99bc9n);
if ((absTick & 0x20000n) !== 0n) m(0x5d6af8dedb81196699c329225ee604n);
if ((absTick & 0x40000n) !== 0n) m(0x2216e584f5fa1ea926041bedfe98n);
if ((absTick & 0x80000n) !== 0n) m(0x48a170391f7dc42444e8fa2n);
if (tick > 0) ratio = (1n << 256n) / ratio;
// sqrtPriceX96 = ratio >> 32
return ratio >> 32n;
}
const Q96 = 1n << 96n;
const liquidityForCurrency1 = (amount1, sqrtPL, sqrtPU) =>
(amount1 * Q96) / (sqrtPU - sqrtPL);
const liquidityForCurrency0 = (amount0, sqrtPL, sqrtPU) =>
(amount0 * sqrtPL * sqrtPU) / Q96 / (sqrtPU - sqrtPL);
const amount1FromL = (L, sqrtPL, sqrtPU) => (L * (sqrtPU - sqrtPL)) / Q96;
const amount0FromL = (L, sqrtPL, sqrtPU) => (L * Q96 * (sqrtPU - sqrtPL)) / (sqrtPU * sqrtPL);
const { tickLower, tickUpper, currentTick, side, amount } = CONFIG;
if (tickLower >= tickUpper) throw new Error('tickLower must be < tickUpper');
if (side === 'currency1' && tickUpper >= currentTick) {
console.warn('WARNING: tickUpper >= currentTick — position will hold currency0 too');
}
if (side === 'currency0' && tickLower <= currentTick) {
console.warn('WARNING: tickLower <= currentTick — position will hold currency1 too');
}
const sqrtPL = getSqrtRatioAtTick(tickLower);
const sqrtPU = getSqrtRatioAtTick(tickUpper);
let L, recheck;
if (side === 'currency1') {
L = liquidityForCurrency1(amount, sqrtPL, sqrtPU);
recheck = amount1FromL(L, sqrtPL, sqrtPU);
} else {
L = liquidityForCurrency0(amount, sqrtPL, sqrtPU);
recheck = amount0FromL(L, sqrtPL, sqrtPU);
}
console.log('sqrtPL :', sqrtPL.toString());
console.log('sqrtPU :', sqrtPU.toString());
console.log('L :', L.toString());
console.log('L hex :', '0x' + L.toString(16));
console.log('target :', amount.toString());
console.log('rederive:', recheck.toString());
console.log('delta :', (amount - recheck).toString(), '(should be small/non-negative)');
```
### build-mint-calldata.js
Given PoolKey, ticks, L, max amounts, recipient → outputs ready-to-submit `modifyLiquidities` calldata for the `0x020d` (MINT_POSITION + SETTLE_PAIR) sequence.
```js
// Build Uniswap v4 PositionManager.modifyLiquidities calldata for a single-sided MINT_POSITION.
// Requires viem. bun add viem (or npm i viem)
// node build-mint-calldata.js
import { encodeAbiParameters, encodeFunctionData } from 'viem';
const CONFIG = {
poolKey: {
currency0: '0x4200000000000000000000000000000000000006', // WETH on Base (replace for other chains/pairs)
currency1: '0x0000000000000000000000000000000000000000', // the ERC20 you're LPing
fee: 8388608, // 0x800000 = dynamic-fee sentinel (Doppler). For static-fee pools use e.g. 3000 = 0.30%.
tickSpacing: 200,
hooks: '0x0000000000000000000000000000000000000000', // hook address, or 0x0000...0000 for hookless pool
},
tickLower: 0,
tickUpper: 0,
liquidity: 0n, // from compute-liquidity.js
amount0Max: 0n, // 0 for single-sided currency1 (healthy tick gap)
amount1Max: 0n, // target + ~1% buffer for single-sided currency1
recipient: '0x0000000000000000000000000000000000000000', // usually msg.sender
hookData: '0x',
deadlineSecondsFromNow: 3600,
};
const POSITION_MANAGER = '0x7C5f5A4bBd8fD63184577525326123B519429bDc'; // Base
const ACTION_MINT_POSITION = 0x02;
const ACTION_SETTLE_PAIR = 0x0d;
const { poolKey, tickLower, tickUpper, liquidity, amount0Max, amount1Max, recipient, hookData } = CONFIG;
const mintParams = encodeAbiParameters(
[
{ type: 'tuple', components: [
{ type: 'address' }, { type: 'address' },
{ type: 'uint24' }, { type: 'int24' }, { type: 'address' },
]},
{ type: 'int24' }, { type: 'int24' },
{ type: 'uint256' },
{ type: 'uint128' }, { type: 'uint128' },
{ type: 'address' },
{ type: 'bytes' },
],
[
[poolKey.currency0, poolKey.currency1, poolKey.fee, poolKey.tickSpacing, poolKey.hooks],
tickLower, tickUpper,
liquidity,
amount0Max, amount1Max,
recipient,
hookData,
]
);
const settleParams = encodeAbiParameters(
[{ type: 'address' }, { type: 'address' }],
[poolKey.currency0, poolKey.currency1]
);
const actions = '0x'
+ ACTION_MINT_POSITION.toString(16).padStart(2,'0')
+ ACTION_SETTLE_PAIR.toString(16).padStart(2,'0');
const unlockData = encodeAbiParameters(
[{ type: 'bytes' }, { type: 'bytes[]' }],
[actions, [mintParams, settleParams]]
);
const deadline = BigInt(Math.floor(Date.now()/1000) + CONFIG.deadlineSecondsFromNow);
const data = encodeFunctionData({
abi: [{ name:'modifyLiquidities', type:'function', inputs:[
{ name:'unlockData', type:'bytes' },
{ name:'deadline', type:'uint256' },
]}],
functionName: 'modifyLiquidities',
args: [unlockData, deadline],
});
console.log('to :', POSITION_MANAGER);
console.log('value : 0');
console.log('deadline:', deadline.toString());
console.log('data :', data);
```
### build-unwind-calldata.js
Given tokenId, the two pool currencies, and a recipient → outputs `modifyLiquidities` calldata for the `0x0311` (BURN_POSITION + TAKE_PAIR) sequence. Fully exits the position and burns the LP NFT in one tx.
```js
// Build Uniswap v4 PositionManager.modifyLiquidities calldata for a full-exit UNWIND.
// BURN_POSITION decreases ALL remaining liquidity AND burns the NFT — one tx, full exit.
// Requires viem. bun add viem (or npm i viem)
// node build-unwind-calldata.js
import { encodeAbiParameters, encodeFunctionData } from 'viem';
const CONFIG = {
tokenId: 0n, // LP NFT tokenId to unwind (PositionManager ERC721)
currency0: '0x4200000000000000000000000000000000000006', // pool's currency0 (lower address)
currency1: '0x0000000000000000000000000000000000000000', // pool's currency1 (higher address)
amount0Min: 0n, // slippage floor for currency0 returned (0 = accept anything)
amount1Min: 0n, // slippage floor for currency1 returned
recipient: '0x0000000000000000000000000000000000000000', // where the freed currencies land
hookData: '0x',
deadlineSecondsFromNow: 3600,
};
const POSITION_MANAGER = '0x7C5f5A4bBd8fD63184577525326123B519429bDc'; // Base
const ACTION_BURN_POSITION = 0x03;
const ACTION_TAKE_PAIR = 0x11;
const { tokenId, currency0, currency1, amount0Min, amount1Min, recipient, hookData } = CONFIG;
const burnParams = encodeAbiParameters(
[
{ type: 'uint256' }, // tokenId
{ type: 'uint128' }, // amount0Min
{ type: 'uint128' }, // amount1Min
{ type: 'bytes' }, // hookData
],
[tokenId, amount0Min, amount1Min, hookData]
);
const takeParams = encodeAbiParameters(
[
{ type: 'address' }, // currency0
{ type: 'address' }, // currency1
{ type: 'address' }, // recipient
],
[currency0, currency1, recipient]
);
const actions = '0x'
+ ACTION_BURN_POSITION.toString(16).padStart(2,'0')
+ ACTION_TAKE_PAIR.toString(16).padStart(2,'0');
const unlockData = encodeAbiParameters(
[{ type: 'bytes' }, { type: 'bytes[]' }],
[actions, [burnParams, takeParams]]
);
const deadline = BigInt(Math.floor(Date.now()/1000) + CONFIG.deadlineSecondsFromNow);
const data = encodeFunctionData({
abi: [{ name:'modifyLiquidities', type:'function', inputs:[
{ name:'unlockData', type:'bytes' },
{ name:'deadline', type:'uint256' },
]}],
functionName: 'modifyLiquidities',
args: [unlockData, deadline],
});
console.log('to :', POSITION_MANAGER);
console.log('value : 0');
console.log('deadline:', deadline.toString());
console.log('data :', data);
```
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment