mirror of
https://github.com/luxfi/graph.git
synced 2026-09-24 00:26:07 +00:00
A token is swappable when a pool holds it
The list was every Token the indexer had ever written, and a Uniswap-v2 PAIR is itself an ERC-20 — so are v3 position receipts, vault shares and an NFT collection. All four were offered in the picker as things to trade. Pool membership excludes them without naming any of them, and is the honest rule anyway: you can trade a token exactly when there is somewhere to trade it. It also never offered the chain's own coin. The form sells native LUX through the zero address, so without that row the commonest trade on the exchange cannot be expressed at all. The sentinel takes the wrapped token's decimals, because a scale that disagrees misprices every trade starting in native, and it appears only when a pool actually holds the wrapped token. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+1
-1
@@ -183,7 +183,7 @@ func main() {
|
||||
if chainID, err := engine.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", engine.HandleSwappableTokens(store, chainID))
|
||||
mux.HandleFunc("GET "+prefix+"/swappable_tokens", engine.HandleSwappableTokens(store, chainID, os.Getenv("COIN"), os.Getenv("WRAPPED_NATIVE")))
|
||||
slog.Info("token list served", "path", prefix+"/swappable_tokens", "chain", chainID)
|
||||
|
||||
// What a swap would pay, over the same book. QUOTER_V2 is the V3
|
||||
|
||||
+55
-2
@@ -66,8 +66,26 @@ func requestID() string {
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// nativeSentinelAddr is how the swap form names the chain's own coin. It is not
|
||||
// a contract; the quoter maps it onto the wrapped token because that is what the
|
||||
// pools hold. Offering it is what makes selling native LUX possible at all, and
|
||||
// it is the commonest trade on the exchange.
|
||||
const nativeSentinelAddr = "0x0000000000000000000000000000000000000000"
|
||||
|
||||
// HandleSwappableTokens answers the swap form's token list for one chain.
|
||||
func HandleSwappableTokens(store *storage.Store, chainID int64) http.HandlerFunc {
|
||||
//
|
||||
// Swappable means a pool holds it. That is the whole rule, and it is the book's
|
||||
// own answer rather than a list somebody maintains: you can trade a token
|
||||
// exactly when there is somewhere to trade it.
|
||||
//
|
||||
// Listing every indexed Token instead put four kinds of thing in a picker that
|
||||
// nobody can swap — a Uniswap-v2 PAIR is itself an ERC-20, so are v3 position
|
||||
// receipts, vault shares and an NFT collection, and all four were offered as
|
||||
// assets. Pool membership excludes all of them without naming any of them.
|
||||
//
|
||||
// `native` is the wrapped token, so the sentinel is offered under the chain's
|
||||
// own coin symbol whenever a pool holds the wrapped one.
|
||||
func HandleSwappableTokens(store *storage.Store, chainID int64, coin, native string) 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
|
||||
@@ -92,10 +110,45 @@ func HandleSwappableTokens(store *storage.Store, chainID int64) http.HandlerFunc
|
||||
id = n
|
||||
}
|
||||
|
||||
// Which tokens a pool actually holds. Anything else in the token table
|
||||
// is a contract this indexer met, not an asset anyone can trade.
|
||||
held := map[string]bool{}
|
||||
for _, p := range store.PoolsRaw() {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
held[strings.ToLower(p.Token0)] = true
|
||||
held[strings.ToLower(p.Token1)] = true
|
||||
}
|
||||
|
||||
raw := store.TokensRaw()
|
||||
out := make([]swapToken, 0, len(raw))
|
||||
|
||||
// The coin comes first, and comes from the wrapped token's own row —
|
||||
// its decimals are the coin's decimals, and a sentinel with the wrong
|
||||
// scale misprices every trade that starts in native.
|
||||
if wrapped := strings.ToLower(native); wrapped != "" && held[wrapped] {
|
||||
if t := raw[wrapped]; t != nil {
|
||||
sym := coin
|
||||
if sym == "" {
|
||||
sym = strings.TrimPrefix(t.Symbol, "W")
|
||||
}
|
||||
out = append(out, swapToken{
|
||||
Address: nativeSentinelAddr,
|
||||
ChainID: id,
|
||||
Name: sym,
|
||||
Symbol: sym,
|
||||
Decimals: t.Decimals,
|
||||
Project: swapProject{
|
||||
Logo: swapLogo{URL: iconURL(sym)},
|
||||
SafetyLevel: "VERIFIED",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for addr, t := range raw {
|
||||
if t == nil || t.Symbol == "" {
|
||||
if t == nil || t.Symbol == "" || !held[strings.ToLower(addr)] {
|
||||
continue
|
||||
}
|
||||
out = append(out, swapToken{
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/graph/storage"
|
||||
)
|
||||
|
||||
// A picker offers what a pool holds, and the chain's own coin. Everything else
|
||||
// the indexer met is a contract, not an asset: a v2 PAIR is an ERC-20, so are
|
||||
// position receipts, vault shares and an NFT collection, and all four were once
|
||||
// offered as things to trade.
|
||||
func TestSwappableTokens_OffersOnlyWhatAPoolHolds(t *testing.T) {
|
||||
const (
|
||||
wlux = "0x4888e4a2ee0f03051c72d2bd3acf755ed3498b3e"
|
||||
usdc = "0x8031e9b0d02a792cfefaa2bdca6e1289d385426f"
|
||||
pair = "0xf07b65def8cbe9f2645157bf69e3e5212d3ced9d" // a v2 pair token
|
||||
nft = "0x10310fed33f22e75a935ace74564a411d5b5196b" // the Genesis collection
|
||||
)
|
||||
store, err := storage.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("storage: %v", err)
|
||||
}
|
||||
if err := store.Init(t.Context()); err != nil {
|
||||
t.Fatalf("init: %v", err)
|
||||
}
|
||||
store.SeedToken(wlux, &storage.SeedTokenData{Symbol: "WLUX", Name: "Wrapped LUX", Decimals: 18})
|
||||
store.SeedToken(usdc, &storage.SeedTokenData{Symbol: "USDC", Name: "USD Coin", Decimals: 18})
|
||||
// Both of these are real ERC-20s the indexer has seen, and neither is held
|
||||
// by any pool.
|
||||
store.SeedToken(pair, &storage.SeedTokenData{Symbol: "UNI-V2", Name: "Uniswap V2", Decimals: 18})
|
||||
store.SeedToken(nft, &storage.SeedTokenData{Symbol: "GENESIS", Name: "Lux Genesis", Decimals: 0})
|
||||
store.SeedPool("0x5914d1fb5ec9aa5ac9610afdb8e9a2f209d2b345",
|
||||
&storage.SeedPoolData{Token0: wlux, Token1: usdc, FeeTier: 10})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
HandleSwappableTokens(store, 96369, "LUX", wlux)(rec, httptest.NewRequest(http.MethodGet, "/v1/swappable_tokens", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Tokens []swapToken `json:"tokens"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
got := map[string]swapToken{}
|
||||
for _, tok := range body.Tokens {
|
||||
got[tok.Address] = tok
|
||||
}
|
||||
|
||||
for _, addr := range []string{wlux, usdc} {
|
||||
if _, ok := got[addr]; !ok {
|
||||
t.Errorf("%s is held by a pool and must be offered", addr)
|
||||
}
|
||||
}
|
||||
for _, addr := range []string{pair, nft} {
|
||||
if tok, ok := got[addr]; ok {
|
||||
t.Errorf("%s (%s) is held by no pool and must not be offered", addr, tok.Symbol)
|
||||
}
|
||||
}
|
||||
|
||||
// Selling the chain's own coin is the commonest trade there is, and the form
|
||||
// can only express it through the zero address.
|
||||
native, ok := got[nativeSentinelAddr]
|
||||
if !ok {
|
||||
t.Fatal("the native sentinel is missing — native LUX cannot be sold without it")
|
||||
}
|
||||
if native.Symbol != "LUX" {
|
||||
t.Errorf("sentinel symbol = %q, want LUX", native.Symbol)
|
||||
}
|
||||
// Its scale is the wrapped token's scale. A sentinel carrying the wrong
|
||||
// decimals misprices every trade that starts in native.
|
||||
if native.Decimals != 18 {
|
||||
t.Errorf("sentinel decimals = %d, want 18 (WLUX's)", native.Decimals)
|
||||
}
|
||||
if len(body.Tokens) != 3 {
|
||||
t.Errorf("offered %d tokens, want 3 (WLUX, USDC, the coin)", len(body.Tokens))
|
||||
}
|
||||
}
|
||||
|
||||
// A chain whose wrapped token no pool holds has nothing to price the coin
|
||||
// against, so the coin is not offered rather than offered unbacked.
|
||||
func TestSwappableTokens_NoSentinelWithoutAPoolForTheWrapped(t *testing.T) {
|
||||
const wlux = "0x4888e4a2ee0f03051c72d2bd3acf755ed3498b3e"
|
||||
store, err := storage.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("storage: %v", err)
|
||||
}
|
||||
if err := store.Init(t.Context()); err != nil {
|
||||
t.Fatalf("init: %v", err)
|
||||
}
|
||||
store.SeedToken(wlux, &storage.SeedTokenData{Symbol: "WLUX", Decimals: 18})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
HandleSwappableTokens(store, 96369, "LUX", wlux)(rec, httptest.NewRequest(http.MethodGet, "/v1/swappable_tokens", nil))
|
||||
|
||||
var body struct {
|
||||
Tokens []swapToken `json:"tokens"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
if len(body.Tokens) != 0 {
|
||||
t.Errorf("offered %d tokens on a chain with no pools, want 0", len(body.Tokens))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user