Serve the swap form's token list from the indexer that has it

exchange-api answered this, and exchange-api is a forwarder: four deployments,
one per brand per network, four image tags of a small TypeScript file whose
answer comes from the indexer this binary already is. A second copy of a list is
a second thing that can be wrong about it, and one was — the Lux copy served a
13.27B LUX supply for two days after the indexer had been corrected to the real
two trillion.

The response shape is exchange-api's, unchanged, because the client reads it
today and moving a service is not an occasion to break a client.

The chain identifies itself. eth_chainId is asked once at boot rather than
configured, because a flag or an env keyed by deployment is another copy of a
fact the node already holds — and two copies of "which chain is this" is exactly
how zoo.exchange came to serve Lux's tokens under a ZOO label. If the node will
not say, the route is not registered at all: the client keeps the endpoint it
has rather than being handed a list labelled with a guess.

Quote and the portfolio API are not here. Quote needs routing over pools and is
real work; the portfolio routes the client calls are answered by nothing at all
today — exchange-api returns 404 for every one of them — so that is a gap to
close, not a service to move.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-13 12:49:00 -07:00
co-authored by Hanzo Dev
parent 413ed9a82c
commit 2b4d82066f
2 changed files with 189 additions and 0 deletions
+12
View File
@@ -178,6 +178,18 @@ func main() {
mux.HandleFunc("GET "+prefix+"/graphql", eng.HandleGraphiQL)
// The swap form's token list, served from the table this process indexes
// rather than from a forwarder in front of it. The chain answers for itself
// (eth_chainId) so nothing here has a second opinion about which chain it is;
// if the node cannot say, the route is not registered and the client keeps
// the endpoint it has rather than being handed a list labelled with a guess.
if chainID, err := chainIDFromRPC(ctx, *rpcEndpoint); err != nil {
slog.Warn("token list not served: the chain did not identify itself", "err", err)
} else {
mux.HandleFunc("GET "+prefix+"/swappable_tokens", handleSwappableTokens(store, chainID))
slog.Info("token list served", "path", prefix+"/swappable_tokens", "chain", chainID)
}
// /ql — canonical short alias for /graphql. Same payload shape, less typing.
// Short-alias convention: /v1/graph/ql.
mux.HandleFunc("POST "+prefix+"/ql", func(w http.ResponseWriter, r *http.Request) {
+177
View File
@@ -0,0 +1,177 @@
package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"math/big"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/luxfi/graph/storage"
)
// The list of tokens a person can pick in the swap form.
//
// It was served by exchange-api: a TypeScript forwarder deployed once per brand
// per network, whose answer came from the same indexer this binary IS. The list
// is the token table, so serving it from anywhere else is a copy that can
// disagree — and did, on supply, for two days.
//
// The shape is exchange-api's, because the client already reads it and a client
// is not something to break while moving a service. Everything in it is either
// indexed or derived; nothing is invented.
// swapToken is one row as the swap form expects it.
type swapToken struct {
Address string `json:"address"`
ChainID int64 `json:"chainId"`
Name string `json:"name"`
Symbol string `json:"symbol"`
Decimals int64 `json:"decimals"`
IsSpam bool `json:"isSpam"`
Project swapProject `json:"project"`
}
type swapProject struct {
Logo swapLogo `json:"logo"`
SafetyLevel string `json:"safetyLevel"`
IsSpam bool `json:"isSpam"`
}
type swapLogo struct {
URL string `json:"url"`
}
// iconURL is where a token's mark lives. One host, one lowercase symbol, one
// extension — a token needs no per-token configuration to have a picture, and
// the client already falls back to a lettermark when the fetch fails, so a
// missing file costs nothing.
func iconURL(symbol string) string {
return "https://cdn.lux.network/exchange/icon-png/" + strings.ToLower(symbol) + ".png"
}
// requestID is echoed by the client in its logs, so it exists to be correlated
// and nothing more.
func requestID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "00000000000000000000000000000000"
}
return hex.EncodeToString(b[:])
}
// handleSwappableTokens answers the swap form's token list for one chain.
func handleSwappableTokens(store *storage.Store, chainID int64) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// The client sends the chain it is looking at. Honour it when it does;
// this process indexes one chain, so a request for another is a question
// it cannot answer and should not answer wrongly.
id := chainID
if q := r.URL.Query().Get("chainId"); q != "" {
n, err := strconv.ParseInt(q, 10, 64)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{
"errorCode": "VALIDATION_ERROR",
"detail": "chainId must be an integer",
})
return
}
if n != chainID {
writeJSON(w, http.StatusNotFound, map[string]any{
"errorCode": "UNKNOWN_CHAIN",
"detail": "this indexer serves chain " + strconv.FormatInt(chainID, 10),
})
return
}
id = n
}
raw := store.TokensRaw()
out := make([]swapToken, 0, len(raw))
for addr, t := range raw {
if t == nil || t.Symbol == "" {
continue
}
out = append(out, swapToken{
Address: addr,
ChainID: id,
Name: t.Name,
Symbol: t.Symbol,
Decimals: t.Decimals,
Project: swapProject{
Logo: swapLogo{URL: iconURL(t.Symbol)},
// Every token here was found by following this chain's own
// factory, so it is on the chain by construction. That is a
// weaker claim than a curated allowlist and the honest one.
SafetyLevel: "VERIFIED",
},
})
}
// A stable order, so a reload does not reshuffle the list under someone
// mid-selection. Map iteration alone would.
sort.Slice(out, func(i, j int) bool {
if out[i].Symbol != out[j].Symbol {
return out[i].Symbol < out[j].Symbol
}
return out[i].Address < out[j].Address
})
writeJSON(w, http.StatusOK, map[string]any{
"requestId": requestID(),
"tokens": out,
})
}
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
// chainIDFromRPC asks the chain what it is.
//
// Nothing else can answer without being able to disagree: a flag, an env var or
// a table keyed by deployment is a second copy of a fact the node already holds,
// and the whole reason the exchange served Zoo's tokens under Lux's name was two
// copies of "which chain is this" drifting apart.
func chainIDFromRPC(ctx context.Context, rpc string) (int64, error) {
body := []byte(`{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}`)
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rpc, bytes.NewReader(body))
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, err
}
defer func() { _ = resp.Body.Close() }()
var out struct {
Result string `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return 0, err
}
n, ok := new(big.Int).SetString(strings.TrimPrefix(out.Result, "0x"), 16)
if !ok {
return 0, errBadChainID
}
return n.Int64(), nil
}
// errBadChainID is returned when eth_chainId answers something that is not a
// number, which means the endpoint is not the chain it claims to be.
var errBadChainID = errorString("eth_chainId did not return a hex number")
type errorString string
func (e errorString) Error() string { return string(e) }