From be857c7f516c74e7922eb3ad9a443c16fb8d22c3 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:58:14 +0200 Subject: [PATCH] feat: add Serialized as provider on benches 004 and 008 Wires serialized.xyz into metadata-coverage (004) and wallet-labels (008), the two benches where their endpoints map 1:1 to the existing scoring rule. Both providers throttle client-side at ~16 rps: Serialized enforces a hard 40 req/s burst cap and an unthrottled worker pool turns coverage into a rate-limit artifact (measured 77% -> 37%). Adds the registry entry, logo and a full onboarding audit documenting the apples-to-apples numbers and two scoring flaws the tests exposed in our own benches (logo presence vs resolution on 004, name-service strings counted as entity labels on 008). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CpArutAtXuBb1BVNUDXoYA --- .../serialized-onboarding-audit.md | 313 ++++++++++++++++++ .../metadata-coverage/cmd/script/config.go | 2 + .../cmd/script/metadata_coverage_monitor.go | 72 ++-- .../cmd/script/serialized_rest_monitor.go | 162 +++++++++ harnesses/wallet-labels/.env.example | 3 + harnesses/wallet-labels/cmd/script/config.go | 35 +- harnesses/wallet-labels/cmd/script/main.go | 1 + .../wallet-labels/cmd/script/serialized.go | 129 ++++++++ public/logos/serialized-wordmark.svg | 15 + public/logos/serialized.svg | 5 + src/data/provider-registry.ts | 41 +++ src/lib/logo-manifest.ts | 1 + 12 files changed, 740 insertions(+), 39 deletions(-) create mode 100644 docs/methodology/serialized-onboarding-audit.md create mode 100644 harnesses/metadata-coverage/cmd/script/serialized_rest_monitor.go create mode 100644 harnesses/wallet-labels/cmd/script/serialized.go create mode 100644 public/logos/serialized-wordmark.svg create mode 100644 public/logos/serialized.svg diff --git a/docs/methodology/serialized-onboarding-audit.md b/docs/methodology/serialized-onboarding-audit.md new file mode 100644 index 000000000..a34244c51 --- /dev/null +++ b/docs/methodology/serialized-onboarding-audit.md @@ -0,0 +1,313 @@ +# Provider onboarding audit — Serialized (serialized.xyz) + +> **Pre-onboarding evaluation.** Run before Serialized is wired into any live harness, so the +> decision to include or exclude them on each bench is documented and reproducible. +> +> **Version:** v1.0, first commit 2026-09-05. Author: internal. Key used: tenant `OpenChainBench`, +> plan `starter`, keyId `d5511a080aaa`, issued 2026-09-04. + +--- + +## 1. What this document is + +Serialized is a candidate provider for several existing OpenChainBench benchmarks. This file +records the apples-to-apples tests run against them, the exact methodology of each test, the +numbers that came back, and the methodology problems those tests exposed in **our own benches**. + +Every test below replicates the scoring rule of the target bench rather than inventing a new one, +so the numbers are directly comparable to the published leaderboards. + +## 2. Test harness and vantage point + +| Property | Value | +|---|---| +| Host | `ocb-par-main` (the VPS that runs the production harnesses) | +| Rationale | Same egress, same region, same network path as the live monitors. A latency or coverage number taken from a laptop is not comparable to a published bench value. | +| Incumbent credentials | Read from the running `ocb-metadata-coverage` container env, never copied off the box | +| Scripts | `~/serbench/ab.py`, `ab2.py`, `ab3.py`, `ab4.py`, `wsab.py` | +| Date of run | 2026-09-05 | + +**Throttling matters.** Serialized enforces a hard burst cap of 40 in-flight requests per second. +An unthrottled 8-worker pool produced 60 `429 RATE_LIMITED` responses out of 100 anchors and made +their coverage look like 37%. The same test throttled to 12 rps produced 0 errors and 77%. Any +harness that talks to them must rate-limit client-side, and any measurement that does not is wrong. + +## 3. Provider surface + +19 chains: 18 EVM plus Solana. `evm:1`, `evm:56`, `evm:130`, `evm:143`, `evm:196`, `evm:988`, +`evm:1514`, `evm:2741`, `evm:4217`, `evm:4326`, `evm:4663`, `evm:5042`, `evm:8453`, `evm:9745`, +`evm:42161`, `evm:43114`, `evm:57073`, `evm:645749`, `solana`. Audit engine covers the 18 EVM chains. + +Auth is a raw `Authorization` header, no `Bearer` prefix (same convention as Mobula). The +documented `demo.serialized.xyz` server returns 403 outside their docs playground, so there is no +keyless path for a harness. + +## 4. Rate limits and quota (measured, not quoted) + +| Property | Documented | Measured | +|---|---|---| +| Monthly credits (starter) | 150,000 | **1,000,000** on our key | +| Per-minute rate | 1,200 | 1,200 (`x-ratelimit-limit` header) | +| Burst | 40 req/s | Exactly 40. 60/100/150 concurrent all yielded exactly 40× `200` and the rest `429`. Deterministic, no jitter. | +| Sustained | not stated | 891/891 `200` over 60 s at 15 rps, p50 38 ms, p99 67 ms | + +Response headers expose `x-ratelimit-limit`, `x-ratelimit-remaining`, `x-ratelimit-reset` and +`x-credits-remaining`. Good enough to instrument a harness without guessing. + +Streams bill 1 credit per connection-minute. Limits are 5 concurrent connections, 20 subscriptions +per connection and 50 distinct tokens or pools per key. Bench 001 runs 3 regions × 4 chains, which +does not fit inside one key's 5-connection budget: it needs one key per region. + +## 5. Bench 008 — wallet-labels-coverage + +**Replica rule.** Identical to `harnesses/wallet-labels`: the same 178-anchor curated list, filtered +to the 5 chains Serialized covers (100 anchors, 59 contract / 41 EOA); a "hit" is any non-generic +name, using the harness's exact `genericLabel` exclusion set; Mobula queried through +`POST /api/1/wallet/labels` with the same field-precedence (`entityName` → `entityLabels` → `labels`). +Serialized queried through `GET /v1/wallet/profile`, taking the first non-generic of +`displayName` → `ensName` → `basename` → `solName`. + +**Added dimension (not in the bench today):** accuracy. A hit is counted accurate when the returned +label shares a meaningful token with the curated `Hint` for that anchor. + +| Provider | Coverage | Contract | EOA | Accurate | Accurate given hit | p50 | +|---|---|---|---|---|---|---| +| **Serialized** | **77.0%** | 76.3% | 78.0% | **58.0%** | 75.3% | 45 ms | +| Mobula | 59.0% | 54.2% | 65.9% | 44.0% | 74.6% | 34 ms | + +Per chain (coverage / accuracy): + +| Chain | n | Serialized | Mobula | +|---|---|---|---| +| ethereum | 32 | 96.9% / 78.1% | 62.5% / 53.1% | +| bnb | 15 | 80.0% / 60.0% | 80.0% / 46.7% | +| base | 17 | 76.5% / 58.8% | 70.6% / 47.1% | +| arbitrum | 17 | 58.8% / 52.9% | 47.1% / 41.2% | +| solana | 19 | 57.9% / 26.3% | 36.8% / 26.3% | + +**Verdict: include.** Serialized leads on coverage and on absolute accuracy on every chain in scope. + +**Bench flaw this exposed.** 25% of Serialized's hits are wrong (19 of 77). Mobula's ratio is +almost identical (74.6% accurate given hit). The bench scores presence of a non-generic string, so a +personal ENS or `.sol` name registered against a well-known contract counts as a correct entity +label. Concrete cases: Permit2 → `dex.davywoodfi.eth`, Uniswap V3 Router 2 → `factory.vibebet.eth`, +Base USDC → `jakie.base.eth`, Raydium Authority → `bonklanatoken.sol`, BSC USDT → `Fake_Phishing6512`, +OKX 1 → `Bittrex 3`, Bitfinex → `Polygon`. + +This is a pre-existing, provider-neutral gameability hole. It should be fixed **before** Serialized +is published, not after, otherwise the fix looks like a reaction to a new entrant beating the +incumbent. Recommended fix: score against the curated `Hint` (the harness already carries it and +already ignores it), or exclude name-service strings from the hit rule. + +## 6. Bench 004 — metadata-coverage + +**Replica rule.** Same 4 fields as the bench (`logo`, `description`, `twitter`, `website`). Discovery +via GeckoTerminal `new_pools` (an independent third source, so neither provider's own discovery +biases the sample). Both providers queried on the **same token set**, and only tokens that **both** +resolved are scored, so the denominator is identical. + +| Chain | paired n | Serialized | Mobula | +|---|---|---|---| +| solana | 48 | 10.4% | 34.4% | +| base | 37 | 19.6% | 34.5% | +| bsc | 38 | 60.5% | 80.9% | +| **total** | 123 | **28.7%** | **48.8%** | + +Field breakdown: + +| Chain | Field | Serialized | Mobula | +|---|---|---|---| +| solana | logo | 22.9% | 100.0% | +| solana | description | 8.3% | 25.0% | +| solana | twitter | 8.3% | 8.3% | +| solana | website | 2.1% | 4.2% | +| base | logo | 37.8% | 100.0% | +| base | description | 13.5% | 13.5% | +| base | twitter | 18.9% | 16.2% | +| base | website | 8.1% | 8.1% | +| bsc | logo | 78.9% | 100.0% | +| bsc | description | 78.9% | 71.1% | +| bsc | twitter | 78.9% | 76.3% | +| bsc | website | 5.3% | 76.3% | + +**Verdict: include, but fix the logo field first.** + +**Bench flaw this exposed.** Mobula returns `logo` = 100% on all three chains. That is not a data +advantage, it is a URL-shape artifact: Mobula rewrites every logo onto `metadata.mobula.io` at a +deterministic path (`/assets/logos/__
`), so the field is never empty +regardless of whether an image exists. Serialized returns the upstream source URL instead +(`ipfs.io`, `gmgn.ai`, `axiomtrading.axiom-cdn.io`, `pbs.twimg.com`, `flap.sh`). A HEAD check on 12 +distinct Mobula logo URLs resolved 11 and 404'd 1. + +The bench currently measures *"is the field non-empty"*, which any provider can win by construction +by rewriting to its own CDN. It should measure *"does the logo resolve"* (HEAD 200 with an image +content type). Mobula is our own product and it is the beneficiary of the current rule, so this needs +fixing on fairness grounds before a competitor is added to the same leaderboard. + +Excluding the logo field entirely, on the remaining three fields Serialized is level with Mobula on +Base, ahead on BSC description and twitter, and behind on Solana and on BSC website. + +## 7. Benches 005 / 090 — chain-count coverage + +| Bench | Incumbents | Serialized | +|---|---|---| +| 005 asset-registry | CoinGecko 465, CoinPaprika 310, CoinStats 149, Mobula 81 | **19** | +| 090 dex-network | GeckoTerminal 247, Codex 123, Sim by Dune 64, DexPaprika 35 | **19** | + +**Verdict: exclude for now.** Serialized would rank last by a wide margin on both. The metric is +breadth, their product is deliberately narrow-and-deep. Adding them here produces a true but +uninformative row and gives them a reason to refuse every other bench. Revisit only if they ask. + +Note: the GeckoTerminal count returned 100 in this run because the ad-hoc pager stopped early on +rate limit. The production harness value of 247 is the correct one. + +## 8. Bench 001 — aggregator-head-lag + +**Replica rule.** Single process on `ocb-par-main`, two WebSocket connections open simultaneously, +subscribed to the **same three tokens** (BONK / Solana, DEGEN / Base, CAKE / BNB). Serialized: +`wss://api.serialized.xyz/v1/stream`, `subscribe` on channel `trades` with `{chain, address}`. +Mobula: `wss://api.mobula.io`, `fast-trade` with `assetMode: true`. Trades matched by transaction +hash, so every comparison is the same on-chain event seen by both pipelines. 240 s window. + +Note on protocol shape: Serialized's `params.pools` is a comma-separated **string**, not an array, +and `address` is required even when `pools` is supplied. Their trade events carry the hash inside +`data.id` as `:`, not as a `txHash` field, despite the docs naming `txHash` as the +dedup key. + +### Relative arrival, the only comparison free of self-reported timestamps + +| Chain | matched n | p10 | p50 | p90 | Serialized first | +|---|---|---|---|---|---| +| solana | 74 | −288 ms | **−0 ms** | +29 ms | 51% | +| base | 13 | −43 ms | +88 ms | +177 ms | 23% | +| bnb | 0 | — | — | — | Mobula returned no CAKE events in this window | +| **all** | 87 | | **+2 ms** | | **47%** | + +Negative means Serialized delivered the trade first. **It is a dead heat.** Across 87 matched +trades the median difference is 2 ms and the two feeds trade the lead roughly half the time. On +Base, Mobula was actually ahead on 77% of trades despite Serialized running a preconfirmation feed. + +### The finding that matters: providers disagree about when the trade happened + +For the **same transaction hash**, the two providers' own on-chain timestamps differ: + +| Chain | serialized `at` minus mobula `date` | p10 | p50 | p90 | +|---|---|---|---|---| +| solana | | −1,620 ms | **−707 ms** | −353 ms | +| base | | +1,000 ms | **+1,000 ms** | +2,000 ms | + +Consequence, measured directly: + +| Chain | Provider | Self-reported lag p50 | Actually delivered first | +|---|---|---|---| +| solana | Mobula | +0.04 s | 49% | +| solana | Serialized | +0.75 s | 51% | +| base | Serialized | −0.33 s (13/13 negative) | 23% | +| base | Mobula | +0.78 s | 77% | + +Read those two tables together. On Solana, Mobula's self-reported lag is 19× better than +Serialized's, and the two arrive at the same instant. On Base, Serialized's self-reported lag is +negative while Mobula beats it to the wire on three trades out of four. **Any head-lag number built +on a provider's own timestamp is not a latency measurement, it is a measurement of where that +provider chooses to put its clock.** + +Bench 001 already does the right thing by referencing archive nodes and validating against block +hashes, so the published leaderboard is not affected by this. It does mean two things going forward: +the archive-node reference is load-bearing and must never be relaxed to a self-reported field, and +Serialized cannot be onboarded through a shortcut that trusts their `at`. + +**Blocking issue: Base preconfirmations.** Serialized emits Base trades from flashblocks +preconfirmations, ahead of the block timestamp they attach to the event. Measured on their stream, +Base events arrive with a **negative** lag versus their own `at` field (p50 −1.86 s, 3/3 negative in +the first sample). Their docs state this explicitly (~2.5 s ahead). + +Measured on Base against Mobula on matched hashes: Serialized reports 13/13 negative self-lag while +losing the actual race 77% of the time. So the preconfirmation feed does **not** currently translate +into earlier delivery on Base, it only translates into an earlier timestamp. That distinction has to +survive into whatever the bench publishes. Options, in order of preference: + +1. Add a `confirmation` dimension (`confirmed` / `preconfirmed`) and rank within it. +2. Clamp negative lag to 0 and footnote it. +3. Exclude Base for Serialized. + +Option 3 is the least honest, because their preconfirmed feed is a real product advantage for a +trading UI. Option 1 is the one that survives a public dispute. + +## 9. Bench 067 — portfolio-chain-coverage + +`GET /v1/wallet/positions` returned `200` on all 19 chains with zero errors. Rows came back on 6 +chains (ethereum 238, bsc 121, hyperevm 23, solana 13, arbitrum 12, avalanche 9) and 0 rows on the +other 13. + +**This test is inconclusive and must not be quoted.** The zero-row chains reflect probe addresses +that hold nothing there, not unsupported chains. Bench 067 compares self-declared coverage against +probe-verified coverage, which requires a curated funded address per chain. That curation is the +work item; the endpoint itself is ready. + +## 10. Bench 102 / 033 — not applicable + +Serialized is not a swap router and returns no quotes. `token-quote-coverage` (102) and +`evm-quote-latency` (033) cannot include them. Their `/v1/pulse` endpoint tracks ~90 launchpads and +is usable as an **alternative discovery source** for bench 102, which is a separate question. + +## 11. Latency, head to head + +REST, identical call shape, 20 samples each, from `ocb-par-main`: + +| Chain | Serialized p50 / p90 | Mobula p50 / p90 | +|---|---|---| +| base | 72 ms / 180 ms | 74 ms / 364 ms | +| solana | 50 ms / 58 ms | 39 ms / 180 ms | +| ethereum | 43 ms / 57 ms | 40 ms / 186 ms | + +Median is a tie. The tail is not: Serialized's p90 is 2× to 3× tighter on every chain. That +consistency is the more defensible claim, and it is not currently measured by any bench. + +## 12. Stretch tests + +| Test | Result | +|---|---| +| Burst threshold | Hard cap at exactly 40 concurrent. 20 and 40 pass clean; 60/100/150 return 40× `200` and the remainder `429` with an explicit `Burst limit: max 40 requests per second` message | +| Sustained 60 s @ 15 rps | 891/891 `200`, p50 38 ms, p99 67 ms, zero degradation | +| `POST /v1/token` batch | 25 items → `200`, 526 ms, 25 rows | +| `POST /v1/token/price` batch | 100 items → `200`, 38 ms, 100 rows | +| Batch over cap | 200 items → `400 INVALID_PARAM`, "must NOT have more than 100 items". Enforced, not silently truncated | +| OHLCV page cap | `limit` ≤ 500, enforced with a clear `400` | +| OHLCV history depth | 1s → 0.01 d, 1m → 0.35 d, 5m → 1.73 d, 1h → 20.8 d, 1d → 499 d (back to 2025-04-24), 1w → 973 d (back to 2024-01-04) | +| Trades pagination | 10 cursor pages, 1,000 trades in 6.4 s, no gaps or repeats | +| Error contract | `INVALID_CHAIN`, `INVALID_PARAM`, `NOT_FOUND`, `UNAUTHORIZED`, `RATE_LIMITED` all machine-readable and correct for the case | + +## 13. Defects found + +| # | Endpoint | Symptom | +|---|---|---| +| 1 | `GET /v1/wallet/equity/history` | `503 UPSTREAM_ERROR` after a 10 s hang, reproduced twice | +| 2 | `GET /v1/wallet/transfers` | 6.5 s response on a routine call. Not benchmarkable as-is | +| 3 | `GET /v1/token/trades`, `/stats`, `/dev-tokens` | `404` for the native wrapped mint (`So111…112`). Native is treated as a quote asset, never as a token. Any harness iterating a standard basket will hit this | +| 4 | Parameter naming | Three conventions on one API: `/v1/pulse` takes `chains` (plural), `/v1/wallet/*` takes `wallet`, `/v1/wallet/profile` takes `address` | + +## 14. Third-party sourcing + +Worth knowing before any commercial discussion, neutral observation either way: + +- Their token `iconUrl` values are upstream URLs from `cdn.dexscreener.com`, `ipfs.io`, + `raw.githubusercontent.com`, `arweave.net`, `gmgn.ai`, `axiomtrading.axiom-cdn.io`. +- Their wallet-profile entity avatars are served from `metadata.mobula.io`, our own CDN. + +## 15. Recommended sequence + +1. Fix the bench 004 logo rule (resolve-check instead of presence-check) and the bench 008 hit rule + (score against the curated hint). Both are provider-neutral fairness fixes and both should land + before a new entrant appears on those leaderboards. +2. Onboard Serialized to bench 004 and bench 008. Both are 1:1 endpoint mappings. +3. Bench 001: they are level with Mobula on wall-clock delivery (p50 +2 ms over 87 matched trades), + so they belong on the leaderboard. Decide the Base preconfirmation policy first, and keep the + archive-node reference: this audit showed self-reported timestamps disagree by up to 1.6 s on the + same transaction. +4. Curate funded probe addresses per chain for bench 067, then onboard. +5. Leave 005 and 090 alone unless they ask. +6. Consider a new token-security bench, where their `/v1/token/security` (18 fields) and + `/v1/audit/contract` are a genuine differentiator rather than a last-place row. + +Every onboarding needs a `docker build --no-cache` of the materialize-worker on `ocb-par-main` +after the harness change, or the new provider will not appear. diff --git a/harnesses/metadata-coverage/cmd/script/config.go b/harnesses/metadata-coverage/cmd/script/config.go index a3fd51143..57fe834e2 100644 --- a/harnesses/metadata-coverage/cmd/script/config.go +++ b/harnesses/metadata-coverage/cmd/script/config.go @@ -10,6 +10,7 @@ import ( type Config struct { CoinGeckoAPIKey string MobulaAPIKey string + SerializedAPIKey string DefinedSessionCookie string MonitorRegion string // Deployment region: us-west, us-east, singapore, etc. MobulaWSURL string // Mobula fast-trade WebSocket endpoint (allows staging to use EU-specific cluster) @@ -21,6 +22,7 @@ func loadEnv() (*Config, error) { // First, try to load from environment variables (for production/Railway) config.CoinGeckoAPIKey = strings.TrimSpace(os.Getenv("COINGECKO_API_KEY")) config.MobulaAPIKey = strings.TrimSpace(os.Getenv("MOBULA_API_KEY")) + config.SerializedAPIKey = strings.TrimSpace(os.Getenv("SERIALIZED_API_KEY")) config.DefinedSessionCookie = strings.TrimSpace(os.Getenv("DEFINED_SESSION_COOKIE")) config.MonitorRegion = strings.TrimSpace(os.Getenv("MONITOR_REGION")) config.MobulaWSURL = strings.TrimSpace(os.Getenv("MOBULA_WS_URL")) diff --git a/harnesses/metadata-coverage/cmd/script/metadata_coverage_monitor.go b/harnesses/metadata-coverage/cmd/script/metadata_coverage_monitor.go index 106898ecd..57e555301 100644 --- a/harnesses/metadata-coverage/cmd/script/metadata_coverage_monitor.go +++ b/harnesses/metadata-coverage/cmd/script/metadata_coverage_monitor.go @@ -65,18 +65,20 @@ type ProviderCoverage struct { // MetadataCoverageStats holds overall stats type MetadataCoverageStats struct { - mu sync.Mutex - Mobula ProviderCoverage - Codex ProviderCoverage - Jupiter ProviderCoverage - LastPrint time.Time + mu sync.Mutex + Mobula ProviderCoverage + Codex ProviderCoverage + Jupiter ProviderCoverage + Serialized ProviderCoverage + LastPrint time.Time } var ( coverageStats = &MetadataCoverageStats{ - Mobula: ProviderCoverage{Provider: "mobula"}, - Codex: ProviderCoverage{Provider: "codex"}, - Jupiter: ProviderCoverage{Provider: "jupiter"}, + Mobula: ProviderCoverage{Provider: "mobula"}, + Codex: ProviderCoverage{Provider: "codex"}, + Jupiter: ProviderCoverage{Provider: "jupiter"}, + Serialized: ProviderCoverage{Provider: "serialized"}, } tokenQueue = make(chan TokenToCheck, 500) metadataClient = &http.Client{Timeout: 10 * time.Second} @@ -194,12 +196,12 @@ type CodexTokenResponse struct { // CodexEnhancedToken matches the EnhancedToken type from Codex API type CodexEnhancedToken struct { - Address string `json:"address"` - Name string `json:"name"` - Symbol string `json:"symbol"` - Decimals int `json:"decimals"` - NetworkID int `json:"networkId"` - Info *CodexTokenInfo `json:"info"` + Address string `json:"address"` + Name string `json:"name"` + Symbol string `json:"symbol"` + Decimals int `json:"decimals"` + NetworkID int `json:"networkId"` + Info *CodexTokenInfo `json:"info"` SocialLinks *CodexSocialLinks `json:"socialLinks"` } @@ -215,11 +217,11 @@ type CodexTokenInfo struct { // CodexSocialLinks contains social media links for the token type CodexSocialLinks struct { - Twitter string `json:"twitter"` - Website string `json:"website"` - Telegram string `json:"telegram"` - Discord string `json:"discord"` - Github string `json:"github"` + Twitter string `json:"twitter"` + Website string `json:"website"` + Telegram string `json:"telegram"` + Discord string `json:"discord"` + Github string `json:"github"` } func getCodexNetworkID(chainID string) int { @@ -553,6 +555,8 @@ func updateStats(provider string, fields MetadataFields) { stats = &coverageStats.Codex case "jupiter": stats = &coverageStats.Jupiter + case "serialized": + stats = &coverageStats.Serialized default: return } @@ -601,7 +605,7 @@ func printCoverageStats() { fmt.Printf("║ Provider │ Checks │ Logo │ Name │ Symbol│ Desc │Twitter│Website│Telegram│ Errors │\n") fmt.Printf("╠══════════════════════════════════════════════════════════════════════════════╣\n") - for _, stats := range []*ProviderCoverage{&coverageStats.Mobula, &coverageStats.Codex, &coverageStats.Jupiter} { + for _, stats := range []*ProviderCoverage{&coverageStats.Mobula, &coverageStats.Codex, &coverageStats.Jupiter, &coverageStats.Serialized} { if stats.TotalChecks == 0 { fmt.Printf("║ %-8s │ %6d │ - │ - │ - │ - │ - │ - │ - │ %6d ║\n", stats.Provider, stats.TotalChecks, stats.ErrorCount) @@ -691,6 +695,23 @@ func checkTokenMetadata(token TokenToCheck, config *Config) { RecordMetadataLatency("jupiter", chainName, jupiterResult.ResponseTimeMs, config.MonitorRegion) } + // Check Serialized (18 EVM chains + Solana; skipped elsewhere) + var serializedResult MetadataFields + if _, supported := serializedChainID(token.ChainID); supported { + serializedResult = checkSerializedMetadata(token, config.SerializedAPIKey) + if serializedResult.Error != "" { + fmt.Printf("[META][SERIALIZED][%s] %s | %s | err=%s\n", + chainName, token.Symbol, token.Address, serializedResult.Error) + } + updateStats("serialized", serializedResult) + + RecordMetadataCoverage("serialized", chainName, "logo", serializedResult.HasLogo, config.MonitorRegion) + RecordMetadataCoverage("serialized", chainName, "description", serializedResult.HasDescription, config.MonitorRegion) + RecordMetadataCoverage("serialized", chainName, "twitter", serializedResult.HasTwitter, config.MonitorRegion) + RecordMetadataCoverage("serialized", chainName, "website", serializedResult.HasWebsite, config.MonitorRegion) + RecordMetadataLatency("serialized", chainName, serializedResult.ResponseTimeMs, config.MonitorRegion) + } + // Single condensed log line boolToIcon := func(b bool) string { if b { @@ -709,11 +730,17 @@ func checkTokenMetadata(token TokenToCheck, config *Config) { // without cross-referencing logs. Address goes after symbol; 4 boolean // columns per provider so website is visible alongside logo/desc/twitter // (the page renders 4 fields, the prior 3-column line hid that one). - fmt.Printf("[META] %s/%s %s | M:%s%s%s%s | C:%s%s%s%s | J:%s\n", + serializedCols := "----" + if _, supported := serializedChainID(token.ChainID); supported { + serializedCols = boolToIcon(serializedResult.HasLogo) + boolToIcon(serializedResult.HasDescription) + + boolToIcon(serializedResult.HasTwitter) + boolToIcon(serializedResult.HasWebsite) + } + + fmt.Printf("[META] %s/%s %s | M:%s%s%s%s | C:%s%s%s%s | J:%s | S:%s\n", token.Symbol, chainName, token.Address, boolToIcon(mobulaResult.HasLogo), boolToIcon(mobulaResult.HasDescription), boolToIcon(mobulaResult.HasTwitter), boolToIcon(mobulaResult.HasWebsite), boolToIcon(codexResult.HasLogo), boolToIcon(codexResult.HasDescription), boolToIcon(codexResult.HasTwitter), boolToIcon(codexResult.HasWebsite), - jupiterLogo) + jupiterLogo, serializedCols) // Print stats every 50 checks (reduced from 10) coverageStats.mu.Lock() @@ -808,4 +835,3 @@ func runMetadataCoverageMonitor(config *Config, stopChan <-chan struct{}) { } } } - diff --git a/harnesses/metadata-coverage/cmd/script/serialized_rest_monitor.go b/harnesses/metadata-coverage/cmd/script/serialized_rest_monitor.go new file mode 100644 index 000000000..812b134ea --- /dev/null +++ b/harnesses/metadata-coverage/cmd/script/serialized_rest_monitor.go @@ -0,0 +1,162 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// ============================================================================ +// Serialized — token metadata coverage +// +// GET /v1/token/metadata?chain=&address= returns the four +// canonical fields this bench scores, under different names than Mobula +// and Codex: +// +// logo -> iconUrl +// description -> description +// twitter -> twitterUrl +// website -> websiteUrl +// +// Chain ids are already in the bench's own shape ("solana", "evm:56", +// "evm:8453"), so no translation table is needed beyond normalising the +// legacy "solana:solana" form that Pulse V2 sometimes emits. +// +// One asymmetry worth knowing when reading the leaderboard: Serialized +// returns the *upstream* icon URL (ipfs.io, cdn.dexscreener.com, twimg, +// launchpad CDNs) while Mobula rewrites every logo onto its own CDN at a +// deterministic path, so Mobula's logo field is non-empty by construction. +// The bench currently scores "field non-empty", not "image resolves". +// See docs/methodology/serialized-onboarding-audit.md §6. +// ============================================================================ + +const serializedTokenMetadataURL = "https://api.serialized.xyz/v1/token/metadata" + +// Serialized enforces a hard burst cap of 40 requests per second per key +// and returns 429 above it. The queue-driven monitor can burst well past +// that during a launch spike, which would show up as coverage loss rather +// than as a rate-limit error. Pace the calls at a fixed floor instead. +var ( + serializedMetaMu sync.Mutex + serializedMetaLast time.Time +) + +const serializedMetaMinInterval = 60 * time.Millisecond // ~16 rps against a 40 rps cap + +func serializedMetaThrottle() { + serializedMetaMu.Lock() + defer serializedMetaMu.Unlock() + if wait := time.Until(serializedMetaLast.Add(serializedMetaMinInterval)); wait > 0 { + time.Sleep(wait) + } + serializedMetaLast = time.Now() +} + +// serializedChainID normalises the bench's chain id to what Serialized +// accepts. Returns false when the chain is outside their coverage, so the +// caller skips the check instead of recording a miss. +func serializedChainID(chainID string) (string, bool) { + c := chainID + if c == "solana:solana" { + c = "solana" + } + if c == "solana" { + return c, true + } + if !strings.HasPrefix(c, "evm:") { + return "", false + } + // 18 EVM chains, live as of onboarding (2026-09-05). + switch c { + case "evm:1", "evm:56", "evm:130", "evm:143", "evm:196", "evm:988", + "evm:1514", "evm:2741", "evm:4217", "evm:4326", "evm:4663", + "evm:5042", "evm:8453", "evm:9745", "evm:42161", "evm:43114", + "evm:57073", "evm:645749": + return c, true + } + return "", false +} + +type SerializedTokenMetadataResponse struct { + Data struct { + Name string `json:"name"` + Symbol string `json:"symbol"` + IconURL string `json:"iconUrl"` + Description string `json:"description"` + TwitterURL string `json:"twitterUrl"` + WebsiteURL string `json:"websiteUrl"` + TelegramURL string `json:"telegramUrl"` + } `json:"data"` +} + +func checkSerializedMetadata(token TokenToCheck, apiKey string) MetadataFields { + result := MetadataFields{} + + chain, ok := serializedChainID(token.ChainID) + if !ok { + result.Error = "chain_unsupported" + return result + } + if apiKey == "" { + result.Error = "no_api_key" + return result + } + + serializedMetaThrottle() + + params := url.Values{} + params.Add("chain", chain) + params.Add("address", token.Address) + + req, err := http.NewRequest("GET", fmt.Sprintf("%s?%s", serializedTokenMetadataURL, params.Encode()), nil) + if err != nil { + result.Error = fmt.Sprintf("request_create_error: %v", err) + return result + } + // Raw key, no Bearer prefix — a prefixed key is rejected with 401. + req.Header.Set("Authorization", apiKey) + req.Header.Set("Accept", "application/json") + + startTime := time.Now() + resp, err := metadataClient.Do(req) + result.ResponseTimeMs = float64(time.Since(startTime).Milliseconds()) + if err != nil { + result.Error = fmt.Sprintf("request_error: %v", err) + return result + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + result.Error = fmt.Sprintf("status_%d", resp.StatusCode) + return result + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + result.Error = fmt.Sprintf("read_error: %v", err) + return result + } + + var response SerializedTokenMetadataResponse + if err := json.Unmarshal(body, &response); err != nil { + result.Error = fmt.Sprintf("parse_error: %v", err) + return result + } + + d := response.Data + result.HasName = d.Name != "" + result.HasSymbol = d.Symbol != "" + result.HasLogo = d.IconURL != "" + result.LogoURL = d.IconURL + result.HasDescription = d.Description != "" + result.HasTwitter = d.TwitterURL != "" + result.HasWebsite = d.WebsiteURL != "" + result.HasTelegram = d.TelegramURL != "" + + return result +} diff --git a/harnesses/wallet-labels/.env.example b/harnesses/wallet-labels/.env.example index dc1a7d7da..0d1d17414 100644 --- a/harnesses/wallet-labels/.env.example +++ b/harnesses/wallet-labels/.env.example @@ -10,6 +10,9 @@ MORALIS_API_KEY= # Helius (Solana) HELIUS_API_KEY= +# Serialized (18 EVM chains + Solana). Raw key, no Bearer prefix. +SERIALIZED_API_KEY= + # Tuning WALLET_LABELS_CHECK_DELAY_SECONDS=30 WALLET_LABELS_WORKERS=8 diff --git a/harnesses/wallet-labels/cmd/script/config.go b/harnesses/wallet-labels/cmd/script/config.go index 134cc7417..2bb00dd97 100644 --- a/harnesses/wallet-labels/cmd/script/config.go +++ b/harnesses/wallet-labels/cmd/script/config.go @@ -11,27 +11,29 @@ import ( // printed in full — only their length, so misconfigured deploys // fail loudly without leaking material. type Config struct { - MobulaAPIKey string - MoralisAPIKey string - HeliusAPIKey string + MobulaAPIKey string + MoralisAPIKey string + HeliusAPIKey string + SerializedAPIKey string - CheckDelay time.Duration - Workers int - QueueSize int - PromListen string - LogsToken string + CheckDelay time.Duration + Workers int + QueueSize int + PromListen string + LogsToken string } func loadConfig() *Config { c := &Config{ - MobulaAPIKey: os.Getenv("MOBULA_API_KEY"), - MoralisAPIKey: os.Getenv("MORALIS_API_KEY"), - HeliusAPIKey: os.Getenv("HELIUS_API_KEY"), - CheckDelay: parseDurationSec("WALLET_LABELS_CHECK_DELAY_SECONDS", 30), - Workers: parseInt("WALLET_LABELS_WORKERS", 8), - QueueSize: parseInt("WALLET_LABELS_QUEUE_SIZE", 2000), - PromListen: envDefault("PROM_LISTEN_ADDR", ":2112"), - LogsToken: os.Getenv("LOGS_TOKEN"), + MobulaAPIKey: os.Getenv("MOBULA_API_KEY"), + MoralisAPIKey: os.Getenv("MORALIS_API_KEY"), + HeliusAPIKey: os.Getenv("HELIUS_API_KEY"), + SerializedAPIKey: os.Getenv("SERIALIZED_API_KEY"), + CheckDelay: parseDurationSec("WALLET_LABELS_CHECK_DELAY_SECONDS", 30), + Workers: parseInt("WALLET_LABELS_WORKERS", 8), + QueueSize: parseInt("WALLET_LABELS_QUEUE_SIZE", 2000), + PromListen: envDefault("PROM_LISTEN_ADDR", ":2112"), + LogsToken: os.Getenv("LOGS_TOKEN"), } fmt.Println("=== Wallet Labels Coverage Monitor ===") @@ -42,6 +44,7 @@ func loadConfig() *Config { fmt.Printf(" Mobula key set: %v (len=%d)\n", c.MobulaAPIKey != "", len(c.MobulaAPIKey)) fmt.Printf(" Moralis key set: %v (len=%d)\n", c.MoralisAPIKey != "", len(c.MoralisAPIKey)) fmt.Printf(" Helius key set: %v (len=%d)\n", c.HeliusAPIKey != "", len(c.HeliusAPIKey)) + fmt.Printf(" Serialized key set: %v (len=%d)\n", c.SerializedAPIKey != "", len(c.SerializedAPIKey)) fmt.Println() return c diff --git a/harnesses/wallet-labels/cmd/script/main.go b/harnesses/wallet-labels/cmd/script/main.go index 0e3d5071c..abdfea2c2 100644 --- a/harnesses/wallet-labels/cmd/script/main.go +++ b/harnesses/wallet-labels/cmd/script/main.go @@ -53,6 +53,7 @@ func buildProviders(cfg *Config) []Provider { NewMobulaProvider(cfg.MobulaAPIKey), NewMoralisProvider(cfg.MoralisAPIKey), NewHeliusProvider(cfg.HeliusAPIKey), + NewSerializedProvider(cfg.SerializedAPIKey), NewBlockscoutProvider(), NewOLIProvider(), NewTonAPIProvider(), diff --git a/harnesses/wallet-labels/cmd/script/serialized.go b/harnesses/wallet-labels/cmd/script/serialized.go new file mode 100644 index 000000000..b35c79545 --- /dev/null +++ b/harnesses/wallet-labels/cmd/script/serialized.go @@ -0,0 +1,129 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sync" + "time" +) + +// Serialized exposes an identity graph rather than a pure entity-label +// service: GET /v1/wallet/profile returns a display name, ENS / Basename / +// .sol resolution, socials and linked wallets for an address. +// +// We read it with the same precedence rule every other provider gets — +// first non-generic name signal wins — so the bench compares like with +// like. Note for whoever reads the leaderboard: because `displayName` +// can resolve to a personal name-service record rather than a curated +// entity, a share of Serialized's hits name the *holder* of an address +// rather than the *entity* behind it (a measured ~25% of hits at +// onboarding time, against ~25% for Mobula on the same sample). That is +// a property of the bench's hit rule, not of this provider, and the fix +// belongs in the scoring rule for everyone at once. See +// docs/methodology/serialized-onboarding-audit.md §5. +type SerializedProvider struct { + apiKey string +} + +func NewSerializedProvider(key string) *SerializedProvider { + return &SerializedProvider{apiKey: key} +} + +func (p *SerializedProvider) Name() string { return "serialized" } + +// serializedChains are the anchor-list chains Serialized indexes. Their +// full surface is 18 EVM chains plus Solana; the ones below are the +// intersection with the curated anchor sample. Chains outside this set +// are skipped rather than counted as misses, same as every other +// chain-restricted provider in this harness. +var serializedChains = map[string]bool{ + "ethereum": true, + "bnb": true, + "base": true, + "arbitrum": true, + "solana": true, +} + +func (p *SerializedProvider) Supports(chain string) bool { return serializedChains[chain] } + +// Serialized enforces a hard burst cap of 40 requests per second per key +// and answers anything above it with 429. The harness runs 8 workers with +// sub-100ms responses, which clears that cap easily and silently turns +// coverage into a rate-limit artifact (measured: 60 of 100 anchors lost to +// 429, dropping apparent coverage from 77% to 37%). Serialize the calls at +// a conservative fixed interval instead of relying on worker count. +var ( + serializedMu sync.Mutex + serializedLast time.Time +) + +const serializedMinInterval = 60 * time.Millisecond // ~16 rps, well under the 40 rps cap + +func serializedThrottle() { + serializedMu.Lock() + defer serializedMu.Unlock() + if wait := time.Until(serializedLast.Add(serializedMinInterval)); wait > 0 { + time.Sleep(wait) + } + serializedLast = time.Now() +} + +func (p *SerializedProvider) Lookup(ctx context.Context, chain, address string) LabelResult { + res := LabelResult{Provider: p.Name(), Chain: chain, Address: address} + if !p.Supports(chain) || p.apiKey == "" { + return res + } + + serializedThrottle() + + start := time.Now() + req, _ := http.NewRequestWithContext(ctx, "GET", + "https://api.serialized.xyz/v1/wallet/profile?address="+address, nil) + // Raw key, no Bearer prefix — a prefixed key is rejected with 401. + req.Header.Set("Authorization", p.apiKey) + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + res.LatencyMs = time.Since(start).Milliseconds() + if err != nil { + res.Err = err + return res + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + res.Err = fmt.Errorf("status_%d", resp.StatusCode) + return res + } + + var body struct { + Data struct { + Profile *struct { + DisplayName string `json:"displayName"` + ENSName string `json:"ensName"` + Basename string `json:"basename"` + SolName string `json:"solName"` + } `json:"profile"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + res.Err = fmt.Errorf("parse: %w", err) + return res + } + if body.Data.Profile == nil { + return res + } + + prof := body.Data.Profile + for _, candidate := range []string{prof.DisplayName, prof.ENSName, prof.Basename, prof.SolName} { + if !genericLabel(candidate) { + res.Label = candidate + res.HasLabel = true + res.Raw = map[string]any{"label": candidate, "source": "wallet_profile"} + break + } + } + return res +} diff --git a/public/logos/serialized-wordmark.svg b/public/logos/serialized-wordmark.svg new file mode 100644 index 000000000..7f3d83a9b --- /dev/null +++ b/public/logos/serialized-wordmark.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/logos/serialized.svg b/public/logos/serialized.svg new file mode 100644 index 000000000..3198acb25 --- /dev/null +++ b/public/logos/serialized.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/data/provider-registry.ts b/src/data/provider-registry.ts index afdb90338..c9782f304 100644 --- a/src/data/provider-registry.ts +++ b/src/data/provider-registry.ts @@ -115,6 +115,47 @@ export const PROVIDER_REGISTRY: Record = { "Independent crypto market data API. Token prices, OHLCV, exchange tickers, and contract/platform lookups across 300+ supported chains. Public free tier with no auth.", twitter: "@coinpaprika", }, + serialized: { + url: "https://serialized.xyz", + description: + "Onchain market data and token security API for trading apps. Own indexers across 18 EVM chains plus Solana, REST plus a single-connection WebSocket for trades and token updates.", + longDescription: + "Serialized runs its own indexers rather than reselling a third-party pipeline, and scopes coverage deliberately narrow: 18 EVM chains plus Solana, weighted toward venues where launchpad and memecoin flow actually lands (Base, BNB, Solana, HyperEVM, Abstract, Monad, MegaETH, Plasma, Tempo, Arc, Robinhood Chain). The surface splits into market data (token details, price, stats, OHLCV from 1s, trades tape, pools, screener, Axiom-style launchpad lifecycle filters), wallet analytics (positions, PnL, closed positions, transfers, funding, an identity graph behind wallet profiles), and token security (holder concentration, sniper and bundler share, LP burn and lock state, plus a separate async contract-audit engine). Streams run over one WebSocket at wss://api.serialized.xyz/v1/stream, billed per connection-minute rather than per message. On Base the trade tape emits preconfirmed flashblock trades ahead of the block timestamp.", + twitter: "@serializedaudit", + docs: "https://docs.serialized.xyz", + chains: [ + "ethereum", + "base", + "bnb", + "arbitrum", + "avalanche", + "solana", + "abstract", + "hyperevm", + "ink", + "story", + "xlayer", + "plasma", + "unichain", + "monad", + "megaeth", + "tempo", + "robinhood", + "arc", + "stable", + ], + features: [ + "Token details, price, windowed stats and OHLCV from 1s to 1M", + "Trades tape with maker resolution, sniper/pro-trader/wash badges", + "Wallet positions, realized and unrealized PnL, funding and transfers", + "Token security: holder concentration, sniper and bundler share, LP burn and lock", + "Async contract audit engine across 18 EVM chains", + "Launchpad lifecycle filters over ~90 launchpads", + "WebSocket streams for trades, token updates and pool updates", + ], + pricing: + "Credit-metered. Free 15k credits/month, Starter 150k, Growth 2M, Enterprise unlimited. Most endpoints cost 1 credit; token/security costs 10 and a fresh contract audit 750.", + }, dexpaprika: { url: "https://dexpaprika.com", description: diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts index 9c3f74ba7..b84802cf3 100644 --- a/src/lib/logo-manifest.ts +++ b/src/lib/logo-manifest.ts @@ -99,6 +99,7 @@ const RAW: Record = { // ─── Providers ─── mobula: "/logos/mobula.svg", + serialized: "/logos/serialized.svg", codex: "/logos/codex.svg", polymarket: "/logos/polymarket.png", "polymarket-us": "/logos/polymarket.png",