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]>
270 lines
11 KiB
Go
270 lines
11 KiB
Go
// 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()
|
||
}
|