Files
zeekayandHanzo Dev b52cb4efc5 coherence: cross-venue reconciler + position rebalance + V4 discovery
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]>
2026-07-04 20:48:56 -07:00

270 lines
11 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.
// v3router.go is the WRITE path for the V3 venue: a minimal SwapRouter02 client the
// reconciler uses to execute a bounded corrective swap on a Uniswap-V3-style pool.
// The READ path (marginal spot from slot0) is already provider_v3.go's v3Spot — one
// price reader, reused. This file adds only the corrective-trade edge:
// exactInputSingle with an amountOutMinimum floor derived from the oracle, so a
// correction can never be sandwiched below oracle·(1−slippage).
//
// The SwapRouter02 exactInputSingle params tuple carries NO deadline (that is the
// '02' variant, distinct from the original SwapRouter) — the migration/exchange
// stack deploys SwapRouter02 (0x939bC0…), so this matches the on-chain ABI. The
// calldata is built with the geth ABI encoder (one ABI string, the one place this
// method is packed).
package maker
import (
"context"
"fmt"
"math"
"math/big"
"strings"
"github.com/luxfi/crypto"
"github.com/luxfi/geth/accounts/abi"
"github.com/luxfi/geth/common"
)
// sigV3PoolFee is the V3 pool fee() getter selector (uint24 fee tier), computed
// from the signature so it can never drift from a hand-copied hex.
var sigV3PoolFee = crypto.Keccak256([]byte("fee()"))[:4]
// v3PoolFee reads a V3 pool's fee tier (basis-points·100, e.g. 3000 = 0.30%) from
// its fee() getter — the tier the corrective swap must target so the write pool is
// the pool we priced.
func v3PoolFee(ctx context.Context, rpcURL string, pool common.Address) (uint32, error) {
res, err := callContract(ctx, rpcURL, pool, sigV3PoolFee)
if err != nil {
return 0, err
}
if len(res) < 32 {
return 0, fmt.Errorf("v3 fee(): short result (%d bytes)", len(res))
}
return uint32(new(big.Int).SetBytes(res[:32]).Uint64()), nil
}
// v3RouterABI is the minimal SwapRouter02 surface: exactInputSingle (single-hop
// exact-input swap with a min-out floor and an optional sqrt price limit).
const v3RouterABI = `[
{"inputs":[{"components":[{"name":"tokenIn","type":"address"},{"name":"tokenOut","type":"address"},{"name":"fee","type":"uint24"},{"name":"recipient","type":"address"},{"name":"amountIn","type":"uint256"},{"name":"amountOutMinimum","type":"uint256"},{"name":"sqrtPriceLimitX96","type":"uint160"}],"name":"params","type":"tuple"}],"name":"exactInputSingle","outputs":[{"name":"amountOut","type":"uint256"}],"stateMutability":"payable","type":"function"}]`
// v3ExactInParams mirrors the ABI tuple (field order is load-bearing for packing).
type v3ExactInParams struct {
TokenIn common.Address
TokenOut common.Address
Fee *big.Int
Recipient common.Address
AmountIn *big.Int
AmountOutMinimum *big.Int
SqrtPriceLimitX96 *big.Int
}
// V3Venue is the V3-pool Corrector: it reads marginal spot via the factory+pool
// slot0 (provider_v3.go) and executes a bounded corrective exactInputSingle via the
// SwapRouter02. It resolves the pool's fee tier once per correction (the same tier
// v3FindPool selects for the read), so the read price and the write pool agree.
type V3Venue struct {
rpc string
factory common.Address
router common.Address
p9 *Pool9999
erc abi.ABI // ERC-20 approve
rtr abi.ABI // router exactInputSingle
decimals int
}
// NewV3Venue builds the V3 venue, or nil if rpc/factory/router are not all set (no
// V3 config → the reconciler simply has no V3 venue). One pair-decimals value scales
// wei<->whole (mainnet L*/WLUX are all 18).
func NewV3Venue(rpc, factoryAddr, routerAddr string, p9 *Pool9999, decimals int) (*V3Venue, error) {
if strings.TrimSpace(rpc) == "" || strings.TrimSpace(factoryAddr) == "" || strings.TrimSpace(routerAddr) == "" {
return nil, nil
}
rtr, err := abi.JSON(strings.NewReader(v3RouterABI))
if err != nil {
return nil, fmt.Errorf("v3 router abi: %w", err)
}
erc, err := abi.JSON(strings.NewReader(erc20ABI))
if err != nil {
return nil, fmt.Errorf("erc20 abi: %w", err)
}
if decimals <= 0 {
decimals = 18
}
return &V3Venue{
rpc: rpc, factory: common.HexToAddress(factoryAddr), router: common.HexToAddress(routerAddr),
p9: p9, erc: erc, rtr: rtr, decimals: decimals,
}, nil
}
// Name identifies the venue in logs + metrics.
func (v *V3Venue) Name() string { return "v3" }
// Spot returns the market's marginal spot (quote-per-base) from the V3 pool slot0,
// reusing provider_v3.go's v3Spot so there is one V3 price reader.
func (v *V3Venue) Spot(ctx context.Context, m CoherenceMarket) (float64, error) {
pairs := map[string]V3Pair{m.Symbol: {
Base: m.Base, Quote: m.Quote, BaseDecimals: v.decimals, QuoteDecimals: v.decimals,
}}
return v3Spot(ctx, v.rpc, v.factory, pairs, m.Symbol)
}
// Correct executes one bounded corrective exactInputSingle on the V3 pool: a
// SellBase swaps base→quote (pushing a rich pool down), a BuyBase swaps quote→base
// (pushing a cheap pool up). The min-out floor from the pure decision is scaled to
// wei and passed as amountOutMinimum, so the SwapRouter refuses a fill below
// oracle·(1−slippage). It resolves the fee tier via v3FindPool (same tier as the
// read), approves the router for the exact input, and verifies status==1. Realized
// loss is measured from the recipient's actual output vs the fair-value expectation.
func (v *V3Venue) Correct(ctx context.Context, acct Account, m CoherenceMarket, act ReconcileAction) (CorrectionResult, error) {
var tokenIn, tokenOut common.Address
var amountInWhole, expectOutWhole float64
switch act.Side {
case SellBase:
tokenIn, tokenOut = m.Base, m.Quote
amountInWhole, expectOutWhole = act.BaseSize, act.MinOut
case BuyBase:
tokenIn, tokenOut = m.Quote, m.Base
amountInWhole, expectOutWhole = act.QuoteSize, act.MinOut
default:
return CorrectionResult{}, fmt.Errorf("v3 correct: nothing to do (hold)")
}
// Resolve the pool's fee tier (same selection the read used) so we swap the pool
// we priced. No pool → skip (the reconciler treats this as "no V3 venue here").
pool, err := v3FindPool(ctx, v.rpc, v.factory, tokenIn, tokenOut)
if err != nil {
return CorrectionResult{}, fmt.Errorf("v3 no pool: %w", err)
}
fee, err := v3PoolFee(ctx, v.rpc, pool)
if err != nil {
return CorrectionResult{}, fmt.Errorf("v3 pool fee: %w", err)
}
scale := math.Pow10(v.decimals)
amountInWei := wholeToWei(amountInWhole, scale)
minOutWei := wholeToWei(expectOutWhole, scale)
if amountInWei.Sign() <= 0 {
return CorrectionResult{}, fmt.Errorf("v3 correct: non-positive amountIn")
}
// Approve the router to pull exactly amountIn of tokenIn (idempotent: the exact
// input each cycle; a stale allowance from a prior cycle is simply re-set).
if err := v.approve(acct, tokenIn, amountInWei); err != nil {
return CorrectionResult{}, err
}
beforeOut, err := v.p9.ERC20BalanceOf(tokenOut, acct.Addr)
if err != nil {
return CorrectionResult{}, fmt.Errorf("v3 read out balance: %w", err)
}
params := v3ExactInParams{
TokenIn: tokenIn, TokenOut: tokenOut, Fee: big.NewInt(int64(fee)),
Recipient: acct.Addr, AmountIn: amountInWei, AmountOutMinimum: minOutWei,
SqrtPriceLimitX96: big.NewInt(0),
}
data, err := v.rtr.Pack("exactInputSingle", params)
if err != nil {
return CorrectionResult{}, fmt.Errorf("pack exactInputSingle: %w", err)
}
r, err := v.p9.mustOK(acct, &v.router, big.NewInt(0), data, 500_000, "v3 exactInputSingle")
if err != nil {
return CorrectionResult{}, err
}
afterOut, _ := v.p9.ERC20BalanceOf(tokenOut, acct.Addr)
gotWhole := weiToWholeF(new(big.Int).Sub(afterOut, beforeOut), scale)
// Realized loss (in quote): only meaningful for a SellBase (out is quote). For a
// BuyBase the out is base; value the base shortfall at ref via the action's
// intent — MinOut is already the fair-value floor, so shortfall below it would
// have reverted; the realized loss vs expectation is 0 on a successful buy.
loss := 0.0
if act.Side == SellBase && gotWhole > 0 {
if expected := act.BaseSize; expected > 0 {
// expectedOut@ref ≈ BaseSize·ref, but the decision only carried MinOut
// (= that ·(1−slip)); charge the shortfall below MinOut, clamped ≥ 0.
if short := act.MinOut - gotWhole; short > 0 {
loss = short
}
}
}
return CorrectionResult{Gas: r.GasUsed, RealizedLossQuote: loss, TxHash: r.TxHash}, nil
}
// approve sets the router allowance for tokenIn to amount (exact-input sizing).
func (v *V3Venue) approve(owner Account, token common.Address, amount *big.Int) error {
data, err := v.erc.Pack("approve", v.router, amount)
if err != nil {
return fmt.Errorf("pack approve: %w", err)
}
if _, err := v.p9.mustOK(owner, &token, big.NewInt(0), data, 120_000, "v3 approve"); err != nil {
return err
}
return nil
}
// --- book venue (read-only in the reconciler) --------------------------------
// BookVenue reads the 0x9999 CLOB book mid for the cross-venue gap metric. It is
// READ-ONLY in the reconciler: the book's price is corrected by coherence.go's
// arbMarket, so exposing it here as a Venue (not a Corrector) gives the cross-venue
// coherence view without ever double-correcting the book.
type BookVenue struct{ p9 *Pool9999 }
// NewBookVenue wraps the 0x9999 client as a read-only book venue.
func NewBookVenue(p9 *Pool9999) *BookVenue { return &BookVenue{p9: p9} }
// Name identifies the venue in logs + metrics.
func (b *BookVenue) Name() string { return "book" }
// Spot returns the book mid (best bid/ask average) from getBestBidAsk, or an error
// if the read surface is unavailable (older luxd) or the book is empty.
func (b *BookVenue) Spot(_ context.Context, m CoherenceMarket) (float64, error) {
bid, ask, fromBook, err := b.p9.BestBidAsk(m.poolID)
if err != nil {
return 0, err
}
if !fromBook || bid <= 0 || ask <= 0 {
return 0, fmt.Errorf("book: no live bid/ask")
}
return (bid + ask) / 2, nil
}
// --- shared wei<->whole helpers (one place) ----------------------------------
// weiToWholeF converts a wei big.Int to whole tokens (float) given 10^decimals as a
// float scale. A nil/zero input is 0.
func weiToWholeF(wei *big.Int, scale float64) float64 {
if wei == nil || wei.Sign() == 0 {
return 0
}
f := new(big.Float).Quo(new(big.Float).SetInt(wei), big.NewFloat(scale))
v, _ := f.Float64()
return v
}
// wholeToWei converts whole tokens (float) to a wei big.Int given 10^decimals as a
// float scale (rounded), clamped at 0 for a non-positive input.
func wholeToWei(whole, scale float64) *big.Int {
if whole <= 0 {
return big.NewInt(0)
}
f := new(big.Float).Mul(big.NewFloat(whole), big.NewFloat(scale))
wei, _ := f.Int(nil)
if wei == nil {
return big.NewInt(0)
}
return wei
}
// wholeToWeiU64 is wholeToWei clamped into a uint64 (the CLOB swap/min-out unit,
// which maps 1:1 to wei). Saturates at math.MaxUint64 rather than wrapping.
func wholeToWeiU64(whole, scale float64) uint64 {
wei := wholeToWei(whole, scale)
if !wei.IsUint64() {
return math.MaxUint64
}
return wei.Uint64()
}