Files
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

295 lines
9.5 KiB
Go

package gateway
import (
"context"
"fmt"
"math/big"
"strconv"
)
// VenueNameNative is what the Lux precompile venue calls itself on the wire.
// Named once: it is what a caller reads to tell a market we settle from a
// market we only read, and two spellings of it is one bug away from a V3 pool
// on somebody else's chain reporting as ours.
const VenueNameNative = "v4_native"
// feeScale is the unit a fee is written in on this surface: hundredths of a
// basis point, so 3000 is three tenths of one percent. It is how Uniswap V3
// states a tier, how SwapOrder.Fee is read, and how a price puts back what a
// pool took on the way through.
//
// An arm whose own arithmetic is in basis points converts here and nowhere
// else. It used to convert nowhere at all: the V2 arm answered 30 and the V3
// arm answered 3000 for the same three tenths of a percent, so one number meant
// two things depending on which pool happened to win.
const feeScale = 1_000_000
// feeFromBPS writes a basis-point fee in the unit above.
func feeFromBPS(bps int) string { return strconv.Itoa(bps * feeScale / 10_000) }
// Venue represents a liquidity source — on-chain pool or off-chain exchange.
type Venue interface {
// Name returns the unique identifier for this venue (e.g. "v4_native", "alpaca").
Name() string
// Quote returns a price quote for the given request from this venue.
// Returns nil VenueQuote with no error if the venue has no liquidity for the pair.
Quote(ctx context.Context, req VenueQuoteRequest) (*VenueQuote, error)
// IsExecutable returns true if this venue can produce on-chain transactions,
// false if it only provides quote-only off-chain prices.
IsExecutable() bool
// Swap builds the transaction that takes a quote this venue gave. A venue
// returns nil when it cannot build one — a price it does not settle — and
// that is an answer, not an error.
Swap(order SwapOrder) (*UnsignedTxResponse, error)
}
// VenueQuoteRequest is the input for a single venue quote.
// Uses string-based fields to decouple from the gateway's typed QuoteRequest.
type VenueQuoteRequest struct {
TokenIn string
TokenOut string
Amount string // wei decimal string
Type string // "EXACT_INPUT" or "EXACT_OUTPUT"
PreferredVenue string // optional: restrict to this venue name
}
// VenueQuote is a quote from a single venue.
type VenueQuote struct {
Venue string `json:"venue"`
AmountOut string `json:"amountOut"`
Fee string `json:"fee"`
GasEstimate string `json:"gasEstimate"`
Executable bool `json:"executable"`
}
// VenueInfo describes an available liquidity venue.
type VenueInfo struct {
Name string `json:"name"`
Status string `json:"status"`
Executable bool `json:"executable"`
Type string `json:"type"`
}
// VenueQuoteType constants.
const (
VenueQuoteTypeExactInput = "EXACT_INPUT"
VenueQuoteTypeExactOutput = "EXACT_OUTPUT"
)
// V4Venue quotes from V4 pools via the DEX precompile (LXRouter at 0x9012).
// In production this would call the tenant EVM RPC. The current implementation
// uses a deterministic pricing model for the mock backend.
type V4Venue struct {
rpcURL string
// pools maps "tokenIn:tokenOut" to a pool config for deterministic quoting.
pools map[string]*v4Pool
}
// v4Pool holds mock pool state for deterministic quoting.
type v4Pool struct {
PoolID string
Fee int // basis points
Reserve0 *big.Int
Reserve1 *big.Int
}
// NewV4Venue creates a V4Venue with the given RPC endpoint.
// If rpcURL is empty, the venue operates in mock mode with synthetic pools.
func NewV4Venue(rpcURL string) *V4Venue {
v := &V4Venue{
rpcURL: rpcURL,
pools: make(map[string]*v4Pool),
}
return v
}
// AddPool registers a pool for quoting. tokenIn and tokenOut are 0x-prefixed addresses.
func (v *V4Venue) AddPool(tokenIn, tokenOut string, poolID string, feeBPS int, reserve0, reserve1 *big.Int) {
key := tokenIn + ":" + tokenOut
reverseKey := tokenOut + ":" + tokenIn
pool := &v4Pool{
PoolID: poolID,
Fee: feeBPS,
Reserve0: new(big.Int).Set(reserve0),
Reserve1: new(big.Int).Set(reserve1),
}
v.pools[key] = pool
// Reverse direction uses the same pool with swapped reserves.
v.pools[reverseKey] = &v4Pool{
PoolID: poolID,
Fee: feeBPS,
Reserve0: new(big.Int).Set(reserve1),
Reserve1: new(big.Int).Set(reserve0),
}
}
func (v *V4Venue) Name() string { return VenueNameNative }
func (v *V4Venue) IsExecutable() bool { return true }
func (v *V4Venue) Quote(_ context.Context, req VenueQuoteRequest) (*VenueQuote, error) {
key := req.TokenIn + ":" + req.TokenOut
pool, ok := v.pools[key]
if !ok {
return nil, nil // no liquidity
}
amountIn, ok := new(big.Int).SetString(req.Amount, 10)
if !ok {
return nil, fmt.Errorf("invalid amount: %s", req.Amount)
}
var amountOut *big.Int
switch req.Type {
case VenueQuoteTypeExactInput:
amountOut = constantProductQuote(amountIn, pool.Reserve0, pool.Reserve1, pool.Fee)
case VenueQuoteTypeExactOutput:
// For exact output, the "amount" is the desired output.
// Compute how much input is needed.
amountOut = new(big.Int).Set(amountIn) // amountOut IS the requested amount
amountIn = constantProductInverse(amountOut, pool.Reserve0, pool.Reserve1, pool.Fee)
default:
return nil, fmt.Errorf("unsupported quote type: %s", req.Type)
}
if amountOut.Sign() <= 0 || amountIn.Sign() <= 0 {
return nil, nil // insufficient liquidity
}
gasEstimate := "150000" // typical V4 single-hop swap gas
return &VenueQuote{
Venue: v.Name(),
AmountOut: amountOut.String(),
Fee: feeFromBPS(pool.Fee),
GasEstimate: gasEstimate,
Executable: true,
}, nil
}
// constantProductQuote computes output from a constant-product AMM.
// amountOut = (amountIn * (10000 - feeBPS) * reserveOut) / (reserveIn * 10000 + amountIn * (10000 - feeBPS))
func constantProductQuote(amountIn, reserveIn, reserveOut *big.Int, feeBPS int) *big.Int {
feeMultiplier := big.NewInt(int64(10000 - feeBPS))
tenK := big.NewInt(10000)
// numerator = amountIn * feeMultiplier * reserveOut
num := new(big.Int).Mul(amountIn, feeMultiplier)
num.Mul(num, reserveOut)
// denominator = reserveIn * 10000 + amountIn * feeMultiplier
denom := new(big.Int).Mul(reserveIn, tenK)
inputAfterFee := new(big.Int).Mul(amountIn, feeMultiplier)
denom.Add(denom, inputAfterFee)
if denom.Sign() == 0 {
return big.NewInt(0)
}
return num.Div(num, denom)
}
// constantProductInverse computes the input amount needed for a desired output.
// amountIn = (reserveIn * amountOut * 10000) / ((reserveOut - amountOut) * (10000 - feeBPS))
func constantProductInverse(amountOut, reserveIn, reserveOut *big.Int, feeBPS int) *big.Int {
feeMultiplier := big.NewInt(int64(10000 - feeBPS))
tenK := big.NewInt(10000)
// If requested output exceeds reserves, return 0 (insufficient liquidity).
if amountOut.Cmp(reserveOut) >= 0 {
return big.NewInt(0)
}
// numerator = reserveIn * amountOut * 10000
num := new(big.Int).Mul(reserveIn, amountOut)
num.Mul(num, tenK)
// denominator = (reserveOut - amountOut) * (10000 - feeBPS)
denom := new(big.Int).Sub(reserveOut, amountOut)
denom.Mul(denom, feeMultiplier)
if denom.Sign() == 0 {
return big.NewInt(0)
}
// Round up: (num + denom - 1) / denom
result := new(big.Int).Add(num, new(big.Int).Sub(denom, big.NewInt(1)))
return result.Div(result, denom)
}
// BrokerVenue quotes from external exchanges via Lux Broker.
// In production this would call the Broker HTTP API. The current implementation
// uses a configurable spread model for the mock backend.
type BrokerVenue struct {
brokerURL string
provider string
// mockPrice is the simulated mid-market price (tokenOut per tokenIn unit).
// Set to 0 to simulate no liquidity.
mockPrice *big.Int
// spreadBPS is the venue's spread in basis points (applied to mock price).
spreadBPS int
// feeBPS is the venue's trading fee in basis points.
feeBPS int
}
// NewBrokerVenue creates a BrokerVenue for the given provider.
func NewBrokerVenue(brokerURL, provider string) *BrokerVenue {
return &BrokerVenue{
brokerURL: brokerURL,
provider: provider,
spreadBPS: 10, // 0.1% default spread
feeBPS: 25, // 0.25% default fee
}
}
// SetMockPrice sets a deterministic price for this venue (tokenOut per 1e18 tokenIn).
func (b *BrokerVenue) SetMockPrice(price *big.Int) {
b.mockPrice = price
}
// SetFees configures spread and fee in basis points.
func (b *BrokerVenue) SetFees(spreadBPS, feeBPS int) {
b.spreadBPS = spreadBPS
b.feeBPS = feeBPS
}
func (b *BrokerVenue) Name() string { return b.provider }
func (b *BrokerVenue) IsExecutable() bool { return false }
func (b *BrokerVenue) Quote(_ context.Context, req VenueQuoteRequest) (*VenueQuote, error) {
if b.mockPrice == nil || b.mockPrice.Sign() == 0 {
return nil, nil // no liquidity
}
amountIn, ok := new(big.Int).SetString(req.Amount, 10)
if !ok {
return nil, fmt.Errorf("invalid amount: %s", req.Amount)
}
// amountOut = amountIn * mockPrice / 1e18, then apply spread + fee
e18 := new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil)
amountOut := new(big.Int).Mul(amountIn, b.mockPrice)
amountOut.Div(amountOut, e18)
// Apply spread: reduce output by spreadBPS
spreadDeduction := new(big.Int).Mul(amountOut, big.NewInt(int64(b.spreadBPS)))
spreadDeduction.Div(spreadDeduction, big.NewInt(10000))
amountOut.Sub(amountOut, spreadDeduction)
if amountOut.Sign() <= 0 {
return nil, nil
}
return &VenueQuote{
Venue: b.Name(),
AmountOut: amountOut.String(),
Fee: feeFromBPS(b.feeBPS),
GasEstimate: "0",
Executable: false,
}, nil
}