Files
maker/v3positions.go
zeekayandHanzo Dev 1ac4c806b2 Go 1.27.1, and the code it can say more simply
Modules declare go 1.27.1 and the build images follow. go fix applied the
modernizers; omitzero is not applied, since it changes what the JSON says.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-16 19:23:15 -07:00

277 lines
13 KiB
Go

// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// v3positions.go is the V3 concrete PositionManager: it enumerates the ops account's
// own concentrated-liquidity NFTs on a market (NonfungiblePositionManager) and
// recenters one — decrease all → collect → mint a fresh range around the oracle.
// It only ever sees the ops account's positions (balanceOf/tokenOfOwnerByIndex are
// owner-scoped), so the treasury (0x9011) positions are NEVER touched — exactly the
// "treasury read-only" boundary the task draws.
//
// The recenter SENDS only when dryRun is false (REBALANCE_EXECUTE=true); by default
// it computes and returns the plan (new ticks + amounts) without moving value. The
// tick bounds come from the round-trip-tested priceToTick (rebalance.go); the pool
// price + range come from the same slot0 reader provider_v3.go uses.
package maker
import (
"context"
"fmt"
"math/big"
"strings"
"time"
"github.com/luxfi/geth/accounts/abi"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/types"
)
// v3NPMABI is the NonfungiblePositionManager surface: enumerate + read a position,
// decrease its liquidity, collect the freed tokens, mint a fresh position.
const v3NPMABI = `[
{"inputs":[{"name":"owner","type":"address"}],"name":"balanceOf","outputs":[{"name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
{"inputs":[{"name":"owner","type":"address"},{"name":"index","type":"uint256"}],"name":"tokenOfOwnerByIndex","outputs":[{"name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
{"inputs":[{"name":"tokenId","type":"uint256"}],"name":"positions","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"}],"stateMutability":"view","type":"function"},
{"inputs":[{"components":[{"name":"tokenId","type":"uint256"},{"name":"liquidity","type":"uint128"},{"name":"amount0Min","type":"uint256"},{"name":"amount1Min","type":"uint256"},{"name":"deadline","type":"uint256"}],"name":"params","type":"tuple"}],"name":"decreaseLiquidity","outputs":[{"name":"amount0","type":"uint256"},{"name":"amount1","type":"uint256"}],"stateMutability":"payable","type":"function"},
{"inputs":[{"components":[{"name":"tokenId","type":"uint256"},{"name":"recipient","type":"address"},{"name":"amount0Max","type":"uint128"},{"name":"amount1Max","type":"uint128"}],"name":"params","type":"tuple"}],"name":"collect","outputs":[{"name":"amount0","type":"uint256"},{"name":"amount1","type":"uint256"}],"stateMutability":"payable","type":"function"},
{"inputs":[{"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"}],"name":"params","type":"tuple"}],"name":"mint","outputs":[{"name":"tokenId","type":"uint256"},{"name":"liquidity","type":"uint128"},{"name":"amount0","type":"uint256"},{"name":"amount1","type":"uint256"}],"stateMutability":"payable","type":"function"}]`
// v3TickSpacings is the canonical fee→tickSpacing map (the same the V3 factory
// enforces): 0.05% ticks by 10, 0.30% by 60, 1.00% by 200.
var v3TickSpacings = map[uint32]int32{500: 10, 3000: 60, 10000: 200}
// V3PositionManager recenters the ops account's own V3 positions on a market.
type V3PositionManager struct {
rpc string
factory common.Address
npm common.Address
p9 *Pool9999
npmABI abi.ABI
erc abi.ABI
decimals int
owner common.Address // enumerate only THIS owner's positions (the ops account)
}
// NewV3PositionManager builds the manager, or nil if rpc/factory/npm are not all set.
// owner is the ops account whose positions are managed (never the treasury).
func NewV3PositionManager(rpc, factoryAddr, npmAddr string, p9 *Pool9999, owner common.Address, decimals int) (*V3PositionManager, error) {
if strings.TrimSpace(rpc) == "" || strings.TrimSpace(factoryAddr) == "" || strings.TrimSpace(npmAddr) == "" {
return nil, nil
}
na, err := abi.JSON(strings.NewReader(v3NPMABI))
if err != nil {
return nil, fmt.Errorf("v3 npm abi: %w", err)
}
ea, err := abi.JSON(strings.NewReader(erc20ABI))
if err != nil {
return nil, fmt.Errorf("erc20 abi: %w", err)
}
if decimals <= 0 {
decimals = 18
}
return &V3PositionManager{
rpc: rpc, factory: common.HexToAddress(factoryAddr), npm: common.HexToAddress(npmAddr),
p9: p9, npmABI: na, erc: ea, decimals: decimals, owner: owner,
}, nil
}
// Name identifies the manager in logs + metrics.
func (v *V3PositionManager) Name() string { return "v3-npm" }
// Positions enumerates the ops owner's V3 positions that match the market's pair and
// prices each: the range [low, high] in quote-per-base (from the position ticks) and
// the live pool price (from slot0). Positions on other pairs, or with zero liquidity,
// are skipped. Read-only.
func (v *V3PositionManager) Positions(ctx context.Context, m CoherenceMarket) ([]RangePosition, error) {
nRaw, err := v.readCall(ctx, v.npm, v.npmABI, "balanceOf", v.owner)
if err != nil {
return nil, fmt.Errorf("npm balanceOf: %w", err)
}
n := int(nRaw[0].(*big.Int).Int64())
baseIsToken0 := m.Base.Cmp(m.Quote) < 0
var out []RangePosition
for i := range n {
idRaw, err := v.readCall(ctx, v.npm, v.npmABI, "tokenOfOwnerByIndex", v.owner, big.NewInt(int64(i)))
if err != nil {
return out, fmt.Errorf("tokenOfOwnerByIndex[%d]: %w", i, err)
}
tokenID := idRaw[0].(*big.Int)
pos, err := v.readCall(ctx, v.npm, v.npmABI, "positions", tokenID)
if err != nil {
return out, fmt.Errorf("positions(%s): %w", tokenID, err)
}
token0 := pos[2].(common.Address)
token1 := pos[3].(common.Address)
if !pairMatches(token0, token1, m.Base, m.Quote) {
continue
}
liquidity := pos[7].(*big.Int)
if liquidity == nil || liquidity.Sign() == 0 {
continue
}
tickLower := int32(pos[5].(*big.Int).Int64())
tickUpper := int32(pos[6].(*big.Int).Int64())
// Tick bounds → quote-per-base prices (orientation-aware). token1<->token0
// invert the ordering when base is token1, so sort into [low, high].
p1 := tickToPrice(tickLower, v.decimals, v.decimals, baseIsToken0)
p2 := tickToPrice(tickUpper, v.decimals, v.decimals, baseIsToken0)
low, high := p1, p2
if low > high {
low, high = high, low
}
pool, err := v3FindPool(ctx, v.rpc, v.factory, m.Base, m.Quote)
if err != nil {
continue
}
sqrtP, err := v3Slot0SqrtPrice(ctx, v.rpc, pool)
if err != nil {
continue
}
poolPrice := v3SpotFromSqrt(sqrtP, V3Pair{Base: m.Base, Quote: m.Quote, BaseDecimals: v.decimals, QuoteDecimals: v.decimals})
out = append(out, RangePosition{ID: tokenID.String(), RangeLow: low, RangeHigh: high, PoolPrice: poolPrice})
}
return out, nil
}
// Recenter decreases the position's full liquidity, collects the freed tokens to the
// ops account, and mints a fresh position on the new [newLow, newHigh] range. When
// dryRun is true it computes and returns the plan (tick bounds) WITHOUT sending —
// the default posture. Each SEND is status-verified (mustOK); a revert aborts the
// recenter, leaving no half-moved position (decrease then collect is atomic-enough:
// a failed mint leaves the freed tokens safely in the ops wallet, re-mintable).
func (v *V3PositionManager) Recenter(ctx context.Context, acct Account, m CoherenceMarket, pos RangePosition, newLow, newHigh float64, dryRun bool) (RebalanceResult, error) {
tokenID, ok := new(big.Int).SetString(pos.ID, 10)
if !ok {
return RebalanceResult{}, fmt.Errorf("bad position id %q", pos.ID)
}
// Re-read the live position for its exact fee + liquidity (state may have moved).
praw, err := v.readCall(ctx, v.npm, v.npmABI, "positions", tokenID)
if err != nil {
return RebalanceResult{}, fmt.Errorf("positions(%s): %w", tokenID, err)
}
token0 := praw[2].(common.Address)
token1 := praw[3].(common.Address)
fee := uint32(praw[4].(*big.Int).Uint64())
liquidity := praw[7].(*big.Int)
spacing := v3TickSpacings[fee]
if spacing == 0 {
spacing = 60
}
baseIsToken0 := m.Base.Cmp(m.Quote) < 0
newTickLower := priceToTick(newLow, v.decimals, v.decimals, baseIsToken0, spacing)
newTickUpper := priceToTick(newHigh, v.decimals, v.decimals, baseIsToken0, spacing)
if !baseIsToken0 {
// base is token1: a lower quote-per-base price maps to a HIGHER tick, so the
// price-ordered [low, high] inverts in tick space — swap to keep lower<upper.
newTickLower, newTickUpper = newTickUpper, newTickLower
}
if newTickLower >= newTickUpper {
return RebalanceResult{}, fmt.Errorf("degenerate new range: ticks [%d, %d]", newTickLower, newTickUpper)
}
if dryRun {
return RebalanceResult{DryRun: true}, nil // plan computed; nothing sent
}
deadline := uint64(time.Now().Add(10 * time.Minute).Unix())
var txs []common.Hash
var gas uint64
// 1) Decrease the full liquidity (min-out 0: recenter is not price-sensitive on
// the withdraw; the freed tokens re-enter as the new position).
decParams := struct {
TokenId, Liquidity, Amount0Min, Amount1Min, Deadline *big.Int
}{tokenID, liquidity, big.NewInt(0), big.NewInt(0), new(big.Int).SetUint64(deadline)}
if r, err := v.sendNPM(acct, "decreaseLiquidity", 600_000, decParams); err != nil {
return RebalanceResult{Txs: txs, Gas: gas}, err
} else {
txs = append(txs, r.TxHash)
gas += r.GasUsed
}
// 2) Collect the freed principal + fees to the ops account.
colParams := struct {
TokenId *big.Int
Recipient common.Address
Amount0Max, Amount1Max *big.Int
}{tokenID, acct.Addr, uint128MaxMaker(), uint128MaxMaker()}
if r, err := v.sendNPM(acct, "collect", 400_000, colParams); err != nil {
return RebalanceResult{Txs: txs, Gas: gas}, err
} else {
txs = append(txs, r.TxHash)
gas += r.GasUsed
}
// 3) Mint the recentered position with the collected balances. Approve the NPM to
// pull both tokens, then mint the freed balances into the new range.
bal0, _ := v.p9.ERC20BalanceOf(token0, acct.Addr)
bal1, _ := v.p9.ERC20BalanceOf(token1, acct.Addr)
if err := v.approveNPM(acct, token0, bal0); err != nil {
return RebalanceResult{Txs: txs, Gas: gas}, err
}
if err := v.approveNPM(acct, token1, bal1); err != nil {
return RebalanceResult{Txs: txs, Gas: gas}, err
}
mintParams := struct {
Token0, Token1 common.Address
Fee *big.Int
TickLower, TickUpper *big.Int
Amount0Desired, Amount1Desired, Amount0Min, Amount1Min *big.Int
Recipient common.Address
Deadline *big.Int
}{
token0, token1, big.NewInt(int64(fee)),
big.NewInt(int64(newTickLower)), big.NewInt(int64(newTickUpper)),
bal0, bal1, big.NewInt(0), big.NewInt(0),
acct.Addr, new(big.Int).SetUint64(deadline),
}
if r, err := v.sendNPM(acct, "mint", 700_000, mintParams); err != nil {
return RebalanceResult{Txs: txs, Gas: gas}, err
} else {
txs = append(txs, r.TxHash)
gas += r.GasUsed
}
return RebalanceResult{Gas: gas, Txs: txs}, nil
}
func (v *V3PositionManager) sendNPM(acct Account, method string, gas uint64, params any) (*types.Receipt, error) {
data, err := v.npmABI.Pack(method, params)
if err != nil {
return nil, fmt.Errorf("pack %s: %w", method, err)
}
return v.p9.mustOK(acct, &v.npm, big.NewInt(0), data, gas, "npm "+method)
}
func (v *V3PositionManager) approveNPM(owner Account, token common.Address, amount *big.Int) error {
if amount == nil || amount.Sign() == 0 {
return nil
}
data, err := v.erc.Pack("approve", v.npm, amount)
if err != nil {
return fmt.Errorf("pack approve: %w", err)
}
_, err = v.p9.mustOK(owner, &token, big.NewInt(0), data, 120_000, "npm approve")
return err
}
func (v *V3PositionManager) readCall(ctx context.Context, to common.Address, a abi.ABI, method string, args ...any) ([]any, error) {
data, err := a.Pack(method, args...)
if err != nil {
return nil, fmt.Errorf("pack %s: %w", method, err)
}
out, err := callContract(ctx, v.rpc, to, data)
if err != nil {
return nil, err
}
return a.Unpack(method, out)
}
// pairMatches reports whether a position's (token0, token1) is the market's pair,
// in either order.
func pairMatches(token0, token1, base, quote common.Address) bool {
return (token0 == base && token1 == quote) || (token0 == quote && token1 == base)
}
// uint128MaxMaker is amount{0,1}Max for collect — sweep everything owed.
func uint128MaxMaker() *big.Int {
return new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 128), big.NewInt(1))
}