# Position
NFT-based liquidity position management for concentrated liquidity.
## Overview
Each liquidity position is a unique GRC721 NFT. Stored state includes the pool
key, price range, liquidity, fee-growth checkpoints, tokens owed, burned marker,
and operator. Current token balances are derived from the current pool price,
range, and liquidity; they are not permanently stored balances.
The pool accounting key encodes only the lower/upper tick pair and is scoped by
pool. NFTs with the same range in one pool share the pool-level accounting entry.
## Configuration
- **Withdrawal Fee**: 1% by default on fee-bearing swap-fee collection
- **Max Position Size**: No separate position-level cap; pool tick limits apply
- **Transfers**: Unstaked NFTs follow GRC721 owner/approval/operator rules;
staked NFTs are locked to staker-mediated transfers
## Core Functions
### `Mint`
Creates new position NFT with initial liquidity.
- Validates tick range alignment
- Calculates optimal token ratio
- Returns actual amounts used
### `IncreaseLiquidity`
Adds liquidity to an existing position.
- Maintains the existing price range
- Uses the current-price token ratio
- Can clear a burned marker when the position is used again
### `DecreaseLiquidity`
Removes liquidity while keeping the NFT.
- One atomic public operation: internally collects swap fees, burns liquidity,
then collects principal through the pool's fee-free `Collect` path
- Returns fee amounts net of the withdrawal fee and collected principal
- Amount-minimum checks apply to the principal actually collected
### `CollectFee`
Claims accumulated swap fees without removing liquidity.
- No liquidity removal required
- Returns net collected amounts plus the raw pre-withdrawal-fee amounts
- The configured withdrawal fee applies only to this fee-bearing path
### `Reposition`
Updates an existing position's price range.
- Requires the position to be clear first (zero liquidity and tokens owed)
- Reuses the same position ID and NFT
- Adds new liquidity to the updated range and clears the burned marker
## Technical Details
### Tick Alignment
Ticks must align with pool's tick spacing:
```
0.01% fee: every 1 tick
0.05% fee: every 10 ticks
0.3% fee: every 60 ticks
1% fee: every 200 ticks
```
### Optimal Range Width
**Stable Pairs (USDC/USDT)**:
- Narrow: ±0.05% (max efficiency)
- Medium: ±0.1% (balanced)
- Wide: ±0.5% (safety)
**Correlated Pairs (WETH/stETH)**:
- Narrow: ±0.5%
- Medium: ±1%
- Wide: ±2%
**Volatile Pairs (WETH/USDC)**:
- Narrow: ±5%
- Medium: ±10%
- Wide: ±25%
### Capital Efficiency
Concentration factor vs infinite range:
```
Range ±0.1% → 2000x efficient
Range ±1% → 200x efficient
Range ±10% → 20x efficient
Range ±50% → 4x efficient
```
### Token Calculations
For liquidity `L` and square-root prices `sqrtLower`, `sqrtCurrent`, and
`sqrtUpper`:
**Below range (`current < lower`, token0 only)**:
```
amount0 = L * (sqrtUpper - sqrtLower) / (sqrtUpper * sqrtLower)
amount1 = 0
```
**In range (`lower <= current < upper`, both tokens)**:
```
amount0 = L * (sqrtUpper - sqrtCurrent) / (sqrtUpper * sqrtCurrent)
amount1 = L * (sqrtCurrent - sqrtLower)
```
**Above range (`current >= upper`, token1 only)**:
```
amount0 = 0
amount1 = L * (sqrtUpper - sqrtLower)
```
## Approval and Transfer Requirements
`Mint`, `IncreaseLiquidity`, and `Reposition` pull token0 and token1 from the
caller inside the **pool** realm, so the approved spender is the pool realm
address, not the position realm.
- Approve the pool realm for both token contracts before calling a
liquidity-adding function.
- Approving the position realm alone is not sufficient; the position realm never
holds or pulls the pair tokens itself.
- Approve at least `amount0Desired` / `amount1Desired`. Any desired amount the
pool does not consume stays with the caller.
- `DecreaseLiquidity` and `CollectFee` pay out to the caller and require no
approval.
```go
// Approve the pool realm for both pair tokens before minting
poolAddress := access.MustGetAddress(prabc.ROLE_POOL.String())
weth.Approve(cross(cur), poolAddress, 1000000)
usdc.Approve(cross(cur), poolAddress, 2000000000)
```
## Usage
These snippets call the public domain proxy from a realm function with a current `cur` token.
Import the proxy package and qualify its function names in integrating code.
```go
// Mint a new position through the domain proxy
tokenId, liquidity, amount0, amount1 := Mint(
cross(cur),
"gno.land/r/gnoland/wugnot.wugnot", // token0
"gno.land/r/gnoswap/gns.GNS", // token1
3000, // fee
-887220, // tickLower
887220, // tickUpper
"1000000", // amount0Desired
"2000000000", // amount1Desired
"950000", // amount0Min
"1900000000", // amount1Min
deadline,
recipient, // mintTo
"", // referrer
)
// Add liquidity
positionId, liquidity, amount0, amount1, poolPath := IncreaseLiquidity(
cross(cur),
tokenId,
"500000",
"1000000000",
"475000",
"950000000",
deadline,
)
// Collect swap fees
positionId, collected0, collected1, poolPath, rawAmount0, rawAmount1 := CollectFee(
cross(cur),
tokenId,
)
// Reposition to a new range (requires a clear position)
positionId, liquidity, tickLower, tickUpper, amount0, amount1 := Reposition(
cross(cur),
tokenId,
-443610, // new tickLower
443610, // new tickUpper
"1000000", // amount0Desired
"2000000000", // amount1Desired
"950000", // amount0Min
"1900000000", // amount1Min
deadline,
)
```
## Lifecycle
A full decrease that leaves zero liquidity and zero tokens owed sets the
`burned` marker but does not destroy the NFT. `IncreaseLiquidity` and
`Reposition` clear the marker when the position is used again; the marker does
not by itself block an increase.
## Security
- Tick range validation prevents invalid positions
- Slippage protection applies to liquidity-changing operations; fee collection
has no amount-minimum parameter
- Deadlines prevent stale liquidity-changing transactions
- Unstaked NFTs follow standard GRC721 transfer authorization; staked NFTs
can move only through staker-mediated flows
- Liquidity changes and repositioning require the owner; fee collection also
permits the position's approved operator where applicable
NewPositionV1(positionStore interface {GetPosition func(uint64) (gno.land/r/gnoswap/position.Position, bool); GetPositionNextID func() uint64; GetPositions func() *gno.land/p/nt/bptree/v0.BPTree; HasPosition func(uint64) bool; HasPositionNextIDStoreKey func() bool; HasPositionsStoreKey func() bool; RemovePosition func(int, .uverse.realm, uint64) .uverse.error; SetPosition func(int, .uverse.realm, uint64, gno.land/r/gnoswap/position.Position) .uverse.error; SetPositionNextID func(int, .uverse.realm, uint64) .uverse.error; SetPositions func(int, .uverse.realm, *gno.land/p/nt/bptree/v0.BPTree) .uverse.error}, accessor interface {Approve func(int, .uverse.realm, .uverse.address, gno.land/p/nt/grc721/v0.TokenID) .uverse.error; Burn func(int, .uverse.realm, gno.land/p/nt/grc721/v0.TokenID); Exists func(gno.land/p/nt/grc721/v0.TokenID) bool; Mint func(int, .uverse.realm, .uverse.address, gno.land/p/nt/grc721/v0.TokenID) gno.land/p/nt/grc721/v0.TokenID; OwnerOf func(gno.land/p/nt/grc721/v0.TokenID) (.uverse.address, .uverse.error); TotalSupply func() int64}) interface {CollectFee func(int, .uverse.realm, uint64) (uint64, string, string, string, string, string); DecreaseLiquidity func(int, .uverse.realm, uint64, string, string, string, int64) (uint64, string, string, string, string, string, string); GetPositionFeeGrowthInside0LastX128 func(uint64) (string, .uverse.error); GetPositionFeeGrowthInside1LastX128 func(uint64) (string, .uverse.error); GetPositionFeeGrowthInsideLastX128 func(uint64) (string, string, .uverse.error); GetPositionLiquidity func(uint64) (string, .uverse.error); GetPositionOperator func(uint64) (.uverse.address, .uverse.error); GetPositionOwner func(uint64) (.uverse.address, .uverse.error); GetPositionPoolKey func(uint64) (string, .uverse.error); GetPositionTickLower func(uint64) (int32, .uverse.error); GetPositionTickUpper func(uint64) (int32, .uverse.error); GetPositionTicks func(uint64) (int32, int32, .uverse.error); GetPositionTokenBalances func(uint64) (int64, int64, .uverse.error); GetPositionTokensOwed func(uint64) (int64, int64, .uverse.error); GetPositionTokensOwed0 func(uint64) (int64, .uverse.error); GetPositionTokensOwed1 func(uint64) (int64, .uverse.error); GetPositions func() *gno.land/p/nt/bptree/rotree/v0.ReadOnlyTree; GetUnclaimedFee func(uint64) (*gno.land/p/gnoswap/uint256/v1.Uint, *gno.land/p/gnoswap/uint256/v1.Uint, .uverse.error); IncreaseLiquidity func(int, .uverse.realm, uint64, string, string, string, string, int64) (uint64, string, string, string, string); IsBurned func(uint64) (bool, .uverse.error); IsInRange func(uint64) (bool, .uverse.error); Mint func(int, .uverse.realm, string, string, uint32, int32, int32, string, string, string, string, int64, .uverse.address, string) (uint64, string, string, string); Render func(string) string; Reposition func(int, .uverse.realm, uint64, int32, int32, string, string, string, string, int64) (uint64, string, int32, int32, string, string); SetPositionOperator func(int, .uverse.realm, uint64, .uverse.address)}
Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.
vm/qrender output, sanitized (docs/render-security.md) and displayed in an empty-sandbox iframe — scripts, forms and popups cannot run. Links stay inert in-preview; right-click to open.