Files
dex/pkg/gateway/catalog_test.go
zeekayandHanzo Dev 38d67d52b6 A ticker beats a name, and a reading nobody waited for is not an answer
Two things the first cut got the wrong way round.

Search ranked a name starting with what was typed above a symbol containing it,
so "eth" reached Ethena and Ethereum Name Service before WETH. People type
tickers at a market screen. The rule is now one sentence — the symbol beats the
name, and within each an exact match beats a start beats a mention.

And a reading abandoned because the caller went away was still published to
whoever was waiting on it, and kept for as long as an unreadable chain. It is
neither: the answer and the entry now agree, and nothing is kept.

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

243 lines
7.8 KiB
Go

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))
}
}
// The symbol beats the name. People type tickers at a market screen, so "eth"
// has to reach WETH before it reaches Ethena and Ethereum Name Service, whose
// NAMES start with it and whose symbols say nothing about ether.
func TestASymbolBeatsAName(t *testing.T) {
c := NewCatalog([]ChainID{ChainIDEthereum})
got := c.List(ChainIDEthereum, "eth", 6)
names := symbols(got)
var firstName, firstSymbol = -1, -1
for i, tok := range got {
byName := !strings.Contains(strings.ToLower(tok.Symbol), "eth")
if byName && firstName < 0 {
firstName = i
}
if !byName && firstSymbol < 0 {
firstSymbol = i
}
}
if firstSymbol < 0 {
t.Fatalf("eth found no token whose symbol says so: %v", names)
}
if firstName >= 0 && firstName < firstSymbol {
t.Errorf("eth put %v before a symbol match: %v", got[firstName].Symbol, names)
}
// An exact symbol is first however many names mention it.
for _, typed := range []string{"uni", "dai", "shib"} {
if got := c.List(ChainIDEthereum, typed, 5); len(got) == 0 || !strings.EqualFold(got[0].Symbol, typed) {
t.Errorf("%q found %v", typed, symbols(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:])
}