Files
maker/reconcile.go
zeekayandHanzo Dev 78b31552c9 feat(venue): constant-product AMM venue + per-leg decimals
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]>
2026-07-28 01:30:23 -07:00

323 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}