mirror of
https://github.com/luxfi/maker.git
synced 2026-10-30 16:20:04 +00:00
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]>
199 lines
7.6 KiB
Go
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)
|
|
}
|