diff --git a/benchmarks/token-trade-coverage.yml b/benchmarks/token-trade-coverage.yml index 24dd9d96..4aabbcc2 100644 --- a/benchmarks/token-trade-coverage.yml +++ b/benchmarks/token-trade-coverage.yml @@ -3,8 +3,8 @@ slug: token-trade-coverage number: "090" title: Most complete onchain trade data API, live coverage across Solana, EVM and Stellar -seo_title: "Best token trade data API 2026 (Mobula vs Bitquery vs Codex vs Moralis)" -seo_description: "Which onchain data API returns the most complete trade history for a newly launched token? Live coverage % across Mobula, Bitquery, Codex and Moralis on Solana, Ethereum, BSC, Base and Stellar." +seo_title: "Best token trade data API 2026 (Mobula vs Bitquery vs Codex)" +seo_description: "Which onchain data API returns the most complete trade history for a newly launched token? Live coverage % across Mobula, Bitquery and Codex on Solana, Ethereum, BSC, Base and Stellar." subtitle: For each reference token per chain, we fetch every trade in a fixed measurement window from each provider and compare against the union baseline. Higher percent means the provider returned more of the true trade set, with fewer missed DEXs and fewer dropped fills. category: Aggregators status: draft @@ -17,15 +17,15 @@ seo_intro: | onchain data API actually returns. For a fixed set of reference tokens per chain (Solana, Ethereum, BSC, Base, Stellar), the harness fetches every trade in the same measurement window from Mobula, Bitquery, - Codex and Moralis, then computes each provider's capture rate against - the union baseline (max count across the four). A rate of 100 means + Codex, then computes each provider's capture rate against + the union baseline (max count across the three). A rate of 100 means the provider returned as many trades as the best-covering provider on that token. A rate of 20 means the provider missed 80 percent of the swaps that did happen. The comparison is chain-scoped because coverage is not uniform: a provider strong on Solana DEX indexing can be weak on niche BSC pools, and no provider covers every chain equally (Stellar coverage in particular is uneven since only two of - the four measured providers index it at all today). + the three measured providers index it at all today). abstract: | Coverage is measured on a fixed set of reference tokens per chain, @@ -36,8 +36,7 @@ abstract: | volume in the measurement window so a coverage gap actually shows. Providers use their official public endpoints. Mobula through /api/2/trades/filters, Bitquery through streaming.bitquery.io - GraphQL, Codex through graph.codex.io GraphQL, Moralis through the - Solana gateway for Solana and the EVM API for Ethereum, BSC, Base. + GraphQL, Codex through graph.codex.io GraphQL. Stellar is measured only for providers that officially list a Stellar trades endpoint; providers that do not index Stellar at all are omitted from that chain's row rather than counted as zero. @@ -48,8 +47,7 @@ methodology: - "Mobula: `GET https://api.mobula.io/api/2/trades/filters?from={ts_ms}&to={ts_ms}` with `Authorization: `. Cursor pagination, limit=5000." - "Bitquery: `POST https://streaming.bitquery.io/graphql` with `X-API-KEY`. GraphQL query on `DEXTradeByTokens` with `Block.Time.since` / `till` on the realtime dataset. Hard limit 10000 per call." - "Codex: `POST https://graph.codex.io/graphql` with `Authorization: `. GraphQL query on `getTokenEvents` with `timestamp` bounds. Cursor pagination." - - "Moralis: `GET https://solana-gateway.moralis.io/token/mainnet/{addr}/swaps` for Solana; `GET https://deep-index.moralis.io/api/v2.2/erc20/{addr}/swaps` for EVM. `X-API-Key` header. Manual cursor pagination, limit=100." - - "Stellar coverage measured only for providers that ship an official Stellar trades endpoint. Providers with no Stellar support are excluded from the Stellar row rather than counted as zero (that would penalise all four providers unfairly against a chain none of them promised)." + - "Stellar coverage measured only for providers that ship an official Stellar trades endpoint. Providers with no Stellar support are excluded from the Stellar row rather than counted as zero (that would penalise all three providers unfairly against a chain none of them promised)." - "Capture rate: `provider_count / max(provider_count across all providers on that (chain, token)) * 100`. Union baseline is the largest observed count, not a synthetic ground truth." - "Cadence: every 30 minutes per (provider, chain, token). One measurement is short (a handful of API calls per token) so the full cycle finishes well inside the 30-minute window." - "Failure handling: HTTP timeouts and 5xx count as zero trades for that cycle. Persistent 5xx (three cycles in a row) surfaces as `unresponsive` on the leaderboard, not silent zeros." @@ -59,7 +57,6 @@ findings: - "{{name:mobula}} averages {{p50:mobula}}. Mobula's advantage on this bench is DEX breadth. Coverage of pump.fun, meme AMMs and small aggregator DEXs is what drives the number up on new-launch Solana tokens where competitors often index only Raydium or Meteora." - "{{name:bitquery}} averages {{p50:bitquery}}. Bitquery ships strong Solana and EVM coverage through its `DEXTradeByTokens` GraphQL, but the 10000-row hard cap on a single query truncates high-volume tokens in the first hour after launch; the harness paginates when possible, but not every trade type is paginable." - "{{name:codex}} averages {{p50:codex}}. Codex (formerly Defined) prioritises depth on EVM and Solana DEX event indexing, which shows here as strong per-chain coverage on the chains they index and no coverage at all on Stellar." - - "{{name:moralis}} averages {{p50:moralis}}. Moralis's Solana coverage is limited to two DEXs (PumpSwap and Meteora Dynamic AMM v2), so on any Solana token that trades across more than those two venues the capture rate drops fast; on EVM chains coverage is broader." - "Stellar coverage is uneven: only providers that officially list a Stellar trades endpoint appear on that chain's row. This is the honest way to represent the current state of the market. Mobula added Stellar recently, others have not shipped it yet." source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/token-trade-coverage @@ -77,8 +74,6 @@ faq: a: "In principle you could walk the block explorer and reconstruct every swap. In practice the ground truth depends on which DEX programs, protocols and vault contracts you include, and that decision is itself opinionated. The union-of-providers baseline is a defensible proxy that mirrors what a trader integrating a public API can actually see. It undercounts the truth uniformly across providers, so relative ranking is preserved even if absolute completeness is not 100." - q: "Which chains are covered?" a: "Solana, Ethereum, BSC, Base and Stellar. Every provider is measured on each chain it officially supports. Stellar is included because Mobula added Stellar trades to its API recently; other providers that do not ship a Stellar endpoint are excluded from the Stellar row rather than counted as zero." - - q: "Is Moralis really that far behind on Solana?" - a: "On the Solana row Moralis's rate averages {{p50:moralis:chain:solana}} in this bench. The reason is DEX coverage. Moralis's Solana swaps endpoint at time of writing indexes PumpSwap and Meteora Dynamic AMM v2. Any trade routed through Raydium, Jupiter, Meteora DAMM v1, Orca or the long tail of aggregator DEXs is missed. Providers that index the full DEX set (Mobula, Bitquery, Codex) show much higher capture rates on the same tokens." dimensions: chain: @@ -134,16 +129,3 @@ providers: success: clamp_max(avg_over_time(ocb_token_trade_probe_ok{provider="codex"}[24h]), 1) sample_size: count_over_time(ocb_token_trade_capture_pct{provider="codex"}[24h]) series: ocb_token_trade_capture_pct{provider="codex"} - - - slug: moralis - name: Moralis - tag: solana-gateway.moralis.io + deep-index.moralis.io - formula: "Trades returned by Moralis per-token swaps endpoint (Solana gateway or EVM API) divided by union-across-providers on (chain, token) rolling 60 min. Excluded from Stellar row." - queries: - p50: avg_over_time(ocb_token_trade_capture_pct{provider="moralis"}[24h]) - p90: quantile_over_time(0.90, ocb_token_trade_capture_pct{provider="moralis"}[24h]) - p99: quantile_over_time(0.99, ocb_token_trade_capture_pct{provider="moralis"}[24h]) - mean: avg_over_time(ocb_token_trade_capture_pct{provider="moralis"}[24h]) - success: clamp_max(avg_over_time(ocb_token_trade_probe_ok{provider="moralis"}[24h]), 1) - sample_size: count_over_time(ocb_token_trade_capture_pct{provider="moralis"}[24h]) - series: ocb_token_trade_capture_pct{provider="moralis"} diff --git a/harnesses/token-trade-coverage/.env.example b/harnesses/token-trade-coverage/.env.example index bd51d009..26697984 100644 --- a/harnesses/token-trade-coverage/.env.example +++ b/harnesses/token-trade-coverage/.env.example @@ -7,7 +7,6 @@ MOBULA_API_KEY= BITQUERY_API_KEY= CODEX_API_KEY= -MORALIS_API_KEY= # Cadence + runtime knobs. SWEEP_SEC=1800 diff --git a/harnesses/token-trade-coverage/cmd/scanner/config.go b/harnesses/token-trade-coverage/cmd/scanner/config.go index 63278ca4..a0b1e993 100644 --- a/harnesses/token-trade-coverage/cmd/scanner/config.go +++ b/harnesses/token-trade-coverage/cmd/scanner/config.go @@ -27,7 +27,6 @@ type Config struct { MobulaKey string BitqueryKey string CodexKey string - MoralisKey string Tokens []Token Capabilities map[string]ProviderCapability // provider -> {chain -> supported} MeasurementWinMs int64 // rolling window per measurement @@ -46,7 +45,6 @@ func LoadConfig() *Config { MobulaKey: os.Getenv("MOBULA_API_KEY"), BitqueryKey: os.Getenv("BITQUERY_API_KEY"), CodexKey: os.Getenv("CODEX_API_KEY"), - MoralisKey: os.Getenv("MORALIS_API_KEY"), Tokens: []Token{ // Solana. Pump.fun-era memes with high trade frequency across // multiple DEXs so any provider indexing only one venue drops @@ -79,9 +77,6 @@ func LoadConfig() *Config { "codex": { "solana": true, "ethereum": true, "bsc": true, "base": true, }, - "moralis": { - "solana": true, "ethereum": true, "bsc": true, "base": true, - }, }, } } diff --git a/harnesses/token-trade-coverage/cmd/scanner/main.go b/harnesses/token-trade-coverage/cmd/scanner/main.go index 30e44ced..eb454ccc 100644 --- a/harnesses/token-trade-coverage/cmd/scanner/main.go +++ b/harnesses/token-trade-coverage/cmd/scanner/main.go @@ -24,7 +24,7 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" ) -var providers = []string{"mobula", "bitquery", "codex", "moralis"} +var providers = []string{"mobula", "bitquery", "codex"} func main() { cfg := LoadConfig() @@ -143,8 +143,6 @@ func fetchOne( return fetchBitquery(ctx, client, cfg.BitqueryKey, tok, windowStart, windowEnd) case "codex": return fetchCodex(ctx, client, cfg.CodexKey, tok, windowStart, windowEnd) - case "moralis": - return fetchMoralis(ctx, client, cfg.MoralisKey, tok, windowStart, windowEnd) } return 0, 0, fmt.Errorf("unknown provider %s", provider) } diff --git a/harnesses/token-trade-coverage/cmd/scanner/mobula.go b/harnesses/token-trade-coverage/cmd/scanner/mobula.go index a9b7e9b7..2dc140c4 100644 --- a/harnesses/token-trade-coverage/cmd/scanner/mobula.go +++ b/harnesses/token-trade-coverage/cmd/scanner/mobula.go @@ -16,13 +16,25 @@ const mobulaBase = "https://api.mobula.io/api/2/trades/filters" // mobulaResp is the minimal envelope we care about: we only need the // count (via len(data)) and the DEX per row for coverage-breadth. Full // trade objects are 30+ fields we intentionally ignore. +// +// Field names track Mobula's `/api/2/trades/filters` response as of +// 2026-07-23. `transactionHash` doubles as the dedup key across +// paginated pages. DEX identity comes from `platform.name` when the +// venue is a mapped protocol (Raydium, Uniswap v3, …); otherwise the +// pool address in `marketAddress` acts as a fallback venue proxy. +type mobulaTrade struct { + TransactionHash string `json:"transactionHash"` + Platform *struct { + Name string `json:"name"` + } `json:"platform"` + MarketAddress string `json:"marketAddress"` +} + type mobulaResp struct { - Data []struct { - DEX string `json:"dex"` - Hash string `json:"hash"` - } `json:"data"` + Data []mobulaTrade `json:"data"` Pagination struct { - Cursor string `json:"cursor"` + HasMore bool `json:"hasMore"` + NextCursor string `json:"nextCursor"` } `json:"pagination"` } @@ -48,11 +60,12 @@ func fetchMobula( for page := 0; page < maxPages; page++ { u, _ := url.Parse(mobulaBase) q := u.Query() - q.Set("chain", mobulaChainName(tok.Chain)) - q.Set("token", tok.Address) + q.Set("blockchain", mobulaChainName(tok.Chain)) + q.Set("tokenAddress", tok.Address) q.Set("from", strconv.FormatInt(windowStart, 10)) q.Set("to", strconv.FormatInt(windowEnd, 10)) q.Set("limit", "5000") + q.Set("sortOrder", "asc") if cursor != "" { q.Set("cursor", cursor) } @@ -85,19 +98,26 @@ func fetchMobula( // Dedupe by tx hash across pages. Providers occasionally // return the same hash on consecutive pages when a cursor // resets under the hood. - if _, seen := hashSet[t.Hash]; seen { + if _, seen := hashSet[t.TransactionHash]; seen { continue } - hashSet[t.Hash] = struct{}{} + hashSet[t.TransactionHash] = struct{}{} total++ - if t.DEX != "" { - dexSet[t.DEX] = struct{}{} + // DEX identity: prefer `platform.name` (mapped protocol), + // fall back to `marketAddress` (raw pool address) so a + // venue with no mapped platform still contributes one + // distinct venue to the coverage-breadth metric instead + // of being dropped silently. + if t.Platform != nil && t.Platform.Name != "" { + dexSet[t.Platform.Name] = struct{}{} + } else if t.MarketAddress != "" { + dexSet[t.MarketAddress] = struct{}{} } } - if r.Pagination.Cursor == "" || len(r.Data) == 0 { + if !r.Pagination.HasMore || r.Pagination.NextCursor == "" || len(r.Data) == 0 { break } - cursor = r.Pagination.Cursor + cursor = r.Pagination.NextCursor // Cursor pages back-to-back can trip provider rate limits; a // short breather keeps the fetch inside its budget without // starving the cadence. diff --git a/harnesses/token-trade-coverage/cmd/scanner/moralis.go b/harnesses/token-trade-coverage/cmd/scanner/moralis.go deleted file mode 100644 index 6851211b..00000000 --- a/harnesses/token-trade-coverage/cmd/scanner/moralis.go +++ /dev/null @@ -1,141 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "time" -) - -// Moralis has two hosts: a Solana gateway with its own routes and the -// EVM deep-index API with shared /erc20 routes. -const ( - moralisSolanaBase = "https://solana-gateway.moralis.io" - moralisEVMBase = "https://deep-index.moralis.io/api/v2.2" -) - -type moralisResp struct { - Cursor string `json:"cursor"` - Result []struct { - TransactionHash string `json:"transactionHash"` - ExchangeName string `json:"exchangeName"` - PairAddress string `json:"pairAddress"` - } `json:"result"` -} - -// fetchMoralis returns (trade count, distinct DEX venues, error). -// Moralis paginates via `cursor` on both hosts; page size caps at 100. -func fetchMoralis( - ctx context.Context, - client *http.Client, - apiKey string, - tok Token, - windowStart, windowEnd int64, -) (int, int, error) { - if apiKey == "" { - return 0, 0, fmt.Errorf("MORALIS_API_KEY not set") - } - - fromISO := time.Unix(windowStart/1000, 0).UTC().Format(time.RFC3339) - toISO := time.Unix(windowEnd/1000, 0).UTC().Format(time.RFC3339) - - var ( - total int - cursor string - dexSet = map[string]struct{}{} - hashSet = map[string]struct{}{} - maxPages = 100 - ) - - for page := 0; page < maxPages; page++ { - var reqURL string - if tok.Chain == "solana" { - base := fmt.Sprintf("%s/token/mainnet/%s/swaps", moralisSolanaBase, tok.Address) - u, _ := url.Parse(base) - q := u.Query() - q.Set("fromDate", fromISO) - q.Set("toDate", toISO) - q.Set("order", "ASC") - q.Set("limit", "100") - q.Set("transactionTypes", "buy,sell") - if cursor != "" { - q.Set("cursor", cursor) - } - u.RawQuery = q.Encode() - reqURL = u.String() - } else { - chain := moralisEVMChain(tok.Chain) - if chain == "" { - return 0, 0, fmt.Errorf("moralis: unsupported chain %s", tok.Chain) - } - base := fmt.Sprintf("%s/erc20/%s/swaps", moralisEVMBase, tok.Address) - u, _ := url.Parse(base) - q := u.Query() - q.Set("chain", chain) - q.Set("fromDate", fromISO) - q.Set("toDate", toISO) - q.Set("order", "ASC") - q.Set("limit", "100") - if cursor != "" { - q.Set("cursor", cursor) - } - u.RawQuery = q.Encode() - reqURL = u.String() - } - - req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) - if err != nil { - return total, len(dexSet), err - } - req.Header.Set("X-API-Key", apiKey) - req.Header.Set("Accept", "application/json") - - resp, err := client.Do(req) - if err != nil { - return total, len(dexSet), err - } - body, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - return total, len(dexSet), err - } - if resp.StatusCode >= 300 { - return total, len(dexSet), fmt.Errorf("moralis http %d: %s", resp.StatusCode, truncate(string(body), 200)) - } - var r moralisResp - if err := json.Unmarshal(body, &r); err != nil { - return total, len(dexSet), fmt.Errorf("moralis parse: %w", err) - } - for _, t := range r.Result { - if _, seen := hashSet[t.TransactionHash]; seen { - continue - } - hashSet[t.TransactionHash] = struct{}{} - total++ - if t.ExchangeName != "" { - dexSet[t.ExchangeName] = struct{}{} - } - } - if r.Cursor == "" || len(r.Result) == 0 { - break - } - cursor = r.Cursor - time.Sleep(50 * time.Millisecond) - } - return total, len(dexSet), nil -} - -func moralisEVMChain(chain string) string { - switch chain { - case "ethereum": - return "0x1" - case "bsc": - return "0x38" - case "base": - return "0x2105" - } - return "" -}