mirror of
https://github.com/luxfi/graph.git
synced 2026-09-24 00:26:07 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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) }
|
||||
Reference in New Issue
Block a user