What tokens exist is a question no pool can answer

A chain's pools say what a pair trades at and nothing about what is listed on
it: an AMM has no list. Our own chains have an indexer that keeps one, and
every other chain had nothing — so an interface switched to Ethereum asked for
nothing at all and drew an empty table, which is exactly what a reader has been
looking at. The hosted API would answer and refuses without a contract.

CoinGecko publishes both halves openly: a per-chain token list with names,
decimals and logos, and a price by contract address with the day's change and
volume. No key, no account; a key only raises the rate limit. Measured on the
open endpoints: 5,821 tokens on Ethereum, 4,214 on BNB, 2,679 on Base, every
one with a logo.

The lists are large and change slowly, so they are held for six hours; a price
is a reading of a market and is held for a minute. Both are held in the gateway
rather than in a browser, so a thousand readers are one request to a service
that asks nothing of us. A token it does not price is absent from the answer
rather than present at zero — a screen can draw a dash, and it cannot un-draw a
wrong number. One batch refused is not every price unknown.

Measured through the running binary: /v1/trade/tokens?chainId=1 answers WETH,
USDC, 1INCH, AAVE with logos; a search for USDC returns USD Coin alone; and the
price still comes from our own pool read — WETH at 2474.5369 via uniswap_v3
against USDC — with this provider supplying only what the pool cannot.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-09 12:42:28 -07:00
co-authored by Hanzo Dev
parent d0d8a84e03
commit 95a7e42332
2 changed files with 353 additions and 0 deletions
+9
View File
@@ -31,6 +31,7 @@ import (
"time"
"github.com/luxfi/dex/pkg/gateway"
"github.com/luxfi/dex/pkg/gateway/coingecko"
"github.com/luxfi/dex/pkg/gateway/uniswap"
)
@@ -102,6 +103,14 @@ func main() {
log.Printf("hosted upstream registered for %v", up.Chains)
}
// What tokens exist, and what they are worth. A chain's pools say what a
// pair trades at and nothing about what is listed on it, so without this a
// screen switched to Ethereum asks for nothing and draws an empty table.
// Open endpoints, no key; a key only raises the rate limit.
if err := gw.RegisterProvider(coingecko.New(coingecko.Config{APIKey: os.Getenv("COINGECKO_API_KEY")})); err != nil {
log.Fatalf("coingecko provider: %v", err)
}
ttl := 10 * time.Second
if n, err := strconv.Atoi(env("GATEWAY_CACHE_TTL", "10")); err == nil && n >= 0 {
ttl = time.Duration(n) * time.Second
+344
View File
@@ -0,0 +1,344 @@
// Where the tokens come from.
//
// A chain's pools tell you what a pair is worth and nothing about what tokens
// exist: an AMM has no list. Our own chains have an indexer that keeps one, and
// every other chain had nothing — which is why an interface switched to
// Ethereum, asked for nothing, and drew an empty table. The hosted Uniswap API
// would answer, and refuses without a contract.
//
// CoinGecko publishes both halves openly: a per-chain token list with names,
// decimals and logos, and a price by contract address with the day's change and
// volume. No key, no account. Measured: 5,821 tokens on Ethereum, 4,214 on BNB,
// 2,679 on Base, every one with a logo.
//
// The lists are large and change slowly, so they are held for hours; a price is
// a reading of a market and is held for a minute. Both are held HERE rather
// than in a browser, so a thousand readers are one request to a service that
// asks nothing of us.
package coingecko
import (
"context"
"encoding/json"
"fmt"
"math/big"
"net/http"
"strings"
"sync"
"time"
"github.com/luxfi/dex/pkg/gateway"
)
// The name CoinGecko gives each chain. A chain absent here is a chain this
// provider does not claim — it is not an error, it is a gap, and the registry
// asks somebody else.
var platforms = map[gateway.ChainID]string{
gateway.ChainIDEthereum: "ethereum",
gateway.ChainIDBase: "base",
gateway.ChainIDArbitrum: "arbitrum-one",
gateway.ChainIDPolygon: "polygon-pos",
gateway.ChainIDOptimism: "optimistic-ethereum",
gateway.ChainIDBNB: "binance-smart-chain",
}
const (
listHost = "https://tokens.coingecko.com"
priceHost = "https://api.coingecko.com/api/v3"
listTTL = 6 * time.Hour
priceTTL = time.Minute
// A price call takes a batch of addresses. CoinGecko's URL is the limit
// rather than a documented count, so the batch is small enough that the
// request stays well inside any gateway's line length.
batch = 40
)
type Provider struct {
http *http.Client
key string // optional; a free account raises the rate limit and nothing else
mu sync.Mutex
lists map[gateway.ChainID]listing
price map[string]quoted
}
type listing struct {
tokens []gateway.Token
until time.Time
}
type quoted struct {
price gateway.TokenPrice
until time.Time
}
type Config struct {
APIKey string
Timeout time.Duration
}
func New(cfg Config) *Provider {
t := cfg.Timeout
if t == 0 {
t = 20 * time.Second
}
return &Provider{
http: &http.Client{Timeout: t},
key: strings.TrimSpace(cfg.APIKey),
lists: map[gateway.ChainID]listing{},
price: map[string]quoted{},
}
}
func (p *Provider) Info() gateway.ProviderInfo {
chains := make([]gateway.ChainID, 0, len(platforms))
for id := range platforms {
chains = append(chains, id)
}
return gateway.ProviderInfo{
Name: "coingecko",
Version: "1.0.0",
Description: "Token lists and prices for public chains, read from CoinGecko's open endpoints",
SupportedChains: chains,
Priority: 200,
}
}
func (p *Provider) HealthCheck(ctx context.Context) gateway.HealthCheck {
start := time.Now()
err := p.get(ctx, priceHost+"/ping", new(map[string]any))
return gateway.HealthCheck{
Provider: "coingecko",
Healthy: err == nil,
Latency: time.Since(start).Milliseconds(),
LastCheck: time.Now(),
Error: errText(err),
}
}
func (p *Provider) Close() error { return nil }
func errText(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func (p *Provider) get(ctx context.Context, url string, into any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("accept", "application/json")
if p.key != "" {
req.Header.Set("x-cg-demo-api-key", p.key)
}
res, err := p.http.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("coingecko: %s answered %d", url, res.StatusCode)
}
return json.NewDecoder(res.Body).Decode(into)
}
// GetTokenList returns every token CoinGecko lists for a chain.
func (p *Provider) GetTokenList(ctx context.Context, chain gateway.ChainID) ([]gateway.Token, error) {
platform, ok := platforms[chain]
if !ok {
return nil, fmt.Errorf("coingecko: no list for chain %d", chain)
}
p.mu.Lock()
if got, ok := p.lists[chain]; ok && time.Now().Before(got.until) {
p.mu.Unlock()
return got.tokens, nil
}
p.mu.Unlock()
var body struct {
Tokens []struct {
Address string `json:"address"`
ChainID int64 `json:"chainId"`
Decimals int `json:"decimals"`
Symbol string `json:"symbol"`
Name string `json:"name"`
LogoURI string `json:"logoURI"`
} `json:"tokens"`
}
if err := p.get(ctx, fmt.Sprintf("%s/%s/all.json", listHost, platform), &body); err != nil {
return nil, err
}
tokens := make([]gateway.Token, 0, len(body.Tokens))
for _, t := range body.Tokens {
// The list carries every chain it knows in one file on some platforms;
// a row for another chain under this chain's name is the one mistake
// that would put a price on an address that does not exist here.
if t.ChainID != 0 && gateway.ChainID(t.ChainID) != chain {
continue
}
tokens = append(tokens, gateway.Token{
Address: strings.ToLower(t.Address),
ChainID: chain,
Decimals: t.Decimals,
Symbol: t.Symbol,
Name: t.Name,
LogoURI: t.LogoURI,
})
}
p.mu.Lock()
p.lists[chain] = listing{tokens: tokens, until: time.Now().Add(listTTL)}
p.mu.Unlock()
return tokens, nil
}
// SearchTokens answers by symbol, name or address, over the same list.
func (p *Provider) SearchTokens(ctx context.Context, chain gateway.ChainID, query string) ([]gateway.Token, error) {
all, err := p.GetTokenList(ctx, chain)
if err != nil {
return nil, err
}
q := strings.ToLower(strings.TrimSpace(query))
if q == "" {
return all, nil
}
// An exact symbol first, then an address, then anything containing it —
// so searching USDC does not bury USD Coin under a hundred tokens whose
// names merely mention it.
var exact, addr, rest []gateway.Token
for _, t := range all {
switch {
case strings.ToLower(t.Symbol) == q:
exact = append(exact, t)
case strings.ToLower(t.Address) == q:
addr = append(addr, t)
case strings.Contains(strings.ToLower(t.Symbol), q) || strings.Contains(strings.ToLower(t.Name), q):
rest = append(rest, t)
}
}
return append(append(exact, addr...), rest...), nil
}
// GetToken returns one token by address.
func (p *Provider) GetToken(ctx context.Context, chain gateway.ChainID, address string) (*gateway.Token, error) {
all, err := p.GetTokenList(ctx, chain)
if err != nil {
return nil, err
}
want := strings.ToLower(address)
for i := range all {
if all[i].Address == want {
return &all[i], nil
}
}
return nil, fmt.Errorf("coingecko: %s is not listed on chain %d", address, chain)
}
// GetTokenPrice returns one token's price.
func (p *Provider) GetTokenPrice(ctx context.Context, token gateway.Token) (*gateway.TokenPrice, error) {
prices, err := p.GetTokenPrices(ctx, []gateway.Token{token})
if err != nil {
return nil, err
}
if len(prices) == 0 {
return nil, fmt.Errorf("coingecko: no price for %s", token.Address)
}
return &prices[0], nil
}
// GetTokenPrices prices a batch by contract address.
//
// A token CoinGecko does not price is absent from the answer rather than
// present at zero. A screen can draw a dash; it cannot un-draw a wrong number.
func (p *Provider) GetTokenPrices(ctx context.Context, tokens []gateway.Token) ([]gateway.TokenPrice, error) {
if len(tokens) == 0 {
return nil, nil
}
chain := tokens[0].ChainID
platform, ok := platforms[chain]
if !ok {
return nil, fmt.Errorf("coingecko: no prices for chain %d", chain)
}
out := make([]gateway.TokenPrice, 0, len(tokens))
var ask []gateway.Token
p.mu.Lock()
for _, t := range tokens {
if got, ok := p.price[key(chain, t.Address)]; ok && time.Now().Before(got.until) {
out = append(out, got.price)
continue
}
ask = append(ask, t)
}
p.mu.Unlock()
for start := 0; start < len(ask); start += batch {
end := start + batch
if end > len(ask) {
end = len(ask)
}
part := ask[start:end]
addresses := make([]string, len(part))
for i, t := range part {
addresses[i] = strings.ToLower(t.Address)
}
var body map[string]struct {
USD float64 `json:"usd"`
Change24h float64 `json:"usd_24h_change"`
Volume24h float64 `json:"usd_24h_vol"`
MarketCap float64 `json:"usd_market_cap"`
}
url := fmt.Sprintf(
"%s/simple/token_price/%s?contract_addresses=%s&vs_currencies=usd&include_24hr_change=true&include_24hr_vol=true&include_market_cap=true",
priceHost, platform, strings.Join(addresses, ","))
if err := p.get(ctx, url, &body); err != nil {
// One batch refused is not every price unknown: keep what the
// others gave rather than failing a screen over a rate limit.
continue
}
now := time.Now()
p.mu.Lock()
for _, t := range part {
got, ok := body[strings.ToLower(t.Address)]
if !ok || got.USD <= 0 {
continue
}
price := gateway.TokenPrice{
Token: t,
PriceUSD: got.USD,
PriceChange: got.Change24h,
Volume24h: whole(got.Volume24h),
MarketCap: whole(got.MarketCap),
UpdatedAt: now,
}
p.price[key(chain, t.Address)] = quoted{price: price, until: now.Add(priceTTL)}
out = append(out, price)
}
p.mu.Unlock()
}
return out, nil
}
func key(chain gateway.ChainID, address string) string {
return fmt.Sprintf("%d:%s", chain, strings.ToLower(address))
}
// whole turns a figure CoinGecko states as a float into the integer the type
// carries, and returns nil for nothing — an absent volume is absent, not zero.
func whole(f float64) *big.Int {
if f <= 0 {
return nil
}
n, _ := big.NewFloat(f).Int(nil)
return n
}