mirror of
https://github.com/luxfi/maker.git
synced 2026-10-30 16:20:04 +00:00
reconcile.go was built to keep AMM pools coherent but only ever shipped V3 and V4 adapters, so the constant-product stack on mainnet 96369 had no maker. V2Venue is the third Corrector: Spot from getReserves(), Correct as one swapExactTokensForTokens whose amountOutMin is the pure decision's floor, so the router enforces the bound on-chain. Allowances are sized to the exact swap input, never unlimited. No decision logic is added — decideReconcile already sells a rich pool and buys a cheap one under band, notional, inventory and daily-loss bounds, which is what makes reserves oscillate instead of draining one way. Per-leg decimals is a real latent bug this exposed, not a V2-only concern. V3Venue/V4Venue take one decimals for both legs and walletWhole scaled both balances by it. Every market they were built for is 18/18 so it never showed; the mainnet AMM is WLUX(18)/USDC(6) and WLUX(18)/BTC(8). A 900,000-USDC balance read at 18 decimals is 0.0000000009, which is under any inventory floor, so every BuyBase would have held forever and the maker would have quoted one side only — exactly the drift-to-zero failure the caps exist to prevent. Decimals are now read per token and memoized on Pool9999; the configured value is only the fallback for a token that will not answer decimals(). 18/18 markets are byte-identical. CoherenceConfig.NoBook makes the 0x9999 book exactly as optional as the reconciler already was — the book loops become optionalTickers, and Setup skips the vault approve/deposit/initialize so an AMM-only run leaves no standing PoolManager allowance. Zero value is false, so every existing deployment is unaffected. Running the AMM maker alone is configuration (COHERENCE_BOOK=false VENUES=v2), not a new mode. go.sum: luxfi/zap v0.8.10's recorded zip hash no longer matched the published tag, failing every cold-cache build closed with a checksum SECURITY ERROR. Same class as the earlier luxfi/pq v1.0.3 fix; the /go.mod hash was already correct and is unchanged. Tests cover the decimal handling against real mainnet pool states, the empty-pool guard, and the two properties that make the loop safe to leave running: it trades back toward the reference from either side, and every floor halts it rather than draining the wallet. Co-authored-by: Hanzo Dev <[email protected]>
323 lines
14 KiB
Go
323 lines
14 KiB
Go
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||
// See the file LICENSE for licensing terms.
|
||
|
||
// reconcile.go is the CROSS-VENUE price reconciler: the loop that, per market,
|
||
// reads the marginal spot on every AMM venue the maker is configured to keep
|
||
// coherent (V3 pools, V4 pools once the migration workstream publishes them) and,
|
||
// when a venue's spot has drifted past the no-arb band from the live oracle
|
||
// reference, executes ONE bounded corrective trade on that venue to push its price
|
||
// back toward fair. It is the AMM-venue twin of coherence.go's arbMarket, which
|
||
// already keeps the 0x9999 CLOB BOOK coherent — so the book stays with arb, the
|
||
// AMM pools stay with this reconciler, and the two are orthogonal (no venue is
|
||
// corrected twice).
|
||
//
|
||
// DECOMPLECTED (church of Hickey): the DECISION (decideReconcile) is a pure
|
||
// function of (venue spot, oracle ref, caps, available inventory, day-loss used) →
|
||
// an action or a hold. It has no chain, no I/O, no clock, so it is exercised
|
||
// identically by the table-driven unit test and by the live loop. The READ
|
||
// (Venue.Spot) and the EXECUTE (Corrector.Correct) are the only effectful edges,
|
||
// each behind a one-method interface, one adapter per venue. Values, not places.
|
||
//
|
||
// FAIL-CLOSED (defense in depth): every corrective trade is gated by, in order —
|
||
// 1. the no-arb band (never trade inside it: no churn),
|
||
// 2. a per-cycle notional cap (never move more than authorized in one nudge),
|
||
// 3. inventory floors (never spend base/quote below the configured reserve),
|
||
// 4. a daily realized-loss bound (halt the venue's corrections for the day once
|
||
// cumulative adverse cost crosses the cap),
|
||
// 5. a slippage min-out floor (never accept a fill worse than oracle·(1−slip)).
|
||
// Any missing/zero price, any unreadable venue, any exceeded gate → HOLD, never a
|
||
// blind send. A correction is a nudge, not a market order to fair — repeated
|
||
// rate-limited nudges converge the pool while bounding value-at-risk per cycle.
|
||
|
||
package maker
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"math"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/luxfi/geth/common"
|
||
)
|
||
|
||
// ReconcileSide is the direction of a corrective trade against an AMM venue.
|
||
type ReconcileSide int
|
||
|
||
const (
|
||
// Hold means no corrective trade this cycle (within band / gated / no price).
|
||
Hold ReconcileSide = iota
|
||
// SellBase pushes a RICH pool (spot above ref) down: sell base into it for quote.
|
||
SellBase
|
||
// BuyBase pushes a CHEAP pool (spot below ref) up: buy base from it with quote.
|
||
BuyBase
|
||
)
|
||
|
||
func (s ReconcileSide) String() string {
|
||
switch s {
|
||
case SellBase:
|
||
return "sell-base"
|
||
case BuyBase:
|
||
return "buy-base"
|
||
default:
|
||
return "hold"
|
||
}
|
||
}
|
||
|
||
// ReconcileCaps are the fail-closed bounds on a corrective trade, all in WHOLE
|
||
// tokens (not wei) so the decision is unit-agnostic; the venue adapter scales to
|
||
// wei by the pair's decimals. Every field defaults to a safe posture (a zero
|
||
// notional cap holds; a zero band would trade on any drift, so the caller sets a
|
||
// real band — coherence_cmd defaults it).
|
||
type ReconcileCaps struct {
|
||
BandBps float64 // no-trade band: |spot-ref|/ref·1e4 must EXCEED this to trade
|
||
MaxNotionalQuote float64 // max quote notional moved per corrective trade (whole quote tokens)
|
||
SlippageBps float64 // min-out floor = expectedOut(at ref)·(1 - SlippageBps/1e4)
|
||
MinBaseReserve float64 // never let wallet base drop below this (whole base tokens)
|
||
MinQuoteReserve float64 // never let wallet quote drop below this (whole quote tokens)
|
||
DailyLossCapQuote float64 // 0 = unbounded; halt this venue's corrections once day loss ≥ cap (whole quote tokens)
|
||
}
|
||
|
||
// ReconcileAction is the pure decision's output: a bounded corrective trade, or a
|
||
// Hold with a human reason. Sizes are WHOLE tokens; MinOut is the slippage floor in
|
||
// the OUTPUT token (quote for a SellBase, base for a BuyBase).
|
||
type ReconcileAction struct {
|
||
Side ReconcileSide
|
||
BaseSize float64 // whole base tokens to sell (SellBase)
|
||
QuoteSize float64 // whole quote tokens to spend (BuyBase)
|
||
MinOut float64 // whole tokens: required minimum output (slippage floor)
|
||
GapBps float64 // signed (spot-ref)/ref·1e4, for logs + metrics
|
||
Reason string
|
||
}
|
||
|
||
// held builds a Hold action carrying the measured gap and a reason (never sends).
|
||
func held(gapBps float64, reason string) ReconcileAction {
|
||
return ReconcileAction{Side: Hold, GapBps: gapBps, Reason: reason}
|
||
}
|
||
|
||
// decideReconcile is the PURE reconciliation decision. Given a venue's marginal
|
||
// spot, the oracle reference, the caps, and the wallet's available base/quote plus
|
||
// the quote loss already realized on this venue today, it returns exactly one
|
||
// bounded corrective trade or a Hold. No side effects, no clock, no chain.
|
||
//
|
||
// spot, ref quote-per-base; both must be > 0 or the result is a Hold
|
||
// baseAvail wallet base balance in whole tokens (for the SellBase floor)
|
||
// quoteAvail wallet quote balance in whole tokens (for the BuyBase floor)
|
||
// dayLossUsedQuote cumulative realized adverse cost on this venue today (whole quote)
|
||
func decideReconcile(spot, ref float64, caps ReconcileCaps, baseAvail, quoteAvail, dayLossUsedQuote float64) ReconcileAction {
|
||
if ref <= 0 || spot <= 0 {
|
||
return held(0, "no price (venue or oracle non-positive)")
|
||
}
|
||
gapBps := (spot - ref) / ref * 1e4
|
||
// Fail-closed daily loss bound: once the realized adverse cost crosses the cap,
|
||
// this venue is done correcting for the day (a stuck pool must not bleed).
|
||
if caps.DailyLossCapQuote > 0 && dayLossUsedQuote >= caps.DailyLossCapQuote {
|
||
return held(gapBps, fmt.Sprintf("daily loss cap reached (%.6g >= %.6g quote)", dayLossUsedQuote, caps.DailyLossCapQuote))
|
||
}
|
||
if caps.MaxNotionalQuote <= 0 {
|
||
return held(gapBps, "notional cap is zero (correction disabled)")
|
||
}
|
||
if math.Abs(gapBps) <= caps.BandBps {
|
||
return held(gapBps, fmt.Sprintf("within band (|%.2f| <= %.2f bps)", gapBps, caps.BandBps))
|
||
}
|
||
slip := caps.SlippageBps / 1e4
|
||
|
||
if spot > ref {
|
||
// RICH pool: sell base into it for quote, pushing the price down toward ref.
|
||
// Cap the base sold by the quote-notional budget at the current (rich) spot.
|
||
baseSize := caps.MaxNotionalQuote / spot
|
||
room := baseAvail - caps.MinBaseReserve
|
||
if room <= 0 {
|
||
return held(gapBps, fmt.Sprintf("base at/under reserve (avail %.6g <= reserve %.6g)", baseAvail, caps.MinBaseReserve))
|
||
}
|
||
if baseSize > room {
|
||
baseSize = room
|
||
}
|
||
if baseSize <= 0 {
|
||
return held(gapBps, "computed base size non-positive")
|
||
}
|
||
// Selling baseSize base should return ≈ baseSize·ref quote at fair; floor at
|
||
// (1-slip) of that. ref (< spot) is the conservative valuation for the floor.
|
||
minOut := baseSize * ref * (1 - slip)
|
||
return ReconcileAction{
|
||
Side: SellBase, BaseSize: baseSize, MinOut: minOut, GapBps: gapBps,
|
||
Reason: fmt.Sprintf("pool rich by %.2f bps: sell %.6g base", gapBps, baseSize),
|
||
}
|
||
}
|
||
|
||
// CHEAP pool: buy base from it with quote, pushing the price up toward ref.
|
||
quoteSize := caps.MaxNotionalQuote
|
||
room := quoteAvail - caps.MinQuoteReserve
|
||
if room <= 0 {
|
||
return held(gapBps, fmt.Sprintf("quote at/under reserve (avail %.6g <= reserve %.6g)", quoteAvail, caps.MinQuoteReserve))
|
||
}
|
||
if quoteSize > room {
|
||
quoteSize = room
|
||
}
|
||
if quoteSize <= 0 {
|
||
return held(gapBps, "computed quote size non-positive")
|
||
}
|
||
// Spending quoteSize quote should return ≈ quoteSize/ref base at fair; floor at
|
||
// (1-slip). ref (> spot) is the conservative valuation for the base-out floor.
|
||
minOut := quoteSize / ref * (1 - slip)
|
||
return ReconcileAction{
|
||
Side: BuyBase, QuoteSize: quoteSize, MinOut: minOut, GapBps: gapBps,
|
||
Reason: fmt.Sprintf("pool cheap by %.2f bps: buy with %.6g quote", gapBps, quoteSize),
|
||
}
|
||
}
|
||
|
||
// --- venue abstraction (the effectful edges) ---------------------------------
|
||
|
||
// Venue reads a market's marginal spot (quote-per-base) on one liquidity venue.
|
||
// A venue with no live pool for the market returns an error and the reconciler
|
||
// skips it (never a fabricated price).
|
||
type Venue interface {
|
||
Name() string
|
||
Spot(ctx context.Context, m CoherenceMarket) (float64, error)
|
||
}
|
||
|
||
// Corrector is a Venue that can also execute a bounded corrective trade. Read-only
|
||
// venues (the book, corrected by arb) implement only Venue.
|
||
type Corrector interface {
|
||
Venue
|
||
// Correct executes act against the venue on behalf of acct. It returns the gas
|
||
// used and the realized quote loss (max(0, expectedOut@ref - actualOut) valued
|
||
// in quote), so the loop can charge the daily-loss bound from MEASURED shortfall.
|
||
// A DryRun result means the intended trade was logged, not sent (V4 before its
|
||
// execution ABI is confirmed): gas 0, loss 0.
|
||
Correct(ctx context.Context, acct Account, m CoherenceMarket, act ReconcileAction) (CorrectionResult, error)
|
||
}
|
||
|
||
// CorrectionResult is the measured outcome of one corrective trade.
|
||
type CorrectionResult struct {
|
||
Gas uint64
|
||
RealizedLossQuote float64 // max(0, expectedOut@ref - actualOut), valued in quote
|
||
TxHash common.Hash
|
||
DryRun bool
|
||
}
|
||
|
||
// venueLoss tracks per-venue, per-day realized loss for the daily-loss bound. Keyed
|
||
// by (venue, symbol); rolls over at UTC midnight so the cap is a daily budget.
|
||
type venueLoss struct {
|
||
mu sync.Mutex
|
||
day string // current UTC date (YYYY-MM-DD)
|
||
used map[string]float64 // "venue|symbol" -> realized quote loss today
|
||
}
|
||
|
||
func newVenueLoss() *venueLoss { return &venueLoss{used: map[string]float64{}} }
|
||
|
||
func (v *venueLoss) key(venue, symbol string) string { return venue + "|" + symbol }
|
||
|
||
// get returns today's realized loss for (venue, symbol), rolling the day if needed.
|
||
func (v *venueLoss) get(venue, symbol string, now time.Time) float64 {
|
||
v.mu.Lock()
|
||
defer v.mu.Unlock()
|
||
v.rollLocked(now)
|
||
return v.used[v.key(venue, symbol)]
|
||
}
|
||
|
||
// add charges realized loss and returns the new daily total for (venue, symbol).
|
||
func (v *venueLoss) add(venue, symbol string, loss float64, now time.Time) float64 {
|
||
v.mu.Lock()
|
||
defer v.mu.Unlock()
|
||
v.rollLocked(now)
|
||
k := v.key(venue, symbol)
|
||
v.used[k] += loss
|
||
return v.used[k]
|
||
}
|
||
|
||
func (v *venueLoss) rollLocked(now time.Time) {
|
||
d := now.UTC().Format("2006-01-02")
|
||
if d != v.day {
|
||
v.day = d
|
||
v.used = map[string]float64{}
|
||
}
|
||
}
|
||
|
||
// ReconcileConfig parameterizes the cross-venue reconciler. Empty Venues (or a nil
|
||
// slice) leaves the reconciler OFF — the coherence service runs exactly as before,
|
||
// so this is a compatible superset of the live deployment.
|
||
type ReconcileConfig struct {
|
||
Interval time.Duration // reconcile cadence (0 = disabled)
|
||
Caps ReconcileCaps
|
||
Decimals int // pair decimals for wei<->whole scaling (mainnet L*/WLUX = 18)
|
||
}
|
||
|
||
// reconcileMarket reads every configured AMM venue's spot for m, records the gap vs
|
||
// the oracle, and — for each Corrector venue past the band — executes one bounded
|
||
// corrective trade under all the caps. The 0x9999 BOOK is read here only for the
|
||
// cross-venue gap metric; its correction stays with arbMarket (orthogonal, no
|
||
// double-trade). Uses the MAKER account (the deep-inventory LP) for corrections.
|
||
func (s *CoherenceService) reconcileMarket(ctx context.Context, m CoherenceMarket) {
|
||
ref, err := s.oracle.GetMidPrice(ctx, m.Symbol)
|
||
if err != nil || ref <= 0 {
|
||
return
|
||
}
|
||
now := time.Now()
|
||
// Read every venue's spot for the cross-venue coherence view (book included).
|
||
for _, v := range s.venues {
|
||
spot, err := v.Spot(ctx, m)
|
||
if err != nil || spot <= 0 {
|
||
continue // no live pool on this venue for this market: skip, never fabricate
|
||
}
|
||
gapBps := (spot - ref) / ref * 1e4
|
||
s.metrics.recordVenue(v.Name(), m.Symbol, spot, gapBps)
|
||
|
||
cor, ok := v.(Corrector)
|
||
if !ok {
|
||
continue // read-only venue (book): arb corrects it
|
||
}
|
||
baseAvail, quoteAvail := s.walletWhole(m, s.reconcileCfg.Decimals)
|
||
used := s.loss.get(v.Name(), m.Symbol, now)
|
||
act := decideReconcile(spot, ref, s.reconcileCfg.Caps, baseAvail, quoteAvail, used)
|
||
if act.Side == Hold {
|
||
continue
|
||
}
|
||
res, err := cor.Correct(ctx, s.maker, m, act)
|
||
if err != nil {
|
||
s.logger.Warn("venue correct failed", "venue", v.Name(), "symbol", m.Symbol, "side", act.Side.String(), "error", err)
|
||
s.metrics.mu.Lock()
|
||
s.metrics.Errors++
|
||
s.metrics.mu.Unlock()
|
||
continue
|
||
}
|
||
s.metrics.addGas(res.Gas)
|
||
newUsed := used
|
||
if res.RealizedLossQuote > 0 {
|
||
newUsed = s.loss.add(v.Name(), m.Symbol, res.RealizedLossQuote, now)
|
||
}
|
||
s.metrics.mu.Lock()
|
||
s.metrics.Reconciles++
|
||
s.metrics.mu.Unlock()
|
||
s.logger.Info("venue reconcile",
|
||
"venue", v.Name(), "symbol", m.Symbol, "side", act.Side.String(),
|
||
"ref", round2(ref), "spot", round2(spot), "gap_bps", round2(gapBps),
|
||
"loss_quote", round2(res.RealizedLossQuote), "day_loss", round2(newUsed),
|
||
"dry_run", res.DryRun, "tx", res.TxHash.Hex())
|
||
}
|
||
}
|
||
|
||
// walletWhole reads the maker wallet's whole-token base/quote balances for a market
|
||
// (the inputs the SellBase/BuyBase floors gate on). A failed read yields 0, which is
|
||
// the safe posture — a zero balance holds any correction (never sends past a floor
|
||
// it cannot verify).
|
||
//
|
||
// Each leg is scaled by ITS OWN decimals, read from the token (memoized). decimals
|
||
// is only the fallback for a leg that will not answer decimals(). A single scale for
|
||
// both legs is wrong for any pair that is not d/d — WLUX(18)/USDC(6) would read a
|
||
// 900,000-USDC balance as 0.0000000009 and therefore hold every BuyBase forever,
|
||
// i.e. the maker would quote one side only and walk the pool down. For the 18/18
|
||
// markets this venue already served, the per-leg read returns 18 and behaviour is
|
||
// unchanged.
|
||
func (s *CoherenceService) walletWhole(m CoherenceMarket, decimals int) (baseAvail, quoteAvail float64) {
|
||
baseScale := math.Pow10(s.p9.ERC20DecimalsOr(m.Base, decimals))
|
||
quoteScale := math.Pow10(s.p9.ERC20DecimalsOr(m.Quote, decimals))
|
||
if b, err := s.p9.ERC20BalanceOf(m.Base, s.maker.Addr); err == nil {
|
||
baseAvail = weiToWholeF(b, baseScale)
|
||
}
|
||
if q, err := s.p9.ERC20BalanceOf(m.Quote, s.maker.Addr); err == nil {
|
||
quoteAvail = weiToWholeF(q, quoteScale)
|
||
}
|
||
return baseAvail, quoteAvail
|
||
}
|