V3 positions are non-fungible. Each position is uniquely identified by the pool, your public key, and the tick range. You can have multiple positions in the same pool at different ranges.
List Your Positions
async function listPositions() {
const response = await client.listConcentratedPositions();
console.log(`Total positions: ${response.totalCount}`);
for (const position of response.positions) {
console.log(`Pool: ${position.poolId}`);
console.log(` Range: ${position.tickLower} to ${position.tickUpper}`);
console.log(` Liquidity: ${position.liquidity}`);
console.log(` In range: ${position.inRange}`);
console.log(` Uncollected fees: ${position.uncollectedFeesA} A, ${position.uncollectedFeesB} B`);
}
return response.positions;
}
Filter by Pool
const response = await client.listConcentratedPositions({
poolId: "specific-pool-id",
});
const response = await client.listConcentratedPositions({
page: 2,
pageSize: 50, // max 100
});
console.log(`Page ${response.page} of ${Math.ceil(response.totalCount / response.pageSize)}`);
Position Data
Each position includes:
| Field | Description |
|---|
poolId | Pool identifier (LP identity public key) |
owner | Your public key |
tickLower | Lower tick of your range |
tickUpper | Upper tick of your range |
liquidity | V3 liquidity units in your position |
uncollectedFeesA | Earned fees for asset A not yet claimed |
uncollectedFeesB | Earned fees for asset B not yet claimed |
assetAAddress | Token A identifier |
assetBAddress | Token B identifier |
inRange | Whether current price is within your range |
createdAt | Position creation timestamp |
Understanding Liquidity Units
The liquidity field is a V3 math value, not a token amount. To understand what assets you have:
import { V3TickMath } from '@flashnet/sdk';
function estimatePositionValue(
liquidity: string,
tickLower: number,
tickUpper: number,
currentTick: number
) {
// If out of range below, position is 100% asset A
if (currentTick < tickLower) {
console.log('Position is 100% asset A (below range)');
}
// If out of range above, position is 100% asset B
else if (currentTick >= tickUpper) {
console.log('Position is 100% asset B (above range)');
}
// If in range, mixed
else {
console.log('Position is mixed (in range)');
}
// For precise amounts, use getPoolLiquidity which returns range data
}
Monitoring Position Health
Check Range Status
async function checkPositionHealth(poolId: string, tickLower: number, tickUpper: number) {
const positions = await client.listConcentratedPositions({ poolId });
const position = positions.positions.find(
p => p.tickLower === tickLower && p.tickUpper === tickUpper
);
if (!position) {
throw new Error('Position not found');
}
if (position.inRange) {
console.log('Position is earning fees');
} else {
console.log('Position is out of range - consider rebalancing');
}
// Check uncollected fees
const hasSignificantFees =
BigInt(position.uncollectedFeesA) > 0n ||
BigInt(position.uncollectedFeesB) > 0n;
if (hasSignificantFees) {
console.log('Consider collecting fees:', {
feeA: position.uncollectedFeesA,
feeB: position.uncollectedFeesB,
});
}
return position;
}
Calculate Range as Prices
import { V3TickMath } from '@flashnet/sdk';
function getPositionPriceRange(
tickLower: number,
tickUpper: number,
baseDecimals: number,
quoteDecimals: number
) {
const priceLower = V3TickMath.tickToHumanPrice({
tick: tickLower,
baseDecimals,
quoteDecimals,
});
const priceUpper = V3TickMath.tickToHumanPrice({
tick: tickUpper,
baseDecimals,
quoteDecimals,
});
console.log(`Price range: $${priceLower} - $${priceUpper}`);
return { priceLower, priceUpper };
}
Position Lifecycle
Create New Position
async function createPosition(
poolId: string,
priceLower: string,
priceUpper: string,
amountA: string,
amountB: string
) {
// Convert prices to ticks
const range = V3TickMath.tickRangeFromPrices({
priceLower,
priceUpper,
baseDecimals: 8,
quoteDecimals: 6,
tickSpacing: 60,
});
const response = await client.increaseLiquidity({
poolId,
tickLower: range.tickLower,
tickUpper: range.tickUpper,
amountADesired: amountA,
amountBDesired: amountB,
amountAMin: "0",
amountBMin: "0",
});
console.log('Position created');
console.log('Liquidity:', response.liquidityAdded);
console.log('Used:', response.amountAUsed, 'A,', response.amountBUsed, 'B');
return response;
}
Add to Existing Position
Adding liquidity to the same tick range increases your existing position:
async function addToPosition(
poolId: string,
tickLower: number,
tickUpper: number,
additionalA: string,
additionalB: string
) {
const response = await client.increaseLiquidity({
poolId,
tickLower,
tickUpper,
amountADesired: additionalA,
amountBDesired: additionalB,
amountAMin: "0",
amountBMin: "0",
});
console.log('Added to position');
console.log('New total liquidity increase:', response.liquidityAdded);
return response;
}
Collect Fees
async function collectPositionFees(poolId: string, tickLower: number, tickUpper: number) {
const response = await client.collectFees({
poolId,
tickLower,
tickUpper,
});
console.log('Fees collected');
console.log('Asset A:', response.feesCollectedA);
console.log('Asset B:', response.feesCollectedB);
return response;
}
Remove Partial Liquidity
async function removePartial(
poolId: string,
tickLower: number,
tickUpper: number,
liquidityToRemove: string
) {
const response = await client.decreaseLiquidity({
poolId,
tickLower,
tickUpper,
liquidityToRemove,
amountAMin: "0",
amountBMin: "0",
});
console.log('Removed:', response.liquidityRemoved, 'liquidity');
console.log('Received:', response.amountA, 'A,', response.amountB, 'B');
console.log('Fees:', response.feesCollectedA, 'A,', response.feesCollectedB, 'B');
return response;
}
Close Position
Pass "0" as liquidity to remove everything:
async function closePosition(poolId: string, tickLower: number, tickUpper: number) {
const response = await client.decreaseLiquidity({
poolId,
tickLower,
tickUpper,
liquidityToRemove: "0", // Remove all
amountAMin: "0",
amountBMin: "0",
});
console.log('Position closed');
console.log('Total received:', response.amountA, 'A,', response.amountB, 'B');
console.log('Final fees:', response.feesCollectedA, 'A,', response.feesCollectedB, 'B');
return response;
}
Rebalance to New Range
When price moves out of your range, rebalance atomically:
Rebalancing only supports moving to a tighter range (subset of the original). The new range must require less or equal capital. Rebalancing to a wider range that requires additional deposits is not supported.
async function rebalanceToTighterRange(poolId: string, currentPosition: {
tickLower: number;
tickUpper: number;
}) {
// Get current pool state
const liquidity = await client.getPoolLiquidity(poolId);
const currentTick = liquidity.currentTick;
const tickSpacing = liquidity.tickSpacing;
// Create a TIGHTER range inside the current position
// New range must be a subset of old range to avoid requiring additional deposits
const shrinkAmount = tickSpacing * 2;
const newLower = currentPosition.tickLower + shrinkAmount;
const newUpper = currentPosition.tickUpper - shrinkAmount;
const response = await client.rebalancePosition({
poolId,
oldTickLower: currentPosition.tickLower,
oldTickUpper: currentPosition.tickUpper,
newTickLower: newLower,
newTickUpper: newUpper,
liquidityToMove: "0", // Move all
});
console.log('Rebalanced');
console.log('New range:', newLower, 'to', newUpper);
console.log('Old liquidity:', response.oldLiquidity);
console.log('New liquidity:', response.newLiquidity);
return response;
}
Multiple Positions Strategy
You can deploy capital across multiple ranges:
async function deployMultipleRanges(poolId: string, totalAmountA: string, totalAmountB: string) {
const ranges = [
{ priceLower: "80000", priceUpper: "85000", weight: 20 },
{ priceLower: "85000", priceUpper: "95000", weight: 60 },
{ priceLower: "95000", priceUpper: "100000", weight: 20 },
];
const totalWeight = ranges.reduce((sum, r) => sum + r.weight, 0);
for (const range of ranges) {
const amountA = (BigInt(totalAmountA) * BigInt(range.weight) / BigInt(totalWeight)).toString();
const amountB = (BigInt(totalAmountB) * BigInt(range.weight) / BigInt(totalWeight)).toString();
const tickRange = V3TickMath.tickRangeFromPrices({
priceLower: range.priceLower,
priceUpper: range.priceUpper,
baseDecimals: 8,
quoteDecimals: 6,
tickSpacing: 60,
});
await client.increaseLiquidity({
poolId,
tickLower: tickRange.tickLower,
tickUpper: tickRange.tickUpper,
amountADesired: amountA,
amountBDesired: amountB,
amountAMin: "0",
amountBMin: "0",
});
console.log(`Created position at ${range.priceLower}-${range.priceUpper}`);
}
}
Slippage Protection
Use amountAMin and amountBMin to protect against price movement:
async function addLiquidityWithSlippage(
poolId: string,
tickLower: number,
tickUpper: number,
amountA: string,
amountB: string,
slippagePercent: number = 1 // 1% default
) {
const slippageMultiplier = (100 - slippagePercent) / 100;
const response = await client.increaseLiquidity({
poolId,
tickLower,
tickUpper,
amountADesired: amountA,
amountBDesired: amountB,
amountAMin: Math.floor(Number(amountA) * slippageMultiplier).toString(),
amountBMin: Math.floor(Number(amountB) * slippageMultiplier).toString(),
});
return response;
}
If actual amounts fall below minimums due to price movement, the transaction fails and funds are returned.
Next Steps