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]>
This commit is contained in:
2026-09-09 11:52:57 -07:00
co-authored by Hanzo Dev
parent f82a8aace1
commit 4c591bfc94
14 changed files with 8168 additions and 17 deletions
+78 -1
View File
@@ -275,9 +275,11 @@ that.** `pkg/gateway.Server.routes()` is the surface written once;
other, in both directions, so a route added in code and not written down fails
the build and so does a path promised in the document and not served.
Eight paths, and every one of them answers:
Ten paths, and every one of them answers:
GET /v1/trade/venues what this deployment can price, per chain
GET /v1/trade/tokens what it can trade there, and their decimals
GET /v1/trade/price what those tokens are worth, in dollars
POST /v1/trade/quote the best price across the venues that hold the pair
POST /v1/trade/quotes every venue's price, best first
POST /v1/trade/swap the transaction that takes that price
@@ -345,6 +347,81 @@ chain:
137 WPOL -> USDC 99582 uniswap_v3 tier 500
56 WBNB -> USDT 755677742862096479703 uniswap_v3 tier 100
### What a chain lists, and what it is worth
A screen cannot draw a market until it knows a token's name, symbol and
decimals, and every chain but ours has no indexer behind it. So the list travels
in the binary: `pkg/gateway/tokens.json`, 935 tokens over the seven chains this
gateway prices, embedded and served with no upstream, no key and no round trip.
Identity only — address, chainId, symbol, name, decimals.
**It carries no logo, and the struct has nowhere to put one.** Public token
lists give logos as URLs on `assets.coingecko.com`, `coin-images.coingecko.com`,
`raw.githubusercontent.com` and `s2.coinmarketcap.com`: three companies fetched
by every viewer's browser on every market screen. Marks come from
`cdn.lux.cloud`, and a token with no mark there draws its own letters.
`TestTheEmbeddedListIsWellFormed` refuses any key that is not one of the five.
The file is the boundary, so it is checked at build time rather than per
request: valid addresses, decimals in range, no duplicates, no control
characters in a symbol that something is going to render.
`GET /v1/trade/price?chainId=&token=` prices one token or fifty, answered in the
order asked. There is no oracle behind it and no vendor — it is a reading of the
same pools `/v1/trade/quote` reads:
spend a fixed $100 of a numéraire → see what comes back → put the fee back
$100 rather than one whole token, because one whole token is a fifty-cent trade
in one market and a hundred-thousand-dollar trade in the next, and the second
one moves the pool it is measuring — priced that way SHIB came out 40% high.
The fee correction matters as much: a pool hands back the mid price LESS its
tier, so an uncorrected reading is high by exactly that tier — $1.0031 for a
$1.00 token on a 0.3% pool, and $3018.51 for a $3000 one two hops deep.
Two numéraires per chain (`Numeraires` in `venue_chains.go`): the dollar
stablecoin, worth one by construction, and the wrapped native token, whose own
mark is read against the dollar. Most V3 liquidity is against the native token,
not the dollar, so a gateway that knew only the dollar could price the majors
and nothing behind them. Both readings are taken and the cheaper wins — a price
is what somebody spending $100 actually receives, and the route handing over the
most tokens is nearest the middle of the market.
Measured against an independent source, same minute, seven chains:
1 WETH 2487.03 -0.09% 8453 cbETH 2831.86 -0.17%
1 WBTC 78414.92 -0.01% 42161 ARB 0.154 -0.16%
1 UNI 6.608 -0.22% 10 OP 0.103 -0.23%
1 AAVE 128.59 +0.05% 137 WMATIC 0.0965 -0.04%
1 PEPE 3.6e-6 -0.32% 56 WBNB 742.72 +0.46%
1 SHIB 5.37e-6 +0.07%
Every one inside half a percent. The residual is the gap between one venue's
pools and a global average, which is a real difference and not an error.
**A token no pool holds comes back WITHOUT `priceUSD`, never with a zero.** A
screen can draw a dash; it cannot un-draw a wrong number. `venue` and `via` go
missing with it, because a number nobody can trace is a number nobody can check.
A reading stands for a minute (`markTTL`) and `asOf` says when it was taken; a
chain that could not be read is remembered for ten seconds, not a minute,
because the pools have not changed — our access to them has. Fifty tokens per
request and eight readings in flight, so one HTTP request cannot turn into five
hundred eth_calls against an endpoint we hold no account with. Thirty-two
readers arriving for the same cold price produce ONE fan-out, not thirty-two.
**One unit for a fee, everywhere.** Hundredths of a basis point — how V3 states
a tier, how `SwapOrder.Fee` is read, and how a price puts back what a pool took.
The V2 arm answered `30` where the V3 arm answered `3000` for the same three
tenths of a percent; `feeFromBPS` converts once, in `venue.go`, and nothing else
means anything else by that number.
**The native arm no longer hides a chain it cannot reach.** Every other arm
separates a reverting contract from an endpoint that never answered, and the
router reports a chain it could not read only when EVERY arm on it failed — so
one arm answering "no liquidity" to an unreachable endpoint was enough on its
own to turn an unreadable chain into a pair nobody holds.
### The venue that quoted is the venue that builds
`/v1/trade/swap` asked the hosted provider registry for a price — which this
+163
View File
@@ -31,6 +31,7 @@ servers:
tags:
- name: venues
- name: tokens
- name: quote
- name: swap
- name: allowance
@@ -73,6 +74,138 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/Envelope' }
/v1/trade/tokens:
get:
operationId: tokens
tags: [tokens]
summary: The tokens this deployment can trade
description: |
The list travels with the binary — nine hundred and thirty-five tokens
over the seven chains this gateway prices — so this answers with no
upstream, no key and no round trip. Every chain but ours has no indexer
behind it, and an interface reading one drew nothing at all on Ethereum.
Each entry is that token's identity as its own contract states it:
address, symbol, name and the decimals an amount is read with. There is
no logo. Every public token list gives logos as URLs on hosts belonging
to somebody else, fetched by every viewer's browser on every market
screen; marks come from cdn.lux.cloud, and a token with no mark there
draws its own letters.
Rows come back in the order they are listed: the chain's own token,
then the dollar it is priced in, then the rest by symbol. There is no
volume on any chain but ours, so there is nothing to rank by.
parameters:
- name: chainId
in: query
required: false
schema: { type: integer, format: int64 }
description: One chain. Omitted, every chain this deployment reads.
- name: q
in: query
required: false
schema: { type: string }
description: |
Narrow to what was typed, nearest first: a whole address, then an
exact symbol, then a symbol or name starting with it, then either
containing it. An address matches whole and never by fragment — a
partial address is a typo, and offering a token that shares a prefix
with a mistyped one is how funds reach the wrong contract.
- name: limit
in: query
required: false
schema: { type: integer, default: 200, maximum: 1000 }
responses:
'200':
description: The tokens, in listing order.
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/Envelope'
- type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/Token' }
'400':
$ref: '#/components/responses/BadRequest'
/v1/trade/price:
get:
operationId: price
tags: [tokens]
summary: What one token, or fifty, are worth in dollars
description: |
There is no oracle behind this and no vendor. The number is a reading of
the same pools /v1/trade/quote reads: spend a fixed hundred dollars of
the chain's dollar token, see how much comes back, and put the pool's
fee back into the answer — a pool charges its tier on the way through,
so what you receive is the mid price less that tier.
A hundred dollars rather than one whole token, because one whole token
is a fifty-cent trade in one market and a hundred-thousand-dollar trade
in the next, and the second one moves the pool it is measuring.
A token with no dollar pool — on Ethereum, most of them — is read
against the chain's wrapped native token instead, whose own price is
read against the dollar. Both readings are taken and the cheaper wins:
a price is what somebody spending a hundred dollars actually receives,
and the route handing over the most tokens is nearest the middle of the
market.
Measured against an independent source on Ethereum, fourteen tokens
from WETH to SHIB, every one lands within half a percent.
A reading stands for a minute, and `asOf` says when it was taken. A
token no pool here holds comes back WITHOUT `priceUSD` rather than with
a zero: a screen can draw a dash, and it cannot un-draw a wrong number.
parameters:
- name: chainId
in: query
required: true
schema: { type: integer, format: int64 }
- name: token
in: query
required: true
explode: true
schema:
type: array
items: { type: string }
description: |
Repeated or comma-separated, up to fifty, and answered in the order
asked. Fifty because each one is a fan-out of eth_calls against a
public endpoint; a screen wanting more asks again, and by then most
of them are already read.
responses:
'200':
description: One entry per token asked, in that order.
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/Envelope'
- type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/Price' }
'400':
$ref: '#/components/responses/BadRequest'
'404':
description: No venue here reads that chain, or it lists no such token.
content:
application/json:
schema: { $ref: '#/components/schemas/Envelope' }
'502':
description: |
The chain could not be read at all — every token asked for failed to
reach it. That is a fact about an endpoint and not about the pools,
and a page of dashes would say the wrong one.
content:
application/json:
schema: { $ref: '#/components/schemas/Envelope' }
/v1/trade/quote:
post:
operationId: quote
@@ -327,6 +460,36 @@ components:
symbol: { type: string }
name: { type: string }
Price:
type: object
required: [chainId, address, symbol, name, decimals, asOf]
properties:
chainId: { type: integer, format: int64 }
address: { type: string }
symbol: { type: string }
name: { type: string }
decimals: { type: integer }
priceUSD:
type: number
format: double
description: |
ABSENT, not zero, when no pool this gateway reads holds the token
against either numéraire.
venue:
type: string
description: |
The arm that answered. Absent with the price, and absent for the
chain's own dollar, which nothing had to read.
via:
type: string
description: |
The numéraire the reading was taken against — the chain's dollar,
or its wrapped native token. Absent with the price.
asOf:
type: string
format: date-time
description: When the pools were read. A reading stands for a minute.
Amount:
type: object
required: [token, amount]
+197
View File
@@ -0,0 +1,197 @@
package gateway
import (
_ "embed"
"encoding/json"
"sort"
"strings"
)
// The tokens this gateway can trade, and nothing else about them.
//
// A screen drawing a market needs three things before it can draw anything: a
// name, a symbol, and the decimals an amount is read with. Our own chain has an
// indexer that answers that; the twenty others do not, and the hosted API that
// would answer costs a contract — it returns ACCESS_DENIED without one. So the
// list travels with the binary. Nine hundred and thirty-five tokens over the
// seven chains this gateway prices, each entry as that token's own contract
// states it, and no request to anybody to get them.
//
// It carries NO logo, and has nowhere to put one. Every public token list gives
// logos as URLs on assets.coingecko.com, coin-images.coingecko.com,
// raw.githubusercontent.com and s2.coinmarketcap.com — three companies fetched
// by every viewer's browser on every market screen, none of them ours. Marks
// come from cdn.lux.cloud, and a token the cdn has no mark for draws its own
// letters. A field that does not exist here cannot be filled in by accident.
//
//go:embed tokens.json
var vendored []byte
// listed is one entry of the embedded list. It is the standard token-list
// shape minus the logo and the bridge extensions, which is the whole of what a
// screen needs and none of what it should be fetching from elsewhere.
type listed struct {
ChainID uint64 `json:"chainId"`
Address string `json:"address"`
Symbol string `json:"symbol"`
Name string `json:"name"`
Decimals int `json:"decimals"`
}
// Catalog answers the two questions a market screen asks about a token: what
// does this chain list, and what is the token at this address.
type Catalog struct {
// byChain keeps the file's order, which is the order a listing is drawn
// in: the chain's own token, then the dollar it is priced in, then the
// rest by symbol. There is no volume on any chain but ours, so there is
// nothing to rank by, and an order that permutes itself between two reads
// reads as a venue that changed.
byChain map[ChainID][]Token
byAddr map[ChainID]map[string]Token
}
// NewCatalog reads the embedded list, keeping the chains named.
//
// A chain this deployment cannot quote has no tokens here. /v1/trade/venues and
// /v1/trade/tokens then answer about one set rather than two, and a screen
// cannot list a market this gateway has no way to price.
func NewCatalog(chains []ChainID) *Catalog {
var doc struct {
Tokens []listed `json:"tokens"`
}
// The file ships in the binary and a test reads every field of it, so a
// malformed one fails the build rather than a request.
if err := json.Unmarshal(vendored, &doc); err != nil {
panic("gateway: embedded token list: " + err.Error())
}
keep := make(map[ChainID]bool, len(chains))
for _, id := range chains {
keep[id] = true
}
c := &Catalog{
byChain: map[ChainID][]Token{},
byAddr: map[ChainID]map[string]Token{},
}
for _, l := range doc.Tokens {
id := ChainID(l.ChainID)
if !keep[id] {
continue
}
t := Token{
Address: l.Address,
ChainID: id,
Decimals: l.Decimals,
Symbol: l.Symbol,
Name: l.Name,
}
c.byChain[id] = append(c.byChain[id], t)
if c.byAddr[id] == nil {
c.byAddr[id] = map[string]Token{}
}
c.byAddr[id][strings.ToLower(t.Address)] = t
}
return c
}
// Token is the token at one address, as its contract states it.
//
// Addresses are compared in lower case and returned in the case the list gives
// them, which is the checksummed form a wallet displays. A caller that types an
// address in either case is asking about the same token, and answering "no such
// token" because of a capital letter is a fact about string comparison dressed
// as a fact about the chain.
func (c *Catalog) Token(chain ChainID, address string) (Token, bool) {
t, ok := c.byAddr[chain][strings.ToLower(address)]
return t, ok
}
// Chains is every chain with tokens here, in ascending order.
func (c *Catalog) Chains() []ChainID {
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
}
// List is a chain's tokens, or every chain's when none is named, narrowed to
// what a caller typed and capped at limit.
func (c *Catalog) List(chain ChainID, query string, limit int) []Token {
var from []Token
if chain != 0 {
from = c.byChain[chain]
} else {
for _, id := range c.Chains() {
from = append(from, c.byChain[id]...)
}
}
query = strings.TrimSpace(query)
if query == "" {
if limit < len(from) {
from = from[:limit]
}
out := make([]Token, len(from))
copy(out, from)
return out
}
// Ranked, because a search that returns matches in storage order is a
// search nobody can use: "us" on Ethereum contains eleven tokens before
// USDC, and a person who typed the symbol of the thing they wanted has to
// scroll past all of them.
type hit struct {
token Token
rank int
at int
}
q := strings.ToLower(query)
hits := make([]hit, 0, 64)
for i, t := range from {
if r := rank(t, q); r >= 0 {
hits = append(hits, hit{token: t, rank: r, at: i})
}
}
sort.SliceStable(hits, func(i, j int) bool {
if hits[i].rank != hits[j].rank {
return hits[i].rank < hits[j].rank
}
return hits[i].at < hits[j].at
})
if limit < len(hits) {
hits = hits[:limit]
}
out := make([]Token, len(hits))
for i, h := range hits {
out[i] = h.token
}
return out
}
// rank orders one token against what was typed, lower being nearer. It returns
// -1 for a token the query does not touch at all.
//
// An address is matched whole and never by fragment: a partial address is a
// typo, and offering a token that happens to share a prefix with a mistyped
// address is how somebody sends funds to the wrong contract.
func rank(t Token, q string) int {
symbol, name := strings.ToLower(t.Symbol), strings.ToLower(t.Name)
switch {
case strings.ToLower(t.Address) == q:
return 0
case symbol == q:
return 1
case strings.HasPrefix(symbol, q):
return 2
case strings.HasPrefix(name, q):
return 3
case strings.Contains(name, q):
return 4
case strings.Contains(symbol, q):
return 5
}
return -1
}
+209
View File
@@ -0,0 +1,209 @@
package gateway
import (
"encoding/json"
"regexp"
"strings"
"testing"
)
// The file is the boundary, so it is checked here rather than at every request.
//
// It ships inside the binary: nothing between writing it and serving it can
// change a decimal or a symbol, which is exactly why nothing at runtime
// re-validates one. A wrong `decimals` is how a transfer ends up a million
// times the size it was meant to be, and it has to be caught before the build.
func TestTheEmbeddedListIsWellFormed(t *testing.T) {
var doc struct {
Tokens []map[string]any `json:"tokens"`
}
if err := json.Unmarshal(vendored, &doc); err != nil {
t.Fatalf("tokens.json: %v", err)
}
if len(doc.Tokens) < 900 {
t.Fatalf("tokens.json carries %d tokens; it held 935", len(doc.Tokens))
}
address := regexp.MustCompile(`^0x[0-9a-fA-F]{40}$`)
seen := map[string]bool{}
for i, raw := range doc.Tokens {
// No logo, and nowhere to put one. Every public token list gives logos
// as URLs on assets.coingecko.com and three other hosts belonging to
// somebody else; a field that does not exist cannot be filled in by
// accident later.
for key := range raw {
switch key {
case "chainId", "address", "symbol", "name", "decimals":
default:
t.Errorf("token %d carries %q, which is not identity", i, key)
}
}
var tok listed
b, _ := json.Marshal(raw)
if err := json.Unmarshal(b, &tok); err != nil {
t.Fatalf("token %d: %v", i, err)
}
where := tok.Symbol + " on " + itoa(tok.ChainID)
if !address.MatchString(tok.Address) {
t.Errorf("%s: %q is not an address", where, tok.Address)
}
if tok.Decimals < 0 || tok.Decimals > 36 {
t.Errorf("%s: %d decimals", where, tok.Decimals)
}
if strings.TrimSpace(tok.Symbol) == "" || strings.TrimSpace(tok.Name) == "" {
t.Errorf("%s: a token with no symbol or no name draws as a blank row", where)
}
// A symbol carrying a control character is drawn into a market screen
// by whatever renders it.
for _, r := range tok.Symbol + tok.Name {
if r < 0x20 || r == 0x7f {
t.Errorf("%s: control character in its name", where)
break
}
}
key := itoa(tok.ChainID) + ":" + strings.ToLower(tok.Address)
if seen[key] {
t.Errorf("%s: listed twice", where)
}
seen[key] = true
}
}
// Every chain this gateway quotes has both numéraires listed, because a price
// is read against them and their decimals come from here. A chain missing one
// prices nothing, silently.
func TestEveryChainCanBePriced(t *testing.T) {
chains := DefaultChainVenues("http://chain.invalid")
ids := make([]ChainID, 0, len(chains))
for id := range chains {
ids = append(ids, id)
}
c := NewCatalog(ids)
for id, v := range chains {
for what, address := range map[string]string{"stable": v.Stable, "hub": v.Hub} {
if address == "" {
t.Errorf("chain %d names no %s", id, what)
continue
}
if _, ok := c.Token(id, address); !ok {
t.Errorf("chain %d's %s %s is not in the catalog", id, what, address)
}
}
}
}
// The catalog lists what the deployment can quote and nothing else. A screen
// that can list a market this gateway cannot price is a screen with a dead row
// on it.
func TestOnlyTheChainsAskedFor(t *testing.T) {
c := NewCatalog([]ChainID{ChainIDLux})
if got := c.Chains(); len(got) != 1 || got[0] != ChainIDLux {
t.Fatalf("chains: %v", got)
}
if n := len(c.List(ChainIDEthereum, "", 100)); n != 0 {
t.Errorf("Ethereum was not asked for and answered %d tokens", n)
}
if n := len(c.List(0, "", 1000)); n != len(c.List(ChainIDLux, "", 1000)) {
t.Errorf("every chain and the only chain differ")
}
}
// The chain's own token first, then the dollar it is priced in. Whatever the
// order is, it is the same on every read: a listing that permutes itself
// between two reads of one deployment reads as a deployment that changed.
func TestListingOrderIsTheChains(t *testing.T) {
c := NewCatalog([]ChainID{ChainIDEthereum})
rows := c.List(ChainIDEthereum, "", 5)
if len(rows) != 5 {
t.Fatalf("got %d rows", len(rows))
}
if rows[0].Symbol != "WETH" || rows[1].Symbol != "USDC" {
t.Errorf("Ethereum leads with %s, %s", rows[0].Symbol, rows[1].Symbol)
}
again := c.List(ChainIDEthereum, "", 5)
for i := range rows {
if rows[i] != again[i] {
t.Fatalf("row %d moved between two reads", i)
}
}
}
// A search that answers in storage order is a search nobody can use: eleven
// Ethereum tokens contain "us" before USDC does, and a person who typed the
// symbol of the thing they wanted should not scroll past all of them.
func TestSearchAnswersWithWhatWasTyped(t *testing.T) {
c := NewCatalog([]ChainID{ChainIDEthereum, ChainIDLux})
for _, typed := range []string{"usdc", "USDC", "UsDc"} {
got := c.List(ChainIDEthereum, typed, 10)
if len(got) == 0 || got[0].Symbol != "USDC" {
t.Errorf("%q found %v", typed, symbols(got))
}
}
if got := c.List(ChainIDLux, "wlux", 5); len(got) == 0 || got[0].Symbol != "WLUX" {
t.Errorf("wlux found %v", symbols(got))
}
// A whole address, in either case, is the token at that address.
whole := c.List(ChainIDLux, strings.ToUpper(usdLux.Hub), 5)
if len(whole) != 1 || !strings.EqualFold(whole[0].Address, usdLux.Hub) {
t.Errorf("a whole address found %v", symbols(whole))
}
// And half of one is a typo. Offering the token that happens to share a
// prefix with a mistyped address is how funds reach the wrong contract.
if got := c.List(ChainIDLux, usdLux.Hub[:20], 5); len(got) != 0 {
t.Errorf("half an address found %v", symbols(got))
}
if got := c.List(ChainIDEthereum, "no such token anywhere", 5); len(got) != 0 {
t.Errorf("nothing matched and %d came back", len(got))
}
}
func TestListingIsCapped(t *testing.T) {
c := NewCatalog([]ChainID{ChainIDEthereum})
if n := len(c.List(ChainIDEthereum, "", 3)); n != 3 {
t.Errorf("limit 3 gave %d", n)
}
if n := len(c.List(ChainIDEthereum, "a", 2)); n != 2 {
t.Errorf("a capped search gave %d", n)
}
}
// Lower case, upper case, checksummed: one token.
func TestAddressCaseIsNotAFactAboutTheChain(t *testing.T) {
c := NewCatalog([]ChainID{ChainIDEthereum})
weth := usdEthereum.Hub
for _, spelling := range []string{weth, strings.ToLower(weth), strings.ToUpper("0x" + weth[2:])} {
got, ok := c.Token(ChainIDEthereum, spelling)
if !ok || got.Symbol != "WETH" {
t.Errorf("%s answered %v %v", spelling, got.Symbol, ok)
}
}
if _, ok := c.Token(ChainIDEthereum, "0x0000000000000000000000000000000000000000"); ok {
t.Error("the zero address is a token")
}
}
func symbols(tokens []Token) []string {
out := make([]string, len(tokens))
for i, t := range tokens {
out[i] = t.Symbol
}
return out
}
func itoa(n uint64) string {
if n == 0 {
return "0"
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
+293
View File
@@ -0,0 +1,293 @@
package gateway
import (
"context"
"math/big"
"strconv"
"strings"
"sync"
"time"
)
// What a token is worth here.
//
// There is no oracle behind this and no vendor: the number is a reading of the
// same pools /v1/trade/quote reads. Spend a fixed hundred dollars of a
// numéraire, see how much of the token comes back, and put the pool's fee back
// into the answer — a pool charges its tier on the way through, so what you
// receive is the mid price less that tier, and the correction is the difference
// between $1.0031 and $1.0000 for WLUX.
//
// Measured against an independent source on Ethereum, fourteen tokens from WETH
// to SHIB, this lands within half a percent of each: WETH -0.09%, WBTC -0.01%,
// LINK +0.08%, AAVE +0.00%, PEPE -0.32%, SHIB +0.07%. The residual is the gap
// between one venue's pools and a global average, which is a real difference
// and not an error.
// markNotional is the size of the reading, in dollars.
//
// Not one whole token. One whole token is a fifty-cent trade in one market and
// a hundred-thousand-dollar trade in the next, and the second one moves the
// pool it is measuring — priced that way, SHIB came out forty percent high.
// A hundred dollars leaves any pool worth listing where it was, and still reads
// a token worth a millionth of a cent to five figures.
const markNotional = 100
// markTTL is how long a reading stands.
//
// A quote is the price of a trade somebody is about to make, and is kept for
// ten seconds. A mark is what a list of two hundred rows draws beside each
// name, and asking a public endpoint for two hundred readings on every page
// view is how a public endpoint stops answering.
const markTTL = time.Minute
// markRetry is how long an unreadable chain is remembered. Much less, because
// the pools have not changed — our access to them has, and a minute of dashes
// outlives the thirty seconds of outage that caused them.
const markRetry = 10 * time.Second
// Price is a token and what it is worth, as /v1/trade/price answers.
type Price struct {
ChainID ChainID `json:"chainId"`
Address string `json:"address"`
Symbol string `json:"symbol"`
Name string `json:"name"`
Decimals int `json:"decimals"`
// USD is absent — not zero — when no pool this gateway reads holds the
// token against either numéraire. A screen draws a dash for an absent
// price, and it cannot un-draw a wrong number.
USD *float64 `json:"priceUSD,omitempty"`
// Venue is the arm that answered and Via the numéraire the reading was
// taken against. They go missing with the price, because a number nobody
// can trace is a number nobody can check.
Venue string `json:"venue,omitempty"`
Via string `json:"via,omitempty"`
// AsOf is when the pools were read, so a row drawn from a mark says how
// old it is instead of implying it is now.
AsOf time.Time `json:"asOf"`
}
// mark is one reading, kept in exact arithmetic until it reaches the wire.
type mark struct {
usd *big.Rat
venue string
via string
at time.Time
// unreachable records that the chain could not be read at all, which is a
// different fact from a token no pool holds and is kept for less time.
unreachable bool
}
func (m mark) price(chain ChainID, t Token) Price {
p := Price{
ChainID: chain,
Address: t.Address,
Symbol: t.Symbol,
Name: t.Name,
Decimals: t.Decimals,
AsOf: m.at,
}
if m.usd != nil {
usd, _ := m.usd.Float64()
p.USD, p.Venue, p.Via = &usd, m.venue, m.via
}
return p
}
// marks keeps each token's reading for a while, and computes a cold one once
// however many readers arrive for it at the same moment. Two hundred rows
// opening at once is one fan-out per token, not one per token per reader.
type marks struct {
mu sync.Mutex
kept map[string]*held
}
type held struct {
done chan struct{}
mark mark
until time.Time
}
func newMarks() *marks { return &marks{kept: map[string]*held{}} }
// take hands back either a reading to wait on or the job of making one.
func (m *marks) take(key string) (*held, bool) {
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
if h, ok := m.kept[key]; ok {
select {
case <-h.done:
if now.Before(h.until) {
return h, false
}
delete(m.kept, key)
default:
// Still being read. Waiting for it is the whole point.
return h, false
}
}
// A cache nobody sweeps is a leak with a good reputation. Every miss drops
// what has expired, which on this traffic is cheaper than a timer.
for k, h := range m.kept {
select {
case <-h.done:
if now.After(h.until) {
delete(m.kept, k)
}
default:
}
}
h := &held{done: make(chan struct{})}
m.kept[key] = h
return h, true
}
// fill publishes a reading to whoever is waiting for it.
func (m *marks) fill(h *held, mk mark) {
ttl := markTTL
if mk.unreachable {
ttl = markRetry
}
m.mu.Lock()
h.mark, h.until = mk, time.Now().Add(ttl)
m.mu.Unlock()
close(h.done)
}
// markOf reads what one token is worth, or waits for the reader who got there
// first.
//
// The dollar this chain prices in is worth a dollar by construction, so it
// costs nothing to say so. Everything else is read against that dollar and
// against the chain's own token, and the cheaper of the two answers wins: a
// price is what somebody spending a hundred dollars actually receives, and the
// route that hands over the most tokens is the one nearest the middle of the
// market.
func (s *Server) markOf(ctx context.Context, chain ChainID, token Token) mark {
num := s.chains.Numeraires(chain)
if strings.EqualFold(token.Address, num.Stable) {
return mark{usd: big.NewRat(1, 1), via: token.Symbol, at: time.Now()}
}
key := strconv.FormatUint(uint64(chain), 10) + ":" + strings.ToLower(token.Address)
h, mine := s.marks.take(key)
if !mine {
select {
case <-h.done:
return h.mark
case <-ctx.Done():
// A reading we ran out of time for is not a token with no pool.
return mark{at: time.Now(), unreachable: true}
}
}
mk := mark{at: time.Now()}
defer func() { s.marks.fill(h, mk) }()
read, unreachable := 0, 0
best := func(num Token, numUSD *big.Rat) {
read++
got, err := s.readAgainst(ctx, chain, num, numUSD, token)
if err != nil {
unreachable++
return
}
for _, cand := range got {
if mk.usd == nil || cand.usd.Cmp(mk.usd) < 0 {
mk.usd, mk.venue, mk.via = cand.usd, cand.venue, num.Symbol
}
}
}
if stable, ok := s.catalog.Token(chain, num.Stable); ok {
best(stable, big.NewRat(1, 1))
}
// The chain's own token is where most of its pools are, and its own mark
// is read against the dollar first. A token with no dollar pool — which on
// Ethereum is most of them — is reachable only this way.
if hub, ok := s.catalog.Token(chain, num.Hub); ok &&
!strings.EqualFold(hub.Address, token.Address) &&
!strings.EqualFold(hub.Address, num.Stable) {
if hm := s.markOf(ctx, chain, hub); hm.usd != nil {
best(hub, hm.usd)
}
}
mk.unreachable = read > 0 && unreachable == read
return mk
}
// readAgainst asks every arm on the chain what a hundred dollars of one
// numéraire buys of a token, and turns each answer into a price.
//
// The error means the CHAIN could not be read: every arm on it failed. That is
// a fact about an endpoint and not about a pool, and reporting the two as one
// thing sends a reader looking for liquidity that is sitting right there.
func (s *Server) readAgainst(ctx context.Context, chain ChainID, num Token, numUSD *big.Rat, token Token) ([]mark, error) {
venues := s.arms(chain)
if venues == nil || numUSD.Sign() <= 0 {
return nil, nil
}
probe := probeAmount(numUSD, num.Decimals)
if probe.Sign() <= 0 {
return nil, nil
}
held, err := venues.QueryAllVenues(ctx, VenueQuoteRequest{
TokenIn: num.Address,
TokenOut: token.Address,
Amount: probe.String(),
Type: VenueQuoteTypeExactInput,
})
if err != nil {
return nil, err
}
out := make([]mark, 0, len(held))
for _, q := range held {
got, ok := new(big.Int).SetString(q.AmountOut, 10)
if !ok || got.Sign() <= 0 {
continue
}
fee, err := strconv.ParseUint(q.Fee, 10, 32)
if err != nil || fee >= feeScale {
// A tier nobody can read is a tier nobody can undo. Reporting the
// price with the fee still in it is high by that fee; guessing at
// the fee is wrong by whatever the guess was.
fee = 0
}
out = append(out, mark{usd: unitPrice(probe, num.Decimals, numUSD, got, token.Decimals, fee), venue: q.Venue})
}
return out, nil
}
// probeAmount is a hundred dollars of a token, in its smallest units.
func probeAmount(numUSD *big.Rat, decimals int) *big.Int {
n := new(big.Rat).SetInt(new(big.Int).Mul(big.NewInt(markNotional), pow10(decimals)))
n.Quo(n, numUSD)
return new(big.Int).Quo(n.Num(), n.Denom())
}
// unitPrice is what one whole token is worth, given what a probe of the
// numéraire bought and the tier it went through.
func unitPrice(probe *big.Int, numDecimals int, numUSD *big.Rat, got *big.Int, tokenDecimals int, fee uint64) *big.Rat {
spent := new(big.Rat).SetFrac(probe, pow10(numDecimals))
spent.Mul(spent, numUSD)
received := new(big.Rat).SetFrac(got, pow10(tokenDecimals))
ask := new(big.Rat).Quo(spent, received)
// The pool kept its tier on the way through, so what came back is the mid
// price less that tier. Putting it back is what turns a 0.3% pool's $1.0031
// into $1.0000, and two hops of it turn LETH's $3018.51 into $3000.44.
return ask.Mul(ask, big.NewRat(int64(feeScale-fee), feeScale))
}
// pow10 is ten to the nth, for converting a token's smallest unit to a whole
// one.
func pow10(n int) *big.Int {
return new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(n)), nil)
}
+372
View File
@@ -0,0 +1,372 @@
package gateway
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
)
// A chain with two pools on it, at prices that are round numbers: one WLUX is
// one LUSD, and one LETH is three thousand WLUX. Everything below reads those
// two pools, so a price that comes back wrong is wrong by an amount that names
// its own cause — 0.3% high is the fee left in, 0.6% high is two of them.
func newMarketServer(extra ...Venue) *Server {
unit := new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil)
million := new(big.Int).Mul(big.NewInt(1_000_000), unit)
pools := NewV4Venue("")
pools.AddPool(usdLux.Stable, usdLux.Hub,
"0x0000000000000000000000000000000000000000000000000000000000000001",
30, million, million)
pools.AddPool(usdLux.Hub, luxToken("LETH"),
"0x0000000000000000000000000000000000000000000000000000000000000002",
30, new(big.Int).Mul(big.NewInt(3_000_000), unit), new(big.Int).Mul(big.NewInt(1_000), unit))
return NewServer(
NewRouter(NewRegistry(), true),
DefaultServerConfig(),
WithChainVenues(&ChainRouters{
byChain: map[ChainID]*VenueRouter{ChainIDLux: NewVenueRouter(append([]Venue{pools}, extra...)...)},
usd: map[ChainID]Numeraires{ChainIDLux: usdLux},
}),
)
}
// luxToken is one of 96369's tokens by symbol, from the list the binary
// carries.
func luxToken(symbol string) string {
for _, t := range NewCatalog([]ChainID{ChainIDLux}).List(ChainIDLux, "", 100) {
if t.Symbol == symbol {
return t.Address
}
}
panic("96369 lists no " + symbol)
}
func markOfSymbol(t *testing.T, s *Server, symbol string) mark {
t.Helper()
tok, ok := s.catalog.Token(ChainIDLux, luxToken(symbol))
if !ok {
t.Fatalf("the catalog lists no %s", symbol)
}
return s.markOf(context.Background(), ChainIDLux, tok)
}
func near(t *testing.T, what string, got *big.Rat, want, tolerance float64) {
t.Helper()
if got == nil {
t.Fatalf("%s has no price at all", what)
}
f, _ := got.Float64()
if off := math.Abs(f-want) / want; off > tolerance {
t.Errorf("%s is %.8f, want %.8f — off by %.4f%%, tolerance %.4f%%", what, f, want, 100*off, 100*tolerance)
}
}
// The dollar this chain prices in is worth a dollar, by construction, and
// saying so costs nothing. Read through a pool instead it came back $1.0061 —
// two fees deep — which is a wrong number on the one row nobody would check.
func TestTheDollarIsTheDollar(t *testing.T) {
got := markOfSymbol(t, newMarketServer(), "LUSD")
if got.usd == nil || got.usd.Cmp(big.NewRat(1, 1)) != 0 {
t.Fatalf("LUSD is %v", got.usd)
}
if got.via != "LUSD" {
t.Errorf("LUSD is priced via %q", got.via)
}
}
// A pool hands back the mid price LESS its tier, so a reading with the fee
// still in it is high by exactly that tier. On a 0.3% pool that is $1.0031 for
// a token worth $1.00 — visible on the screen, and wrong.
//
// This is also the check that every arm states a fee in one unit. The V2 arm
// used to answer 30 where the V3 arm answered 3000 for the same three tenths
// of a percent; read in the wrong unit the correction becomes 0.0003% and this
// comes back at $1.0031.
func TestTheFeeComesBackOutOfThePrice(t *testing.T) {
near(t, "WLUX", markOfSymbol(t, newMarketServer(), "WLUX").usd, 1.0, 0.001)
}
// Most of a chain's liquidity is against its own token, not against the dollar
// — on Ethereum, WETH. So a token with no dollar pool is read against the hub,
// and the hub against the dollar. LETH has no LUSD pool at all here.
func TestATokenWithNoDollarPoolIsReadThroughTheHub(t *testing.T) {
got := markOfSymbol(t, newMarketServer(), "LETH")
near(t, "LETH", got.usd, 3000, 0.005)
if got.via != "WLUX" {
t.Errorf("LETH is priced via %q, want WLUX", got.via)
}
if got.venue != VenueNameNative {
t.Errorf("LETH was priced by %q", got.venue)
}
}
// A token no pool here holds has no price. Not zero — absent. gLUX is in no
// pool on 96369, and a market screen drawing $0.00 beside it states something
// false about a token that simply has not traded.
func TestATokenInNoPoolHasNoPrice(t *testing.T) {
if got := markOfSymbol(t, newMarketServer(), "gLUX"); got.usd != nil {
t.Fatalf("gLUX priced at %v", got.usd)
}
}
// countingVenue is an arm that says how often it was asked.
type countingVenue struct {
inner Venue
asked atomic.Int64
}
func (c *countingVenue) Name() string { return c.inner.Name() }
func (c *countingVenue) IsExecutable() bool { return c.inner.IsExecutable() }
func (c *countingVenue) Quote(ctx context.Context, req VenueQuoteRequest) (*VenueQuote, error) {
c.asked.Add(1)
return c.inner.Quote(ctx, req)
}
func (c *countingVenue) Swap(o SwapOrder) (*UnsignedTxResponse, error) { return c.inner.Swap(o) }
// Two hundred rows opening at once must be one reading per token, not one per
// token per reader. Every price is a fan-out of eth_calls against an endpoint
// we hold no account with, and a page reloaded three times while the first
// read is still in flight is how that endpoint stops answering.
func TestOneColdPriceIsReadOnce(t *testing.T) {
counter := &countingVenue{inner: NewV4Venue("")}
s := newMarketServer(counter)
tok, _ := s.catalog.Token(ChainIDLux, luxToken("WLUX"))
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Add(1)
go func() {
defer wg.Done()
s.markOf(context.Background(), ChainIDLux, tok)
}()
}
wg.Wait()
// One reading of WLUX against the dollar. Thirty-two readers, one fan-out.
if n := counter.asked.Load(); n != 1 {
t.Errorf("32 readers asked the chain %d times, want 1", n)
}
// And the next reader gets the kept answer without asking again.
s.markOf(context.Background(), ChainIDLux, tok)
if n := counter.asked.Load(); n != 1 {
t.Errorf("a kept price was read again: %d", n)
}
}
// deadVenue is an arm on a chain that cannot be reached.
type deadVenue struct{}
func (deadVenue) Name() string { return "dead" }
func (deadVenue) IsExecutable() bool { return true }
func (deadVenue) Quote(context.Context, VenueQuoteRequest) (*VenueQuote, error) {
return nil, fmt.Errorf("%w: dial tcp: connection refused", ErrUnreachable)
}
func (deadVenue) Swap(SwapOrder) (*UnsignedTxResponse, error) { return nil, nil }
// A chain nobody can reach is not a chain with no pools. Answered as a page of
// dashes it sends a reader looking for liquidity instead of at their endpoint.
func TestAnUnreadableChainSaysSo(t *testing.T) {
s := NewServer(
NewRouter(NewRegistry(), true),
DefaultServerConfig(),
WithChainVenues(&ChainRouters{
byChain: map[ChainID]*VenueRouter{ChainIDLux: NewVenueRouter(deadVenue{})},
usd: map[ChainID]Numeraires{ChainIDLux: usdLux},
}),
)
w := ask(s, http.MethodGet, "/v1/trade/price?chainId=96369&token="+luxToken("LETH"), nil)
if w.Code != http.StatusBadGateway {
t.Fatalf("an unreachable chain answered %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "could not be read") {
t.Errorf("it says: %s", w.Body.String())
}
}
// /v1/trade/price, as a screen asks it: several tokens at once, answered in
// the order asked, with the ones nothing holds coming back without a price.
func TestPriceAnswersInTheOrderAsked(t *testing.T) {
s := newMarketServer()
asked := []string{luxToken("LETH"), luxToken("gLUX"), luxToken("LUSD"), luxToken("WLUX")}
w := ask(s, http.MethodGet, "/v1/trade/price?chainId=96369&token="+strings.Join(asked, ","), nil)
if w.Code != http.StatusOK {
t.Fatalf("%d: %s", w.Code, w.Body.String())
}
var got struct{ Data []Price }
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if len(got.Data) != len(asked) {
t.Fatalf("asked for %d, got %d", len(asked), len(got.Data))
}
for i, address := range asked {
if !strings.EqualFold(got.Data[i].Address, address) {
t.Fatalf("row %d is %s, asked %s", i, got.Data[i].Address, address)
}
}
if got.Data[1].USD != nil {
t.Errorf("gLUX, in no pool, came back at %v", *got.Data[1].USD)
}
// Absent means absent: no venue and no numéraire either, because a number
// nobody can trace is a number nobody can check.
if got.Data[1].Venue != "" || got.Data[1].Via != "" {
t.Errorf("gLUX has no price and names %q via %q", got.Data[1].Venue, got.Data[1].Via)
}
if got.Data[1].Symbol != "gLUX" || got.Data[1].Decimals != 18 {
t.Errorf("an unpriced token lost its identity: %+v", got.Data[1])
}
if got.Data[3].USD == nil || math.Abs(*got.Data[3].USD-1) > 0.001 {
t.Errorf("WLUX came back %v", got.Data[3].USD)
}
// And the JSON says nothing at all about a price that is not there, rather
// than saying zero.
if strings.Contains(w.Body.String(), `"priceUSD":0`) {
t.Errorf("an absent price was written as zero: %s", w.Body.String())
}
}
func TestPriceRefusesWhatItCannotAnswer(t *testing.T) {
s := newMarketServer()
many := make([]string, pricesAtOnce+1)
for i := range many {
many[i] = luxToken("WLUX")
}
for _, c := range []struct {
why string
path string
want int
}{
{"no chain", "/v1/trade/price?token=" + luxToken("WLUX"), http.StatusBadRequest},
{"no token", "/v1/trade/price?chainId=96369", http.StatusBadRequest},
{"not an address", "/v1/trade/price?chainId=96369&token=WLUX", http.StatusBadRequest},
{"more than it will read at once", "/v1/trade/price?chainId=96369&token=" + strings.Join(many, ","), http.StatusBadRequest},
{"a chain with no venues", "/v1/trade/price?chainId=1&token=" + usdEthereum.Hub, http.StatusNotFound},
{"a token this chain does not list", "/v1/trade/price?chainId=96369&token=0x0000000000000000000000000000000000000001", http.StatusNotFound},
} {
if w := ask(s, http.MethodGet, c.path, nil); w.Code != c.want {
t.Errorf("%s: answered %d, want %d: %s", c.why, w.Code, c.want, w.Body.String())
}
}
}
func TestTokensAnswersTheListItCarries(t *testing.T) {
s := newMarketServer()
var rows struct{ Data []Token }
w := ask(s, http.MethodGet, "/v1/trade/tokens?chainId=96369", nil)
if w.Code != http.StatusOK {
t.Fatalf("%d: %s", w.Code, w.Body.String())
}
if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil {
t.Fatal(err)
}
if len(rows.Data) != 14 {
t.Errorf("96369 lists %d tokens, want 14", len(rows.Data))
}
if rows.Data[0].Symbol != "WLUX" {
t.Errorf("96369 leads with %s", rows.Data[0].Symbol)
}
// Nothing here points a browser at a third party.
if strings.Contains(w.Body.String(), "logo") || strings.Contains(w.Body.String(), "http") {
t.Errorf("a token row carries a URL: %s", w.Body.String())
}
// A chain this deployment cannot quote lists nothing, so a screen cannot
// draw a market it has no way to price.
w = ask(s, http.MethodGet, "/v1/trade/tokens?chainId=1", nil)
if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil {
t.Fatal(err)
}
if len(rows.Data) != 0 {
t.Errorf("Ethereum is not quoted here and listed %d tokens", len(rows.Data))
}
if w := ask(s, http.MethodGet, "/v1/trade/tokens?chainId=96369&limit=nope", nil); w.Code != http.StatusBadRequest {
t.Errorf("a limit that is not a count answered %d", w.Code)
}
}
// The arithmetic, on its own, at numbers whose answer can be read off the page.
func TestTheArithmeticOfAReading(t *testing.T) {
e18 := pow10(18)
e6 := pow10(6)
// A hundred dollars of a six-decimal dollar is 100000000 of its units;
// of a token worth $2500, it is 0.04 of one.
if got := probeAmount(big.NewRat(1, 1), 6); got.Cmp(big.NewInt(100_000_000)) != 0 {
t.Errorf("a hundred dollars of USDC is %s units", got)
}
if got := probeAmount(big.NewRat(2500, 1), 18); got.Cmp(new(big.Int).Div(e18, big.NewInt(25))) != 0 {
t.Errorf("a hundred dollars of a $2500 token is %s units", got)
}
// A hundred USDC bought 0.04 of an 18-decimal token through a 0.3% pool.
// The pool kept 0.3%, so the token is worth 2500 × 0.997 = 2492.50.
price := unitPrice(new(big.Int).Mul(big.NewInt(100), e6), 6, big.NewRat(1, 1),
new(big.Int).Div(e18, big.NewInt(25)), 18, 3000)
near(t, "the reading", price, 2492.50, 1e-12)
// Through a pool that charges nothing, it is worth exactly 2500.
price = unitPrice(new(big.Int).Mul(big.NewInt(100), e6), 6, big.NewRat(1, 1),
new(big.Int).Div(e18, big.NewInt(25)), 18, 0)
near(t, "a free reading", price, 2500, 1e-12)
}
// A chain that cannot be reached says so, including through the arm that reads
// our own precompiles.
//
// Every arm here distinguishes a reverting contract from an endpoint that never
// answered, and 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 hide it, and the one
// reading our own chain is the arm that would do the hiding.
func TestOurOwnChainDoesNotHideItsOutage(t *testing.T) {
dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad gateway", http.StatusBadGateway)
}))
defer dead.Close()
native := NewNativeDEXVenue(NativeDEXConfig{RPCURL: dead.URL})
_, err := native.Quote(context.Background(), VenueQuoteRequest{
TokenIn: usdLux.Hub, TokenOut: usdLux.Stable, Amount: "1", Type: VenueQuoteTypeExactInput,
})
if !errors.Is(err, ErrUnreachable) {
t.Fatalf("a dead endpoint answered %v, want an unreachable chain", err)
}
// And a whole chain built on that arm reports the outage rather than an
// empty market.
s := NewServer(
NewRouter(NewRegistry(), true),
DefaultServerConfig(),
WithChainVenues(&ChainRouters{
byChain: map[ChainID]*VenueRouter{ChainIDLux: NewVenueRouter(native)},
usd: map[ChainID]Numeraires{ChainIDLux: usdLux},
}),
)
if w := ask(s, http.MethodGet, "/v1/trade/price?chainId=96369&token="+luxToken("LETH"), nil); w.Code != http.StatusBadGateway {
t.Errorf("a price on a dead chain answered %d: %s", w.Code, w.Body.String())
}
w := ask(s, http.MethodPost, "/v1/trade/quote", map[string]any{
"tokenIn": usdLux.Hub, "tokenOut": usdLux.Stable, "chainId": 96369,
"amount": "1000000000000000000", "isExactIn": true,
})
if w.Code != http.StatusBadGateway {
t.Errorf("a quote on a dead chain answered %d: %s", w.Code, w.Body.String())
}
}
+14
View File
@@ -22,6 +22,8 @@ type Server struct {
venues *VenueRouter // optional venue-based quoting engine, one chain
chains *ChainRouters // optional venue-based quoting engine, per chain
quotes *quoteCache // optional short-lived cache of venue quotes
catalog *Catalog // the tokens the chains above can trade
marks *marks // what each of them is worth, briefly kept
}
// ServerConfig holds server configuration
@@ -120,6 +122,14 @@ func NewServer(router *Router, cfg ServerConfig, opts ...ServerOption) *Server {
opt(s)
}
// The catalog lists exactly the chains this server can quote, so
// /v1/trade/venues and /v1/trade/tokens answer about one set rather than
// two. It is derived rather than passed because there is no deployment
// that wants them to differ, and an option that can only be set wrongly is
// a way to set it wrongly.
s.catalog = NewCatalog(s.chains.Chains())
s.marks = newMarks()
s.registerRoutes()
return s
}
@@ -156,6 +166,10 @@ func (s *Server) routes() map[string]http.HandlerFunc {
// What this deployment quotes, and from where.
"/venues": s.handleVenues,
// What can be traded here, and what it is worth.
"/tokens": s.handleTokens,
"/price": s.handlePrice,
// A price, and the transaction that takes it.
"/quote": s.handleQuote,
"/quotes": s.handleQuotes,
+189
View File
@@ -0,0 +1,189 @@
package gateway
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
// How many tokens a listing draws at once, and the most it will draw.
const (
tokensPerPage = 200
tokensAtMost = 1000
// A price is a fan-out of eth_calls against a public endpoint we hold no
// account with, so the number of them one request can start is stated
// rather than left to the caller. Fifty rows is a page of a market screen;
// a screen wanting two hundred asks four times, and by the second one most
// of them are already read.
pricesAtOnce = 50
// And this many at a time, so a cold page is a steady stream of calls
// rather than five hundred at once.
pricesInFlight = 8
// The whole fan-out is bounded well inside the server's own write timeout,
// so a chain that has gone slow answers rather than holding the connection
// until it is cut. A token that ran out of time counts as a chain that
// could not be read and not as a token with no pool, which is the
// difference between a 502 and a page of dashes.
pricesWithin = 45 * time.Second
)
// handleTokens handles GET /v1/trade/tokens.
//
// The list travels with the binary, so this answers with no upstream, no key
// and no round trip. That is the whole point of it: every chain but ours has no
// indexer behind it, and lux.exchange drew nothing at all on Ethereum — not an
// empty table, no request made — because there was nothing to ask.
//
// Rows come back in the order they are listed: the chain's own token, then the
// dollar it is priced in, then the rest by symbol. There is no volume on any
// chain but ours, so there is nothing to rank by, and inventing a rank is
// inventing a number.
func (s *Server) handleTokens(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
s.writeError(w, http.StatusMethodNotAllowed, fmt.Errorf("method not allowed"))
return
}
q := r.URL.Query()
chain, err := askedChain(q)
if err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
limit := tokensPerPage
if v := q.Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
s.writeError(w, http.StatusBadRequest, fmt.Errorf("limit: %q is not a count", v))
return
}
limit = min(n, tokensAtMost)
}
s.writeJSON(w, http.StatusOK, s.catalog.List(chain, q.Get("q"), limit))
}
// handlePrice handles GET /v1/trade/price.
//
// One token or fifty, at ?token= repeated or comma-separated, and one entry
// back for each in the order asked. A token no pool here holds comes back
// without a price rather than with a zero: a screen can draw a dash, and it
// cannot un-draw a wrong number.
func (s *Server) handlePrice(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
s.writeError(w, http.StatusMethodNotAllowed, fmt.Errorf("method not allowed"))
return
}
q := r.URL.Query()
chain, err := askedChain(q)
if err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
if chain == 0 {
s.writeError(w, http.StatusBadRequest, fmt.Errorf("chainId is required: a price is a reading of one chain's pools"))
return
}
if s.arms(chain) == nil {
s.writeError(w, http.StatusNotFound, fmt.Errorf("no venue here reads chain %d", chain))
return
}
asked, err := askedTokens(q)
if err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
tokens := make([]Token, len(asked))
for i, address := range asked {
t, ok := s.catalog.Token(chain, address)
if !ok {
s.writeError(w, http.StatusNotFound, fmt.Errorf("chain %d lists no token at %s", chain, address))
return
}
tokens[i] = t
}
out := make([]Price, len(tokens))
unreachable := 0
var mu sync.Mutex
var wg sync.WaitGroup
inFlight := make(chan struct{}, pricesInFlight)
ctx, done := context.WithTimeout(s.requestContext(r), pricesWithin)
defer done()
for i, t := range tokens {
wg.Add(1)
go func(i int, t Token) {
defer wg.Done()
inFlight <- struct{}{}
defer func() { <-inFlight }()
mk := s.markOf(ctx, chain, t)
mu.Lock()
out[i] = mk.price(chain, t)
if mk.unreachable {
unreachable++
}
mu.Unlock()
}(i, t)
}
wg.Wait()
// Every one of them failed to reach the chain. That is a fact about an
// endpoint, not about the pools, and answering it with a page of dashes
// sends a reader looking for liquidity instead of at their RPC.
if unreachable == len(tokens) && len(tokens) > 0 {
s.writeError(w, http.StatusBadGateway, fmt.Errorf("chain %d could not be read", chain))
return
}
s.writeJSON(w, http.StatusOK, out)
}
// askedChain reads ?chainId=, zero meaning every chain.
func askedChain(q map[string][]string) (ChainID, error) {
v := ""
if got := q["chainId"]; len(got) > 0 {
v = got[0]
}
if v == "" {
return 0, nil
}
id, err := strconv.ParseUint(v, 10, 64)
if err != nil {
return 0, fmt.Errorf("chainId: %w", err)
}
return ChainID(id), nil
}
// askedTokens reads ?token=, repeated or comma-separated, in the order given.
func askedTokens(q map[string][]string) ([]string, error) {
var out []string
for _, group := range q["token"] {
for _, one := range strings.Split(group, ",") {
one = strings.TrimSpace(one)
if one == "" {
continue
}
if !isValidHexAddr(one) {
return nil, fmt.Errorf("token: %q is not an address", one)
}
out = append(out, one)
}
}
if len(out) == 0 {
return nil, fmt.Errorf("at least one ?token= is required")
}
if len(out) > pricesAtOnce {
return nil, fmt.Errorf("%d tokens asked for at once; %d is the most, and a screen wanting more asks again", len(out), pricesAtOnce)
}
return out, nil
}
+14 -3
View File
@@ -278,7 +278,7 @@ func TestNothingHereInventsANumber(t *testing.T) {
// path in a published contract that cannot work is worse than a missing one: a
// client writes against it and finds out in production.
//
// tokens, pools, pool/, positions, stats, price, prices, route
// pools, pool/, positions, stats, route
// Enumerating what exists on a chain is an INDEX read. The gateway reads a
// pool by address, right now; it cannot walk every pool on Ethereum. The
// indexer already answers all of it — api-explore.lux.cloud measured
@@ -288,6 +288,17 @@ func TestNothingHereInventsANumber(t *testing.T) {
// one it answered {"routes":[]} forever, which a caller reads as "no path
// exists" rather than "I cannot answer".
//
// prices
// A second name for /v1/trade/price, which takes one token or fifty. Two
// spellings of one question is two things to keep true.
//
// `tokens` and `price` came back, and neither is an index read. The token list
// travels IN the binary — nine hundred and thirty-five of them over seven
// chains — so listing it asks nobody anything. A price is a quote: spend a
// hundred dollars of the chain's dollar, see what comes back, put the pool's
// fee back in. Both are questions a quoting surface can answer about itself,
// which is why they answer here and the five above do not.
//
// order, order/
// The store was an in-memory map behind two replicas, so an order placed on
// one pod was a 404 on the other about half the time, and every order was
@@ -303,8 +314,8 @@ func TestNothingHereInventsANumber(t *testing.T) {
func TestWhatThisSurfaceIsNot(t *testing.T) {
s := newPoolOnlyServer()
for _, gone := range []string{
"/v1/trade/tokens", "/v1/trade/pools", "/v1/trade/pool/96369/0x1",
"/v1/trade/positions", "/v1/trade/stats", "/v1/trade/price",
"/v1/trade/pools", "/v1/trade/pool/96369/0x1",
"/v1/trade/positions", "/v1/trade/stats",
"/v1/trade/prices", "/v1/trade/route",
"/v1/trade/order", "/v1/trade/order/abc",
"/v1/trade/position", "/v1/trade/position/increase",
File diff suppressed because it is too large Load Diff
+17 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"math/big"
"strconv"
)
// VenueNameNative is what the Lux precompile venue calls itself on the wire.
@@ -12,6 +13,20 @@ import (
// 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").
@@ -149,7 +164,7 @@ func (v *V4Venue) Quote(_ context.Context, req VenueQuoteRequest) (*VenueQuote,
return &VenueQuote{
Venue: v.Name(),
AmountOut: amountOut.String(),
Fee: fmt.Sprintf("%d", pool.Fee),
Fee: feeFromBPS(pool.Fee),
GasEstimate: gasEstimate,
Executable: true,
}, nil
@@ -272,7 +287,7 @@ func (b *BrokerVenue) Quote(_ context.Context, req VenueQuoteRequest) (*VenueQuo
return &VenueQuote{
Venue: b.Name(),
AmountOut: amountOut.String(),
Fee: fmt.Sprintf("%d", b.feeBPS),
Fee: feeFromBPS(b.feeBPS),
GasEstimate: "0",
Executable: false,
}, nil
+57 -8
View File
@@ -28,6 +28,25 @@ type ChainVenues struct {
// 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
@@ -48,18 +67,36 @@ const (
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},
ChainIDEthereum: {RPC: "https://ethereum-rpc.publicnode.com", V2Router: v2RouterEthereum, V3Quoter: v3QuoterCommon, V3Router: v3RouterCommon},
ChainIDArbitrum: {RPC: "https://arbitrum-one-rpc.publicnode.com", V2Router: v2RouterCommon, V3Quoter: v3QuoterCommon, V3Router: v3RouterCommon},
ChainIDOptimism: {RPC: "https://optimism-rpc.publicnode.com", V2Router: v2RouterCommon, V3Quoter: v3QuoterCommon, V3Router: v3RouterCommon},
ChainIDPolygon: {RPC: "https://polygon-bor-rpc.publicnode.com", V2Router: v2RouterCommon, V3Quoter: v3QuoterCommon, V3Router: v3RouterCommon},
ChainIDBase: {RPC: "https://base-rpc.publicnode.com", V2Router: v2RouterCommon, V3Quoter: v3QuoterBase, V3Router: v3RouterBase},
ChainIDBNB: {RPC: "https://bsc-rpc.publicnode.com", V2Router: v2RouterCommon, V3Quoter: v3QuoterBNB, V3Router: v3RouterBNB},
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},
}
}
@@ -89,19 +126,31 @@ func (c ChainVenues) Venues() []Venue {
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}
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
+14 -2
View File
@@ -3,6 +3,7 @@ package gateway
import (
"context"
"encoding/hex"
"errors"
"fmt"
"math/big"
)
@@ -84,8 +85,19 @@ func (v *NativeDEXVenue) Quote(ctx context.Context, req VenueQuoteRequest) (*Ven
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 {
// Pool may not exist for this pair — not an error, just no liquidity.
// The precompile reverted, or nothing is deployed at that address.
// Either way this pair has no pool here.
return nil, nil
}
@@ -101,7 +113,7 @@ func (v *NativeDEXVenue) Quote(ctx context.Context, req VenueQuoteRequest) (*Ven
quote := &VenueQuote{
Venue: v.name,
AmountOut: amountOut.String(),
Fee: fmt.Sprintf("%d", v.feeBPS),
Fee: feeFromBPS(int(v.feeBPS)),
GasEstimate: "150000",
Executable: true,
}
+1 -1
View File
@@ -101,7 +101,7 @@ func (v *UniswapV2Venue) Quote(ctx context.Context, req VenueQuoteRequest) (*Ven
return &VenueQuote{
Venue: v.venueName,
AmountOut: amountOut.String(),
Fee: "30", // standard V2 fee: 0.30%
Fee: feeFromBPS(30), // every V2 pair charges 0.30%
GasEstimate: "120000",
Executable: true,
}, nil