Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
313 changes: 313 additions & 0 deletions docs/methodology/serialized-onboarding-audit.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions harnesses/metadata-coverage/cmd/script/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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"`
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
Expand Down Expand Up @@ -808,4 +835,3 @@ func runMetadataCoverageMonitor(config *Config, stopChan <-chan struct{}) {
}
}
}

162 changes: 162 additions & 0 deletions harnesses/metadata-coverage/cmd/script/serialized_rest_monitor.go
Original file line number Diff line number Diff line change
@@ -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=<chain>&address=<addr> 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
}
3 changes: 3 additions & 0 deletions harnesses/wallet-labels/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 19 additions & 16 deletions harnesses/wallet-labels/cmd/script/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ===")
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions harnesses/wallet-labels/cmd/script/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ func buildProviders(cfg *Config) []Provider {
NewMobulaProvider(cfg.MobulaAPIKey),
NewMoralisProvider(cfg.MoralisAPIKey),
NewHeliusProvider(cfg.HeliusAPIKey),
NewSerializedProvider(cfg.SerializedAPIKey),
NewBlockscoutProvider(),
NewOLIProvider(),
NewTonAPIProvider(),
Expand Down
Loading
Loading