mirror of
https://github.com/luxfi/maker.git
synced 2026-10-30 16:20:04 +00:00
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]>
277 lines
13 KiB
Go
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))
|
|
}
|