Rebalance Your Staked LP Position¶
This tutorial walks you through the full rebalance flow for a staked SushiSwap V3 LP position on Katana: unstake, remove liquidity, mint a new position at the correct price range, and restake.
Goal¶
By the end of this tutorial, you’ll be able to:
- Unstake your LP position from the SushiStaker contract
- Remove liquidity from your SushiSwap V3 position
- Mint a new position centered around the current price
- Restake the new position to continue earning KAT emissions
Prerequisites¶
- A staked SushiSwap V3 LP position on Katana
- Tokens to provide as new liquidity (you’ll recover these when removing liquidity)
- Basic understanding of concentrated liquidity and tick ranges
- Familiarity with viem or wagmi
If you’re new to LP staking, read Stake Your LP first.
Why Rebalance?¶
SushiSwap V3 uses concentrated liquidity, your position only earns fees and KAT emissions when the current price is within your tick range. If the price moves outside your range, your position becomes inactive. Rebalancing means closing your old position and opening a new one centered on the current price.
Contract Addresses¶
// SushiStaker (proxy)
const SUSHI_STAKER = "0xbe12e1b5C4859a3d141412748279B67458F729E9";
// SushiSwap V3 NonfungiblePositionManager
const POSITION_MANAGER = "0x2659C6085D26144117D904C46B48B6d180393d27";
// SushiSwap V3 Factory
const SUSHI_FACTORY = "0x203e8740894c8955cB8950759876d7E7E45E04c1";
Set Up viem Clients¶
import {
createPublicClient,
createWalletClient,
http,
defineChain,
maxUint128,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
const katana = defineChain({
id: 747474,
name: "Katana",
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
rpcUrls: {
default: { http: ["https://rpc.katana.network"] },
},
blockExplorers: {
default: { name: "Katanascan", url: "https://katanascan.com" },
},
});
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const publicClient = createPublicClient({
chain: katana,
transport: http(),
});
const walletClient = createWalletClient({
account,
chain: katana,
transport: http(),
});
ABIs¶
SushiStaker ABI (click to expand)
const sushiStakerAbi = [
{
name: "unstake",
type: "function",
stateMutability: "nonpayable",
inputs: [{ name: "tokenId", type: "uint256" }],
outputs: [],
},
{
name: "stake",
type: "function",
stateMutability: "nonpayable",
inputs: [{ name: "tokenId", type: "uint256" }],
outputs: [],
},
{
name: "isStaked",
type: "function",
stateMutability: "view",
inputs: [{ name: "tokenId", type: "uint256" }],
outputs: [{ type: "bool" }],
},
{
name: "getPositionInfo",
type: "function",
stateMutability: "view",
inputs: [{ name: "tokenId", type: "uint256" }],
outputs: [
{ name: "token0", type: "address" },
{ name: "token1", type: "address" },
{ name: "fee", type: "uint24" },
{ name: "tickLower", type: "int24" },
{ name: "tickUpper", type: "int24" },
{ name: "liquidity", type: "uint128" },
],
},
] as const;
NonfungiblePositionManager ABI (click to expand)
const positionManagerAbi = [
{
name: "positions",
type: "function",
stateMutability: "view",
inputs: [{ name: "tokenId", type: "uint256" }],
outputs: [
{ name: "nonce", type: "uint96" },
{ name: "operator", type: "address" },
{ name: "token0", type: "address" },
{ name: "token1", type: "address" },
{ name: "fee", type: "uint24" },
{ name: "tickLower", type: "int24" },
{ name: "tickUpper", type: "int24" },
{ name: "liquidity", type: "uint128" },
{ name: "feeGrowthInside0LastX128", type: "uint256" },
{ name: "feeGrowthInside1LastX128", type: "uint256" },
{ name: "tokensOwed0", type: "uint128" },
{ name: "tokensOwed1", type: "uint128" },
],
},
{
name: "decreaseLiquidity",
type: "function",
stateMutability: "nonpayable",
inputs: [
{
name: "params",
type: "tuple",
components: [
{ name: "tokenId", type: "uint256" },
{ name: "liquidity", type: "uint128" },
{ name: "amount0Min", type: "uint256" },
{ name: "amount1Min", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
},
],
outputs: [
{ name: "amount0", type: "uint256" },
{ name: "amount1", type: "uint256" },
],
},
{
name: "collect",
type: "function",
stateMutability: "payable",
inputs: [
{
name: "params",
type: "tuple",
components: [
{ name: "tokenId", type: "uint256" },
{ name: "recipient", type: "address" },
{ name: "amount0Max", type: "uint128" },
{ name: "amount1Max", type: "uint128" },
],
},
],
outputs: [
{ name: "amount0", type: "uint256" },
{ name: "amount1", type: "uint256" },
],
},
{
name: "mint",
type: "function",
stateMutability: "payable",
inputs: [
{
name: "params",
type: "tuple",
components: [
{ name: "token0", type: "address" },
{ name: "token1", type: "address" },
{ name: "fee", type: "uint24" },
{ name: "tickLower", type: "int24" },
{ name: "tickUpper", type: "int24" },
{ name: "amount0Desired", type: "uint256" },
{ name: "amount1Desired", type: "uint256" },
{ name: "amount0Min", type: "uint256" },
{ name: "amount1Min", type: "uint256" },
{ name: "recipient", type: "address" },
{ name: "deadline", type: "uint256" },
],
},
],
outputs: [
{ name: "tokenId", type: "uint256" },
{ name: "liquidity", type: "uint128" },
{ name: "amount0", type: "uint256" },
{ name: "amount1", type: "uint256" },
],
},
{
name: "approve",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "to", type: "address" },
{ name: "tokenId", type: "uint256" },
],
outputs: [],
},
] as const;
SushiSwap V3 Pool ABI (click to expand)
const poolAbi = [
{
name: "slot0",
type: "function",
stateMutability: "view",
inputs: [],
outputs: [
{ name: "sqrtPriceX96", type: "uint160" },
{ name: "tick", type: "int24" },
{ name: "observationIndex", type: "uint16" },
{ name: "observationCardinality", type: "uint16" },
{ name: "observationCardinalityNext", type: "uint16" },
{ name: "feeProtocol", type: "uint8" },
{ name: "unlocked", type: "bool" },
],
},
] as const;
SushiSwap V3 Factory ABI (click to expand)
const factoryAbi = [
{
name: "getPool",
type: "function",
stateMutability: "view",
inputs: [
{ name: "tokenA", type: "address" },
{ name: "tokenB", type: "address" },
{ name: "fee", type: "uint24" },
],
outputs: [{ name: "pool", type: "address" }],
},
] as const;
ERC-20 ABI (click to expand)
const erc20Abi = [
{
name: "approve",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "spender", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ type: "bool" }],
},
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ type: "uint256" }],
},
] as const;
The Rebalance Flow¶
The full rebalance is four transactions:
- Unstake — Retrieve your NFT from the SushiStaker contract
- Remove liquidity — Withdraw tokens from your old position
- Mint new position — Create a new LP position at the current price range
- Restake — Stake the new NFT to resume earning KAT emissions
Downtime between unstake and restake
Your position does not earn KAT emissions while unstaked. Complete the full rebalance in one session to minimize missed rewards.
Step 1: Read Your Current Position¶
Before unstaking, check your position details to understand your current range:
const TOKEN_ID = 12345n; // Replace with your NFT token ID
// Check that the position is staked
const staked = await publicClient.readContract({
address: SUSHI_STAKER,
abi: sushiStakerAbi,
functionName: "isStaked",
args: [TOKEN_ID],
});
console.log(`Position staked: ${staked}`);
// Read position details
const [token0, token1, fee, tickLower, tickUpper, liquidity] =
await publicClient.readContract({
address: SUSHI_STAKER,
abi: sushiStakerAbi,
functionName: "getPositionInfo",
args: [TOKEN_ID],
});
console.log(`Pool: ${token0}/${token1} (fee: ${fee})`);
console.log(`Tick range: ${tickLower} to ${tickUpper}`);
console.log(`Liquidity: ${liquidity}`);
Step 2: Get the Current Pool Price¶
Read the current tick from the pool to determine where to center your new range:
// Look up the pool address
const poolAddress = await publicClient.readContract({
address: SUSHI_FACTORY,
abi: factoryAbi,
functionName: "getPool",
args: [token0, token1, fee],
});
// Read current tick from pool
const [sqrtPriceX96, currentTick] = await publicClient.readContract({
address: poolAddress,
abi: poolAbi,
functionName: "slot0",
});
console.log(`Current tick: ${currentTick}`);
console.log(`Your range: ${tickLower} to ${tickUpper}`);
if (currentTick < tickLower || currentTick >= tickUpper) {
console.log("Price is OUT of your range — rebalance recommended");
} else {
console.log("Price is IN your range — rebalance is optional");
}
Step 3: Unstake Your Position¶
Call unstake() on the SushiStaker contract to retrieve your NFT:
const unstakeHash = await walletClient.writeContract({
address: SUSHI_STAKER,
abi: sushiStakerAbi,
functionName: "unstake",
args: [TOKEN_ID],
});
await publicClient.waitForTransactionReceipt({ hash: unstakeHash });
console.log("Position unstaked — NFT returned to your wallet");
Fees on unstake
When you unstake, any trading fees accumulated during the staking period are collected and sent to the protocol fee collector — not to your wallet. This is by design and separate from the KAT emissions you earn.
Step 4: Remove Liquidity¶
Now that you own the NFT again, remove all liquidity from the position. This is
two calls: decreaseLiquidity to convert liquidity back to tokens, then
collect to withdraw those tokens to your wallet.
// Remove all liquidity
const deadline = BigInt(Math.floor(Date.now() / 1000) + 600); // 10 minutes
// Calculate expected token amounts from the current position using sqrtPriceX96.
//
// token0 and token1 are ordered by address on-chain — for example, the USDC/WETH
// pool has token0 = USDC and token1 = WETH. The math below works regardless of
// which token is token0 or token1.
//
// sqrtPriceX96 encodes the pool's current price as sqrt(price) * 2^96.
// We use it alongside the position's tick boundaries to estimate how much
// of each token the position holds.
const sqrtPrice = Number(sqrtPriceX96) / 2 ** 96;
const sqrtLower = Math.sqrt(1.0001 ** Number(tickLower));
const sqrtUpper = Math.sqrt(1.0001 ** Number(tickUpper));
const liq = Number(liquidity);
let expectedAmount0: bigint;
let expectedAmount1: bigint;
if (Number(currentTick) < Number(tickLower)) {
// Price below range — position is entirely token0
expectedAmount0 = BigInt(Math.floor(liq * (1 / sqrtLower - 1 / sqrtUpper)));
expectedAmount1 = 0n;
} else if (Number(currentTick) >= Number(tickUpper)) {
// Price above range — position is entirely token1
expectedAmount0 = 0n;
expectedAmount1 = BigInt(Math.floor(liq * (sqrtUpper - sqrtLower)));
} else {
// Price in range — position holds a mix of both tokens
expectedAmount0 = BigInt(Math.floor(liq * (1 / sqrtPrice - 1 / sqrtUpper)));
expectedAmount1 = BigInt(Math.floor(liq * (sqrtPrice - sqrtLower)));
}
// Apply 0.5% slippage tolerance — reject the transaction if you'd receive
// less than 99.5% of the expected amount. This protects against sandwich
// attacks where a bot manipulates the price right before your transaction.
const SLIPPAGE_BPS = 50n; // 0.5% expressed in basis points (50 / 10000)
const amount0Min = expectedAmount0 - (expectedAmount0 * SLIPPAGE_BPS) / 10000n;
const amount1Min = expectedAmount1 - (expectedAmount1 * SLIPPAGE_BPS) / 10000n;
console.log(`Expected token0: ${expectedAmount0}, min: ${amount0Min}`);
console.log(`Expected token1: ${expectedAmount1}, min: ${amount1Min}`);
const decreaseHash = await walletClient.writeContract({
address: POSITION_MANAGER,
abi: positionManagerAbi,
functionName: "decreaseLiquidity",
args: [
{
tokenId: TOKEN_ID,
liquidity: liquidity, // Full amount from Step 1
amount0Min,
amount1Min,
deadline,
},
],
});
await publicClient.waitForTransactionReceipt({ hash: decreaseHash });
console.log("Liquidity removed");
// Collect the tokens
const collectHash = await walletClient.writeContract({
address: POSITION_MANAGER,
abi: positionManagerAbi,
functionName: "collect",
args: [
{
tokenId: TOKEN_ID,
recipient: account.address,
amount0Max: maxUint128,
amount1Max: maxUint128,
},
],
});
const collectReceipt = await publicClient.waitForTransactionReceipt({
hash: collectHash,
});
console.log("Tokens collected to your wallet");
Slippage protection
The example above uses a 0.5% slippage tolerance. This means the
transaction will revert if the amounts you receive are more than 0.5% below
the expected values — protecting you from sandwich attacks. Adjust
SLIPPAGE_BPS based on market conditions: tighter (e.g., 25n for 0.25%)
in calm markets, wider (e.g., 100n for 1%) during high volatility.
Step 5: Calculate New Tick Range¶
Center your new position around the current price. This example uses a ±25% range — adjust the percentage to your preferred concentration level.
// Helper: calculate tick range for a +/- percentage around current tick
// tickSpacing depends on fee tier (fee 100 = spacing 1, 500 = 10, 3000 = 60, 10000 = 200)
function getTickSpacing(feeTier: number): number {
const spacings: Record<number, number> = { 100: 1, 500: 10, 3000: 60, 10000: 200 };
return spacings[feeTier] || 60;
}
// Calculate +/-25% range in tick space
// Each tick represents a ~0.01% price change, so 25% ≈ 2500 ticks
const RANGE_TICKS = 2500;
const tickSpacing = getTickSpacing(Number(fee));
// Round ticks to nearest valid tickSpacing
const newTickLower =
Math.floor((Number(currentTick) - RANGE_TICKS) / tickSpacing) * tickSpacing;
const newTickUpper =
Math.ceil((Number(currentTick) + RANGE_TICKS) / tickSpacing) * tickSpacing;
console.log(`New tick range: ${newTickLower} to ${newTickUpper}`);
Adjusting concentration
A tighter range (e.g., ±10%) earns more fees when in range but goes out of
range more often. A wider range (e.g., ±50%) is more resilient to price
movement but earns less per unit of liquidity. Adjust RANGE_TICKS to your
preference.
Step 6: Approve Tokens and Mint New Position¶
Approve both tokens for the Position Manager, then mint a new LP position:
// Check your token balances
const balance0 = await publicClient.readContract({
address: token0,
abi: erc20Abi,
functionName: "balanceOf",
args: [account.address],
});
const balance1 = await publicClient.readContract({
address: token1,
abi: erc20Abi,
functionName: "balanceOf",
args: [account.address],
});
console.log(`Token0 balance: ${balance0}`);
console.log(`Token1 balance: ${balance1}`);
// Approve token0
const approve0Hash = await walletClient.writeContract({
address: token0,
abi: erc20Abi,
functionName: "approve",
args: [POSITION_MANAGER, balance0],
});
await publicClient.waitForTransactionReceipt({ hash: approve0Hash });
// Approve token1
const approve1Hash = await walletClient.writeContract({
address: token1,
abi: erc20Abi,
functionName: "approve",
args: [POSITION_MANAGER, balance1],
});
await publicClient.waitForTransactionReceipt({ hash: approve1Hash });
console.log("Both tokens approved");
// Apply 0.5% slippage tolerance to the mint (same approach as Step 4)
const mintSlippageBps = 50n; // 0.5%
const mintAmount0Min = balance0 - (balance0 * mintSlippageBps) / 10000n;
const mintAmount1Min = balance1 - (balance1 * mintSlippageBps) / 10000n;
// Mint new position
const mintHash = await walletClient.writeContract({
address: POSITION_MANAGER,
abi: positionManagerAbi,
functionName: "mint",
args: [
{
token0,
token1,
fee,
tickLower: newTickLower,
tickUpper: newTickUpper,
amount0Desired: balance0,
amount1Desired: balance1,
amount0Min: mintAmount0Min,
amount1Min: mintAmount1Min,
recipient: account.address,
deadline: BigInt(Math.floor(Date.now() / 1000) + 600),
},
],
});
const mintReceipt = await publicClient.waitForTransactionReceipt({
hash: mintHash,
});
console.log("New position minted!", mintReceipt);
To get the new token ID from the mint transaction, parse the Transfer event
from the receipt:
import { parseEventLogs } from "viem";
const transferAbi = [
{
type: "event",
name: "Transfer",
inputs: [
{ name: "from", type: "address", indexed: true },
{ name: "to", type: "address", indexed: true },
{ name: "tokenId", type: "uint256", indexed: true },
],
},
] as const;
const logs = parseEventLogs({
abi: transferAbi,
logs: mintReceipt.logs,
});
const newTokenId = logs[0].args.tokenId;
console.log(`New NFT token ID: ${newTokenId}`);
Step 7: Restake the New Position¶
Approve the SushiStaker contract and stake your new NFT:
// Approve SushiStaker to transfer your NFT
const approveNftHash = await walletClient.writeContract({
address: POSITION_MANAGER,
abi: positionManagerAbi,
functionName: "approve",
args: [SUSHI_STAKER, newTokenId],
});
await publicClient.waitForTransactionReceipt({ hash: approveNftHash });
// Stake the new position
const stakeHash = await walletClient.writeContract({
address: SUSHI_STAKER,
abi: sushiStakerAbi,
functionName: "stake",
args: [newTokenId],
});
await publicClient.waitForTransactionReceipt({ hash: stakeHash });
console.log(`Position ${newTokenId} staked — earning KAT emissions again!`);
Step 8: Verify¶
Confirm your new position is staked and active:
const isNowStaked = await publicClient.readContract({
address: SUSHI_STAKER,
abi: sushiStakerAbi,
functionName: "isStaked",
args: [newTokenId],
});
const [, , , , newLower, newUpper, newLiq] = await publicClient.readContract({
address: SUSHI_STAKER,
abi: sushiStakerAbi,
functionName: "getPositionInfo",
args: [newTokenId],
});
console.log(`Staked: ${isNowStaked}`);
console.log(`New range: ${newLower} to ${newUpper}`);
console.log(`Liquidity: ${newLiq}`);
console.log("Rebalance complete!");
Using wagmi (React)¶
If you’re building a React frontend, here’s the pattern using wagmi hooks. This example covers the full rebalance flow with a step-by-step UI:
import { useWriteContract, useWaitForTransactionReceipt, useReadContract, useAccount } from "wagmi";
import { maxUint128 } from "viem";
const SUSHI_STAKER = "0xbe12e1b5C4859a3d141412748279B67458F729E9";
const POSITION_MANAGER = "0x2659C6085D26144117D904C46B48B6d180393d27";
function RebalanceLP({ tokenId }: { tokenId: bigint }) {
const { address } = useAccount();
const { writeContract, data: hash, isPending } = useWriteContract();
const { isLoading, isSuccess } = useWaitForTransactionReceipt({ hash });
// Read current position
const { data: positionInfo } = useReadContract({
address: SUSHI_STAKER,
abi: sushiStakerAbi,
functionName: "getPositionInfo",
args: [tokenId],
});
// Step 1: Unstake
const handleUnstake = () => {
writeContract({
address: SUSHI_STAKER,
abi: sushiStakerAbi,
functionName: "unstake",
args: [tokenId],
});
};
// Step 2: Remove liquidity
const handleRemoveLiquidity = (
expectedAmount0: bigint,
expectedAmount1: bigint
) => {
if (!positionInfo) return;
const [, , , , , liquidity] = positionInfo;
// 0.5% slippage tolerance
const slippageBps = 50n;
const amount0Min = expectedAmount0 - (expectedAmount0 * slippageBps) / 10000n;
const amount1Min = expectedAmount1 - (expectedAmount1 * slippageBps) / 10000n;
writeContract({
address: POSITION_MANAGER,
abi: positionManagerAbi,
functionName: "decreaseLiquidity",
args: [
{
tokenId,
liquidity,
amount0Min,
amount1Min,
deadline: BigInt(Math.floor(Date.now() / 1000) + 600),
},
],
});
};
// Step 3: Collect tokens
const handleCollect = () => {
if (!address) return;
writeContract({
address: POSITION_MANAGER,
abi: positionManagerAbi,
functionName: "collect",
args: [
{
tokenId,
recipient: address,
amount0Max: maxUint128,
amount1Max: maxUint128,
},
],
});
};
// Step 4: Mint new position (after calculating new ticks)
const handleMint = (
token0: `0x${string}`,
token1: `0x${string}`,
fee: number,
newTickLower: number,
newTickUpper: number,
amount0: bigint,
amount1: bigint
) => {
if (!address) return;
// 0.5% slippage tolerance
const slippageBps = 50n;
const amount0Min = amount0 - (amount0 * slippageBps) / 10000n;
const amount1Min = amount1 - (amount1 * slippageBps) / 10000n;
writeContract({
address: POSITION_MANAGER,
abi: positionManagerAbi,
functionName: "mint",
args: [
{
token0,
token1,
fee,
tickLower: newTickLower,
tickUpper: newTickUpper,
amount0Desired: amount0,
amount1Desired: amount1,
amount0Min,
amount1Min,
recipient: address,
deadline: BigInt(Math.floor(Date.now() / 1000) + 600),
},
],
});
};
// Step 5: Stake new position
const handleStake = (newTokenId: bigint) => {
writeContract({
address: SUSHI_STAKER,
abi: sushiStakerAbi,
functionName: "stake",
args: [newTokenId],
});
};
// Each handler above is called after the previous transaction confirms.
// Wire these to your UI step-by-step — for example:
return (
<div>
<button onClick={handleUnstake} disabled={isPending}>
{isPending ? "Unstaking..." : "1. Unstake"}
</button>
{/* 2. Call handleRemoveLiquidity(expectedAmount0, expectedAmount1) */}
{/* 3. Call handleCollect() */}
{/* 4. Call handleMint(token0, token1, fee, tickLower, tickUpper, amount0, amount1) */}
{/* 5. Call handleStake(newTokenId) */}
</div>
);
}
What’s Next¶
- Stake Your LP — Learn how to stake for the first time
- Vote on Gauges — Direct KAT emissions to your pool
- KAT Staking — Understand the full KAT staking and emissions system