From 31e92135139b99f19dc657ef2d05946943015aab Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sat, 25 Jul 2026 02:51:57 +0200 Subject: [PATCH 1/2] feat(tqc): add Robinhood chain + multi-source discovery (Virtuals/GeckoTerminal/boosts) --- benchmarks/token-quote-coverage.yml | 22 ++++-- .../cmd/monitor/dexscreener.go | 2 +- .../token-quote-coverage/cmd/monitor/gecko.go | 68 +++++++++++++++++ .../cmd/monitor/kyberswap.go | 10 ++- .../cmd/monitor/mobula.go | 9 ++- .../token-quote-coverage/cmd/monitor/odos.go | 10 ++- .../cmd/monitor/provider.go | 6 +- .../cmd/monitor/scheduler.go | 49 +++++++++++-- .../cmd/monitor/virtuals.go | 73 +++++++++++++++++++ 9 files changed, 221 insertions(+), 28 deletions(-) create mode 100644 harnesses/token-quote-coverage/cmd/monitor/gecko.go create mode 100644 harnesses/token-quote-coverage/cmd/monitor/virtuals.go diff --git a/benchmarks/token-quote-coverage.yml b/benchmarks/token-quote-coverage.yml index c4753218..97413e19 100644 --- a/benchmarks/token-quote-coverage.yml +++ b/benchmarks/token-quote-coverage.yml @@ -20,6 +20,10 @@ per_chain_explainer: h2: "Best DEX aggregator for BNB Chain new token coverage" body: | {{best_name:chain:bnb}} currently leads on BNB Chain at {{best_p50:chain:bnb}} coverage (24h) across Four.meme. BNB is the hardest environment in this benchmark for non-native aggregators: Four.meme graduates to PancakeSwap V2, and routing engines that do not maintain a live BNB Chain pool-creation index miss newly-minted pairs entirely. Providers that derive their BNB token list from a shared coingecko-style database rather than on-chain event tracking typically lag by several hours. The probe fires a 1 USDC (via USDC.e bridged) to token quote; KyberSwap has structural coverage advantages over Solana-first providers here due to its live BNB pool-creation event index. + - slug: robinhood + h2: "DEX aggregator coverage on Robinhood Chain" + body: | + Robinhood Chain (chainId 4663) is Robinhood's EVM-compatible Layer 2, launched in 2025. Tokens on Robinhood launch and graduate via the Pons launchpad to Uniswap V3-based pools, with USDG as the primary stablecoin. The bench probes all EVM aggregators (KyberSwap, Odos, Mobula) against Pons-graduated tokens. As of launch, no major third-party aggregator has integrated Robinhood chain routing, so coverage is expected to be near 0% across all providers — this gap is the data point. The bench will capture the first aggregator to integrate Robinhood and reflect coverage improvements in real time. category: Aggregators status: draft @@ -68,11 +72,11 @@ abstract: | launchpads they cover). methodology: - - "Token discovery: Dexscreener /token-boosts/latest/v1 returns recently-active tokens sorted by on-chain boost activity. Up to 20 tokens per 30-minute batch are selected from each supported chain (Solana, Base, BNB). A follow-up batch call to /latest/dex/tokens/{addresses} resolves the originating dexId (pool factory), which maps to a venue label: pumpfun/moonshot/meteora-dlmm→Solana venues, virtual-fun/clanker/wow→Base venues, any BNB pair→four-meme." + - "Token discovery: three sources run in parallel each hour. (1) Dexscreener /token-boosts/latest/v1 covers Solana (pump-fun, moonshot, meteora-dbc) and Robinhood chain (pons). (2) Virtuals Protocol /api/virtuals?filters[status]=SENTIENT covers Base graduated agents (virtuals venue). (3) GeckoTerminal /api/v2/networks/bsc/new_pools covers BSC (four-meme). All token addresses are enriched via a Dexscreener /latest/dex/tokens batch call to resolve the originating dexId and map to venue labels." - "Quote probe: 1 USDC → token, fired in parallel to all in-scope providers with a 10s timeout. Input token is always USDC (native USDC on Solana and Base, bridged USDC.e on BNB). A probe is a coverage hit when the HTTP response is 2xx and the parsed outAmount field is a non-zero positive integer or decimal. All other outcomes (4xx, 5xx, no-route JSON, empty outAmount, timeout) record as misses." - "Aggregator endpoints: Mobula /api/2/swap/quoting (Bearer auth, EVM), Jupiter lite-api.jup.ag/swap/v1/quote (Solana), KyberSwap aggregator-api.kyberswap.com/{chain}/api/v1/routes (EVM), Odos /sor/quote/v2 (EVM). Each adapter normalises chain identifiers, input/output decimals, and error response shapes before recording outcome." - "Jupiter strictMode=false: queried without the curated-token filter so Jupiter will attempt routing for any token with a discoverable on-chain pool, matching how most wallets call the API. strictMode=true would trivially exclude new tokens and defeat the purpose of the benchmark." - - "Chain-scope enforcement: Jupiter probes Solana-only; KyberSwap, Odos, and Mobula probe EVM chains only (Base, BNB). Providers are never penalised for chains they do not support: Solana cells for KyberSwap, Odos, and Mobula are null, not zero, and are excluded from cross-chain aggregates." + - "Chain-scope enforcement: Jupiter probes Solana-only. KyberSwap, Odos, and Mobula probe EVM chains (Base, BNB, Robinhood). On Robinhood chain, all three currently return no-route errors recording 0% coverage — an accurate reflection that no major aggregator has integrated Robinhood yet. Providers are never penalised for chains they structurally do not support: Solana cells for EVM providers are null, not zero." - "Coverage metric: Prometheus counters token_quote_coverage_success_total and token_quote_coverage_attempts_total carry labels {provider, venue, chain}. The bench queries compute increase(success[24h]) / increase(attempts[24h]) * 100 live at read time. No pre-aggregation; the ratio reflects exactly the tokens probed in the rolling window." - "Probe cadence and deduplication: each batch cycles through the current Dexscreener token-boosts feed. Tokens are not deduplicated across batches; the same token may appear in multiple 30-minute windows if it remains boosted. The score therefore reflects freshness of the aggregator's current routing graph, not historical coverage depth." - "Single origin: all probes originate from the eu-west OCB VPS. Regional latency is not a factor in this bench (the outcome is binary hit/miss, not latency). No region dimension in v1." @@ -114,15 +118,17 @@ dimensions: - { value: pump-fun, label: Pump.fun } - { value: virtuals, label: Virtuals } - { value: four-meme, label: Four.meme } + - { value: pons, label: Pons } - { value: wow, label: WOW.XYZ } - { value: moonshot, label: Moonshot } - { value: meteora-dbc, label: Meteora DBC } - { value: clanker, label: Clanker } chain: - - { value: all, label: All chains } - - { value: solana, label: Solana } - - { value: base, label: Base } - - { value: bnb, label: BNB } + - { value: all, label: All chains } + - { value: solana, label: Solana } + - { value: base, label: Base } + - { value: bnb, label: BNB } + - { value: robinhood, label: Robinhood } ledger_columns: - { label: "Coverage rate", slot: p50, unit: pct } @@ -137,7 +143,7 @@ rank_matrix_query: > providers: - slug: mobula name: Mobula - tag: EVM only, Base + BNB, API key required + tag: EVM only, Base + BNB + Robinhood, API key required formula: "Rolling 24h ratio of token_quote_coverage_success_total to token_quote_coverage_attempts_total for provider=mobula, across all launchpads and chains Mobula supports." queries: p50: sum(increase(token_quote_coverage_success_total{provider="mobula"}[24h])) / sum(increase(token_quote_coverage_attempts_total{provider="mobula"}[24h])) * 100 @@ -163,7 +169,7 @@ providers: - slug: kyberswap name: KyberSwap - tag: EVM multi-chain, per-chain aggregator engine + tag: EVM multi-chain, Base + BNB + Robinhood formula: "Rolling 24h ratio for provider=kyberswap. EVM launchpads only (Virtuals, WOW, Four.meme, Clanker). Solana cells null." queries: p50: sum(increase(token_quote_coverage_success_total{provider="kyberswap"}[24h])) / sum(increase(token_quote_coverage_attempts_total{provider="kyberswap"}[24h])) * 100 diff --git a/harnesses/token-quote-coverage/cmd/monitor/dexscreener.go b/harnesses/token-quote-coverage/cmd/monitor/dexscreener.go index ca181ce7..d14c93cf 100644 --- a/harnesses/token-quote-coverage/cmd/monitor/dexscreener.go +++ b/harnesses/token-quote-coverage/cmd/monitor/dexscreener.go @@ -39,7 +39,7 @@ func FetchBoostedTokens(ctx context.Context) ([]boostEntry, error) { return nil, fmt.Errorf("dexscreener boosts parse: %w", err) } - supported := map[string]bool{"solana": true, "base": true, "bsc": true} + supported := map[string]bool{"solana": true, "base": true, "bsc": true, "robinhood": true} var out []boostEntry seen := map[string]bool{} for _, e := range entries { diff --git a/harnesses/token-quote-coverage/cmd/monitor/gecko.go b/harnesses/token-quote-coverage/cmd/monitor/gecko.go new file mode 100644 index 00000000..5c8b7546 --- /dev/null +++ b/harnesses/token-quote-coverage/cmd/monitor/gecko.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// FetchNewBSCTokens returns base tokens from recently created pools on BSC +// via GeckoTerminal. Used as fallback since Four.meme API is not public. +func FetchNewBSCTokens(ctx context.Context) ([]boostEntry, error) { + req, err := http.NewRequestWithContext(ctx, "GET", + "https://api.geckoterminal.com/api/v2/networks/bsc/new_pools?page=1", + nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + + resp, err := dexClient.Do(req) + if err != nil { + return nil, fmt.Errorf("geckoterminal bsc: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("geckoterminal bsc: status=%d", resp.StatusCode) + } + + var result struct { + Data []struct { + Relationships struct { + BaseToken struct { + Data struct { + ID string `json:"id"` // "bsc_0x..." + } `json:"data"` + } `json:"base_token"` + } `json:"relationships"` + } `json:"data"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("geckoterminal bsc parse: %w", err) + } + + var out []boostEntry + seen := map[string]bool{} + for _, pool := range result.Data { + id := pool.Relationships.BaseToken.Data.ID + if !strings.HasPrefix(id, "bsc_") { + continue + } + addr := id[4:] + k := strings.ToLower(addr) + if seen[k] { + continue + } + seen[k] = true + out = append(out, boostEntry{ChainId: "bsc", TokenAddress: addr}) + if len(out) >= 20 { + break + } + } + return out, nil +} diff --git a/harnesses/token-quote-coverage/cmd/monitor/kyberswap.go b/harnesses/token-quote-coverage/cmd/monitor/kyberswap.go index 5a0b36fc..fdae51b9 100644 --- a/harnesses/token-quote-coverage/cmd/monitor/kyberswap.go +++ b/harnesses/token-quote-coverage/cmd/monitor/kyberswap.go @@ -10,8 +10,9 @@ import ( ) const ( - kyberUSDCBase = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" - kyberUSDCBSC = "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d" + kyberUSDCBase = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + kyberUSDCBSC = "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d" + kyberUSDGRobinhood = "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168" ) // KyberSwapProvider hits aggregator-api.kyberswap.com for Base and BSC. @@ -26,7 +27,7 @@ func NewKyberSwapProvider() *KyberSwapProvider { func (p *KyberSwapProvider) Slug() string { return "kyberswap" } func (p *KyberSwapProvider) SupportsChain(chain string) bool { - return chain == "base" || chain == "bsc" + return chain == "base" || chain == "bsc" || chain == "robinhood" } func (p *KyberSwapProvider) Quote(ctx context.Context, token Token) (ok bool) { @@ -38,6 +39,9 @@ func (p *KyberSwapProvider) Quote(ctx context.Context, token Token) (ok bool) { case "bsc": slug = "bsc" usdcAddr = kyberUSDCBSC + case "robinhood": + slug = "robinhood" + usdcAddr = kyberUSDGRobinhood default: RecordProbe(p.Slug(), token.Venue, token.Chain, false) return false diff --git a/harnesses/token-quote-coverage/cmd/monitor/mobula.go b/harnesses/token-quote-coverage/cmd/monitor/mobula.go index 30253efb..6158d915 100644 --- a/harnesses/token-quote-coverage/cmd/monitor/mobula.go +++ b/harnesses/token-quote-coverage/cmd/monitor/mobula.go @@ -10,8 +10,9 @@ import ( ) const ( - mobulaUSDCBase = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" - mobulaUSDCBSC = "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d" + mobulaUSDCBase = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + mobulaUSDCBSC = "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d" + mobulaUSDGRobinhood = "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168" // dummy wallet required by the quoting endpoint (no funds needed) mobulaWallet = "0x0000000000000000000000000000000000000001" ) @@ -31,7 +32,7 @@ func (p *MobulaProvider) Slug() string { return "mobula" } func (p *MobulaProvider) SupportsChain(chain string) bool { switch chain { - case "base", "bsc": + case "base", "bsc", "robinhood": return true } return false @@ -44,6 +45,8 @@ func (p *MobulaProvider) Quote(ctx context.Context, token Token) (ok bool) { chainId, tokenIn = "8453", mobulaUSDCBase case "bsc": chainId, tokenIn = "56", mobulaUSDCBSC + case "robinhood": + chainId, tokenIn = "4663", mobulaUSDGRobinhood default: RecordProbe(p.Slug(), token.Venue, token.Chain, false) return false diff --git a/harnesses/token-quote-coverage/cmd/monitor/odos.go b/harnesses/token-quote-coverage/cmd/monitor/odos.go index d8374e49..6d564331 100644 --- a/harnesses/token-quote-coverage/cmd/monitor/odos.go +++ b/harnesses/token-quote-coverage/cmd/monitor/odos.go @@ -10,8 +10,9 @@ import ( ) const ( - odosUSDCBase = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" - odosUSDCBSC = "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d" + odosUSDCBase = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + odosUSDCBSC = "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d" + odosUSDGRobinhood = "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168" ) // OdosProvider hits api.odos.xyz/sor/quote/v2 for Base and BSC. @@ -26,7 +27,7 @@ func NewOdosProvider() *OdosProvider { func (p *OdosProvider) Slug() string { return "odos" } func (p *OdosProvider) SupportsChain(chain string) bool { - return chain == "base" || chain == "bsc" + return chain == "base" || chain == "bsc" || chain == "robinhood" } func (p *OdosProvider) Quote(ctx context.Context, token Token) (ok bool) { @@ -39,6 +40,9 @@ func (p *OdosProvider) Quote(ctx context.Context, token Token) (ok bool) { case "bsc": chainId = 56 usdcAddr = odosUSDCBSC + case "robinhood": + chainId = 4663 + usdcAddr = odosUSDGRobinhood default: RecordProbe(p.Slug(), token.Venue, token.Chain, false) return false diff --git a/harnesses/token-quote-coverage/cmd/monitor/provider.go b/harnesses/token-quote-coverage/cmd/monitor/provider.go index 02a5ba9b..0613bd96 100644 --- a/harnesses/token-quote-coverage/cmd/monitor/provider.go +++ b/harnesses/token-quote-coverage/cmd/monitor/provider.go @@ -5,8 +5,8 @@ import "context" // Token is one recently-active token fetched from Dexscreener. type Token struct { Address string - Chain string // "solana", "base", "bsc" - Venue string // "pump-fun", "virtuals", "four-meme", etc. + Chain string // "solana", "base", "bsc", "robinhood" + Venue string // "pump-fun", "virtuals", "four-meme", "pons", etc. } // Provider is the chain-aware quote coverage adapter contract. @@ -45,6 +45,8 @@ func venueFromPair(chainId, dexId string) string { } case "bsc": return "four-meme" + case "robinhood": + return "pons" } return "all" } diff --git a/harnesses/token-quote-coverage/cmd/monitor/scheduler.go b/harnesses/token-quote-coverage/cmd/monitor/scheduler.go index 232a5dc5..4853749f 100644 --- a/harnesses/token-quote-coverage/cmd/monitor/scheduler.go +++ b/harnesses/token-quote-coverage/cmd/monitor/scheduler.go @@ -50,22 +50,55 @@ func runScheduler(cfg *Config, stopChan <-chan struct{}) { } } -// doTick fetches tokens from Dexscreener and probes all providers in parallel. +// doTick fetches tokens from all discovery sources in parallel, merges, and probes. func doTick(ctx context.Context, providers []Provider) { fetchCtx, cancel := context.WithTimeout(ctx, fetchTimeout) defer cancel() - fmt.Println("[SCHED] fetching boosted tokens from Dexscreener") - entries, err := FetchBoostedTokens(fetchCtx) - if err != nil { - fmt.Printf("[SCHED] FetchBoostedTokens error: %v\n", err) - return + // Fetch from all discovery sources concurrently: Dexscreener boosts (Solana + + // Robinhood), Virtuals Protocol API (Base), GeckoTerminal new pools (BSC). + type result struct { + name string + entries []boostEntry + err error + } + ch := make(chan result, 3) + go func() { + e, err := FetchBoostedTokens(fetchCtx) + ch <- result{"dexscreener-boosts", e, err} + }() + go func() { + e, err := FetchVirtualsTokens(fetchCtx) + ch <- result{"virtuals", e, err} + }() + go func() { + e, err := FetchNewBSCTokens(fetchCtx) + ch <- result{"geckoterminal-bsc", e, err} + }() + + seen := map[string]bool{} + var entries []boostEntry + for i := 0; i < 3; i++ { + r := <-ch + if r.err != nil { + fmt.Printf("[SCHED] %s error: %v\n", r.name, r.err) + continue + } + for _, e := range r.entries { + key := e.ChainId + ":" + e.TokenAddress + if !seen[key] { + seen[key] = true + entries = append(entries, e) + } + } + fmt.Printf("[SCHED] %s: %d tokens\n", r.name, len(r.entries)) } + if len(entries) == 0 { - fmt.Println("[SCHED] warning: Dexscreener returned 0 tokens") + fmt.Println("[SCHED] warning: all discovery sources returned 0 tokens") return } - fmt.Printf("[SCHED] got %d boosted tokens, enriching with venue data\n", len(entries)) + fmt.Printf("[SCHED] %d unique tokens across all chains, enriching with venue data\n", len(entries)) tokens, err := EnrichWithVenue(fetchCtx, entries) if err != nil { diff --git a/harnesses/token-quote-coverage/cmd/monitor/virtuals.go b/harnesses/token-quote-coverage/cmd/monitor/virtuals.go new file mode 100644 index 00000000..ecfeb584 --- /dev/null +++ b/harnesses/token-quote-coverage/cmd/monitor/virtuals.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// FetchVirtualsTokens returns recently graduated Virtuals Protocol agents on Base. +// SENTIENT status = agents that have a live DEX pool (real tokenAddress set). +// Sorted by lpCreatedAt desc so freshest graduates come first. +func FetchVirtualsTokens(ctx context.Context) ([]boostEntry, error) { + req, err := http.NewRequestWithContext(ctx, "GET", + "https://api.virtuals.io/api/virtuals?filters[status]=SENTIENT&sort[0]=lpCreatedAt:desc&pagination[pageSize]=20", + nil) + if err != nil { + return nil, err + } + + resp, err := dexClient.Do(req) + if err != nil { + return nil, fmt.Errorf("virtuals: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("virtuals: status=%d body=%s", resp.StatusCode, snippet(body)) + } + + // The API returns either {"data": [items]} or {"data": {"data": [items]}}. + // Handle both shapes. + var envelope struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, fmt.Errorf("virtuals parse: %w", err) + } + + type item struct { + TokenAddress string `json:"tokenAddress"` + } + + var items []item + if err := json.Unmarshal(envelope.Data, &items); err != nil { + // try nested {"data": [...]} + var nested struct { + Data []item `json:"data"` + } + if err2 := json.Unmarshal(envelope.Data, &nested); err2 != nil { + return nil, fmt.Errorf("virtuals parse nested: %w", err2) + } + items = nested.Data + } + + var out []boostEntry + seen := map[string]bool{} + for _, it := range items { + if it.TokenAddress == "" { + continue + } + k := strings.ToLower(it.TokenAddress) + if seen[k] { + continue + } + seen[k] = true + out = append(out, boostEntry{ChainId: "base", TokenAddress: it.TokenAddress}) + } + return out, nil +} From a2763dbdb458107bc6a050ea6b6ab8c1f9d532dc Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sat, 25 Jul 2026 02:53:43 +0200 Subject: [PATCH 2/2] fix(tqc): batch EnrichWithVenue in groups of 30 (Dexscreener limit) --- .../cmd/monitor/dexscreener.go | 79 +++++++++++-------- 1 file changed, 46 insertions(+), 33 deletions(-) diff --git a/harnesses/token-quote-coverage/cmd/monitor/dexscreener.go b/harnesses/token-quote-coverage/cmd/monitor/dexscreener.go index d14c93cf..85bacda7 100644 --- a/harnesses/token-quote-coverage/cmd/monitor/dexscreener.go +++ b/harnesses/token-quote-coverage/cmd/monitor/dexscreener.go @@ -73,45 +73,59 @@ type pairResp struct { } `json:"pairs"` } -// EnrichWithVenue batch-looks up pair data for up to 30 tokens and returns -// a Token slice with Venue populated. Tokens with no pair data are skipped. +const dexscreenerBatchSize = 30 + +// EnrichWithVenue resolves pair data for all entries in batches of 30 +// (Dexscreener API limit) and returns a Token slice with Venue populated. +// Tokens with no pair data are skipped. func EnrichWithVenue(ctx context.Context, entries []boostEntry) ([]Token, error) { if len(entries) == 0 { return nil, nil } - // Build comma-sep address list. Dexscreener accepts up to 30. - addrs := make([]string, len(entries)) - for i, e := range entries { - addrs[i] = e.TokenAddress - } - url := "https://api.dexscreener.com/latest/dex/tokens/" + strings.Join(addrs, ",") - - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, err - } - resp, err := dexClient.Do(req) - if err != nil { - return nil, fmt.Errorf("dexscreener tokens: %w", err) - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != 200 { - return nil, fmt.Errorf("dexscreener tokens: status=%d body=%s", resp.StatusCode, snippet(body)) - } - var pr pairResp - if err := json.Unmarshal(body, &pr); err != nil { - return nil, fmt.Errorf("dexscreener tokens parse: %w", err) - } - - // Build index: lower(tokenAddress) -> first pair's dexId for that chain. type key struct{ chain, addr string } dexIdOf := map[key]string{} - for _, pair := range pr.Pairs { - k := key{pair.ChainId, strings.ToLower(pair.BaseToken.Address)} - if _, exists := dexIdOf[k]; !exists { - dexIdOf[k] = pair.DexId + + for i := 0; i < len(entries); i += dexscreenerBatchSize { + end := i + dexscreenerBatchSize + if end > len(entries) { + end = len(entries) + } + batch := entries[i:end] + + addrs := make([]string, len(batch)) + for j, e := range batch { + addrs[j] = e.TokenAddress + } + url := "https://api.dexscreener.com/latest/dex/tokens/" + strings.Join(addrs, ",") + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + resp, err := dexClient.Do(req) + if err != nil { + return nil, fmt.Errorf("dexscreener tokens: %w", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if resp.StatusCode != 200 { + fmt.Printf("[dexscreener] batch %d-%d status=%d, skipping batch\n", i, end, resp.StatusCode) + continue + } + + var pr pairResp + if err := json.Unmarshal(body, &pr); err != nil { + fmt.Printf("[dexscreener] batch %d-%d parse error: %v\n", i, end, err) + continue + } + + for _, pair := range pr.Pairs { + k := key{pair.ChainId, strings.ToLower(pair.BaseToken.Address)} + if _, exists := dexIdOf[k]; !exists { + dexIdOf[k] = pair.DexId + } } } @@ -120,7 +134,6 @@ func EnrichWithVenue(ctx context.Context, entries []boostEntry) ([]Token, error) k := key{e.ChainId, strings.ToLower(e.TokenAddress)} dexId, ok := dexIdOf[k] if !ok { - fmt.Printf("[dexscreener] no pair data for %s on %s, skipping\n", e.TokenAddress, e.ChainId) continue } tokens = append(tokens, Token{