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:
2026-06-28 22:07:41 -07:00
parent 61ec197a34
commit 1e934c363d
2 changed files with 160 additions and 1 deletions
+118
View File
@@ -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
View File
@@ -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