Positions are NFTs
A position is a bounded range of bins — at most 100, i.e. one bin array’s width — owned by a Token-2022 NFT.open_position mints the NFT directly into the caller’s wallet; every
downstream action (deposit, withdraw, claim fee, close) is gated to whoever currently holds
that NFT, not necessarily the original opener. Selling or transferring the NFT transfers the
position along with it. Ownership is checked declaratively against the position mint’s direct
owner — there’s no delegate support.
Depositing by weight
A single instruction,deposit_by_weight, places a deposit across a position’s bins. The
caller supplies:
- a total
(amount_x, amount_y), - a weight vector — one
u16weight per bin in the range, - a
distribution_mode(below), - the client’s believed
active_bin_id(checked against the pool’s real one, within a caller-set tolerance).
- bins below the active bin only ever receive Y (bid liquidity),
- bins above the active bin only ever receive X (ask liquidity),
- the active bin may receive both.
Distribution modes
The active bin is the only bin that can receive both tokens, and how its weight splits between X and Y depends onDistributionMode:
If the active bin currently holds zero liquidity, the composition ratio defaults to 50/50 —
there’s no existing composition to match yet.
A position entirely on one side of the active bin can only ever absorb the corresponding
token — any amount typed for the other side is silently unused (the CPI transfer for that
side is a no-op, not an error). You can derive this yourself from data the SDK already
exposes:
pool.activeBinId < position.data.lowerBinId means the position is entirely above
the active bin (X only, so a Y input should be disabled); pool.activeBinId > position.data.upperBinId means the mirror case (Y only). Worth checking before letting a
user type into the side that will silently be ignored.Withdrawing and closing
withdraw removes a proportional share (bps_to_remove, out of 10,000) of a position’s bins,
rounding down. It’s bps-proportional, not price-exposed — there’s no min_amount_out-style
slippage parameter, by design, since a withdrawal takes back a fixed fraction of whatever the
position currently holds rather than trading against price.
close_position burns the NFT and closes the Position account. It requires the position to
be fully empty first — zero shares in every bin, and zero unclaimed fees. A position that still
has anything in it (principal or fees) must be withdrawn and claimed before it can be closed.
Fees accrue independently of principal
LP swap fees accrue directly into the bins a position holds shares in, tracked per-bin on the position’s ownFeeState slots via a lazily-materialized, checkpoint-based accumulator (the
same pattern as a masterchef-style farm). claim_fee sweeps accrued fees for a caller-specified
sub-range of a position’s bins, independent of withdrawing principal — you can claim fees
without touching your deposited liquidity at all.
Fee state and bin-array lifecycle
A position’s unclaimedfee_owed lives on the Position account itself, not on the BinArray
— so a bin array can, in principle, be deleted (delete_bin_array, admin-only, requires the
array to be fully empty of shares) while a position still has fees owed against one of its bins.
If that happens, claim_fee for that bin fails until the array is recreated
(create_bin_array is idempotent) — the already-earned fee amount is untouched by the deletion,
since it was already materialized onto the Position account before the array went away.