mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
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]>
256 lines
9.7 KiB
Go
256 lines
9.7 KiB
Go
package gateway
|
|
|
|
import (
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Where each chain's pools are read from.
|
|
//
|
|
// Every one of these is a contract on a public chain, read over JSON-RPC with
|
|
// no key and no upstream account. That is the point: an interface that needs a
|
|
// vendor's API to price a Uniswap pool is an interface that stops working when
|
|
// the vendor says no, and it said no — the hosted API answers ACCESS_DENIED
|
|
// without a contract. The pool is the source.
|
|
type ChainVenues struct {
|
|
// RPC is the endpoint the venues read.
|
|
RPC string
|
|
// V2Router is a Router02 (getAmountsOut). Empty means no V2 arm.
|
|
V2Router string
|
|
// V3Quoter is a QuoterV2. Empty means no V3 arm.
|
|
V3Quoter string
|
|
// V3Router is the SwapRouter02 beside that quoter — what executes the
|
|
// price it gave. Empty means the V3 arm quotes and does not build.
|
|
V3Router string
|
|
// Native turns on the Lux precompile venue — the V4 arm, PoolManager at
|
|
// 0x9010 with the order book at 0x9020 beside it. Only our chains have it.
|
|
Native bool
|
|
// Numeraires are what this chain's prices are read against.
|
|
Numeraires
|
|
}
|
|
|
|
// Numeraires are the two tokens a chain's prices are stated against.
|
|
//
|
|
// A pool gives a ratio, never a price. To turn one into dollars something has
|
|
// to be worth a dollar by declaration, and something has to be paired with
|
|
// nearly everything else — on Ethereum most of the liquidity is against WETH,
|
|
// not against USDC, so a gateway that only knew the dollar could price the
|
|
// majors and nothing behind them.
|
|
type Numeraires struct {
|
|
// Stable is the chain's US dollar token, worth exactly one here by
|
|
// construction. Every other reading is denominated in it.
|
|
Stable string
|
|
// Hub is the wrapped native token, which is what most of the chain's pools
|
|
// are paired with. Its own mark is read against Stable, and everything
|
|
// with no dollar pool is read against it.
|
|
Hub string
|
|
}
|
|
|
|
// The addresses Uniswap publishes for its own deployments. V2's Router02 is at
|
|
// one address on the chains it was deployed to later, and at its original one
|
|
// on Ethereum; QuoterV2 is at one address nearly everywhere.
|
|
const (
|
|
v2RouterEthereum = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D"
|
|
v2RouterCommon = "0x4752ba5DBc23f44D87826276BF6Fd6b1C372aD24"
|
|
v3QuoterCommon = "0x61fFE014bA17989E743c5F6cB21bF9697530B21e"
|
|
v3QuoterBase = "0x3d4e44Eb1374240CE5F1B871ab261CD16335B76a"
|
|
v3QuoterBNB = "0x78D78E420Da98ad378D7799bE8f4AF69033EB077"
|
|
|
|
// SwapRouter02 — the contract that executes what those quoters price. One
|
|
// address on Ethereum, Polygon, Arbitrum and Optimism; its own on Base and
|
|
// BNB, which were deployed later.
|
|
v3RouterCommon = "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45"
|
|
v3RouterBase = "0x2626664c2603336E57B271c5C0b26F421741e481"
|
|
v3RouterBNB = "0xB971eF87ede563556b2ED4b1C0b0019111Dd85d2"
|
|
)
|
|
|
|
// The dollar and the wrapped native token on each chain — the two things
|
|
// every price here is read against. All of them are in the catalog, which is
|
|
// where their decimals come from; a numéraire the catalog does not carry is a
|
|
// chain with no prices, and the two are one edit apart on purpose.
|
|
var (
|
|
usdEthereum = Numeraires{Stable: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", Hub: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"}
|
|
usdArbitrum = Numeraires{Stable: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", Hub: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"}
|
|
usdOptimism = Numeraires{Stable: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", Hub: "0x4200000000000000000000000000000000000006"}
|
|
usdPolygon = Numeraires{Stable: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", Hub: "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270"}
|
|
usdBase = Numeraires{Stable: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", Hub: "0x4200000000000000000000000000000000000006"}
|
|
usdBNB = Numeraires{Stable: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", Hub: "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c"}
|
|
|
|
// LUSD and WLUX, from luxfi/standard deployments/venue/96369.json. Both
|
|
// read back from the chain itself: 18 decimals, "Lux Dollar" and
|
|
// "Wrapped LUX".
|
|
usdLux = Numeraires{Stable: "0xF032B713ddD6542dd6631Ac3d52F4982750EDf78", Hub: "0x66dbed9572eb9be45ef05526c65beddf8d60a672"}
|
|
)
|
|
|
|
// DefaultChainVenues is what a deployment reads when it is told nothing else:
|
|
// our own chain, and the chains Uniswap deployed to. The RPCs are public and
|
|
// are meant to be overridden per deployment — a busy interface wants its own.
|
|
func DefaultChainVenues(luxRPC string) map[ChainID]ChainVenues {
|
|
return map[ChainID]ChainVenues{
|
|
96369: {RPC: luxRPC, Native: true, Numeraires: usdLux},
|
|
ChainIDEthereum: {RPC: "https://ethereum-rpc.publicnode.com", V2Router: v2RouterEthereum, V3Quoter: v3QuoterCommon, V3Router: v3RouterCommon, Numeraires: usdEthereum},
|
|
ChainIDArbitrum: {RPC: "https://arbitrum-one-rpc.publicnode.com", V2Router: v2RouterCommon, V3Quoter: v3QuoterCommon, V3Router: v3RouterCommon, Numeraires: usdArbitrum},
|
|
ChainIDOptimism: {RPC: "https://optimism-rpc.publicnode.com", V2Router: v2RouterCommon, V3Quoter: v3QuoterCommon, V3Router: v3RouterCommon, Numeraires: usdOptimism},
|
|
ChainIDPolygon: {RPC: "https://polygon-bor-rpc.publicnode.com", V2Router: v2RouterCommon, V3Quoter: v3QuoterCommon, V3Router: v3RouterCommon, Numeraires: usdPolygon},
|
|
ChainIDBase: {RPC: "https://base-rpc.publicnode.com", V2Router: v2RouterCommon, V3Quoter: v3QuoterBase, V3Router: v3RouterBase, Numeraires: usdBase},
|
|
ChainIDBNB: {RPC: "https://bsc-rpc.publicnode.com", V2Router: v2RouterCommon, V3Quoter: v3QuoterBNB, V3Router: v3RouterBNB, Numeraires: usdBNB},
|
|
}
|
|
}
|
|
|
|
// Venues builds the venue arms one chain offers.
|
|
func (c ChainVenues) Venues() []Venue {
|
|
out := make([]Venue, 0, 3)
|
|
if c.Native {
|
|
out = append(out, NewNativeDEXVenue(NativeDEXConfig{RPCURL: c.RPC, UseDEX: true}))
|
|
}
|
|
if c.V2Router != "" {
|
|
out = append(out, NewUniswapV2Venue(UniswapV2Config{RPCURL: c.RPC, RouterAddress: c.V2Router, Name: "uniswap_v2"}))
|
|
}
|
|
if c.V3Quoter != "" {
|
|
out = append(out, NewUniswapV3Venue(UniswapV3Config{
|
|
RPCURL: c.RPC,
|
|
QuoterAddress: c.V3Quoter,
|
|
RouterAddress: c.V3Router,
|
|
Name: "uniswap_v3",
|
|
}))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ChainRouters is the venues per chain, and the endpoint each was read from.
|
|
// The endpoint is kept because quoting is not the only thing that asks a chain
|
|
// a question — an allowance is read from the same place the pool is.
|
|
type ChainRouters struct {
|
|
byChain map[ChainID]*VenueRouter
|
|
rpc map[ChainID]string
|
|
usd map[ChainID]Numeraires
|
|
}
|
|
|
|
// NewChainRouters builds one venue router per chain.
|
|
func NewChainRouters(chains map[ChainID]ChainVenues) *ChainRouters {
|
|
byChain := make(map[ChainID]*VenueRouter, len(chains))
|
|
rpc := make(map[ChainID]string, len(chains))
|
|
usd := make(map[ChainID]Numeraires, len(chains))
|
|
for id, c := range chains {
|
|
if v := c.Venues(); len(v) > 0 {
|
|
byChain[id] = NewVenueRouter(v...)
|
|
rpc[id] = c.RPC
|
|
usd[id] = c.Numeraires
|
|
}
|
|
}
|
|
return &ChainRouters{byChain: byChain, rpc: rpc, usd: usd}
|
|
}
|
|
|
|
// Numeraires is what one chain's prices are read against, empty for a chain
|
|
// this deployment does not read.
|
|
func (c *ChainRouters) Numeraires(chain ChainID) Numeraires {
|
|
if c == nil {
|
|
return Numeraires{}
|
|
}
|
|
return c.usd[chain]
|
|
}
|
|
|
|
// RPC is the endpoint this deployment reads one chain from, or "" for a chain
|
|
// it does not read.
|
|
func (c *ChainRouters) RPC(chain ChainID) string {
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
return c.rpc[chain]
|
|
}
|
|
|
|
// For returns the venues that read one chain, or nil.
|
|
func (c *ChainRouters) For(chain ChainID) *VenueRouter {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
return c.byChain[chain]
|
|
}
|
|
|
|
// Chains returns every chain with venues, in order, for the health and venue
|
|
// listings. Ordered because a listing that permutes itself between two reads
|
|
// of the same deployment reads as a deployment that changed.
|
|
func (c *ChainRouters) Chains() []ChainID {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
out := make([]ChainID, 0, len(c.byChain))
|
|
for id := range c.byChain {
|
|
out = append(out, id)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
|
return out
|
|
}
|
|
|
|
// A quote is a reading of a pool at a moment, and a pool moves when somebody
|
|
// trades in it. Cached, briefly: the same pair asked twice in the same second
|
|
// is one reading, and every extra call is an eth_call a public endpoint is
|
|
// entitled to refuse. The key is the whole question, so two questions never
|
|
// share an answer.
|
|
type quoteCache struct {
|
|
mu sync.Mutex
|
|
ttl time.Duration
|
|
kept map[string]cached
|
|
}
|
|
|
|
type cached struct {
|
|
quote *SwapQuote
|
|
until time.Time
|
|
}
|
|
|
|
func newQuoteCache(ttl time.Duration) *quoteCache {
|
|
return "eCache{ttl: ttl, kept: map[string]cached{}}
|
|
}
|
|
|
|
func quoteKey(req QuoteRequest) string {
|
|
var b strings.Builder
|
|
b.WriteString(strings.ToLower(req.TokenIn.Address))
|
|
b.WriteByte('>')
|
|
b.WriteString(strings.ToLower(req.TokenOut.Address))
|
|
b.WriteByte('@')
|
|
b.WriteString(strconv.FormatUint(uint64(req.ChainID), 10))
|
|
b.WriteByte('#')
|
|
if req.Amount != nil {
|
|
b.WriteString(req.Amount.String())
|
|
}
|
|
if req.IsExactIn {
|
|
b.WriteString("|in")
|
|
} else {
|
|
b.WriteString("|out")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (c *quoteCache) get(req QuoteRequest) *SwapQuote {
|
|
if c == nil || c.ttl <= 0 {
|
|
return nil
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
got, ok := c.kept[quoteKey(req)]
|
|
if !ok || time.Now().After(got.until) {
|
|
return nil
|
|
}
|
|
return got.quote
|
|
}
|
|
|
|
func (c *quoteCache) put(req QuoteRequest, quote *SwapQuote) {
|
|
if c == nil || c.ttl <= 0 || quote == nil {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
// A cache nobody sweeps is a leak with a good reputation. Every write
|
|
// drops what has expired, which on this traffic is cheaper than a timer.
|
|
now := time.Now()
|
|
for k, v := range c.kept {
|
|
if now.After(v.until) {
|
|
delete(c.kept, k)
|
|
}
|
|
}
|
|
c.kept[quoteKey(req)] = cached{quote: quote, until: now.Add(c.ttl)}
|
|
}
|