mirror of
https://github.com/luxfi/graph.git
synced 2026-09-24 00:26:07 +00:00
fix(indexer): bind clean BASE/QUOTE pair symbol + assetsBound on 0x9999 Initialize
handleInitializeV4 now sets the DEX Market symbol to the human BASE/QUOTE pair (from the two currencies' ERC20 symbols) and marks assetsBound=true when both resolve to a clean symbol. This is the exact shape the public exchange-api real-asset gate (dexMarkets.ts isStructurallyReal) requires: without it every native 0x9999 market was stripped (poolId-hex symbol failed the clean-pair check) and the markets surface was empty despite real on-chain DEXFills. A token with no identifiable symbol yields no pair -> the market stays unbound and hidden. One place computes a market's display identity, read by every downstream surface (explorer /dex, exchange-api gate, FE). New TDD: BindsCleanPairSymbol + UnnamedTokenStaysUnbound.
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
package indexer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// abiString ABI-encodes a short string as an ERC20 symbol()/name() return.
|
||||
func abiString(s string) string {
|
||||
h := hex.EncodeToString([]byte(s))
|
||||
for len(h)%64 != 0 {
|
||||
h += "0"
|
||||
}
|
||||
return "0x" + fmt.Sprintf("%064x", 32) + fmt.Sprintf("%064x", len(s)) + h
|
||||
}
|
||||
|
||||
// perAddrERC20RPC answers eth_call symbol() per-token from a map, decimals()=18.
|
||||
func perAddrERC20RPC(symbols map[string]string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req struct {
|
||||
Method string `json:"method"`
|
||||
Params []interface{} `json:"params"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &req)
|
||||
result := "0x"
|
||||
if req.Method == "eth_call" {
|
||||
if call, ok := req.Params[0].(map[string]interface{}); ok {
|
||||
to, _ := call["to"].(string)
|
||||
switch call["data"] {
|
||||
case selSymbol:
|
||||
if sym, ok := symbols[strings.ToLower(to)]; ok {
|
||||
result = abiString(sym)
|
||||
}
|
||||
case selName:
|
||||
if sym, ok := symbols[strings.ToLower(to)]; ok {
|
||||
result = abiString(sym)
|
||||
}
|
||||
case selDecimals:
|
||||
result = "0x" + strings.Repeat("0", 62) + "12" // 18
|
||||
}
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"` + result + `"}`))
|
||||
}
|
||||
}
|
||||
|
||||
// A market whose two currencies resolve to clean ERC20 symbols gets a human
|
||||
// BASE/QUOTE `symbol` and `assetsBound=true` — EXACTLY the shape the public
|
||||
// exchange-api real-asset gate (dexMarkets.ts isStructurallyReal) requires to
|
||||
// surface a native 0x9999 market. Without this the gate strips every EVM market
|
||||
// (poolId-hex symbol fails the clean-pair check) and the markets surface is empty
|
||||
// despite real on-chain DEXFills.
|
||||
func TestHandleInitializeV4_BindsCleanPairSymbol(t *testing.T) {
|
||||
cur0 := "0x0000000000000000000000000000000000000a01"
|
||||
cur1 := "0x0000000000000000000000000000000000000b02"
|
||||
srv := httptest.NewServer(perAddrERC20RPC(map[string]string{
|
||||
cur0: "LUX",
|
||||
cur1: "LUSD",
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
s := newMemSQLiteStore(t)
|
||||
idx := NewWithConfig(Config{RPC: srv.URL}, s)
|
||||
|
||||
poolID := topic32("cafe")
|
||||
idx.processLog(context.Background(), initV4Log(poolID, cur0, cur1, 3000, 60, 0, 0, "0x1"))
|
||||
|
||||
mk, _ := s.GetByType("Market", poolID)
|
||||
if mk == nil {
|
||||
t.Fatal("expected DEX Market from InitializeV4")
|
||||
}
|
||||
mm := mk.(map[string]interface{})
|
||||
if mm["symbol"] != "LUX/LUSD" {
|
||||
t.Errorf("symbol = %v, want clean pair LUX/LUSD", mm["symbol"])
|
||||
}
|
||||
if mm["assetsBound"] != true {
|
||||
t.Errorf("assetsBound = %v, want true (both currencies are named real assets)", mm["assetsBound"])
|
||||
}
|
||||
// The rich token pair must still be present (regression guard on the merge).
|
||||
if fmt.Sprint(mm["baseToken"]) != cur0 || fmt.Sprint(mm["quoteToken"]) != cur1 {
|
||||
t.Errorf("base/quote = %v/%v, want %s/%s", mm["baseToken"], mm["quoteToken"], cur0, cur1)
|
||||
}
|
||||
}
|
||||
|
||||
// A token with no identifiable symbol (symbol() reverts → address placeholder)
|
||||
// must NOT bind the market: assetsBound stays false and the symbol falls back to
|
||||
// the poolId, so the gate hides it rather than surfacing a junk-symbol market.
|
||||
func TestHandleInitializeV4_UnnamedTokenStaysUnbound(t *testing.T) {
|
||||
s := newMemSQLiteStore(t)
|
||||
// RPC unreachable → readERC20 keeps the short-address placeholder symbol.
|
||||
idx := NewWithConfig(Config{RPC: "http://127.0.0.1:0"}, s)
|
||||
|
||||
poolID := topic32("dead")
|
||||
cur0 := "0x0000000000000000000000000000000000000c03"
|
||||
cur1 := "0x0000000000000000000000000000000000000d04"
|
||||
idx.processLog(context.Background(), initV4Log(poolID, cur0, cur1, 3000, 60, 0, 0, "0x1"))
|
||||
|
||||
mk, _ := s.GetByType("Market", poolID)
|
||||
if mk == nil {
|
||||
t.Fatal("expected DEX Market (unbound, but still recorded)")
|
||||
}
|
||||
mm := mk.(map[string]interface{})
|
||||
if mm["assetsBound"] == true {
|
||||
t.Error("unnamed tokens must NOT bind the market (assetsBound must stay false)")
|
||||
}
|
||||
if mm["symbol"] != poolID {
|
||||
t.Errorf("symbol = %v, want poolId fallback %s", mm["symbol"], poolID)
|
||||
}
|
||||
}
|
||||
+42
-1
@@ -815,6 +815,21 @@ func (idx *Indexer) handleInitializeV4(ctx context.Context, l *logEntry) {
|
||||
idx.seedToken(ctx, token1)
|
||||
idx.bumpFactory(1)
|
||||
|
||||
// A bound market carries a human BASE/QUOTE symbol (not the raw poolId) when
|
||||
// BOTH currencies resolve to a clean ERC20 symbol. `assetsBound` marks the
|
||||
// market as backed by two identified real assets. This is the ONE place the
|
||||
// market's display identity is computed, so every downstream surface — the
|
||||
// explorer /dex tab, the exchange-api real-asset gate (dexMarkets.ts requires
|
||||
// assetsBound + a clean pair symbol), and the FE — reads one complete value.
|
||||
// A token we cannot cleanly name yields no pair → the market stays unbound and
|
||||
// the public surface hides it rather than showing a junk hex symbol.
|
||||
symbol := poolID
|
||||
assetsBound := false
|
||||
if s0, s1 := idx.pairSide(ctx, token0), idx.pairSide(ctx, token1); s0 != "" && s1 != "" {
|
||||
symbol = s0 + "/" + s1
|
||||
assetsBound = true
|
||||
}
|
||||
|
||||
// We only reach here for the 0x9999 PoolManager (gated above), so every
|
||||
// Initialize is also a DEX (CLOB) Market. MERGE with any stub a prior fill
|
||||
// already accumulated (writeFill creates a volume24h/tradeCount stub when a Fill
|
||||
@@ -822,7 +837,8 @@ func (idx *Indexer) handleInitializeV4(ctx context.Context, l *logEntry) {
|
||||
// pair, fee tier); it must NOT reset the accumulated trade aggregates to zero.
|
||||
// Defaults apply only when the field is absent (no prior stub).
|
||||
mk := map[string]interface{}{
|
||||
"id": poolID, "symbol": poolID, "baseToken": token0, "quoteToken": token1,
|
||||
"id": poolID, "symbol": symbol, "assetsBound": assetsBound,
|
||||
"baseToken": token0, "quoteToken": token1,
|
||||
"feeTier": fee, "volume24h": "0", "tradeCount": int64(0), "lastPrice": "0",
|
||||
}
|
||||
if existing, _ := idx.store.GetByType("Market", poolID); existing != nil {
|
||||
@@ -837,6 +853,31 @@ func (idx *Indexer) handleInitializeV4(ctx context.Context, l *logEntry) {
|
||||
idx.store.SetEntity("Market", poolID, mk)
|
||||
}
|
||||
|
||||
// pairSide returns the clean, uppercase symbol for one side of a market pair, or
|
||||
// "" if the token has no identifiable ERC20 symbol. A side is clean iff it is a
|
||||
// real, non-placeholder symbol reduced to [A-Z0-9] of length 2–12 — the exact
|
||||
// shape the exchange-api gate (dexMarkets.ts PAIR_SYMBOL) admits. A token whose
|
||||
// symbol() reverts (placeholder = short address) yields "" so its market stays
|
||||
// unbound and hidden, never surfaced with a junk symbol. The metadata is the same
|
||||
// read-once-cached value seedToken already fetched, so this adds no RPC.
|
||||
func (idx *Indexer) pairSide(ctx context.Context, addr string) string {
|
||||
meta := idx.tokenMeta(ctx, addr)
|
||||
if meta == nil || isPlaceholderSymbol(meta.Symbol, addr) {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToUpper(meta.Symbol) {
|
||||
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
s := b.String()
|
||||
if len(s) < 2 || len(s) > 12 {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// handleSwapV4 records a V4 swap (Swap at the 0x9999 PoolManager) as an AMM swap —
|
||||
// amount0/amount1 are signed int128. This is the AMM-side view only. The DEX
|
||||
// (CLOB) Fill is NOT derived here: a settled native fill is recorded from its
|
||||
|
||||
Reference in New Issue
Block a user