mirror of
https://github.com/luxfi/maker.git
synced 2026-10-30 16:20:04 +00:00
Venue reconciler pegs every live venue (0x9999 book, V3 slot0, V4 via state.json) to the Kraken oracle; drift past band => bounded corrective trade with notional caps + inventory floors. Rebalance re-centers ops range positions on oracle as price moves (rate-limited, idempotent). V4 pools auto-discovered from the migration's state.json (absent => book+V3 only). Tests: reconcile/rebalance/v4state all pass. Co-authored-by: Hanzo Dev <[email protected]>
256 lines
9.1 KiB
Go
256 lines
9.1 KiB
Go
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// v4state.go is the TOLERANT reader of the V4 deployment descriptor the parallel
|
|
// migration workstream publishes at ~/work/lux/migration/state.json — plus the V4
|
|
// venue that reconciles a V4 pool toward the oracle. The schema is OWNED by that
|
|
// workstream; this file only READS it and NEVER writes it. Its absence is a
|
|
// first-class state, not an error: no file → no V4 venue → the reconciler runs on
|
|
// the book + V3 exactly as before. When the file lands, the V4 PoolManager address
|
|
// and per-market pool keys are picked up on the next pod start (12-factor: the state
|
|
// path is env, the values are read fresh).
|
|
//
|
|
// FAIL-CLOSED on the unknown execution ABI: until the migration workstream confirms
|
|
// the V4 PoolManager speaks the canonical 0x9999 CLOB swap ABI (the maker's own
|
|
// architecture note says V2/V3/V4 are liquidity modes of one PoolManager, so it is
|
|
// the LIKELY case — but "likely" is not "verified against real value"), V4
|
|
// corrections default to DRY-RUN: the intended bounded trade is COMPUTED, METRICED,
|
|
// and LOGGED, but not sent. An operator flips V4_EXECUTE=true only after confirming
|
|
// the deployed V4 PoolManager's swap entrypoint — one explicit opt-in, never a
|
|
// guessed-ABI tx against mainnet value.
|
|
|
|
package maker
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"math"
|
|
"math/big"
|
|
"os"
|
|
"strings"
|
|
|
|
dex "github.com/luxfi/precompile/dex"
|
|
"github.com/luxfi/geth/common"
|
|
)
|
|
|
|
// V4Pool is one V4 market as published by the migration workstream. Any field the
|
|
// descriptor omits is derived: an omitted PoolID is computed from the pool key; an
|
|
// omitted Fee/TickSpacing default to the canonical 3000/60 (the same PoolKeyFor uses).
|
|
type V4Pool struct {
|
|
Symbol string
|
|
Base common.Address
|
|
Quote common.Address
|
|
Fee uint32
|
|
TickSpacing int32
|
|
Hooks common.Address
|
|
PoolID common.Hash
|
|
}
|
|
|
|
// V4State is the parsed, tolerant view of state.json: the V4 PoolManager address and
|
|
// the per-symbol pool descriptors. A nil *V4State means "V4 not deployed".
|
|
type V4State struct {
|
|
PoolManager common.Address
|
|
Pools map[string]V4Pool // keyed by market symbol (e.g. "LBTC/LUX")
|
|
}
|
|
|
|
// rawV4State is the permissive decode target: it accepts the several field names the
|
|
// descriptor might use for the manager, and pools as either a symbol→object map or
|
|
// an array of objects with a "symbol" field. Unknown fields are ignored by
|
|
// encoding/json, so a richer schema than this still parses.
|
|
type rawV4State struct {
|
|
PoolManager string `json:"poolManager"`
|
|
PoolManagerU string `json:"pool_manager"`
|
|
V4PoolManager string `json:"v4PoolManager"`
|
|
Manager string `json:"manager"`
|
|
PoolsMap map[string]json.RawMessage `json:"pools"`
|
|
PoolsArr []rawV4Pool `json:"poolList"`
|
|
}
|
|
|
|
type rawV4Pool struct {
|
|
Symbol string `json:"symbol"`
|
|
Base string `json:"base"`
|
|
Quote string `json:"quote"`
|
|
Fee uint32 `json:"fee"`
|
|
TickSpacing int32 `json:"tickSpacing"`
|
|
Hooks string `json:"hooks"`
|
|
PoolID string `json:"poolId"`
|
|
}
|
|
|
|
// LoadV4State reads and parses the V4 descriptor at path. It returns (nil, nil) when
|
|
// the descriptor is not there — the canonical "V4 not yet deployed" signal — so the
|
|
// caller tolerates absence by design. A present-but-malformed file IS an error
|
|
// (fail-loud on corruption, fail-closed on absence). An explicit poolManagerOverride
|
|
// (from V4_POOL_MANAGER) wins over the file's address, so V4 can be armed by env the
|
|
// moment the address is known, before or instead of the file.
|
|
func LoadV4State(path, poolManagerOverride string) (*V4State, error) {
|
|
var raw rawV4State
|
|
haveFile := false
|
|
if strings.TrimSpace(path) != "" {
|
|
b, err := os.ReadFile(path)
|
|
switch {
|
|
case err == nil:
|
|
if uerr := json.Unmarshal(b, &raw); uerr != nil {
|
|
return nil, fmt.Errorf("v4 state %s: %w", path, uerr)
|
|
}
|
|
haveFile = true
|
|
case errors.Is(err, fs.ErrNotExist):
|
|
// absent: fall through to the env override (or nil)
|
|
default:
|
|
return nil, fmt.Errorf("v4 state %s: %w", path, err)
|
|
}
|
|
}
|
|
|
|
mgrHex := firstNonEmpty(poolManagerOverride, raw.PoolManager, raw.PoolManagerU, raw.V4PoolManager, raw.Manager)
|
|
if mgrHex == "" {
|
|
return nil, nil // no manager anywhere → V4 not deployed
|
|
}
|
|
st := &V4State{PoolManager: common.HexToAddress(mgrHex), Pools: map[string]V4Pool{}}
|
|
if !haveFile {
|
|
return st, nil // env override only: pool keys derived per-market from base/quote
|
|
}
|
|
|
|
add := func(rp rawV4Pool) {
|
|
sym := strings.TrimSpace(rp.Symbol)
|
|
if sym == "" {
|
|
return
|
|
}
|
|
p := V4Pool{
|
|
Symbol: sym,
|
|
Base: common.HexToAddress(rp.Base),
|
|
Quote: common.HexToAddress(rp.Quote),
|
|
Fee: rp.Fee, TickSpacing: rp.TickSpacing,
|
|
Hooks: common.HexToAddress(rp.Hooks),
|
|
}
|
|
if h := strings.TrimSpace(rp.PoolID); h != "" {
|
|
p.PoolID = common.HexToHash(h)
|
|
}
|
|
st.Pools[sym] = p
|
|
}
|
|
for sym, rm := range raw.PoolsMap {
|
|
var rp rawV4Pool
|
|
_ = json.Unmarshal(rm, &rp)
|
|
if rp.Symbol == "" {
|
|
rp.Symbol = sym
|
|
}
|
|
add(rp)
|
|
}
|
|
for _, rp := range raw.PoolsArr {
|
|
add(rp)
|
|
}
|
|
return st, nil
|
|
}
|
|
|
|
func firstNonEmpty(xs ...string) string {
|
|
for _, x := range xs {
|
|
if strings.TrimSpace(x) != "" {
|
|
return strings.TrimSpace(x)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// poolKeyFor resolves the V4 pool key for a coherence market: the descriptor's
|
|
// explicit fee/tickSpacing/hooks if published, else the canonical PoolKeyFor
|
|
// (3000/60/no-hooks) the rest of the stack uses.
|
|
func (st *V4State) poolKeyFor(m CoherenceMarket) (dex.PoolKey, common.Hash) {
|
|
if p, ok := st.Pools[m.Symbol]; ok && p.Fee != 0 {
|
|
c0, c1 := m.Base, m.Quote
|
|
if c0.Cmp(c1) > 0 {
|
|
c0, c1 = c1, c0
|
|
}
|
|
key := dex.PoolKey{
|
|
Currency0: dex.Currency{Address: c0}, Currency1: dex.Currency{Address: c1},
|
|
Fee: p.Fee, TickSpacing: p.TickSpacing, Hooks: p.Hooks,
|
|
}
|
|
id := p.PoolID
|
|
if id == (common.Hash{}) {
|
|
id = common.Hash(key.ID())
|
|
}
|
|
return key, id
|
|
}
|
|
key := PoolKeyFor(m.Base, m.Quote)
|
|
return key, common.Hash(key.ID())
|
|
}
|
|
|
|
// V4Venue reconciles a V4 pool toward the oracle over the canonical CLOB PoolManager
|
|
// ABI, routed to the V4 manager address from state.json. Reads use BestBidAskAt;
|
|
// corrections use SwapAt — but ONLY when execute is true (V4_EXECUTE), else the
|
|
// bounded trade is dry-run (computed + logged, not sent). This keeps V4 honest
|
|
// before its execution ABI is confirmed against real value.
|
|
type V4Venue struct {
|
|
state *V4State
|
|
p9 *Pool9999
|
|
execute bool
|
|
decimals int
|
|
}
|
|
|
|
// NewV4Venue builds the V4 venue from a loaded state (nil state → nil venue, i.e.
|
|
// V4 not deployed). execute gates whether corrections send or dry-run.
|
|
func NewV4Venue(state *V4State, p9 *Pool9999, execute bool, decimals int) *V4Venue {
|
|
if state == nil {
|
|
return nil
|
|
}
|
|
if decimals <= 0 {
|
|
decimals = 18
|
|
}
|
|
return &V4Venue{state: state, p9: p9, execute: execute, decimals: decimals}
|
|
}
|
|
|
|
// Name identifies the venue in logs + metrics.
|
|
func (v *V4Venue) Name() string { return "v4" }
|
|
|
|
// Spot reads the V4 pool mid from the V4 PoolManager's getBestBidAsk (best bid/ask
|
|
// average). An unreadable V4 pool errors → the reconciler skips it (never fabricated).
|
|
func (v *V4Venue) Spot(_ context.Context, m CoherenceMarket) (float64, error) {
|
|
_, id := v.state.poolKeyFor(m)
|
|
bid, ask, fromBook, err := v.p9.BestBidAskAt(v.state.PoolManager, id)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if !fromBook || bid <= 0 || ask <= 0 {
|
|
return 0, fmt.Errorf("v4: no live bid/ask for %s", m.Symbol)
|
|
}
|
|
return (bid + ask) / 2, nil
|
|
}
|
|
|
|
// Correct executes (or dry-runs) one bounded corrective swap on the V4 pool via the
|
|
// canonical CLOB swap ABI (SwapAt), keyed by the V4 pool key from state.json. The
|
|
// min-out floor from the pure decision is carried; a SellBase pushes a rich pool
|
|
// down, a BuyBase pushes a cheap pool up. When execute is false, it returns a
|
|
// DryRun result (computed, logged upstream, not sent).
|
|
func (v *V4Venue) Correct(_ context.Context, acct Account, m CoherenceMarket, act ReconcileAction) (CorrectionResult, error) {
|
|
key, _ := v.state.poolKeyFor(m)
|
|
scale := math.Pow10(v.decimals)
|
|
// zeroForOne sells currency0; base==currency0 iff base sorts below quote.
|
|
sellBaseZeroFor := m.Base.Cmp(m.Quote) < 0
|
|
var zeroForOne bool
|
|
var amountInWei = big.NewInt(0)
|
|
var minOutWei uint64
|
|
switch act.Side {
|
|
case SellBase:
|
|
zeroForOne = sellBaseZeroFor
|
|
amountInWei = wholeToWei(act.BaseSize, scale)
|
|
minOutWei = wholeToWeiU64(act.MinOut, scale)
|
|
case BuyBase:
|
|
zeroForOne = !sellBaseZeroFor
|
|
amountInWei = wholeToWei(act.QuoteSize, scale)
|
|
minOutWei = wholeToWeiU64(act.MinOut, scale)
|
|
default:
|
|
return CorrectionResult{}, fmt.Errorf("v4 correct: nothing to do (hold)")
|
|
}
|
|
if amountInWei.Sign() <= 0 {
|
|
return CorrectionResult{}, fmt.Errorf("v4 correct: non-positive amountIn")
|
|
}
|
|
if !v.execute {
|
|
return CorrectionResult{DryRun: true}, nil // computed + logged, not sent
|
|
}
|
|
r, err := v.p9.SwapAt(acct, v.state.PoolManager, key, zeroForOne, amountInWei.Int64(), minOutWei)
|
|
if err != nil {
|
|
return CorrectionResult{}, err
|
|
}
|
|
return CorrectionResult{Gas: r.GasUsed, TxHash: r.TxHash}, nil
|
|
}
|