Files
maker/provider_v3.go
zeekayandHanzo Dev 1ac4c806b2 Go 1.27.1, and the code it can say more simply
Modules declare go 1.27.1 and the build images follow. go fix applied the
modernizers; omitzero is not applied, since it changes what the JSON says.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-16 19:23:15 -07:00

199 lines
7.6 KiB
Go

package maker
import (
"context"
"fmt"
"log/slog"
"math/big"
"github.com/luxfi/crypto"
"github.com/luxfi/geth/common"
)
// provider_v3.go is the LIVE on-chain price reference for LUX. LUX lists ONLY on
// lux.exchange's native V2/V3/V4 — never a CEX — so the honest reference for a
// LUX/* market is the native Uniswap-V3-style AMM. The V4 CLOB maker tracks this
// so its book stays pinned to the live AMM (arbitrage-tight across V2/V3/V4). It
// is a ProviderSource (aggregator.go), exactly like provider_oma.go.
//
// We read the MARGINAL SPOT price from the pool's slot0 (sqrtPriceX96), NOT a
// quoteExactInputSingle trade simulation: a fixed-size quote through a thin pool
// returns a slippage-destroyed price (1 WLUX through the live WLUX/LUSD pool reads
// ~0.0006 LUSD while the marginal price is ~6.4). slot0 is the zero-size spot —
// the correct reference, immune to probe size.
// V3 selectors, computed at init from canonical signatures so they can never drift
// from hand-copied hex. getPool→0x1698ee82, slot0→0x3850c7bd (pinned in tests).
var (
sigGetPool = crypto.Keccak256([]byte("getPool(address,address,uint24)"))[:4]
sigSlot0 = crypto.Keccak256([]byte("slot0()"))[:4]
)
// v3FeeTiers are enumerated to find the pool; the first existing (non-zero) pool
// wins. Proven against mainnet (exchange-api dexRouter): WLUX/LUSD exists only at
// fee 3000; 500/10000 have no pool.
var v3FeeTiers = []uint32{500, 3000, 10000}
// V3Pair maps a maker symbol to the on-chain leg that prices it. Base is the asset
// the price is denominated FOR (LUX, as WLUX on-chain); Quote the asset it is
// denominated IN (e.g. LUSD). Decimals are each ERC-20's decimals.
type V3Pair struct {
Base common.Address
Quote common.Address
BaseDecimals int
QuoteDecimals int
}
// V3ProviderSource reads the live marginal spot (quote per base) from the native
// V3 factory+pool on the Lux C-Chain. A symbol with no live pool yields no levels
// — the aggregator skips it and the seeder falls back to the DEX book mid, then
// the configured reference (graceful, never fabricated).
func V3ProviderSource(name, rpcURL, factoryAddr string, pairs map[string]V3Pair) ProviderSource {
factory := common.HexToAddress(factoryAddr)
return ProviderSource{
Name: name,
GetBBO: func(ctx context.Context, symbol string) (bid, ask, last float64, err error) {
spot, err := v3Spot(ctx, rpcURL, factory, pairs, symbol)
if err != nil {
return 0, 0, 0, err
}
return spot, spot, spot, nil
},
GetBook: func(ctx context.Context, symbol string) (bids, asks []PriceLevel, err error) {
spot, err := v3Spot(ctx, rpcURL, factory, pairs, symbol)
if err != nil {
return nil, nil, err
}
// Marginal spot has no spread; expose it as one phantom level per side so
// the aggregator mid IS the spot. The maker applies its own spread on top.
lvl := func() []PriceLevel { return []PriceLevel{{Price: spot, Qty: 1_000_000.0, Source: name, Phantom: true}} }
return lvl(), lvl(), nil
},
}
}
// NewV3Feed builds a live AMM price feed (a MidProvider the seeder tracks) for the
// maker's pairs, or nil if rpcURL/factoryAddr/wluxAddr are not all set (no V3 env →
// no feed, seeder keeps its prior behavior). Each pair's quote token is the LOW 20
// bytes of its 32-byte Quote asset id — the rail's left-padded ERC-20 convention
// (config.go AssetAddress) — so the feed needs no per-pair address config. A
// zero/ascii-label quote has no on-chain pool and is skipped.
func NewV3Feed(pairs []Pair, rpcURL, factoryAddr, wluxAddr string, decimals int, logger *slog.Logger) MidProvider {
if rpcURL == "" || factoryAddr == "" || wluxAddr == "" {
return nil
}
base := common.HexToAddress(wluxAddr)
v3 := map[string]V3Pair{}
for _, p := range pairs {
quote := common.BytesToAddress(p.Quote[12:]) // low 20 bytes = ERC-20 address
if quote == (common.Address{}) {
continue // native/zero or ascii-label quote: no on-chain pool to read
}
v3[p.Symbol] = V3Pair{Base: base, Quote: quote, BaseDecimals: decimals, QuoteDecimals: decimals}
}
if len(v3) == 0 {
return nil
}
return NewAggregator([]ProviderSource{V3ProviderSource("v3-amm", rpcURL, factoryAddr, v3)}, logger)
}
// v3Spot returns the marginal spot price (quote per base) for a symbol from the
// pool's slot0 sqrtPriceX96, oriented and decimal-adjusted.
func v3Spot(ctx context.Context, rpcURL string, factory common.Address, pairs map[string]V3Pair, symbol string) (float64, error) {
p, ok := pairs[symbol]
if !ok {
return 0, fmt.Errorf("v3: no pair for %s", symbol)
}
pool, err := v3FindPool(ctx, rpcURL, factory, p.Base, p.Quote)
if err != nil {
return 0, err
}
sqrtP, err := v3Slot0SqrtPrice(ctx, rpcURL, pool)
if err != nil {
return 0, fmt.Errorf("v3 slot0 %s: %w", pool.Hex(), err)
}
return v3SpotFromSqrt(sqrtP, p), nil
}
// v3SpotFromSqrt converts a pool sqrtPriceX96 to the human "quote per base" price.
//
// sqrtPriceX96 encodes price = token1_wei/token0_wei where token0 = the lower
// address. raw = (sqrtPriceX96/2^96)^2 is that wei ratio; the human token1/token0
// price is raw·10^dec0/10^dec1. We then orient to quote-per-base: if base is
// token0 (base<quote) that human price already is quote/base; else invert.
func v3SpotFromSqrt(sqrtPriceX96 *big.Int, p V3Pair) float64 {
if sqrtPriceX96 == nil || sqrtPriceX96.Sign() == 0 {
return 0
}
q96 := new(big.Float).SetInt(new(big.Int).Lsh(big.NewInt(1), 96))
s := new(big.Float).Quo(new(big.Float).SetInt(sqrtPriceX96), q96)
raw := new(big.Float).Mul(s, s) // token1_wei / token0_wei
baseIsToken0 := bytesLess(p.Base, p.Quote)
var dec0, dec1 int
if baseIsToken0 {
dec0, dec1 = p.BaseDecimals, p.QuoteDecimals
} else {
dec0, dec1 = p.QuoteDecimals, p.BaseDecimals
}
// humanT1perT0 = raw · 10^dec0 / 10^dec1
human := new(big.Float).Mul(raw, new(big.Float).Quo(
new(big.Float).SetInt(pow10(dec0)), new(big.Float).SetInt(pow10(dec1))))
hf, _ := human.Float64()
if hf <= 0 {
return 0
}
if baseIsToken0 {
return hf // token1/token0 = quote/base
}
return 1 / hf // base is token1 → invert to quote/base
}
// v3FindPool returns the first existing pool address for (a,b) across the fee
// tiers (factory.getPool), erroring if none exists.
func v3FindPool(ctx context.Context, rpcURL string, factory, a, b common.Address) (common.Address, error) {
for _, fee := range v3FeeTiers {
data := make([]byte, 0, 4+3*32)
data = append(data, sigGetPool...)
data = append(data, leftPad32(a.Bytes())...)
data = append(data, leftPad32(b.Bytes())...)
data = append(data, leftPad32(new(big.Int).SetUint64(uint64(fee)).Bytes())...)
res, err := callContract(ctx, rpcURL, factory, data)
if err != nil || len(res) < 32 {
continue
}
pool := common.BytesToAddress(res[12:32])
if pool != (common.Address{}) {
return pool, nil
}
}
return common.Address{}, fmt.Errorf("v3: no pool for %s/%s", a.Hex(), b.Hex())
}
// v3Slot0SqrtPrice reads pool.slot0().sqrtPriceX96 (the first returned word).
func v3Slot0SqrtPrice(ctx context.Context, rpcURL string, pool common.Address) (*big.Int, error) {
res, err := callContract(ctx, rpcURL, pool, sigSlot0)
if err != nil {
return nil, err
}
if len(res) < 32 {
return nil, fmt.Errorf("v3 slot0: short result (%d bytes)", len(res))
}
return new(big.Int).SetBytes(res[:32]), nil
}
// bytesLess reports a < b as big-endian byte strings (V3 token0/token1 ordering).
func bytesLess(a, b common.Address) bool {
for i := range len(a) {
if a[i] != b[i] {
return a[i] < b[i]
}
}
return false
}
// pow10 returns 10^n as a big.Int (token base-unit scale).
func pow10(n int) *big.Int {
return new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(n)), nil)
}