Files
dex/pkg/gateway/venue_native.go
zeekayandHanzo Dev 4c591bfc94 A chain with no indexer still knows its own tokens and what they are worth
Explore on Ethereum drew nothing at all — not an empty table, no request made
— because enumerating a chain went to an indexer that only carries ours, and
the hosted API that would answer costs a contract. Two paths close that without
asking anybody for one.

/v1/trade/tokens serves a list the binary carries: 935 tokens over the seven
chains this gateway prices, identity only, checked at build time rather than
per request. No logo, and the struct has nowhere to put one — every public list
gives logos as URLs on four hosts belonging to somebody else, fetched by every
viewer's browser on every market screen.

/v1/trade/price is a reading of the same pools /v1/trade/quote reads: spend a
fixed hundred dollars of a numéraire, see what comes back, put the pool's fee
back in. Not one whole token — that is a fifty-cent trade in one market and a
hundred-thousand-dollar one in the next, and the second moves the pool it is
measuring. Not the raw quote either: a pool hands back the mid less its tier,
which is $1.0031 for a $1.00 token and $3018.51 for a $3000 one two hops deep.
Two numéraires per chain, the dollar and the wrapped native token, and the
cheaper reading wins. Measured against an independent source on seven chains,
every price inside half a percent.

A token no pool holds comes back WITHOUT priceUSD, never with a zero: a screen
can draw a dash and it cannot un-draw a wrong number. A reading stands for a
minute; an unreadable chain for ten seconds, because the pools have not changed
— our access to them has. Fifty per request, eight in flight, and thirty-two
readers of one cold price produce one fan-out.

Two things this needed that were wrong on their own account. A fee had two
units — the V2 arm answered 30 where the V3 arm answered 3000 for the same
three tenths of a percent — so feeFromBPS converts once and nothing else means
anything else by that number. And the arm reading our own precompiles answered
"no liquidity" to an endpoint that never replied, which is enough on its own to
report a chain nobody could reach as a pair nobody holds.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-09 11:52:57 -07:00

192 lines
5.9 KiB
Go

package gateway
import (
"context"
"encoding/hex"
"errors"
"fmt"
"math/big"
)
// Lux DEX precompile addresses.
const (
// NativePoolManager is the V4 AMM singleton (pool state, swap math).
NativePoolManager = "0x0000000000000000000000000000000000009010"
// NativeSwapRouter is the V4 swap router (ExactInput/Output).
NativeSwapRouter = "0x0000000000000000000000000000000000009012"
// NativeOrderBook is the central limit order book precompile.
NativeOrderBook = "0x0000000000000000000000000000000000009020"
)
// quoteExactInputSingle(address,address,uint256,uint24)
// Selector: first 4 bytes of keccak256("quoteExactInputSingle(address,address,uint256,uint24)")
var selectorNativeQuote = lxrMustDecodeHex("30d07f21")
// NativeDEXVenue quotes from the Lux V4 DEX precompiles via eth_call.
// It calls the PoolManager (0x9010) for AMM quotes and falls back to
// the DEX (0x9020) for limit-order liquidity.
type NativeDEXVenue struct {
evm *EVMClient
name string
feeBPS uint32 // default fee tier for pool lookup
useDEX bool // also query DEX for better prices
}
// NativeDEXConfig configures a NativeDEXVenue.
type NativeDEXConfig struct {
// RPCURL is the Lux EVM RPC endpoint (e.g. https://rpc.dev.lux.network/rpc).
RPCURL string
// FeeBPS is the default fee tier in basis points for pool discovery.
// Common values: 1 (0.01%), 5 (0.05%), 30 (0.30%), 100 (1%).
FeeBPS uint32
// UseDEX enables querying the DEX precompile (0x9020) alongside AMM.
UseDEX bool
}
// NewNativeDEXVenue creates a venue that queries Lux DEX precompiles.
func NewNativeDEXVenue(cfg NativeDEXConfig) *NativeDEXVenue {
feeBPS := cfg.FeeBPS
if feeBPS == 0 {
feeBPS = 30 // 0.30% default
}
return &NativeDEXVenue{
evm: NewEVMClient(cfg.RPCURL),
name: VenueNameNative,
feeBPS: feeBPS,
useDEX: cfg.UseDEX,
}
}
func (v *NativeDEXVenue) Name() string { return v.name }
func (v *NativeDEXVenue) IsExecutable() bool { return true }
func (v *NativeDEXVenue) Quote(ctx context.Context, req VenueQuoteRequest) (*VenueQuote, error) {
// Build quoteExactInputSingle calldata:
// selector(4) + tokenIn(32) + tokenOut(32) + amountIn(32) + fee(32) = 132 bytes
amountIn, ok := new(big.Int).SetString(req.Amount, 10)
if !ok || amountIn.Sign() <= 0 {
return nil, fmt.Errorf("invalid amount: %s", req.Amount)
}
inAddr, err := lxrDecodeAddress(req.TokenIn)
if err != nil {
return nil, fmt.Errorf("tokenIn: %w", err)
}
outAddr, err := lxrDecodeAddress(req.TokenOut)
if err != nil {
return nil, fmt.Errorf("tokenOut: %w", err)
}
calldata := make([]byte, 0, 132)
calldata = append(calldata, selectorNativeQuote...)
calldata = append(calldata, lxrPadAddress(inAddr)...)
calldata = append(calldata, lxrPadAddress(outAddr)...)
calldata = append(calldata, lxrPadUint256(amountIn)...)
calldata = append(calldata, lxrPadUint256(big.NewInt(int64(v.feeBPS)))...)
result, err := v.evm.CallContract(ctx, NativePoolManager, calldata)
if errors.Is(err, ErrUnreachable) {
// Not an answer at all, and this arm is the one that has to say so.
// The router reports a chain it could not read only when EVERY arm on
// that chain failed, so a single arm answering "no liquidity" to an
// endpoint that never replied is enough on its own to turn a chain
// nobody could reach into a pair nobody holds — and a reader then goes
// looking for liquidity instead of at their endpoint. Every other arm
// here already draws the distinction; this one now does too.
return nil, err
}
if err != nil {
// The precompile reverted, or nothing is deployed at that address.
// Either way this pair has no pool here.
return nil, nil
}
if len(result) < 32 {
return nil, nil
}
amountOut := new(big.Int).SetBytes(result[:32])
if amountOut.Sign() <= 0 {
return nil, nil
}
quote := &VenueQuote{
Venue: v.name,
AmountOut: amountOut.String(),
Fee: feeFromBPS(int(v.feeBPS)),
GasEstimate: "150000",
Executable: true,
}
// If DEX enabled, also check order book for better price.
if v.useDEX {
dexQuote, err := v.queryDEX(ctx, req, amountIn)
if err == nil && dexQuote != nil {
dexOut, _ := new(big.Int).SetString(dexQuote.AmountOut, 10)
if dexOut != nil && dexOut.Cmp(amountOut) > 0 {
return dexQuote, nil // DEX has better price
}
}
}
return quote, nil
}
// queryDEX checks the DEX precompile for limit-order liquidity.
// getBestQuote(address,address,uint256,bool)
var selectorDEXQuote = lxrMustDecodeHex("a2e62045")
func (v *NativeDEXVenue) queryDEX(ctx context.Context, req VenueQuoteRequest, amountIn *big.Int) (*VenueQuote, error) {
inAddr, _ := lxrDecodeAddress(req.TokenIn)
outAddr, _ := lxrDecodeAddress(req.TokenOut)
isBuy := byte(1) // buying tokenOut
var buyWord [32]byte
buyWord[31] = isBuy
calldata := make([]byte, 0, 132)
calldata = append(calldata, selectorDEXQuote...)
calldata = append(calldata, lxrPadAddress(inAddr)...)
calldata = append(calldata, lxrPadAddress(outAddr)...)
calldata = append(calldata, lxrPadUint256(amountIn)...)
calldata = append(calldata, buyWord[:]...)
result, err := v.evm.CallContract(ctx, NativeOrderBook, calldata)
if err != nil {
return nil, err
}
if len(result) < 32 {
return nil, nil
}
amountOut := new(big.Int).SetBytes(result[:32])
if amountOut.Sign() <= 0 {
return nil, nil
}
return &VenueQuote{
Venue: v.name + "_dex",
AmountOut: amountOut.String(),
Fee: "0", // DEX maker fees may differ
GasEstimate: "200000",
Executable: true,
}, nil
}
// init-time validation.
func init() {
if len(selectorNativeQuote) != 4 {
panic("selectorNativeQuote must be 4 bytes")
}
if len(selectorDEXQuote) != 4 {
panic("selectorDEXQuote must be 4 bytes")
}
// Validate precompile addresses are valid hex.
for _, addr := range []string{NativePoolManager, NativeSwapRouter, NativeOrderBook} {
if _, err := hex.DecodeString(addr[2:]); err != nil {
panic("invalid precompile address: " + addr)
}
}
}