diff --git a/benchmarks/blast-rpc.yml b/benchmarks/blast-rpc.yml
index df93cbae..899b7b0e 100644
--- a/benchmarks/blast-rpc.yml
+++ b/benchmarks/blast-rpc.yml
@@ -30,7 +30,7 @@ methodology:
findings:
- "{{best_name}} currently leads free Blast RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 4 measured providers."
- - "`rpc.blast.io` posts ~2 ms in every region simultaneously, physically impossible for one origin. Read it as an edge-terminated endpoint: heads are fresh per our stale detection, but the latency column measures CDN termination rather than a node round trip, the same class of caution we document for Cloudflare-eth."
+ - "`rpc.blast.io` posts ~2 ms in every region simultaneously. On-chain response headers confirm geo-distributed nodes: `blast_blast-mainnet_fra` from eu-west, `blast_blast-mainnet_sin` from Singapore, a US node from us-east. Blast runs RPC nodes inside the same GCP datacenters as our Railway probes, producing sub-2 ms figures that reflect co-located infrastructure rather than typical user latency. A user in Paris reaches the Frankfurt node in ~40 ms; a user outside GCP will see 30-100 ms depending on location. Heads are fresh per stale detection."
- "{{name:drpc}} ({{p50:drpc}}) is the honest-infrastructure comparison point: anycast consistency across the three regions with real node round trips behind it, the profile that wins it 10 of the 12 expansion chains."
- "{{name:tenderly}} sits at the expansion's familiar flat ~330 ms in all regions on Blast, single-origin routing on a gateway that is genuinely quick on the major chains."
@@ -44,7 +44,7 @@ faq:
- q: "How is Blast RPC latency measured here?"
a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 60 seconds against each provider from each of 3 regions, with the same plain HTTP client. Wall-clock round-trip is recorded at millisecond precision; p50/p90/p99 are computed via Prometheus `quantile_over_time` over 24 hours. Responses are classified (`ok` / `http_err` / `jsonrpc_err` / `stale` / `timeout`) so an endpoint stuck on an old head or returning errors behind HTTP 200 is never ranked as fastest. The harness is open source and every number on this page is a public Prometheus query you can run yourself."
- q: "Is rpc.blast.io really that fast, or is something else going on?"
- a: "Something else. Two milliseconds simultaneously from Virginia, Amsterdam and Singapore is below the physical round-trip floor for any single origin, so the endpoint is answering at an anycast/CDN edge. Our stale-head detection shows the blocks it returns are current, so it is not serving a stale cache today, but edge termination means the latency figure describes the edge network, not node processing. We keep it ranked with the caveat documented, exactly as we do for Cloudflare's fast-but-permissioned Ethereum endpoint."
+ a: "Blast runs geo-distributed RPC nodes inside major cloud datacenters (GCP us-east, GCP europe-west4/Frankfurt, GCP asia-southeast1/Singapore). Our Railway probes run in the same three GCP regions, so each probe hits the co-located Blast node with a sub-LAN round trip. The `x-node-id` response header confirms this: `blast_blast-mainnet_fra` from eu-west, `blast_blast-mainnet_sin` from Singapore. The blocks are fresh — this is not a cache hit — but the 1-2 ms figure reflects network co-location, not what a developer outside GCP infra will experience. From a typical residential connection or a non-GCP cloud, expect 30-100 ms."
source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities
diff --git a/benchmarks/bridge-fee.yml b/benchmarks/bridge-fee.yml
index accdd3da..db25d8a0 100644
--- a/benchmarks/bridge-fee.yml
+++ b/benchmarks/bridge-fee.yml
@@ -121,7 +121,7 @@ prometheus:
# rank_matrix_query required when dimensions.region is declared.
# label_replace maps bridge= → provider= so badge scoping works per region.
-rank_matrix_query: avg by (provider, region) (label_replace(quantile_over_time(0.50, bridge_cost_percent[24h]), "provider", "$1", "bridge", "(.+)"))
+rank_matrix_query: avg by (provider, region) (label_replace(quantile_over_time(0.50, bridge_cost_percent{from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]), "provider", "$1", "bridge", "(.+)"))
providers:
- slug: mobula
@@ -130,23 +130,23 @@ providers:
formula: "Per-corridor 24h median of total cost percent (fees + slippage + destination gas) on a $300 USDC quote from Mobula's intent layer, averaged across corridors. Sampled every 5 minutes from eu-west."
type: intent
queries:
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- mean: avg(avg_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- success: avg(avg_over_time(bridge_quote_success{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- sample_size: sum(count_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ mean: avg(avg_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ success: avg(avg_over_time(bridge_quote_success{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ sample_size: sum(count_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[1h])
regions:
- region: eu-west
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[1h])
- region: us-east
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[1h])
- region: sgp
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[1h])
- slug: relay
name: Relay
@@ -154,23 +154,23 @@ providers:
formula: "Per-corridor 24h median of total cost percent (fees + slippage + destination gas) on a $300 USDC quote from Relay's API, averaged across corridors. Sampled every 5 minutes from eu-west."
type: relay
queries:
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- mean: avg(avg_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- success: avg(avg_over_time(bridge_quote_success{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- sample_size: sum(count_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ mean: avg(avg_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ success: avg(avg_over_time(bridge_quote_success{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ sample_size: sum(count_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[1h])
regions:
- region: eu-west
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[1h])
- region: us-east
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[1h])
- region: sgp
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[1h])
- slug: lifi
name: LI.FI
@@ -178,23 +178,23 @@ providers:
formula: "Per-corridor 24h median of total cost percent (underlying bridge fee + slippage + destination gas + LI.FI markup) on a $300 USDC quote, averaged across corridors. Sampled every 5 minutes from eu-west."
type: aggregator
queries:
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- mean: avg(avg_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- success: avg(avg_over_time(bridge_quote_success{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- sample_size: sum(count_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ mean: avg(avg_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ success: avg(avg_over_time(bridge_quote_success{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ sample_size: sum(count_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[1h])
regions:
- region: eu-west
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[1h])
- region: us-east
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[1h])
- region: sgp
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[1h])
- slug: debridge
name: deBridge
@@ -202,23 +202,23 @@ providers:
formula: "Per-corridor 24h median of total cost percent (native-token protocol fee + slippage + destination gas) on a $300 USDC quote from deBridge's direct API, averaged across corridors. Sampled every 5 minutes from eu-west."
type: protocol
queries:
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- mean: avg(avg_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- success: avg(avg_over_time(bridge_quote_success{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- sample_size: sum(count_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ mean: avg(avg_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ success: avg(avg_over_time(bridge_quote_success{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ sample_size: sum(count_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[1h])
regions:
- region: eu-west
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[1h])
- region: us-east
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[1h])
- region: sgp
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[1h])
- slug: across
name: Across
@@ -226,23 +226,23 @@ providers:
formula: "Per-corridor 24h median of total cost percent on a $300 USDC quote from the keyless Across Swap API, averaged across corridors. The provider-computed USD fee total nets relayer capital, LP fee, destination gas and swap impact."
type: intent
queries:
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- mean: avg(avg_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- success: avg(avg_over_time(bridge_quote_success{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- sample_size: sum(count_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ mean: avg(avg_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ success: avg(avg_over_time(bridge_quote_success{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ sample_size: sum(count_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[1h])
regions:
- region: eu-west
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[1h])
- region: us-east
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[1h])
- region: sgp
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[1h])
- slug: near-intents
name: Near Intents
@@ -250,23 +250,23 @@ providers:
formula: "Per-corridor 24h median of total cost percent on a $300 USDC quote from the Near Intents 1Click API, averaged across corridors. The quoted amountOut already nets out solver spread, bridge fee and destination withdrawal fee."
type: intent
queries:
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- mean: avg(avg_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- success: avg(avg_over_time(bridge_quote_success{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- sample_size: sum(count_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ mean: avg(avg_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ success: avg(avg_over_time(bridge_quote_success{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ sample_size: sum(count_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[1h])
regions:
- region: eu-west
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[1h])
- region: us-east
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[1h])
- region: sgp
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[1h])
- slug: squid
name: Squid Router
@@ -274,23 +274,23 @@ providers:
formula: "Per-corridor 24h median of total cost percent on a $300 USDC quote from Squid Router's v2 API, averaged across supported EVM corridors (Base to Arb). Cost = fromAmountUSD minus toAmountUSD including all relayer and gas fees."
type: aggregator
queries:
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="squid"}[24h]))
- p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="squid"}[24h]))
- p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="squid"}[24h]))
- mean: avg(avg_over_time(bridge_cost_percent{bridge="squid"}[24h]))
- success: avg(avg_over_time(bridge_quote_success{bridge="squid"}[24h]))
- sample_size: sum(count_over_time(bridge_cost_percent{bridge="squid"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="squid"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="squid", amount_usd="300"}[24h]))
+ p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="squid", amount_usd="300"}[24h]))
+ p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="squid", amount_usd="300"}[24h]))
+ mean: avg(avg_over_time(bridge_cost_percent{bridge="squid", amount_usd="300"}[24h]))
+ success: avg(avg_over_time(bridge_quote_success{bridge="squid", amount_usd="300"}[24h]))
+ sample_size: sum(count_over_time(bridge_cost_percent{bridge="squid", amount_usd="300"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="squid", amount_usd="300"}[1h])
regions:
- region: eu-west
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="squid", region="eu-west"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="squid", region="eu-west"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="squid", amount_usd="300", region="eu-west"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="squid", amount_usd="300", region="eu-west"}[1h])
- region: us-east
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="squid", region="us-east"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="squid", region="us-east"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="squid", amount_usd="300", region="us-east"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="squid", amount_usd="300", region="us-east"}[1h])
- region: sgp
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="squid", region="sgp"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="squid", region="sgp"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="squid", amount_usd="300", region="sgp"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="squid", amount_usd="300", region="sgp"}[1h])
- slug: socket
name: Socket Protocol
@@ -298,20 +298,20 @@ providers:
formula: "All-in cost percent p50 over 24h on a $300 USDC best-output route via Socket Protocol's v2 API (routes through Across, Stargate, CCTP), EVM corridors only (Base to Arb)."
type: aggregator
queries:
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="socket"}[24h]))
- p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="socket"}[24h]))
- p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="socket"}[24h]))
- mean: avg(avg_over_time(bridge_cost_percent{bridge="socket"}[24h]))
- success: avg(avg_over_time(bridge_quote_success{bridge="socket"}[24h]))
- sample_size: sum(count_over_time(bridge_cost_percent{bridge="socket"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="socket"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="socket", amount_usd="300"}[24h]))
+ p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="socket", amount_usd="300"}[24h]))
+ p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="socket", amount_usd="300"}[24h]))
+ mean: avg(avg_over_time(bridge_cost_percent{bridge="socket", amount_usd="300"}[24h]))
+ success: avg(avg_over_time(bridge_quote_success{bridge="socket", amount_usd="300"}[24h]))
+ sample_size: sum(count_over_time(bridge_cost_percent{bridge="socket", amount_usd="300"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="socket", amount_usd="300"}[1h])
regions:
- region: eu-west
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="socket", region="eu-west"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="socket", region="eu-west"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="socket", amount_usd="300", region="eu-west"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="socket", amount_usd="300", region="eu-west"}[1h])
- region: us-east
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="socket", region="us-east"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="socket", region="us-east"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="socket", amount_usd="300", region="us-east"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="socket", amount_usd="300", region="us-east"}[1h])
- region: sgp
- p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="socket", region="sgp"}[24h]))
- series: avg_over_time(bridge_cost_percent{bridge="socket", region="sgp"}[1h])
+ p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="socket", amount_usd="300", region="sgp"}[24h]))
+ series: avg_over_time(bridge_cost_percent{bridge="socket", amount_usd="300", region="sgp"}[1h])
diff --git a/benchmarks/l1-finality.yml b/benchmarks/l1-finality.yml
index 73ba0d3c..b8889763 100644
--- a/benchmarks/l1-finality.yml
+++ b/benchmarks/l1-finality.yml
@@ -4,7 +4,7 @@ slug: l1-finality
number: "006"
title: Fastest L1 blockchain finality, live across 11 chains
seo_title: "Fastest L1 finality 2026"
-seo_description: "Fastest L1 blockchain finality measured live across 11 chains: Gram, SUI, Hedera, Stellar, Solana, Ethereum, Bitcoin and more. Real times, not whitepaper claims."
+seo_description: "{{best_name}} leads L1 finality at {{best_p50}} (p50, 24h) across 11 chains. Ethereum, Solana, Bitcoin, SUI, Gram: live wall-clock measurements, not whitepaper claims."
subtitle: Wall-clock seconds from latest block to the finalized block on Ethereum, Solana, Bitcoin, Gram, SUI, Stellar and 5 more chains, refreshed every 10 seconds.
seo_intro: |
This page measures L1 finality time live for every major Layer-1 blockchain, with p50 / p90 / p99 refreshed every 10 seconds. Stellar finality time is ~5 seconds, the close interval the Stellar Consensus Protocol locks in via federated Byzantine agreement. Solana finality time goes from sub-second on the processed commitment to ~12.8 s on finalized after 32 confirmed slots. Ethereum finality time is about 12.8 minutes in the ideal case, the 2-epoch Casper FFG window; observed wall-clock finality runs longer, and the table below shows the live measurement. Hedera finality time clears in 3-5 seconds via Hashgraph aBFT. SUI finality time and Gram finality time (formerly TON) both sit under one second via Mysticeti DAG-BFT and BAG consensus. BNB and Avalanche finality time land near two seconds through fast-finality forks. Probabilistic chains (Bitcoin, Litecoin, Monero) settle on a confirmation-depth convention measured here in minutes. Bitcoin finality time is roughly one hour at the industry-standard 6 confirmations; the protocol itself never reaches absolute finality.
diff --git a/benchmarks/memecoin-platforms.yml b/benchmarks/memecoin-platforms.yml
new file mode 100644
index 00000000..cb651588
--- /dev/null
+++ b/benchmarks/memecoin-platforms.yml
@@ -0,0 +1,177 @@
+# OpenChainBench. Bench 203
+
+slug: memecoin-platforms
+number: "203"
+title: "Solana memecoin platform fee rates: Axiom vs Fomo vs pump.fun vs GMGN vs Trojan, live 24h"
+seo_title: "Solana memecoin platform fees 2026: Axiom, Fomo, pump.fun, GMGN, Trojan, Photon, Maestro"
+seo_description: "{{best_name}} leads Solana memecoin platforms at {{best_p50}} take rate. Axiom vs Fomo vs pump.fun vs GMGN vs Trojan ranked by protocol revenue per dollar traded, live from Dune fee wallet monitoring."
+subtitle: Live 24-hour protocol fee revenue divided by trading volume for each Solana memecoin platform. Fees sourced from Dune Analytics (on-chain fee wallet inflows, refreshed every 6 hours). Volume from Mobula lighthouse (refreshed every 15 minutes).
+
+category: Trading
+status: live
+metric: 24h take rate
+unit: pct
+higher_is_better: false
+
+disclaimer: |
+ Take rate = fees / volume * 100. Fees are on-chain inflows to known fee wallets (Dune query 8316589, refreshed every 6h). Fomo fees come from DeFiLlama (on-chain USDC + off-chain relay). Volume is total Mobula-attributed volume; the take rate is a lower bound since platforms with variable or waivable fees will show lower observed rates.
+
+seo_intro: |
+ Solana memecoin platforms charge fees in different ways and at different rates,
+ making direct comparison difficult without on-chain data. This benchmark
+ measures the actual protocol revenue collected over 24 hours by monitoring
+ known fee wallets on Dune Analytics, then divides by the total trading volume
+ attributed to each platform by Mobula's lighthouse aggregator. Axiom leads on
+ absolute protocol revenue, consistent with its 0.9% per-trade fee applied
+ across high-volume memecoin flow. The observed take rate for GMGN and Trojan
+ is lower than their stated fee because Mobula's volume attribution is broader
+ than the set of trades on which the platforms actually charge. pump.fun cut its
+ bonding-curve trading fees to 0% in August 2026, so its observed take rate has
+ dropped near zero; protocol revenue now comes from graduation fees only.
+
+abstract: |
+ Fee data: Dune query 8316589 monitors native SOL inflows (via solana.account_activity,
+ address column) and USDC/WSOL inflows (via token_balance_owner column) to known
+ protocol fee wallets for each platform, summed over a rolling 24-hour window.
+ Executed every 6 hours (costs Dune credits); cached results read every 15 minutes.
+ Fomo exception: Fomo combines on-chain USDC sweeps with off-chain relay fees
+ stored in a private Dune table accessible only to DeFiLlama; fees for Fomo come
+ from DeFiLlama /summary/fees/fomo chainBreakdown[Solana]. Volume data: Mobula
+ lighthouse (api.mobula.io/api/2/market/lighthouse) polled every 15 minutes.
+ Trading platforms from byPlatform; pump.fun from byLaunchpad[name=PumpFun].
+ Two take-rate metrics: fee_rate_pct = fees / Mobula total volume (lower bound);
+ fee_paying_rate_pct = fees / fee-paying volume (Dune tx join to dex_solana.trades,
+ comparable across platforms). coverage_pct = fee-paying / Mobula volume.
+
+methodology:
+ - "Fee source (all except Fomo): Dune Analytics query 8316589 on solana.account_activity. Native SOL inflows matched by address = fee wallet; USDC/WSOL inflows matched by token_balance_owner = fee wallet. Summed over NOW() - 1 DAY. Executed every 6h; cached result read every 15min."
+ - "Fee source (Fomo): DeFiLlama /summary/fees/fomo, chainBreakdown[Solana].total24h. DeFiLlama combines on-chain USDC inflows to R4rNJHaffSUotNmqSKNEfDcJE8A7zJUkaoM5Jkd7cYX and off-chain relay fees from dune.tryfomo.fomo_relay_fees (private Dune table)."
+ - "Volume source (Mobula): Mobula lighthouse byPlatform for trading terminals; byLaunchpad[name=PumpFun] for pump.fun (bonding curve + PumpSwap graduated AMM). Updated every 15 minutes. This is total attributed volume; includes trades that may not have generated a fee."
+ - "Volume source (fee-paying, Dune): For each transaction that generated a native SOL, USDC, or WSOL inflow to a fee wallet, the largest swap trade in dex_solana.trades with matching tx_id is taken (MAX amount_usd per tx, to avoid double-counting multi-hop routes). Sum across all such transactions = fee_paying_volume_usd_24h. This denominator is self-consistent with the fee numerator."
+ - "Two take-rate metrics: memecoin_platform_fee_rate_pct = fees / Mobula total volume (lower bound, shows attribution breadth); memecoin_platform_fee_paying_rate_pct = fees / fee-paying volume (comparable across platforms, closer to the actual per-trade fee rate). memecoin_platform_coverage_pct = fee-paying volume / Mobula volume (shows what fraction of attributed volume generated a fee)."
+ - "SOL price: CoinGecko simple/price, refreshed every 5 minutes. Used to convert native SOL and WSOL inflows to USD."
+ - "GMGN: 9 fee wallets verified by DeFiLlama adapter (master, 2026-06) as pure collectors sweeping to a single treasury. DeFiLlama Solana-only fees match ours within 5%, confirming the wallet list is complete. GMGN's $772K/day DeFiLlama total includes BSC, Base, Ethereum, Monad and other EVM chains; this bench is Solana-only."
+ - "pump.fun: fees captured via native SOL and WSOL inflows to the 9 known protocol fee wallets. pump.fun cut its bonding-curve trading fees to 0% in August 2026; observed fee revenue post that date reflects token graduation fees only. Creator fees (0.30%) and LP fees flow to separate recipients not tracked here."
+ - "Trojan: fee wallets capture the per-swap protocol transfer; DeFiLlama Solana match at 6% confirms fees are in the swap transaction, not a separate settlement."
+
+findings:
+ - "{{best_name}} has the highest observed take rate at {{best_p50}} over the last 24h."
+ - "Axiom leads on absolute protocol revenue at roughly $800K per day, consistent with its 0.9% stated fee applied across high-volume memecoin flow."
+ - "Using the self-consistent fee-paying volume denominator (Dune tx join), Axiom shows ~1.25%, GMGN ~1.73%, Trojan ~1.35%, and Maestro ~1.87%, clustered closer to stated rates than the Mobula-volume figures, which are depressed by volume attribution breadth."
+ - "pump.fun cut its bonding-curve trading fees to 0% in August 2026; its observed take rate has dropped near zero as protocol fee revenue is now only graduation fees. Creator and LP fee shares flow to separate recipients."
+ - "GMGN and Trojan show ~8% coverage (fee-paying volume / Mobula volume), meaning only 8% of attributed volume generates a fee inflow. Their Dune fee captures match DeFiLlama Solana within 5-6%, confirming the wallet list is complete."
+ - "Fomo fees include both on-chain USDC sweeps and off-chain relay fees; the latter are not directly capturable via Dune without access to Fomo's private data feed."
+
+source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/memecoin-platforms
+
+prometheus:
+ window: 24h
+ freshness_metric: memecoin_platform_health
+
+faq:
+ - q: "What is a take rate?"
+ a: "Take rate is the fraction of trading volume captured as protocol revenue: fees_usd_24h / volume_usd_24h * 100. A platform with a 1% per-trade fee and full fee coverage on all attributed volume would show a 1% take rate. Lower rates indicate either a lower stated fee, waived fees on some trades, or a volume denominator that includes trades where no fee was charged."
+ - q: "Why is GMGN's take rate 0.14% if they charge 1%?"
+ a: "GMGN charges 1% per trade, and their fee wallets do collect ~$155K/day on Solana, matching DeFiLlama's Solana-only figure within 5%. But Mobula's lighthouse attributes $108M in volume to GMGN, which is broader than the volume on which GMGN actually collects a fee. The 0.14% figure is the ratio of fees to total attributed volume, not the per-trade fee rate. GMGN's total revenue across all chains (BSC, Base, Ethereum, Monad, etc.) is ~$772K/day per DeFiLlama."
+ - q: "Why does pump.fun show a near-zero take rate?"
+ a: "pump.fun cut its bonding-curve trading fees to 0% in August 2026, eliminating its per-swap protocol revenue. The Dune fee wallet monitors now capture primarily token graduation fees rather than trading fees. The byLaunchpad volume denominator remains high ($100M+), so the computed take rate is near zero. Pre-August 2026, pump.fun ran a 0.95% protocol share on buys and sells, producing ~0.45% observed take rate."
+ - q: "Why is Fomo sourced from DeFiLlama instead of Dune directly?"
+ a: "Fomo collects fees in two ways: on-chain USDC sweeps to a fee wallet, and off-chain relay fees reported to a private Dune table (dune.tryfomo.fomo_relay_fees) that only Fomo's team can write to. DeFiLlama has access to this private table via a partnership. Without it, on-chain monitoring alone would capture only a fraction of Fomo's revenue."
+ - q: "How reliable are the fee wallet addresses?"
+ a: "GMGN's 9 Solana wallets are verified by DeFiLlama's adapter (reviewed 2026-06) as pure fee collectors with no inter-wallet flows. Our 5% match to DeFiLlama Solana confirms the list is current. Axiom, Trojan, Photon, and Maestro wallets were identified by reverse-engineering tagged trades and cross-checked against DeFiLlama. A monthly diff of the DeFiLlama adapter detects any rotation."
+ - q: "What is memecoin_platform_fee_paying_rate_pct and how does it differ from fee_rate_pct?"
+ a: "memecoin_platform_fee_rate_pct uses Mobula's total attributed volume as the denominator, which is a lower bound because Mobula attributes more trades to each platform than those that actually paid a fee. memecoin_platform_fee_paying_rate_pct uses only the volume of trades that generated a fee inflow (from Dune: for each fee-receiving transaction, we look up the largest swap in dex_solana.trades via tx_id match and sum those). This denominator is self-consistent with the fee numerator, so the resulting rate is comparable across platforms and approximates the actual per-trade fee. memecoin_platform_coverage_pct shows what fraction of Mobula volume actually generated a fee."
+ - q: "What is memecoin_dune_data_freshness_seconds?"
+ a: "It is the Unix timestamp of the most recent block_time seen in the Dune query results. If Dune's solana.account_activity table lags, the nominal 24-hour window silently shrinks. A gap of more than 4 hours between this metric and the current time indicates stale data."
+
+providers:
+ - slug: axiom
+ name: Axiom
+ tag: "Solana trading terminal, 0.9% fee"
+ formula: "24h protocol fee revenue (Dune wallet inflows) / 24h fee-paying volume (Dune tx join). Fees: native SOL inflows to 20 known fee wallets. Fee-paying volume: largest swap in each fee-receiving tx from dex_solana.trades."
+ queries:
+ p50: memecoin_platform_fee_paying_rate_pct{platform="axiom"}
+ p90: memecoin_platform_fee_paying_rate_pct{platform="axiom"}
+ p99: memecoin_platform_fee_paying_rate_pct{platform="axiom"}
+ mean: memecoin_platform_fee_paying_rate_pct{platform="axiom"}
+ success: memecoin_platform_health
+ sample_size: memecoin_platform_fees_usd_24h{platform="axiom"}
+ series: memecoin_platform_fee_paying_rate_pct{platform="axiom"}
+
+ - slug: fomo
+ name: Fomo
+ tag: "Solana mobile trading app"
+ formula: "24h protocol fee revenue (DeFiLlama Solana) / 24h volume (Mobula lighthouse). Fomo fees include on-chain USDC + off-chain relay fees via DeFiLlama; fee-paying volume not available for Fomo."
+ queries:
+ p50: memecoin_platform_fee_rate_pct{platform="fomo"}
+ p90: memecoin_platform_fee_rate_pct{platform="fomo"}
+ p99: memecoin_platform_fee_rate_pct{platform="fomo"}
+ mean: memecoin_platform_fee_rate_pct{platform="fomo"}
+ success: memecoin_platform_health
+ sample_size: memecoin_platform_fees_usd_24h{platform="fomo"}
+ series: memecoin_platform_fee_rate_pct{platform="fomo"}
+
+ - slug: pump-fun
+ name: pump.fun
+ tag: "Solana meme launchpad + PumpSwap AMM"
+ formula: "24h protocol fee revenue (Dune wallet inflows) / 24h fee-paying volume (Dune tx join). Captures protocol share (0.95%) on bonding curve and PumpSwap trades; creator and LP shares excluded."
+ queries:
+ p50: memecoin_platform_fee_paying_rate_pct{platform="pump-fun"}
+ p90: memecoin_platform_fee_paying_rate_pct{platform="pump-fun"}
+ p99: memecoin_platform_fee_paying_rate_pct{platform="pump-fun"}
+ mean: memecoin_platform_fee_paying_rate_pct{platform="pump-fun"}
+ success: memecoin_platform_health
+ sample_size: memecoin_platform_fees_usd_24h{platform="pump-fun"}
+ series: memecoin_platform_fee_paying_rate_pct{platform="pump-fun"}
+
+ - slug: gmgn
+ name: GMGN
+ tag: "Solana AI trading terminal, 1% fee"
+ formula: "24h Solana protocol fee revenue (Dune, 9 wallet addresses) / 24h fee-paying volume (Dune tx join). Total revenue across all chains is ~5x higher per DeFiLlama."
+ queries:
+ p50: memecoin_platform_fee_paying_rate_pct{platform="gmgn"}
+ p90: memecoin_platform_fee_paying_rate_pct{platform="gmgn"}
+ p99: memecoin_platform_fee_paying_rate_pct{platform="gmgn"}
+ mean: memecoin_platform_fee_paying_rate_pct{platform="gmgn"}
+ success: memecoin_platform_health
+ sample_size: memecoin_platform_fees_usd_24h{platform="gmgn"}
+ series: memecoin_platform_fee_paying_rate_pct{platform="gmgn"}
+
+ - slug: photon
+ name: Photon
+ tag: "Solana trading terminal, ~1% fee"
+ formula: "24h protocol fee revenue (Dune wallet inflows) / 24h fee-paying volume (Dune tx join)."
+ queries:
+ p50: memecoin_platform_fee_paying_rate_pct{platform="photon"}
+ p90: memecoin_platform_fee_paying_rate_pct{platform="photon"}
+ p99: memecoin_platform_fee_paying_rate_pct{platform="photon"}
+ mean: memecoin_platform_fee_paying_rate_pct{platform="photon"}
+ success: memecoin_platform_health
+ sample_size: memecoin_platform_fees_usd_24h{platform="photon"}
+ series: memecoin_platform_fee_paying_rate_pct{platform="photon"}
+
+ - slug: trojan
+ name: Trojan
+ tag: "Solana Telegram bot, ~0.9% fee"
+ formula: "24h protocol fee revenue (Dune wallet inflows) / 24h fee-paying volume (Dune tx join). Fee is collected in the swap transaction; DeFiLlama Solana match within 6% confirms capture."
+ queries:
+ p50: memecoin_platform_fee_paying_rate_pct{platform="trojan"}
+ p90: memecoin_platform_fee_paying_rate_pct{platform="trojan"}
+ p99: memecoin_platform_fee_paying_rate_pct{platform="trojan"}
+ mean: memecoin_platform_fee_paying_rate_pct{platform="trojan"}
+ success: memecoin_platform_health
+ sample_size: memecoin_platform_fees_usd_24h{platform="trojan"}
+ series: memecoin_platform_fee_paying_rate_pct{platform="trojan"}
+
+ - slug: maestro
+ name: Maestro
+ tag: "Solana Telegram bot, ~1% fee"
+ formula: "24h protocol fee revenue (Dune wallet inflows) / 24h fee-paying volume (Dune tx join)."
+ queries:
+ p50: memecoin_platform_fee_paying_rate_pct{platform="maestro"}
+ p90: memecoin_platform_fee_paying_rate_pct{platform="maestro"}
+ p99: memecoin_platform_fee_paying_rate_pct{platform="maestro"}
+ mean: memecoin_platform_fee_paying_rate_pct{platform="maestro"}
+ success: memecoin_platform_health
+ sample_size: memecoin_platform_fees_usd_24h{platform="maestro"}
+ series: memecoin_platform_fee_paying_rate_pct{platform="maestro"}
diff --git a/benchmarks/monad-rpc.yml b/benchmarks/monad-rpc.yml
index 8a14e1a5..0f4885d8 100644
--- a/benchmarks/monad-rpc.yml
+++ b/benchmarks/monad-rpc.yml
@@ -47,7 +47,7 @@ methodology:
findings:
- "{{best_name}} currently leads the free Monad RPC field at {{best_p50}} (p50, 24h) across 5 measured providers."
- - "{{name:monad-official}} answers at {{p50:monad-official}}. The Foundation fronts its primary endpoint with QuickNode infrastructure and rate-limits at 25 rps, generous for development traffic."
+ - "{{name:monad-official}} answers at {{p50:monad-official}}. On-chain response headers (`x-node-id: monad_monad-mainnet_fra`) confirm geo-distributed nodes co-located with our Railway probes in GCP datacenters (Frankfurt for eu-west, equivalent US and Singapore nodes). The sub-2 ms figure reflects network co-location, not what a developer outside GCP infra will experience — expect 30-120 ms from a typical connection. Rate-limit: 25 rps."
- "{{name:drpc}} sits at {{p50:drpc}} and {{name:tenderly}} at {{p50:tenderly}}: the multi-chain gateways reached Monad within weeks of mainnet, but route through their global meshes rather than chain-local nodes."
- "{{name:bloxroute}} at {{p50:bloxroute}} is the outlier entry: a BDN operator exposing a keyless endpoint, unusual for a company whose core product is paid low-latency access."
diff --git a/benchmarks/perp-cost-slope.yml b/benchmarks/perp-cost-slope.yml
index ee8f3802..f8787ac6 100644
--- a/benchmarks/perp-cost-slope.yml
+++ b/benchmarks/perp-cost-slope.yml
@@ -87,9 +87,8 @@ per_chain_explainer:
or higher.
findings:
- - "{{best_name}} leads the cost-slope leaderboard at {{best_p50}} (24h average). A slope of 1.00 means the venue charges the same basis points per unit of notional at $1M as at $1k."
- - "{{name:gains}} and {{name:gmx}} are the only venues in the cohort that structurally score 1.00: they use oracle pricing with no orderbook, so the fee is a flat percentage of position size regardless of trade size."
- - "{{name:hyperliquid}} sits at {{p50:hyperliquid}} cost slope (24h avg). The deepest onchain orderbook in the cohort keeps its impact low, but it still exceeds 1.00 at $1M notional on most assets."
+ - "{{name:gains}} and {{name:gmx}} always score 1.00 by construction, not by performance: oracle-priced venues charge a flat percentage of notional with no orderbook, so the $1M all-in is identical to the $1k all-in in basis points regardless of market conditions. Their top leaderboard position reflects fee structure, not execution quality."
+ - "Among orderbook venues, {{name:hyperliquid}} sits at {{p50:hyperliquid}} cost slope (24h avg) — the lowest impact in the cohort at $1M notional, driven by the deepest onchain book."
- "Venues showing N/A in the slope column cannot reliably fill a $1M market order. The fill-rate panel shows what fraction of measurement cycles the $1M tier was successfully absorbed."
- "The cost slope is asset-specific: use the chain tabs to compare ETH (deepest books), BTC (tightest books) and SOL (thinnest books) separately."
diff --git a/benchmarks/perp-protocol-longevity.yml b/benchmarks/perp-protocol-longevity.yml
index efedea82..f17f01b3 100644
--- a/benchmarks/perp-protocol-longevity.yml
+++ b/benchmarks/perp-protocol-longevity.yml
@@ -59,7 +59,7 @@ methodology:
- "Incident criteria: direct theft or permanent loss of user funds via a documented smart contract vulnerability or privileged key compromise. Oracle manipulation without contract exploit is excluded. Market-structure events (liquidation cascades, large-position forced close) are excluded."
- "gains.trade: launched 2021-12-01. No incidents in tracked sources as of registry date 2026-08-02. Clean streak = days since 2021-12-01."
- "GMX v2: launched 2023-08-01. Incident 2025-07-09: reentrancy in vault and order-flow logic, estimated loss USD 42 000 000, source coinperps.xyz post-mortem. Clean streak = days since 2025-07-09."
- - "Hyperliquid: launched 2023-11-01. No protocol exploit in tracked sources. Note: the March 2025 JellyJelly forced close was a market-structure event (large-position liquidation cascade triggering a governance emergency), not a smart contract exploit, and is excluded per criteria."
+ - "Hyperliquid: launched 2023-11-01. No protocol exploit in tracked sources. Note: the March 2025 JellyJelly forced close was a market-structure event, not a smart contract exploit, and is excluded per criteria. DeFiLlama enrichment filters entries with no confirmed financial loss (amount null or 0) to avoid false positives such as unrelated projects that share a name substring."
- "dYdX v4: launched on the Cosmos appchain 2023-10-01. No protocol exploit on v4. dYdX v1 and v2 on Ethereum had oracle manipulation events that are excluded here (different contract, different era)."
- "Lighter: launched 2023-07-01. No incidents in tracked sources."
- "Paradex: launched 2023-10-01. No incidents in tracked sources."
diff --git a/benchmarks/solana-avg-trade-size.yml b/benchmarks/solana-avg-trade-size.yml
new file mode 100644
index 00000000..34e320e1
--- /dev/null
+++ b/benchmarks/solana-avg-trade-size.yml
@@ -0,0 +1,198 @@
+# OpenChainBench. Bench 206
+
+slug: solana-avg-trade-size
+number: "206"
+title: "Average swap size on Solana: pump.fun vs GMGN vs Fomo vs Axiom, live"
+seo_title: "Average trade size by Solana trading platform 2026: pump.fun vs GMGN vs Axiom vs Fomo"
+seo_description: "{{best_name}} has the largest average swap size at ${{best_p50}} per trade. pump.fun vs GMGN vs Axiom vs Fomo vs Trojan ranked by average trade size, live from Mobula lighthouse."
+subtitle: Live average swap size in USD per attributed transaction for each Solana trading platform and launchpad. Polled every 15 minutes from Mobula lighthouse. Reveals the trader profile of each platform, from retail micro-trades to sophisticated large-order flow.
+
+category: Trading
+status: live
+metric: Avg swap size
+unit: usd
+higher_is_better: true
+
+disclaimer: |
+ Average swap size is computed as 24h attributed volume divided by 24h attributed trade count from Mobula lighthouse. Both numerator and denominator include bot and MEV activity; the metric reflects all on-chain swaps attributed to each platform, not human-only trades. pump.fun volume comes from the byLaunchpad bucket (bonding-curve swaps via pump.fun's own frontend); all other platforms come from byPlatform. Attribution is mutually exclusive per Mobula's referral-tag methodology.
+
+seo_intro: |
+ The average swap size on Solana varies by several multiples across platforms,
+ revealing sharply different trader profiles. pump.fun consistently sits at the
+ bottom of the leaderboard, reflecting the micro-cap degen activity of its
+ bonding-curve launchpad and the heavy bot sniping that inflates trade count while
+ compressing average size. GMGN and Trojan serve more sophisticated traders running
+ larger positions and consistently rank at the top. Fomo sits in the middle,
+ positioning between retail and professional. This benchmark tracks average swap
+ size live across all major Solana trading frontends and launchpads, updated every
+ 15 minutes from Mobula lighthouse attribution data.
+
+abstract: |
+ The harness polls Mobula lighthouse (api.mobula.io/api/2/market/lighthouse) every
+ 15 minutes and records volumeUSD.24h and trades.24h for each platform and launchpad.
+ Average swap size is derived as volumeUSD.24h divided by trades.24h. The harness
+ exposes solana_platform_volume_24h_usd, solana_platform_trades_24h and
+ solana_platform_health per platform. pump.fun is sourced from byLaunchpad and
+ cross-published into the platform gauge; all others come from byPlatform.
+
+methodology:
+ - "Source: Mobula lighthouse (api.mobula.io/api/2/market/lighthouse), poll every 15 min."
+ - "Average swap size: solana_platform_volume_24h_usd / solana_platform_trades_24h, computed in PromQL."
+ - "Attribution: Mobula assigns each on-chain swap to a platform via referral tags, program IDs and memo fields embedded in transactions. Each swap is attributed to exactly one platform."
+ - "Bot inclusion: all attributed swaps are counted including bots and MEV. Platforms with high bot activity (notably pump.fun snipers) show lower average sizes than human-only baselines would suggest."
+ - "pump.fun: sourced from byLaunchpad (bonding-curve swaps on pump.fun's own site, $123M+ on Aug 13 2026 confirming bonding-curve volume persists under the fee-free model). Trades routed through GMGN or Axiom to pump.fun tokens count under those platforms. Mobula documents trades as swap-level count (not fee-event gated); whether zero-fee swaps post August 7 are indexed identically is an implementation detail not confirmed by docs, pending Mobula support response."
+ - "Meteora DBC excluded: Meteora DBC counts LP provision events and micro-rebalances as trades, making its avg size of ~$12 not comparable to pure swap activity."
+
+findings:
+ - "{{best_name}} attracts the largest average swap at ${{best_p50}} per transaction."
+ - "pump.fun has the smallest average swap size in the set, dominated by bot sniping and micro-cap degen retail flow on its bonding-curve launchpad."
+ - "GMGN and Trojan consistently lead on average swap size, serving more sophisticated traders with larger position sizes. Note: Mobula's volume attribution for GMGN and Trojan is broader than fee-paying volume (only ~8% of attributed volume generates a fee event per bench 203), so average size comparisons with pump.fun should be treated as directional."
+ - "Fomo sits mid-market between retail launchpad activity and professional terminals."
+ - "Trade count tells the other story: pump.fun processes more trades per day than all other platforms combined. Mobula defines trades as swap-level count; whether zero-fee bonding-curve swaps post August 7 are included identically is unconfirmed, pending Mobula support."
+
+source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-launchpad-wars
+
+prometheus:
+ window: 24h
+ freshness_metric: solana_platform_trades_24h
+
+faq:
+ - q: "What does average swap size measure?"
+ a: "Average swap size is total 24h attributed volume divided by total 24h attributed trade count. It reflects the typical notional size of a single swap on each platform. A platform with many small trades will show a low average; one with fewer but larger trades will show a high average."
+ - q: "Why does pump.fun have so many trades?"
+ a: "pump.fun's bonding curve generates enormous transaction volume from sniping bots, copy-trade bots and retail buyers all racing to buy newly launched tokens in the first seconds. Bonding-curve volume persists after August 7 2026 under the fee-free model ($123M+ on Aug 13). Mobula documents trades.24h as a swap-level count, not a fee-event count, so the denominator should not be affected by the fee cut. Whether zero-fee swaps are indexed identically in practice is an implementation detail not confirmed by Mobula docs; treat the pump.fun average size as directional until Mobula support confirms."
+ - q: "Is this apple-to-apple between pump.fun and the other platforms?"
+ a: "Directionally yes. Mobula attributes each swap to exactly one frontend using referral tags. A pump.fun token bought via GMGN counts under GMGN, not pump.fun. So both reflect genuine frontend-attributed activity. The difference is that pump.fun's activity skews heavily toward bots and micro-trades on bonding-curve launches, while GMGN and Axiom serve more secondary-market trading."
+ - q: "Why is Fomo's average trade size lower than GMGN despite similar positioning?"
+ a: "Fomo attracts a broader retail base through its social copy-trading and wallet features, which pulls the average down compared to GMGN's power-trader demographic. Fomo's protocol fees appear lower in Mobula's data than in DeFiLlama because Fomo collects part of its revenue as off-chain relay fees stored in a private Dune table (dune.tryfomo.fomo_relay_fees); on-chain attribution alone misses this component."
+
+providers:
+ - slug: pump-fun
+ name: pump.fun
+ tag: "Solana meme launchpad"
+ formula: "Average swap size in USD on pump.fun's bonding curve (solana_platform_volume_24h_usd{platform=pump-fun} / solana_platform_trades_24h{platform=pump-fun}, Mobula lighthouse byLaunchpad, 15 min poll)."
+ queries:
+ p50: solana_platform_volume_24h_usd{platform="pump-fun"} / solana_platform_trades_24h{platform="pump-fun"}
+ p90: solana_platform_volume_24h_usd{platform="pump-fun"} / solana_platform_trades_24h{platform="pump-fun"}
+ p99: solana_platform_volume_24h_usd{platform="pump-fun"} / solana_platform_trades_24h{platform="pump-fun"}
+ mean: solana_platform_volume_24h_usd{platform="pump-fun"} / solana_platform_trades_24h{platform="pump-fun"}
+ success: solana_platform_health{platform="pump-fun"}
+ sample_size: solana_platform_trades_24h{platform="pump-fun"}
+ series: solana_platform_volume_24h_usd{platform="pump-fun"} / solana_platform_trades_24h{platform="pump-fun"}
+
+ - slug: gmgn
+ name: GMGN
+ tag: "AI trading terminal on Solana"
+ formula: "Average swap size in USD routed through GMGN (Mobula lighthouse byPlatform, 15 min poll)."
+ queries:
+ p50: solana_platform_volume_24h_usd{platform="gmgn"} / solana_platform_trades_24h{platform="gmgn"}
+ p90: solana_platform_volume_24h_usd{platform="gmgn"} / solana_platform_trades_24h{platform="gmgn"}
+ p99: solana_platform_volume_24h_usd{platform="gmgn"} / solana_platform_trades_24h{platform="gmgn"}
+ mean: solana_platform_volume_24h_usd{platform="gmgn"} / solana_platform_trades_24h{platform="gmgn"}
+ success: solana_platform_health{platform="gmgn"}
+ sample_size: solana_platform_trades_24h{platform="gmgn"}
+ series: solana_platform_volume_24h_usd{platform="gmgn"} / solana_platform_trades_24h{platform="gmgn"}
+
+ - slug: trojan
+ name: Trojan
+ tag: "Telegram trading bot on Solana"
+ formula: "Average swap size in USD routed through Trojan (Mobula lighthouse byPlatform, 15 min poll)."
+ queries:
+ p50: solana_platform_volume_24h_usd{platform="trojan"} / solana_platform_trades_24h{platform="trojan"}
+ p90: solana_platform_volume_24h_usd{platform="trojan"} / solana_platform_trades_24h{platform="trojan"}
+ p99: solana_platform_volume_24h_usd{platform="trojan"} / solana_platform_trades_24h{platform="trojan"}
+ mean: solana_platform_volume_24h_usd{platform="trojan"} / solana_platform_trades_24h{platform="trojan"}
+ success: solana_platform_health{platform="trojan"}
+ sample_size: solana_platform_trades_24h{platform="trojan"}
+ series: solana_platform_volume_24h_usd{platform="trojan"} / solana_platform_trades_24h{platform="trojan"}
+
+ - slug: maestro
+ name: Maestro
+ tag: "Telegram trading bot on Solana"
+ formula: "Average swap size in USD routed through Maestro (Mobula lighthouse byPlatform, 15 min poll)."
+ queries:
+ p50: solana_platform_volume_24h_usd{platform="maestro"} / solana_platform_trades_24h{platform="maestro"}
+ p90: solana_platform_volume_24h_usd{platform="maestro"} / solana_platform_trades_24h{platform="maestro"}
+ p99: solana_platform_volume_24h_usd{platform="maestro"} / solana_platform_trades_24h{platform="maestro"}
+ mean: solana_platform_volume_24h_usd{platform="maestro"} / solana_platform_trades_24h{platform="maestro"}
+ success: solana_platform_health{platform="maestro"}
+ sample_size: solana_platform_trades_24h{platform="maestro"}
+ series: solana_platform_volume_24h_usd{platform="maestro"} / solana_platform_trades_24h{platform="maestro"}
+
+ - slug: fomo
+ name: Fomo
+ tag: "Solana trading platform"
+ formula: "Average swap size in USD routed through Fomo (Mobula lighthouse byPlatform, 15 min poll)."
+ queries:
+ p50: solana_platform_volume_24h_usd{platform="fomo"} / solana_platform_trades_24h{platform="fomo"}
+ p90: solana_platform_volume_24h_usd{platform="fomo"} / solana_platform_trades_24h{platform="fomo"}
+ p99: solana_platform_volume_24h_usd{platform="fomo"} / solana_platform_trades_24h{platform="fomo"}
+ mean: solana_platform_volume_24h_usd{platform="fomo"} / solana_platform_trades_24h{platform="fomo"}
+ success: solana_platform_health{platform="fomo"}
+ sample_size: solana_platform_trades_24h{platform="fomo"}
+ series: solana_platform_volume_24h_usd{platform="fomo"} / solana_platform_trades_24h{platform="fomo"}
+
+ - slug: axiom
+ name: Axiom
+ tag: "Web trading terminal on Solana"
+ formula: "Average swap size in USD routed through Axiom (Mobula lighthouse byPlatform, 15 min poll)."
+ queries:
+ p50: solana_platform_volume_24h_usd{platform="axiom"} / solana_platform_trades_24h{platform="axiom"}
+ p90: solana_platform_volume_24h_usd{platform="axiom"} / solana_platform_trades_24h{platform="axiom"}
+ p99: solana_platform_volume_24h_usd{platform="axiom"} / solana_platform_trades_24h{platform="axiom"}
+ mean: solana_platform_volume_24h_usd{platform="axiom"} / solana_platform_trades_24h{platform="axiom"}
+ success: solana_platform_health{platform="axiom"}
+ sample_size: solana_platform_trades_24h{platform="axiom"}
+ series: solana_platform_volume_24h_usd{platform="axiom"} / solana_platform_trades_24h{platform="axiom"}
+
+ - slug: photon
+ name: Photon
+ tag: "Solana trading terminal"
+ formula: "Average swap size in USD routed through Photon (Mobula lighthouse byPlatform, 15 min poll)."
+ queries:
+ p50: solana_platform_volume_24h_usd{platform="photon"} / solana_platform_trades_24h{platform="photon"}
+ p90: solana_platform_volume_24h_usd{platform="photon"} / solana_platform_trades_24h{platform="photon"}
+ p99: solana_platform_volume_24h_usd{platform="photon"} / solana_platform_trades_24h{platform="photon"}
+ mean: solana_platform_volume_24h_usd{platform="photon"} / solana_platform_trades_24h{platform="photon"}
+ success: solana_platform_health{platform="photon"}
+ sample_size: solana_platform_trades_24h{platform="photon"}
+ series: solana_platform_volume_24h_usd{platform="photon"} / solana_platform_trades_24h{platform="photon"}
+
+ - slug: padre
+ name: Padre
+ tag: "AI Telegram bot on Solana"
+ formula: "Average swap size in USD routed through Padre (Mobula lighthouse byPlatform, 15 min poll)."
+ queries:
+ p50: solana_platform_volume_24h_usd{platform="padre"} / solana_platform_trades_24h{platform="padre"}
+ p90: solana_platform_volume_24h_usd{platform="padre"} / solana_platform_trades_24h{platform="padre"}
+ p99: solana_platform_volume_24h_usd{platform="padre"} / solana_platform_trades_24h{platform="padre"}
+ mean: solana_platform_volume_24h_usd{platform="padre"} / solana_platform_trades_24h{platform="padre"}
+ success: solana_platform_health{platform="padre"}
+ sample_size: solana_platform_trades_24h{platform="padre"}
+ series: solana_platform_volume_24h_usd{platform="padre"} / solana_platform_trades_24h{platform="padre"}
+
+ - slug: phantom
+ name: Phantom
+ tag: "Solana wallet, DEX aggregator"
+ formula: "Average swap size in USD routed through Phantom wallet (Mobula lighthouse byPlatform, 15 min poll)."
+ queries:
+ p50: solana_platform_volume_24h_usd{platform="phantom"} / solana_platform_trades_24h{platform="phantom"}
+ p90: solana_platform_volume_24h_usd{platform="phantom"} / solana_platform_trades_24h{platform="phantom"}
+ p99: solana_platform_volume_24h_usd{platform="phantom"} / solana_platform_trades_24h{platform="phantom"}
+ mean: solana_platform_volume_24h_usd{platform="phantom"} / solana_platform_trades_24h{platform="phantom"}
+ success: solana_platform_health{platform="phantom"}
+ sample_size: solana_platform_trades_24h{platform="phantom"}
+ series: solana_platform_volume_24h_usd{platform="phantom"} / solana_platform_trades_24h{platform="phantom"}
+
+ - slug: bloom
+ name: Bloom
+ tag: "Solana trading terminal"
+ formula: "Average swap size in USD routed through Bloom (Mobula lighthouse byPlatform, 15 min poll)."
+ queries:
+ p50: solana_platform_volume_24h_usd{platform="bloom"} / solana_platform_trades_24h{platform="bloom"}
+ p90: solana_platform_volume_24h_usd{platform="bloom"} / solana_platform_trades_24h{platform="bloom"}
+ p99: solana_platform_volume_24h_usd{platform="bloom"} / solana_platform_trades_24h{platform="bloom"}
+ mean: solana_platform_volume_24h_usd{platform="bloom"} / solana_platform_trades_24h{platform="bloom"}
+ success: solana_platform_health{platform="bloom"}
+ sample_size: solana_platform_trades_24h{platform="bloom"}
+ series: solana_platform_volume_24h_usd{platform="bloom"} / solana_platform_trades_24h{platform="bloom"}
diff --git a/benchmarks/solana-dex-volume.yml b/benchmarks/solana-dex-volume.yml
new file mode 100644
index 00000000..2c926edf
--- /dev/null
+++ b/benchmarks/solana-dex-volume.yml
@@ -0,0 +1,200 @@
+# OpenChainBench. Bench 205
+
+slug: solana-dex-volume
+number: "205"
+title: "Solana DEX volume: pump.fun vs Axiom vs GMGN vs Fomo, live 24h"
+seo_title: "Solana DEX trading volume 2026: pump.fun vs Axiom vs GMGN vs Fomo vs Trojan vs Photon vs BullX"
+seo_description: "{{best_name}} leads Solana DEX trading platforms at ${{best_p50}} 24h volume. pump.fun vs Axiom vs GMGN vs Fomo vs Trojan vs Photon vs BullX ranked live from DeFiLlama."
+subtitle: Live 24-hour trading volume and protocol revenue for the top Solana DEX platforms. Updated every 30 minutes from DeFiLlama. The fee column shows what each protocol earns, not what users pay in gas.
+
+category: Trading
+status: live
+metric: 24h volume
+unit: usd
+higher_is_better: true
+
+disclaimer: |
+ Volume and revenue data sourced from DeFiLlama's DEX and fees APIs. DeFiLlama attributes volume via on-chain referral tags and program IDs; platforms without standard tags may be undercounted. Figures are 24-hour and 7-day rolling totals updated every 30 minutes. Revenue uses DeFiLlama's dailyRevenue dataType, stripping LP fees. Terminal volume (Axiom, GMGN, Fomo) and launchpad volume (pump.fun) use different attribution methods and are not directly additive.
+
+seo_intro: |
+ The Solana DEX market in summer 2026 is split between pump.fun's launchpad,
+ which dominates early-stage token trading, and a set of competing terminals
+ (Axiom, GMGN, Fomo) targeting active traders on graduated tokens. pump.fun
+ cut its bonding-curve trading fees to zero on August 7 2026, one day after
+ Fomo briefly overtook Axiom in daily revenue. The timing is consistent with
+ a competitive response, though pump.fun has not formally confirmed causality. Axiom and GMGN each process around $70
+ million per day in terminal volume. Fomo reached $50 million daily volume and
+ crossed Axiom on revenue for the first time on August 6 2026. This benchmark
+ tracks all seven platforms in real time from DeFiLlama's DEX data feed,
+ refreshed every 30 minutes.
+
+abstract: |
+ The harness polls DeFiLlama's public DEX API (api.llama.fi/summary/dexs/{slug})
+ and fees API (api.llama.fi/summary/fees/{slug}?dataType=dailyRevenue) every 30
+ minutes for each tracked platform. No API key is required. DeFiLlama attributes
+ swap volume using on-chain referral tags and program IDs embedded in transactions.
+ The harness exposes six Prometheus gauges per platform: defillama_dex_volume_24h_usd,
+ defillama_dex_volume_7d_usd, defillama_dex_fees_24h_usd, defillama_dex_fees_7d_usd,
+ defillama_dex_take_rate and defillama_dex_health.
+
+methodology:
+ - "Source: DeFiLlama public DEX API (api.llama.fi/summary/dexs/{slug}) and fees API (api.llama.fi/summary/fees/{slug}?dataType=dailyRevenue). No authentication required."
+ - "Poll cadence: harness fetches each platform every 30 minutes and updates Prometheus gauges immediately."
+ - "Volume: total24h and total7d fields from DeFiLlama's DEX endpoint, sum of all swap notional values in USD attributed to this platform."
+ - "Revenue: total24h and total7d from the fees endpoint with dataType=dailyRevenue. This strips LP fees that stay in pool accounts and returns only the protocol's own share. Note: dailyRevenue also nets out referral paybacks (e.g. GMGN nets ~34% back to affiliates) and for pump.fun reflects the buyback-adjusted split introduced in 2026. Use dailyFees if comparing what users pay, not what protocols retain."
+ - "Take rate: protocol revenue divided by trading volume (defillama_dex_fees_24h_usd / defillama_dex_volume_24h_usd). Measures how much of each dollar traded the platform captures."
+ - "Attribution: DeFiLlama uses on-chain referral tags, program IDs and memo fields. Platforms without standard tagging may be undercounted."
+ - "pump.fun (slug pump.fun, layer Launchpad): the original bonding-curve launchpad. Users trade tokens directly on the curve; a token graduates to PumpSwap's AMM at roughly $69K market cap. Cut bonding-curve fees to zero on Aug 7 2026."
+ - "Axiom (slug axiom, layer Terminal): web-based Solana trading terminal. Observed take rate 0.85-1.10% (stated approximately 1%). Revenue briefly overtaken by Fomo on Aug 6 2026."
+ - "GMGN (slug gmgn, layer Terminal): AI-powered trading terminal on Solana. Approximately 1% fee on routed swaps."
+ - "fomo Wallet (slug fomo-wallet, layer Terminal): Fomo's DEX routing layer. Observed take rate approximately 1% on routed swaps. First crossed Axiom on daily revenue Aug 6 2026."
+ - "Trojan (slug trojan, layer Bot): Telegram trading bot on Solana. Lower volume than web terminals but serves a distinct Telegram-native user base."
+ - "Photon (slug photon, layer Terminal): Solana trading terminal targeted at advanced traders."
+ - "BullX (slug bullx, layer Terminal): Solana and multi-chain trading terminal. Volume fell sharply from its 2025 highs after competitors expanded; now a fraction of peak levels."
+ - "PumpSwap is pump.fun's backend AMM where graduated tokens trade. It is not tracked here because its volume overlaps with terminal volume: when Axiom or GMGN route a swap to PumpSwap, that trade appears in both the terminal's figures and PumpSwap's. Tracking PumpSwap alongside the terminals would double-count activity."
+ - "Phantom: Phantom Wallet is the largest Solana wallet by user count but does not have its own DeFiLlama DEX slug. Phantom routes swaps through Jupiter and other aggregators, which appear in those protocols' volume figures."
+
+findings:
+ - "{{best_name}} leads Solana DEX platforms at ${{best_p50}} 24h volume."
+ - "pump.fun dominates early-stage token volume with its bonding-curve launchpad, even after cutting trading fees to zero on Aug 7 2026."
+ - "Axiom and GMGN are the leading independent trading terminals, each routing around $70 million per day on graduated tokens."
+ - "Fomo briefly outpaced Axiom in daily revenue on Aug 6 2026, the event that triggered pump.fun's zero-fee response the following day."
+ - "The take rate column reveals the business model split: pump.fun earns on token launches and lifecycle fees, while Axiom, GMGN and Fomo charge frontend routing fees on each swap."
+ - "BullX is tracked but operates at a fraction of peak volume following market share losses in 2026."
+
+source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-dex-volume
+
+prometheus:
+ window: 24h
+ freshness_metric: defillama_dex_volume_24h_usd
+
+metric_panels:
+ - id: fees
+ label: Protocol revenue (24h)
+ label_key: platform
+ metric: defillama_dex_fees_24h_usd
+ unit: usd
+ higher_is_better: true
+ description: "Protocol revenue captured by each platform in the last 24 hours (DeFiLlama dailyRevenue). Excludes LP fees that stay in pool accounts."
+
+ - id: volume_7d
+ label: 7-day volume
+ label_key: platform
+ metric: defillama_dex_volume_7d_usd
+ unit: usd
+ higher_is_better: true
+ description: "Total DEX trading volume over the last 7 days."
+
+ - id: take_rate
+ label: Take rate
+ label_key: platform
+ metric: defillama_dex_take_rate
+ unit: pct
+ higher_is_better: false
+ description: "Protocol revenue as a fraction of trading volume (revenue / volume). Higher means the platform captures more of each dollar traded."
+
+ledger_columns:
+ - { label: "24h Volume", slot: p50 }
+ - { label: "24h Revenue", panel: fees }
+ - { label: "7d Volume", panel: volume_7d }
+
+faq:
+ - q: "Why is PumpSwap not tracked here?"
+ a: "PumpSwap is pump.fun's backend AMM where tokens trade after graduating from the bonding curve. It is excluded because its volume overlaps with the terminal volume already tracked: when Axiom, GMGN or Fomo routes a swap to PumpSwap, that trade appears in both the terminal's DeFiLlama figures and PumpSwap's. Including PumpSwap alongside the terminals would double-count that activity and distort the comparison. The bench tracks platforms where users go directly to trade, not the underlying AMM they route through."
+ - q: "What is Fomo?"
+ a: "Fomo (fomo.family) is a Solana trading platform and wallet launched in April 2025 by a team backed by Index Ventures and USV. It charges 0.5% on routed swaps and briefly led Solana daily DEX revenue on August 6 2026, overtaking Axiom for the first time."
+ - q: "Why did pump.fun cut its fees to zero?"
+ a: "On August 6 2026, Fomo briefly overtook Axiom in daily protocol revenue. pump.fun eliminated bonding-curve trading fees the following day. The back-to-back timing is consistent with a competitive response to frontend terminals gaining share, though pump.fun has not publicly confirmed that causal link. Bonding-curve trading continues under the fee-free model; protocol revenue now comes from token graduation fees and creator fees."
+ - q: "Why is Phantom not tracked here?"
+ a: "Phantom is the largest Solana wallet by user count but it routes swaps through aggregators like Jupiter rather than operating its own DEX contract. DeFiLlama does not assign Phantom a DEX slug because its on-chain footprint is the aggregator's, not Phantom's. Those swaps appear in Jupiter's figures. This bench tracks the platforms that DeFiLlama can attribute volume to directly."
+ - q: "How is this different from the Solana trading platform leaderboard (bench 201)?"
+ a: "Bench 201 uses Mobula's lighthouse API to attribute swaps to the frontend UI (GMGN, Axiom, Fomo, etc.). This bench uses DeFiLlama's protocol-level DEX data. The two sources diverge significantly in practice: on Aug 13 2026 Mobula ranked GMGN ($108M) ahead of Axiom ($94M) while DeFiLlama fees put Axiom 5x above GMGN. Attribution breadth differs because Mobula includes trades where the platform's referral tag is present regardless of fee collection, while DeFiLlama tracks fee-wallet inflows. Neither is wrong; they measure different populations."
+ - q: "Why does Photon show less volume than GMGN and Axiom?"
+ a: "Photon targets advanced traders and has a smaller but higher-frequency user base. Volume varies by day and market conditions. DeFiLlama attribution may also undercount platforms that do not embed standard on-chain referral tags."
+ - q: "What is the take rate and why does it matter?"
+ a: "The take rate is protocol revenue divided by trading volume. It shows how much of each dollar traded the platform keeps. pump.fun has a high take rate because it earns creator fees, graduation fees and protocol fees across the token lifecycle. Terminals like Axiom, GMGN and Fomo earn a routing fee on each swap. Comparing take rates reveals which platforms extract more value per dollar of activity."
+
+providers:
+ - slug: pump-fun
+ name: pump.fun
+ tag: "Launchpad, Solana"
+ formula: "24h DEX trading volume in USD for pump.fun bonding curve (DeFiLlama summary/dexs/pump.fun, total24h, updated every 30 min)."
+ queries:
+ p50: defillama_dex_volume_24h_usd{platform="pump-fun"}
+ p90: defillama_dex_volume_24h_usd{platform="pump-fun"}
+ p99: defillama_dex_volume_24h_usd{platform="pump-fun"}
+ mean: defillama_dex_volume_24h_usd{platform="pump-fun"}
+ success: defillama_dex_health{platform="pump-fun"}
+ series: defillama_dex_volume_24h_usd{platform="pump-fun"}
+
+ - slug: axiom
+ name: Axiom
+ tag: "Terminal, Solana"
+ formula: "24h DEX trading volume in USD for Axiom (DeFiLlama summary/dexs/axiom, total24h, updated every 30 min)."
+ queries:
+ p50: defillama_dex_volume_24h_usd{platform="axiom"}
+ p90: defillama_dex_volume_24h_usd{platform="axiom"}
+ p99: defillama_dex_volume_24h_usd{platform="axiom"}
+ mean: defillama_dex_volume_24h_usd{platform="axiom"}
+ success: defillama_dex_health{platform="axiom"}
+ series: defillama_dex_volume_24h_usd{platform="axiom"}
+
+ - slug: gmgn
+ name: GMGN
+ tag: "Terminal, Solana"
+ formula: "24h DEX trading volume in USD for GMGN (DeFiLlama summary/dexs/gmgn, total24h, updated every 30 min)."
+ queries:
+ p50: defillama_dex_volume_24h_usd{platform="gmgn"}
+ p90: defillama_dex_volume_24h_usd{platform="gmgn"}
+ p99: defillama_dex_volume_24h_usd{platform="gmgn"}
+ mean: defillama_dex_volume_24h_usd{platform="gmgn"}
+ success: defillama_dex_health{platform="gmgn"}
+ series: defillama_dex_volume_24h_usd{platform="gmgn"}
+
+ - slug: fomo
+ name: Fomo
+ tag: "Terminal, Solana"
+ formula: "24h DEX trading volume in USD for Fomo Wallet (DeFiLlama summary/dexs/fomo-wallet, total24h, updated every 30 min)."
+ queries:
+ p50: defillama_dex_volume_24h_usd{platform="fomo"}
+ p90: defillama_dex_volume_24h_usd{platform="fomo"}
+ p99: defillama_dex_volume_24h_usd{platform="fomo"}
+ mean: defillama_dex_volume_24h_usd{platform="fomo"}
+ success: defillama_dex_health{platform="fomo"}
+ series: defillama_dex_volume_24h_usd{platform="fomo"}
+
+ - slug: trojan
+ name: Trojan
+ tag: "Bot, Telegram"
+ formula: "24h DEX trading volume in USD for Trojan (DeFiLlama summary/dexs/trojan, total24h, updated every 30 min)."
+ queries:
+ p50: defillama_dex_volume_24h_usd{platform="trojan"}
+ p90: defillama_dex_volume_24h_usd{platform="trojan"}
+ p99: defillama_dex_volume_24h_usd{platform="trojan"}
+ mean: defillama_dex_volume_24h_usd{platform="trojan"}
+ success: defillama_dex_health{platform="trojan"}
+ series: defillama_dex_volume_24h_usd{platform="trojan"}
+
+ - slug: photon
+ name: Photon
+ tag: "Terminal, Solana"
+ formula: "24h DEX trading volume in USD for Photon (DeFiLlama summary/dexs/photon, total24h, updated every 30 min)."
+ queries:
+ p50: defillama_dex_volume_24h_usd{platform="photon"}
+ p90: defillama_dex_volume_24h_usd{platform="photon"}
+ p99: defillama_dex_volume_24h_usd{platform="photon"}
+ mean: defillama_dex_volume_24h_usd{platform="photon"}
+ success: defillama_dex_health{platform="photon"}
+ series: defillama_dex_volume_24h_usd{platform="photon"}
+
+ - slug: bullx
+ name: BullX
+ tag: "Terminal, multi-chain"
+ formula: "24h DEX trading volume in USD for BullX (DeFiLlama summary/dexs/bullx, total24h, updated every 30 min)."
+ queries:
+ p50: defillama_dex_volume_24h_usd{platform="bullx"}
+ p90: defillama_dex_volume_24h_usd{platform="bullx"}
+ p99: defillama_dex_volume_24h_usd{platform="bullx"}
+ mean: defillama_dex_volume_24h_usd{platform="bullx"}
+ success: defillama_dex_health{platform="bullx"}
+ series: defillama_dex_volume_24h_usd{platform="bullx"}
diff --git a/benchmarks/solana-launchpad-wars.yml b/benchmarks/solana-launchpad-wars.yml
new file mode 100644
index 00000000..627b3d2c
--- /dev/null
+++ b/benchmarks/solana-launchpad-wars.yml
@@ -0,0 +1,176 @@
+# OpenChainBench. Bench 200
+
+slug: solana-launchpad-wars
+number: "200"
+title: "Solana meme launchpad leaderboard: PumpFun vs Flap vs Bankr, live 24h volume"
+seo_title: "Solana meme launchpad comparison 2026: PumpFun, Flap, Bankr, LetsCash"
+seo_description: "{{best_name}} leads Solana meme launchpads at ${{best_p50}} 24h volume. PumpFun vs Flap vs Bankr vs LetsCash vs Meteora DBC vs NOXA Fun ranked live from Mobula lighthouse data."
+subtitle: Live 24-hour trading volume for each Solana meme coin launchpad. Polled every 15 minutes from Mobula lighthouse, which aggregates on-chain swap events across all bonding-curve programs on Solana.
+
+category: Trading
+status: live
+metric: 24h volume
+unit: usd
+higher_is_better: true
+
+disclaimer: |
+ Volume figures are sourced from Mobula's lighthouse aggregator, which indexes on-chain swap events. Data is refreshed every 15 minutes. A launchpad with zero volume may indicate no activity in the last 24 hours or a temporary data gap from Mobula. Fees shown are protocol-side fees detected on-chain; off-chain relay or referral fees are excluded.
+
+seo_intro: |
+ Solana's meme coin launchpad market is dominated by PumpFun but is far from a
+ monopoly. Flap, Bankr, LetsCash, NOXA Fun and Meteora DBC all run active
+ bonding-curve programs on Solana competing for the same launch traffic. This
+ benchmark tracks the 24-hour trading volume, protocol fees and trade count for
+ each platform in real time. PumpFun consistently leads with over 100 million
+ USD in daily volume. In August 2026 PumpFun cut its bonding-curve trading fees
+ to zero, so its daily protocol fee revenue now comes primarily from token
+ graduations rather than per-swap charges. Flap and Meteora DBC occupy the
+ challenger positions. The fee efficiency column (fees divided by volume)
+ reveals which launchpad extracts the most revenue per dollar traded.
+
+abstract: |
+ The harness polls the Mobula lighthouse endpoint (api.mobula.io/api/2/market/lighthouse)
+ every 15 minutes. Mobula aggregates all on-chain swap events into rolling windows
+ (15 min, 1 h, 6 h, 24 h) per launchpad and per trading platform. The harness
+ exposes three gauges per launchpad: solana_launchpad_volume_24h_usd,
+ solana_launchpad_fees_24h_usd and solana_launchpad_trades_24h. All launchpads
+ returned by the API are tracked automatically. The benchmark YAML selects the
+ eight highest-volume Solana-native launchpads as providers.
+
+methodology:
+ - "Source: Mobula lighthouse (api.mobula.io/api/2/market/lighthouse), byLaunchpad field. Mobula indexes all Solana bonding-curve programs and aggregates swap events into 24h rolling windows updated approximately every minute on Mobula's side."
+ - "Poll cadence: harness fetches the endpoint every 15 minutes and updates Prometheus gauges. Prometheus scrapes :2112/metrics every 60 seconds."
+ - "Volume: sum of all swap event notional values (in USD at time of swap) within the last 24 hours for swaps routed through this launchpad's program. Higher = more active market."
+ - "Fees: on-chain protocol fees collected by the launchpad program within the last 24 hours. Off-chain relay fees and referral fees are not included."
+ - "Trades: count of individual swap events within the last 24 hours."
+ - "Launchpad scope: this bench covers bonding-curve launchpads (PumpFun, Flap, Bankr, LetsCash, NOXA Fun, Meteora DBC, Raydium Launchlab). Trading frontends that route through multiple launchpads (GMGN, Axiom, Fomo, Trojan) are a separate bench."
+ - "PumpFun note: PumpFun volume here is bonding-curve activity only. Tokens that graduate from the bonding curve to PumpSwap AMM are counted separately under PumpSwap in the DEX view and are not included here."
+
+findings:
+ - "{{best_name}} leads Solana meme launchpads at ${{best_p50}} 24h volume."
+ - "PumpFun commands the market with over $100 million in daily bonding-curve volume, roughly 70 percent of total tracked launchpad volume. Protocol fee revenue is now primarily from token graduations after cutting bonding-curve trading fees to zero in August 2026."
+ - "Flap and Meteora DBC are the closest challengers, each processing tens of millions in daily volume. Flap has the highest fee-to-volume ratio among active launchpads."
+ - "Bankr, LetsCash and NOXA Fun each run active programs with single-digit millions in daily volume and tens of thousands in daily fees."
+
+source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-launchpad-wars
+
+prometheus:
+ window: 24h
+ freshness_metric: solana_launchpad_volume_24h_usd
+
+faq:
+ - q: "Which Solana meme launchpad has the most volume?"
+ a: "PumpFun leads with over $100 million in 24h bonding-curve volume, roughly 70 percent of total tracked launchpad volume. Flap and Meteora DBC are the closest challengers. The leaderboard updates every 15 minutes from live Mobula lighthouse data."
+ - q: "What counts as launchpad volume here?"
+ a: "On-chain swap events routed through the launchpad's bonding-curve program, aggregated over a rolling 24-hour window by Mobula. For PumpFun this is the bonding-curve phase only; tokens that have graduated to the PumpSwap AMM are not included in this figure."
+ - q: "How does PumpFun compare to Flap on fees?"
+ a: "PumpFun cut its bonding-curve trading fees to zero in August 2026, so its daily protocol fee revenue now comes almost entirely from graduation fees rather than per-swap charges. Flap charges a higher per-trade fee rate. The solana_launchpad_fees_24h_usd gauge tracks live fees for each launchpad."
+ - q: "What is Meteora DBC?"
+ a: "Meteora Dynamic Bonding Curve is Meteora's launchpad product on Solana, competing directly with PumpFun. It uses a configurable bonding curve and routes graduated tokens into Meteora's DLMM liquidity pools."
+ - q: "Are trading bots like GMGN and Axiom included?"
+ a: "No. GMGN, Axiom, Fomo and Trojan are trading frontends that route orders through multiple launchpads and DEXes. Their volume is attributable to the underlying programs, not to a single launchpad. A separate bench tracks the trading platform leaderboard."
+
+providers:
+ - slug: pumpfun
+ name: PumpFun
+ tag: "Solana launchpad, #1 by volume"
+ formula: "24h bonding-curve swap volume in USD. Source: Mobula lighthouse byLaunchpad[name=PumpFun].volumeUSD.24h, updated every 15 min."
+ queries:
+ p50: solana_launchpad_volume_24h_usd{launchpad="pumpfun"}
+ p90: solana_launchpad_volume_24h_usd{launchpad="pumpfun"}
+ p99: solana_launchpad_volume_24h_usd{launchpad="pumpfun"}
+ mean: solana_launchpad_volume_24h_usd{launchpad="pumpfun"}
+ success: solana_launchpad_health{launchpad="pumpfun"}
+ sample_size: solana_launchpad_trades_24h{launchpad="pumpfun"}
+ series: solana_launchpad_volume_24h_usd{launchpad="pumpfun"}
+
+ - slug: flap
+ name: Flap
+ tag: "Solana launchpad, top fee rate"
+ formula: "24h bonding-curve swap volume in USD. Source: Mobula lighthouse byLaunchpad[name=Flap].volumeUSD.24h, updated every 15 min."
+ queries:
+ p50: solana_launchpad_volume_24h_usd{launchpad="flap"}
+ p90: solana_launchpad_volume_24h_usd{launchpad="flap"}
+ p99: solana_launchpad_volume_24h_usd{launchpad="flap"}
+ mean: solana_launchpad_volume_24h_usd{launchpad="flap"}
+ success: solana_launchpad_health{launchpad="flap"}
+ sample_size: solana_launchpad_trades_24h{launchpad="flap"}
+ series: solana_launchpad_volume_24h_usd{launchpad="flap"}
+
+ - slug: meteora-dbc
+ name: Meteora DBC
+ tag: "Meteora DBC, Solana launchpad"
+ formula: "24h bonding-curve swap volume in USD. Source: Mobula lighthouse byLaunchpad[name=Meteora DBC].volumeUSD.24h, updated every 15 min."
+ queries:
+ p50: solana_launchpad_volume_24h_usd{launchpad="meteora-dbc"}
+ p90: solana_launchpad_volume_24h_usd{launchpad="meteora-dbc"}
+ p99: solana_launchpad_volume_24h_usd{launchpad="meteora-dbc"}
+ mean: solana_launchpad_volume_24h_usd{launchpad="meteora-dbc"}
+ success: solana_launchpad_health{launchpad="meteora-dbc"}
+ sample_size: solana_launchpad_trades_24h{launchpad="meteora-dbc"}
+ series: solana_launchpad_volume_24h_usd{launchpad="meteora-dbc"}
+
+ - slug: noxa-fun
+ name: NOXA Fun
+ tag: "Solana meme launchpad"
+ formula: "24h bonding-curve swap volume in USD. Source: Mobula lighthouse byLaunchpad[name=NOXA Fun].volumeUSD.24h, updated every 15 min."
+ queries:
+ p50: solana_launchpad_volume_24h_usd{launchpad="noxa-fun"}
+ p90: solana_launchpad_volume_24h_usd{launchpad="noxa-fun"}
+ p99: solana_launchpad_volume_24h_usd{launchpad="noxa-fun"}
+ mean: solana_launchpad_volume_24h_usd{launchpad="noxa-fun"}
+ success: solana_launchpad_health{launchpad="noxa-fun"}
+ sample_size: solana_launchpad_trades_24h{launchpad="noxa-fun"}
+ series: solana_launchpad_volume_24h_usd{launchpad="noxa-fun"}
+
+ - slug: bankr
+ name: Bankr
+ tag: "Solana meme launchpad"
+ formula: "24h bonding-curve swap volume in USD. Source: Mobula lighthouse byLaunchpad[name=Bankr].volumeUSD.24h, updated every 15 min."
+ queries:
+ p50: solana_launchpad_volume_24h_usd{launchpad="bankr"}
+ p90: solana_launchpad_volume_24h_usd{launchpad="bankr"}
+ p99: solana_launchpad_volume_24h_usd{launchpad="bankr"}
+ mean: solana_launchpad_volume_24h_usd{launchpad="bankr"}
+ success: solana_launchpad_health{launchpad="bankr"}
+ sample_size: solana_launchpad_trades_24h{launchpad="bankr"}
+ series: solana_launchpad_volume_24h_usd{launchpad="bankr"}
+
+ - slug: letscash
+ name: LetsCash
+ tag: "Solana meme launchpad"
+ formula: "24h bonding-curve swap volume in USD. Source: Mobula lighthouse byLaunchpad[name=LetsCash].volumeUSD.24h, updated every 15 min."
+ queries:
+ p50: solana_launchpad_volume_24h_usd{launchpad="letscash"}
+ p90: solana_launchpad_volume_24h_usd{launchpad="letscash"}
+ p99: solana_launchpad_volume_24h_usd{launchpad="letscash"}
+ mean: solana_launchpad_volume_24h_usd{launchpad="letscash"}
+ success: solana_launchpad_health{launchpad="letscash"}
+ sample_size: solana_launchpad_trades_24h{launchpad="letscash"}
+ series: solana_launchpad_volume_24h_usd{launchpad="letscash"}
+
+ - slug: dontblink
+ name: dontblink
+ tag: "Solana meme launchpad"
+ formula: "24h bonding-curve swap volume in USD. Source: Mobula lighthouse byLaunchpad[name=dontblink].volumeUSD.24h, updated every 15 min."
+ queries:
+ p50: solana_launchpad_volume_24h_usd{launchpad="dontblink"}
+ p90: solana_launchpad_volume_24h_usd{launchpad="dontblink"}
+ p99: solana_launchpad_volume_24h_usd{launchpad="dontblink"}
+ mean: solana_launchpad_volume_24h_usd{launchpad="dontblink"}
+ success: solana_launchpad_health{launchpad="dontblink"}
+ sample_size: solana_launchpad_trades_24h{launchpad="dontblink"}
+ series: solana_launchpad_volume_24h_usd{launchpad="dontblink"}
+
+ - slug: raydium-launchlab
+ name: Raydium Launchlab
+ tag: "Raydium launchpad on Solana"
+ formula: "24h bonding-curve swap volume in USD. Source: Mobula lighthouse byLaunchpad[name=Raydium Launchlab].volumeUSD.24h, updated every 15 min."
+ queries:
+ p50: solana_launchpad_volume_24h_usd{launchpad="raydium-launchlab"}
+ p90: solana_launchpad_volume_24h_usd{launchpad="raydium-launchlab"}
+ p99: solana_launchpad_volume_24h_usd{launchpad="raydium-launchlab"}
+ mean: solana_launchpad_volume_24h_usd{launchpad="raydium-launchlab"}
+ success: solana_launchpad_health{launchpad="raydium-launchlab"}
+ sample_size: solana_launchpad_trades_24h{launchpad="raydium-launchlab"}
+ series: solana_launchpad_volume_24h_usd{launchpad="raydium-launchlab"}
diff --git a/benchmarks/solana-trading-platform-wars.yml b/benchmarks/solana-trading-platform-wars.yml
new file mode 100644
index 00000000..5c42bac9
--- /dev/null
+++ b/benchmarks/solana-trading-platform-wars.yml
@@ -0,0 +1,184 @@
+# OpenChainBench. Bench 201
+
+slug: solana-trading-platform-wars
+number: "201"
+title: "Solana trading platform leaderboard: pump.fun vs GMGN vs Axiom vs Fomo, live 24h volume"
+seo_title: "Solana trading platform comparison 2026: pump.fun, GMGN, Axiom, Fomo, Trojan"
+seo_description: "{{best_name}} leads Solana trading platforms at ${{best_p50}} 24h volume. pump.fun vs GMGN vs Axiom vs Fomo vs Trojan vs Padre vs Phantom ranked live from Mobula lighthouse data."
+subtitle: Live 24-hour trading volume for each Solana trading platform and Telegram bot. Polled every 15 minutes from Mobula lighthouse, which aggregates on-chain swap events routed through each frontend across all Solana DEX programs.
+
+category: Trading
+status: live
+metric: 24h volume
+unit: usd
+higher_is_better: true
+
+disclaimer: |
+ Volume figures are sourced from Mobula's lighthouse aggregator, which indexes on-chain swap events attributed to each trading platform or Telegram bot. Data is refreshed every 15 minutes. Platforms with zero volume may indicate no attributable on-chain activity in the last 24 hours or a temporary attribution gap in Mobula's indexing.
+
+seo_intro: |
+ Solana's trading frontend market is a contested race between GMGN, Axiom and
+ Fomo, with Trojan and Padre close behind as the dominant Telegram bots. This
+ benchmark tracks the 24-hour trading volume routed through each platform in
+ real time using Mobula's lighthouse aggregator, which attributes on-chain
+ swap events to the originating frontend. GMGN consistently ranks at or near
+ the top, regularly processing over $100 million in daily volume on active
+ days. Axiom competes closely behind and the two regularly trade places as
+ market conditions shift. Fomo has been narrowing the gap and on active days
+ has matched or exceeded Axiom, making the top three increasingly competitive.
+ Trojan serves the Telegram-native trader segment with significant bot-driven
+ flow. Padre is a web trading terminal. Phantom routes wallet-initiated swaps
+ and collects a 0.85% platform fee on those trades. The fee column shows which platforms capture the most
+ protocol revenue per dollar traded; see methodology for Fomo's fee source.
+
+abstract: |
+ The harness polls the Mobula lighthouse endpoint (api.mobula.io/api/2/market/lighthouse)
+ every 15 minutes. Mobula attributes each on-chain swap event to the originating
+ trading platform or bot using referral tags, program IDs and memo fields embedded
+ in transactions. The harness exposes three gauges per platform:
+ solana_platform_volume_24h_usd, solana_platform_fees_24h_usd and
+ solana_platform_health. All platforms returned by the API are tracked
+ automatically. The benchmark YAML selects the highest-volume Solana-native
+ trading platforms as providers.
+
+methodology:
+ - "Source: Mobula lighthouse (api.mobula.io/api/2/market/lighthouse), byPlatform field. Mobula attributes swap events using on-chain referral tags, program IDs and memo fields."
+ - "Poll cadence: harness fetches the endpoint every 15 minutes and updates Prometheus gauges. Prometheus scrapes :2112/metrics every 60 seconds."
+ - "Volume: sum of all swap event notional values (in USD at time of swap) within the last 24 hours for swaps attributed to this platform. Higher = more user activity routed through this frontend."
+ - "Attribution: Mobula uses on-chain referral tags, program IDs and memo fields. Each swap is attributed to exactly one platform. Platforms without standard referral tags may be undercounted."
+ - "pump.fun: sourced from Mobula's byLaunchpad bucket (bonding-curve swaps via pump.fun's own frontend). PumpSwap trades routed through GMGN or Axiom count under those platforms."
+ - "Phantom: wallet-initiated swaps via Jupiter or other aggregators, not active trading sessions."
+
+findings:
+ - "{{best_name}} leads Solana trading platforms at ${{best_p50}} 24h volume."
+ - "GMGN and Axiom consistently rank at the top of the leaderboard and regularly trade places. Both serve power traders with web-based terminals; GMGN emphasizes AI copy-trading while Axiom focuses on execution speed."
+ - "Fomo closed the gap on Axiom and briefly overtook it on daily revenue around August 5-8 2026; as of mid-August Axiom leads again, making the top three a contested race with positions that shift by the week."
+ - "Trojan serves the Telegram-native segment and accounts for significant bot-driven Solana swap flow. Padre is a web trading terminal, not a Telegram bot."
+ - "Phantom collects a 0.85% platform fee on in-wallet swaps routed to its own fee wallets; it is a monetised frontend, not a passive aggregator pass-through."
+
+source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-trading-platform-wars
+
+prometheus:
+ window: 24h
+ freshness_metric: solana_platform_volume_24h_usd
+
+faq:
+ - q: "Which Solana trading platform has the most volume?"
+ a: "GMGN and Axiom consistently trade places at the top, and Fomo has been narrowing the gap. The leaderboard is live and updates every 15 minutes, so the current ranking is always visible at the top of the page. Headlines claiming one platform has overtaken another are often based on a single day or a specific metric; this bench tracks the rolling 24h window continuously."
+ - q: "Does the pump.fun volume here include PumpSwap?"
+ a: "Partially. The pump.fun line comes from Mobula's byLaunchpad bucket, which covers bonding-curve swaps via pump.fun's own frontend. PumpSwap (the AMM for graduated tokens) is a separate program launched in 2025. Direct PumpSwap trades via pump.fun's interface may be partially included depending on Mobula's indexing of the PumpSwap program. Trades on pump.fun tokens routed through GMGN or Axiom to PumpSwap count under those platforms, not under pump.fun."
+ - q: "What is the difference between GMGN and Axiom?"
+ a: "Both are web-based Solana trading terminals targeting power traders. GMGN emphasizes AI-powered wallet analysis and copy trading. Axiom is a terminal-style platform focused on chart speed and order execution. Both route through Jupiter and direct DEX pools."
+ - q: "Why does Fomo's fee column use a different source?"
+ a: "Fomo collects fees in SOL and USDC to a dedicated fee wallet (R4rNJHaffSUotNmqSKNEfDcJE8A7zJUkaoM5Jkd7cYX), plus an off-chain relay fee component stored in a private Dune table (dune.tryfomo.fomo_relay_fees) that only DeFiLlama can read via a data partnership. Mobula's referral-tag attribution captures only the on-chain component, understating Fomo's total revenue. The fee column for Fomo uses DeFiLlama, which adds both components. All other platforms use Mobula."
+ - q: "Are Trojan and Padre Telegram bots?"
+ a: "Trojan is a Telegram-native trading bot. Padre is a web trading terminal with an AI assistant, not a Telegram bot. Both embed referral tags in transactions which Mobula uses to attribute volume."
+ - q: "How is trading platform volume different from launchpad volume?"
+ a: "Launchpad volume (bench 200) counts swaps through bonding-curve programs like pump.fun or Flap. Platform volume here counts swaps attributed to a trading frontend regardless of which underlying DEX or launchpad was used. Attribution is mutually exclusive per Mobula's referral tag system: a trade on pump.fun tokens routed through GMGN counts under GMGN, not under pump.fun."
+ - q: "Why does BullX show zero or low volume?"
+ a: "BullX has limited Solana attribution in Mobula's lighthouse data. BullX primarily targets EVM chains and its Solana swap volume may not be tagged with referral data that Mobula can attribute. DEX volume for BullX across chains is tracked in bench 205."
+
+providers:
+ - slug: pump-fun
+ name: pump.fun
+ tag: "Solana meme launchpad, bonding curve"
+ formula: "24h swap volume in USD on pump.fun's bonding curve via pump.fun's own frontend (Mobula lighthouse byLaunchpad[name=PumpFun].volumeUSD.24h, updated every 15 min)."
+ queries:
+ p50: solana_platform_volume_24h_usd{platform="pump-fun"}
+ p90: solana_platform_volume_24h_usd{platform="pump-fun"}
+ p99: solana_platform_volume_24h_usd{platform="pump-fun"}
+ mean: solana_platform_volume_24h_usd{platform="pump-fun"}
+ success: solana_platform_health{platform="pump-fun"}
+ sample_size: solana_platform_fees_24h_usd{platform="pump-fun"}
+ series: solana_platform_volume_24h_usd{platform="pump-fun"}
+
+ - slug: gmgn
+ name: GMGN
+ tag: "AI trading terminal on Solana"
+ formula: "24h swap volume in USD attributed to GMGN (Mobula lighthouse byPlatform[name=GMGN].volumeUSD.24h, updated every 15 min)."
+ queries:
+ p50: solana_platform_volume_24h_usd{platform="gmgn"}
+ p90: solana_platform_volume_24h_usd{platform="gmgn"}
+ p99: solana_platform_volume_24h_usd{platform="gmgn"}
+ mean: solana_platform_volume_24h_usd{platform="gmgn"}
+ success: solana_platform_health{platform="gmgn"}
+ sample_size: solana_platform_fees_24h_usd{platform="gmgn"}
+ series: solana_platform_volume_24h_usd{platform="gmgn"}
+
+ - slug: axiom
+ name: Axiom
+ tag: "Web trading terminal on Solana"
+ formula: "24h swap volume in USD attributed to Axiom (Mobula lighthouse byPlatform[name=Axiom].volumeUSD.24h, updated every 15 min)."
+ queries:
+ p50: solana_platform_volume_24h_usd{platform="axiom"}
+ p90: solana_platform_volume_24h_usd{platform="axiom"}
+ p99: solana_platform_volume_24h_usd{platform="axiom"}
+ mean: solana_platform_volume_24h_usd{platform="axiom"}
+ success: solana_platform_health{platform="axiom"}
+ sample_size: solana_platform_fees_24h_usd{platform="axiom"}
+ series: solana_platform_volume_24h_usd{platform="axiom"}
+
+ - slug: fomo
+ name: Fomo
+ tag: "Solana trading platform"
+ formula: "24h swap volume in USD attributed to Fomo (Mobula lighthouse byPlatform[name=Fomo].volumeUSD.24h, updated every 15 min). Fees from DeFiLlama (dailyRevenue)."
+ queries:
+ p50: solana_platform_volume_24h_usd{platform="fomo"}
+ p90: solana_platform_volume_24h_usd{platform="fomo"}
+ p99: solana_platform_volume_24h_usd{platform="fomo"}
+ mean: solana_platform_volume_24h_usd{platform="fomo"}
+ success: solana_platform_health{platform="fomo"}
+ sample_size: defillama_dex_fees_24h_usd{platform="fomo"}
+ series: solana_platform_volume_24h_usd{platform="fomo"}
+
+ - slug: trojan
+ name: Trojan
+ tag: "Telegram trading bot on Solana"
+ formula: "24h swap volume in USD attributed to Trojan (Mobula lighthouse byPlatform[name=Trojan].volumeUSD.24h, updated every 15 min)."
+ queries:
+ p50: solana_platform_volume_24h_usd{platform="trojan"}
+ p90: solana_platform_volume_24h_usd{platform="trojan"}
+ p99: solana_platform_volume_24h_usd{platform="trojan"}
+ mean: solana_platform_volume_24h_usd{platform="trojan"}
+ success: solana_platform_health{platform="trojan"}
+ sample_size: solana_platform_fees_24h_usd{platform="trojan"}
+ series: solana_platform_volume_24h_usd{platform="trojan"}
+
+ - slug: padre
+ name: Padre
+ tag: "AI web trading terminal on Solana"
+ formula: "24h swap volume in USD attributed to Padre (Mobula lighthouse byPlatform[name=Padre].volumeUSD.24h, updated every 15 min)."
+ queries:
+ p50: solana_platform_volume_24h_usd{platform="padre"}
+ p90: solana_platform_volume_24h_usd{platform="padre"}
+ p99: solana_platform_volume_24h_usd{platform="padre"}
+ mean: solana_platform_volume_24h_usd{platform="padre"}
+ success: solana_platform_health{platform="padre"}
+ sample_size: solana_platform_fees_24h_usd{platform="padre"}
+ series: solana_platform_volume_24h_usd{platform="padre"}
+
+ - slug: photon
+ name: Photon
+ tag: "Solana trading terminal"
+ formula: "24h swap volume in USD attributed to Photon (Mobula lighthouse byPlatform[name=Photon].volumeUSD.24h, updated every 15 min)."
+ queries:
+ p50: solana_platform_volume_24h_usd{platform="photon"}
+ p90: solana_platform_volume_24h_usd{platform="photon"}
+ p99: solana_platform_volume_24h_usd{platform="photon"}
+ mean: solana_platform_volume_24h_usd{platform="photon"}
+ success: solana_platform_health{platform="photon"}
+ sample_size: solana_platform_fees_24h_usd{platform="photon"}
+ series: solana_platform_volume_24h_usd{platform="photon"}
+
+ - slug: maestro
+ name: Maestro
+ tag: "Telegram trading bot on Solana"
+ formula: "24h swap volume in USD attributed to Maestro (Mobula lighthouse byPlatform[name=Maestro].volumeUSD.24h, updated every 15 min)."
+ queries:
+ p50: solana_platform_volume_24h_usd{platform="maestro"}
+ p90: solana_platform_volume_24h_usd{platform="maestro"}
+ p99: solana_platform_volume_24h_usd{platform="maestro"}
+ mean: solana_platform_volume_24h_usd{platform="maestro"}
+ success: solana_platform_health{platform="maestro"}
+ sample_size: solana_platform_fees_24h_usd{platform="maestro"}
+ series: solana_platform_volume_24h_usd{platform="maestro"}
diff --git a/benchmarks/solana-unique-traders.yml b/benchmarks/solana-unique-traders.yml
new file mode 100644
index 00000000..bac5ac41
--- /dev/null
+++ b/benchmarks/solana-unique-traders.yml
@@ -0,0 +1,169 @@
+# OpenChainBench. Bench 207
+
+slug: solana-unique-traders
+number: "207"
+title: "Unique daily swap transactions on Solana: pump.fun vs GMGN vs Fomo vs Axiom vs Trojan, live"
+seo_title: "Unique daily swap transactions by Solana platform 2026: pump.fun vs GMGN vs Fomo vs Axiom vs Trojan"
+seo_description: "{{best_name}} leads Solana platforms with {{best_p50}} unique swap transactions in 24h. pump.fun vs GMGN vs Fomo vs Axiom vs Trojan ranked by daily trading activity, live from Dune Analytics."
+subtitle: Live count of unique swap transactions executed through each Solana trading platform in the last 24 hours. Refreshed hourly via Dune Analytics. Reveals which platforms drive the most trading activity and which serve concentrated whales vs broad retail flow.
+
+category: Trading
+status: live
+metric: Unique swap tx 24h
+unit: count
+higher_is_better: true
+
+disclaimer: |
+ Counts unique tx_ids per platform, not unique wallets. Actual unique-wallet count is 3-10x lower since active traders execute multiple transactions per day. Includes bots, MEV searchers, and multi-wallet traders. Terminals (GMGN, Axiom, Fomo, Trojan, Photon, Maestro) use fee wallet inflow detection; pump.fun uses Dune Spellbook (dex_solana.trades, project IN pumpdotfun/pumpswap) to avoid noise from fee-wallet micro-inflows at 0% fee.
+
+seo_intro: |
+ pump.fun leads Solana platforms by raw swap transaction count, driven by bonding-curve
+ launch activity and bot sniping on newly created tokens. Axiom ranks second among
+ trading terminals, reflecting its broad web-native user base. GMGN and Fomo each
+ attract hundreds of thousands of unique daily swap transactions, while Trojan and
+ Maestro serve a smaller but high-conviction Telegram-native segment.
+ pump.fun's count comes from Dune Spellbook (dex_solana.trades) covering both the
+ bonding-curve program (pumpdotfun) and the graduated PumpSwap AMM, making the
+ comparison directly comparable to terminal fee-wallet attribution. This benchmark
+ tracks unique daily swap transactions per platform live, updated hourly from
+ Dune Analytics on-chain data.
+
+abstract: |
+ The harness wraps the Dune Analytics API. A single saved query counts distinct
+ tx_id values per platform using two sources. For trading terminals (GMGN, Axiom,
+ Fomo, Trojan, Photon, Maestro): solana.account_activity where the platform fee
+ wallet received a native SOL inflow (post_balance > pre_balance) or a USDC/WSOL
+ token inflow (token_balance_owner match, post > pre) in the rolling 24h window.
+ For pump.fun: dex_solana.trades where project IN ('pumpdotfun', 'pumpswap') in
+ the rolling 24h window. This avoids the ~60% noise introduced by micro-SOL
+ inflows (rent, account creation) to pump.fun fee wallets after the 0% fee cut
+ on 2026-08-07. The harness re-executes the query every hour, polls for completion,
+ then updates the Prometheus gauge solana_platform_unique_traders_24h per platform.
+
+methodology:
+ - "Source: Dune Analytics (dune.com). Query updated hourly, results fetched every 15 min."
+ - "Terminals (GMGN, Axiom, Fomo, Trojan, Photon, Maestro): COUNT(DISTINCT tx_id) from solana.account_activity WHERE fee wallet received SOL inflow (address match) or USDC/WSOL inflow (token_balance_owner match) in rolling 24h window."
+ - "pump.fun: COUNT(DISTINCT tx_id) from dex_solana.trades WHERE project IN ('pumpdotfun', 'pumpswap') in rolling 24h window. Covers bonding-curve swaps and graduated PumpSwap AMM trades."
+ - "pump.fun switched from fee wallet to dex_solana.trades on 2026-08-13. The fee wallet approach overcounted by ~60% after the 0% bonding-curve fee cut (2026-08-07), as micro-SOL inflows from rent and account creation triggered false positives on every swap tx."
+ - "Metric represents unique swap transactions per platform. Actual unique-wallet count is 3-10x lower since active traders (and bots) execute multiple transactions per day."
+ - "Bot inclusion: all transactions counted including automated traders, sniping bots, and copy-trade bots."
+ - "Double-count across platforms: a tx touching two fee wallets is counted in both. Counts are not mutually exclusive."
+
+findings:
+ - "{{best_name}} leads Solana platforms with {{best_p50}} unique daily swap transactions."
+ - "pump.fun dominates by raw swap count driven by bonding-curve launch activity and bot sniping; its 6M daily txs come from dex_solana.trades (pumpdotfun + pumpswap), comparable to terminal fee-wallet attribution."
+ - "Axiom leads trading terminals on unique swap tx count, reflecting its broad web-native user base and high-frequency retail flow."
+ - "Trojan and Maestro serve a smaller but highly active Telegram-native segment, with fewer unique transactions but higher average trade size."
+ - "Unique swap tx and volume rankings diverge: Axiom leads terminals on tx count while Photon and Trojan serve more concentrated user bases with higher average position sizes."
+
+source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-unique-traders
+
+prometheus:
+ window: 24h
+ freshness_metric: solana_platform_unique_traders_24h
+
+faq:
+ - q: "What does unique daily swap transactions measure?"
+ a: "Unique daily swap transactions counts the number of distinct tx_ids per platform in the rolling 24-hour window. For terminals it detects fee wallet inflows; for pump.fun it uses dex_solana.trades (pumpdotfun + pumpswap). Each tx_id represents one swap event. Actual unique-wallet count is 3-10x lower since active traders and bots execute many transactions per day."
+ - q: "Why does pump.fun have so many more transactions than GMGN?"
+ a: "pump.fun's bonding curve launch mechanic triggers massive bot sniping activity as automated traders race to buy newly created tokens in the first seconds of each launch. GMGN serves a more deliberate, power-trader segment with larger positions and fewer but higher-value transactions."
+ - q: "Is this apple-to-apple between pump.fun and the others?"
+ a: "Approximately yes. Both measure COUNT(DISTINCT tx_id) over 24h: terminals via fee wallet inflow detection (all trades generate a fee inflow), pump.fun via dex_solana.trades Spellbook (all indexed swap events). The methodological difference is the data source, not the underlying event type. Both capture real on-chain swap activity."
+ - q: "Why did you switch pump.fun from fee wallets to dex_solana.trades?"
+ a: "After pump.fun cut bonding-curve trading fees to 0% on 2026-08-07, its fee wallets still appeared in every bonding-curve tx as required program accounts, with micro-SOL balance changes from rent and account-creation side-effects. This inflated the fee-wallet count by ~60% (9.7M vs 6M real swaps). dex_solana.trades indexes actual swap events via the Dune Spellbook, eliminating the noise."
+ - q: "How is Fomo tracked given its off-chain relay fees?"
+ a: "Fomo collects per-trade fees in USDC to a protocol-owned token account. The Dune query matches on token_balance_owner = Fomo's fee wallet in solana.account_activity with USDC mint and positive balance change, capturing on-chain per-trade fee inflows. Fomo's off-chain relay fee component is not counted here."
+ - q: "How is this different from bench 201 (24h volume)?"
+ a: "Bench 201 ranks platforms by 24h swap volume in USD. This bench ranks by unique daily swap transactions. A platform can have high volume with few transactions (large trades) or high tx count with low volume (micro-trades and bots). Both metrics together reveal the full trader profile."
+
+providers:
+ - slug: pump-fun
+ name: pump.fun
+ tag: "Solana meme launchpad"
+ formula: "Unique tx_ids from dex_solana.trades WHERE project IN ('pumpdotfun','pumpswap') in the last 24h (Dune Spellbook, hourly). Covers bonding-curve swaps and graduated PumpSwap AMM."
+ queries:
+ p50: solana_platform_unique_traders_24h{platform="pump-fun"}
+ p90: solana_platform_unique_traders_24h{platform="pump-fun"}
+ p99: solana_platform_unique_traders_24h{platform="pump-fun"}
+ mean: solana_platform_unique_traders_24h{platform="pump-fun"}
+ success: solana_platform_trader_health{platform="pump-fun"}
+ sample_size: solana_platform_unique_traders_24h{platform="pump-fun"}
+ series: solana_platform_unique_traders_24h{platform="pump-fun"}
+
+ - slug: gmgn
+ name: GMGN
+ tag: "AI trading terminal on Solana"
+ formula: "Unique tx_ids where GMGN fee wallets received SOL or USDC/WSOL inflow in the last 24h (Dune: solana.account_activity, hourly refresh)."
+ queries:
+ p50: solana_platform_unique_traders_24h{platform="gmgn"}
+ p90: solana_platform_unique_traders_24h{platform="gmgn"}
+ p99: solana_platform_unique_traders_24h{platform="gmgn"}
+ mean: solana_platform_unique_traders_24h{platform="gmgn"}
+ success: solana_platform_trader_health{platform="gmgn"}
+ sample_size: solana_platform_unique_traders_24h{platform="gmgn"}
+ series: solana_platform_unique_traders_24h{platform="gmgn"}
+
+ - slug: axiom
+ name: Axiom
+ tag: "Web trading terminal on Solana"
+ formula: "Unique tx_ids where Axiom fee wallets received SOL inflow in the last 24h (Dune: solana.account_activity, hourly refresh)."
+ queries:
+ p50: solana_platform_unique_traders_24h{platform="axiom"}
+ p90: solana_platform_unique_traders_24h{platform="axiom"}
+ p99: solana_platform_unique_traders_24h{platform="axiom"}
+ mean: solana_platform_unique_traders_24h{platform="axiom"}
+ success: solana_platform_trader_health{platform="axiom"}
+ sample_size: solana_platform_unique_traders_24h{platform="axiom"}
+ series: solana_platform_unique_traders_24h{platform="axiom"}
+
+ - slug: fomo
+ name: Fomo
+ tag: "Solana mobile trading app"
+ formula: "Unique tx_ids where Fomo's fee wallet (token_balance_owner match) received USDC inflow in the last 24h (Dune: solana.account_activity, hourly). On-chain Jupiter referral fees only."
+ queries:
+ p50: solana_platform_unique_traders_24h{platform="fomo"}
+ p90: solana_platform_unique_traders_24h{platform="fomo"}
+ p99: solana_platform_unique_traders_24h{platform="fomo"}
+ mean: solana_platform_unique_traders_24h{platform="fomo"}
+ success: solana_platform_trader_health{platform="fomo"}
+ sample_size: solana_platform_unique_traders_24h{platform="fomo"}
+ series: solana_platform_unique_traders_24h{platform="fomo"}
+
+ - slug: trojan
+ name: Trojan
+ tag: "Telegram trading bot on Solana"
+ formula: "Unique tx_ids where Trojan fee wallets received SOL inflow in the last 24h (Dune: solana.account_activity, hourly refresh)."
+ queries:
+ p50: solana_platform_unique_traders_24h{platform="trojan"}
+ p90: solana_platform_unique_traders_24h{platform="trojan"}
+ p99: solana_platform_unique_traders_24h{platform="trojan"}
+ mean: solana_platform_unique_traders_24h{platform="trojan"}
+ success: solana_platform_trader_health{platform="trojan"}
+ sample_size: solana_platform_unique_traders_24h{platform="trojan"}
+ series: solana_platform_unique_traders_24h{platform="trojan"}
+
+ - slug: photon
+ name: Photon
+ tag: "Solana trading terminal"
+ formula: "Unique tx_ids where Photon fee wallet received SOL inflow in the last 24h (Dune: solana.account_activity, hourly refresh)."
+ queries:
+ p50: solana_platform_unique_traders_24h{platform="photon"}
+ p90: solana_platform_unique_traders_24h{platform="photon"}
+ p99: solana_platform_unique_traders_24h{platform="photon"}
+ mean: solana_platform_unique_traders_24h{platform="photon"}
+ success: solana_platform_trader_health{platform="photon"}
+ sample_size: solana_platform_unique_traders_24h{platform="photon"}
+ series: solana_platform_unique_traders_24h{platform="photon"}
+
+ - slug: maestro
+ name: Maestro
+ tag: "Telegram trading bot on Solana"
+ formula: "Unique tx_ids where Maestro fee wallets received SOL inflow in the last 24h (Dune: solana.account_activity, hourly refresh)."
+ queries:
+ p50: solana_platform_unique_traders_24h{platform="maestro"}
+ p90: solana_platform_unique_traders_24h{platform="maestro"}
+ p99: solana_platform_unique_traders_24h{platform="maestro"}
+ mean: solana_platform_unique_traders_24h{platform="maestro"}
+ success: solana_platform_trader_health{platform="maestro"}
+ sample_size: solana_platform_unique_traders_24h{platform="maestro"}
+ series: solana_platform_unique_traders_24h{platform="maestro"}
diff --git a/benchmarks/trading-app-execution.yml b/benchmarks/trading-app-execution.yml
new file mode 100644
index 00000000..227c77cd
--- /dev/null
+++ b/benchmarks/trading-app-execution.yml
@@ -0,0 +1,62 @@
+# OpenChainBench. Bench 204
+
+slug: trading-app-execution
+number: "204"
+title: "Trading app execution quality: Axiom vs GMGN vs Trojan vs Maestro, Solana + EVM"
+seo_title: "Solana trading bot execution quality 2026: Axiom, GMGN, Trojan, Maestro, pump.fun"
+seo_description: "Compare on-chain execution quality for Solana trading apps: avg platform fee, priority fee, Jito bundle rate, CU price. Axiom vs GMGN vs Trojan vs Maestro vs pump.fun vs Photon, measured passively from fee accounts, updated hourly."
+subtitle: Passive on-chain execution metrics for the top Solana trading apps and Telegram bots. Measured directly from fee-account transactions, no synthetic trades. Priority fee, Jito rate, CU price, and platform fee per transaction. EVM chain revenue (Ethereum, BSC, Base) on the chain tabs.
+
+category: Trading
+status: live
+metric: Avg platform fee
+unit: sol
+higher_is_better: false
+
+source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-exec
+
+abstract: |
+ The harness monitors the on-chain fee-receiver accounts for each trading app using
+ the Solana JSON-RPC getSignaturesForAddress method. For every incoming fee transaction,
+ it records the priority fee, compute units consumed, CU price, presence of a Jito tip,
+ and the SOL amount transferred to the fee account. Samples are bucketed by hour in
+ PostgreSQL and materialised every 5 minutes. No trades are simulated; the collector
+ reads only existing on-chain activity. For EVM chains (Ethereum, BSC, Base), a separate
+ collector tracks USDC and native ETH/BNB transfers to each platform's fee address using
+ eth_getLogs on public RPCs; no trace API required.
+
+methodology:
+ - "Solana source: Helius RPC getSignaturesForAddress on each platform's fee wallet. 100 transactions sampled per poll (every ~5 min)."
+ - "Priority fee: transaction fee minus the 5 000-lamport base fee per signature. Paid by the user to compete for block space."
+ - "CU price: priority fee divided by compute units consumed (microlamports/CU). p50/p95 from sampled CU data."
+ - "Jito rate: fraction of sampled transactions tipping one of the 8 official Jito tip accounts."
+ - "Platform fee: average SOL transferred to the platform's fee account per incoming transaction."
+ - "Tx count: total transactions at the fee account over the window, from raw getSignaturesForAddress counts (not just the 100-tx sample)."
+ - "EVM source: eth_getLogs for USDC (ERC-20) transfers + native ETH/BNB transfers to each platform's fee address. Base chain: USDC only (no free trace API for native)."
+ - "EVM cadence: block-by-block scanning, bootstrapped 30 days back on first run, then continuous."
+ - "All measurements are passive; no test transactions, no funded wallets, no on-chain footprint."
+
+findings:
+ - "Axiom leads Solana trading apps by transaction volume, processing over 400K fee transactions per 24h across its 20 fee wallets."
+ - "GMGN charges the highest average platform fee per transaction among actively-charging platforms, reflecting its higher-notional trader base."
+ - "Maestro has the highest Jito bundle rate, indicating MEV-sensitive routing for its Telegram bot users."
+ - "Photon has the highest CU price p95, showing aggressive compute-unit bidding during peak hours."
+ - "pump.fun moved to a fee-free model on August 7 2026; bonding-curve trading continues but the protocol no longer collects a per-swap fee in native SOL. PumpSwap protocol fees arrive as WSOL on ATA recipients and are not captured by this harness's SOL-native collector, so the platform fee row understates true pump.fun revenue."
+
+faq:
+ - q: "What is platform fee vs priority fee?"
+ a: "Priority fee is paid to Solana validators to compete for block space; it does not go to the trading app. Platform fee is the SOL transferred to the trading app's own fee wallet per transaction, which is the actual revenue the platform captures from each trade."
+ - q: "Why does Trojan show a near-zero Jito rate?"
+ a: "Trojan does not appear to use Jito MEV bundles for its standard execution path. This is consistent with its design as a Telegram bot that prioritises simplicity over MEV protection."
+ - q: "How is transaction count measured?"
+ a: "Tx count comes from getSignaturesForAddress on the platform's fee wallet(s), which gives the total number of transactions that sent fees to that address. This is the full population count, not a sample; the 100-tx sample is used only for fee amount and CU metrics."
+ - q: "Why are EVM revenues lower than Solana revenues?"
+ a: "Most platforms primarily target Solana users. EVM fee collection varies: some platforms charge mostly in native ETH/BNB via internal transactions which are not traceable without a trace API. The EVM figures reflect directly visible ERC-20 (USDC) and native transfers only."
+ - q: "How often is data updated?"
+ a: "Solana metrics update every 5 minutes (materialiser cycle). EVM revenue updates block-by-block continuously. The page reflects the state at the last materialiser run, shown in the table header."
+ - q: "Which platforms are tracked on EVM chains?"
+ a: "GMGN, Maestro, and Banana Gun are tracked on Ethereum. GMGN, Maestro, Axiom, and Banana Gun are tracked on BSC. GMGN, Maestro, and Banana Gun are tracked on Base. pump.fun Terminal is tracked on all three EVM chains."
+
+# No prometheus section -- this bench uses a custom page renderer
+# at src/app/benchmarks/trading-app-execution/page.tsx that reads
+# directly from the exec REST API (exec.openchainbench.com).
diff --git a/benchmarks/zetachain-rpc.yml b/benchmarks/zetachain-rpc.yml
index 9054761d..a7e540e2 100644
--- a/benchmarks/zetachain-rpc.yml
+++ b/benchmarks/zetachain-rpc.yml
@@ -34,7 +34,8 @@ methodology:
- "Chain scope: every query on this page is pinned to `chain=\"zetachain\"`. Provider coverage: 1 no-key endpoint (Thirdweb). BlockPI returned 404; AllThatNode DNS is dead."
findings:
- - "{{best_name}} currently leads free ZetaChain RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h), 1 measured provider."
+ - "Provider coverage is critically limited: only Thirdweb (`7000.rpc.thirdweb.com`) is configured, and its current success rate from our probes is below 1%; the endpoint responds to one-off requests but blocks sustained per-IP probing. Latency figures reflect the rare successful call and do not represent reliable availability. BlockPI and AllThatNode are dead for ZetaChain; no other free keyless endpoint was found operational at audit time."
+ - "Manual use of `7000.rpc.thirdweb.com` works for individual calls. The 0.5% probe success rate is an artifact of rate-limiting against our fixed Railway probe IPs, not a general outage."
faq:
- q: "What is the fastest free ZetaChain RPC right now?"
diff --git a/harnesses/aggregator-head-lag/scrape-cookie b/harnesses/aggregator-head-lag/scrape-cookie
new file mode 100755
index 00000000..585d7baf
Binary files /dev/null and b/harnesses/aggregator-head-lag/scrape-cookie differ
diff --git a/harnesses/aggregator-head-lag/test-utls b/harnesses/aggregator-head-lag/test-utls
new file mode 100755
index 00000000..549c8db9
Binary files /dev/null and b/harnesses/aggregator-head-lag/test-utls differ
diff --git a/harnesses/bridge-monitor/cmd/monitor/squid_bridge.go b/harnesses/bridge-monitor/cmd/monitor/squid_bridge.go
index afc962b8..e5772f69 100644
--- a/harnesses/bridge-monitor/cmd/monitor/squid_bridge.go
+++ b/harnesses/bridge-monitor/cmd/monitor/squid_bridge.go
@@ -169,7 +169,16 @@ func (s *SquidBridge) TestRoute(route TestRoute, amount, amountUsd float64, rawU
gasUsd += v
}
- costUsd := inUsd - outUsd + gasUsd
+ // Prefer fromAmountUSD - toAmountUSD as the all-in cost (captures spread + fees + gas
+ // as netted by the router). Fall back to explicit fee+gas sum when the USD amounts
+ // are identical (e.g. stable→stable where spot prices cancel out).
+ diffUsd := inUsd - outUsd
+ var costUsd float64
+ if diffUsd > 0.001 {
+ costUsd = diffUsd
+ } else {
+ costUsd = bridgeFeeUsd + gasUsd
+ }
if costUsd < 0 {
costUsd = 0
}
@@ -177,7 +186,7 @@ func (s *SquidBridge) TestRoute(route TestRoute, amount, amountUsd float64, rawU
if amountUsd > 0 {
costPct = (costUsd / amountUsd) * 100
}
- slippage := costUsd - bridgeFeeUsd - gasUsd
+ slippage := diffUsd - bridgeFeeUsd - gasUsd
if slippage < 0 {
slippage = 0
}
diff --git a/harnesses/evm-exec/Dockerfile b/harnesses/evm-exec/Dockerfile
new file mode 100644
index 00000000..4058acc2
--- /dev/null
+++ b/harnesses/evm-exec/Dockerfile
@@ -0,0 +1,13 @@
+FROM golang:1.24-alpine AS builder
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+RUN CGO_ENABLED=0 go build -o /app/collector ./cmd/collector
+RUN CGO_ENABLED=0 go build -o /app/materializer ./cmd/materializer
+
+FROM alpine:3.20
+RUN apk add --no-cache ca-certificates tzdata
+COPY --from=builder /app/collector /app/collector
+COPY --from=builder /app/materializer /app/materializer
+COPY migrations/ /app/migrations/
diff --git a/harnesses/evm-exec/cmd/collector/main.go b/harnesses/evm-exec/cmd/collector/main.go
new file mode 100644
index 00000000..b91fc079
--- /dev/null
+++ b/harnesses/evm-exec/cmd/collector/main.go
@@ -0,0 +1,377 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+ "time"
+
+ "github.com/ChainBench/OpenChainBench/harnesses/evm-exec/internal/platform"
+ "github.com/ChainBench/OpenChainBench/harnesses/evm-exec/internal/source"
+ "github.com/ChainBench/OpenChainBench/harnesses/evm-exec/internal/store"
+)
+
+var chainRPC map[string]string
+
+func main() {
+ chainRPC = map[string]string{
+ "ethereum": envOrDefault("ETH_RPC_URL", "https://eth.drpc.org"),
+ "bsc": bscRPC(),
+ "base": envOrDefault("BASE_RPC_URL", "https://base.drpc.org"),
+ "robinhood": envOrDefault("RH_RPC_URL", "https://rpc.mainnet.chain.robinhood.com"),
+ }
+
+ ctx := context.Background()
+ db, err := store.New(ctx, mustEnv("DATABASE_URL"))
+ if err != nil {
+ log.Fatalf("collector: %v", err)
+ }
+ defer db.Close()
+
+ etherscanKey := mustEnv("ETHERSCAN_API_KEY")
+
+ if err := bootstrap(ctx, db); err != nil {
+ log.Fatalf("collector: bootstrap: %v", err)
+ }
+ log.Printf("collector: ready")
+
+ for {
+ for plt, chains := range platform.PlatformConfig {
+ for chain, cfg := range chains {
+ if err := collectPlatform(ctx, db, etherscanKey, plt, chain, cfg); err != nil {
+ log.Printf("collector: %s/%s: %v", plt, chain, err)
+ }
+ }
+ }
+ time.Sleep(10 * time.Minute)
+ }
+}
+
+// bootstrap seeds cursors at (head - 30d) for each (platform, chain, asset) that has no cursor.
+func bootstrap(ctx context.Context, db *store.DB) error {
+ for plt, chains := range platform.PlatformConfig {
+ for chain, cfg := range chains {
+ rpc := chainRPC[chain]
+ head, err := source.HeadBlock(ctx, rpc)
+ if err != nil {
+ return err
+ }
+ bpd := platform.BlocksPerDay[chain]
+ start := uint64(0)
+ if head > bpd*uint64(cfg.BootstrapDays) {
+ start = head - bpd*uint64(cfg.BootstrapDays)
+ }
+
+ for _, token := range cfg.ERC20Tokens {
+ if err := db.SeedCursorIfAbsent(ctx, chain, plt, token, start); err != nil {
+ return err
+ }
+ }
+ if cfg.NativeEnabled {
+ if err := db.SeedCursorIfAbsent(ctx, chain, plt, "native", start); err != nil {
+ return err
+ }
+ }
+ log.Printf("collector: bootstrap %s/%s start=%d", plt, chain, start)
+ }
+ }
+ return nil
+}
+
+func collectPlatform(ctx context.Context, db *store.DB, etherscanKey, plt, chain string, cfg platform.EVMPlatform) error {
+ rpc := chainRPC[chain]
+ toBlock, err := finalityBlock(ctx, rpc, chain)
+ if err != nil {
+ return err
+ }
+
+ for _, token := range cfg.ERC20Tokens {
+ if err := collectERC20(ctx, db, rpc, plt, chain, cfg.FeeCollector, token, toBlock); err != nil {
+ log.Printf("collector: %s/%s ERC20 %s...: %v", plt, chain, token[:8], err)
+ }
+ }
+
+ if cfg.NativeEnabled {
+ switch chain {
+ case "ethereum":
+ if err := collectNativeEtherscan(ctx, db, etherscanKey, source.ChainIDEthereum, plt, chain, cfg.FeeCollector, toBlock); err != nil {
+ log.Printf("collector: %s/%s native ETH: %v", plt, chain, err)
+ }
+ case "base":
+ if err := collectNativeEtherscan(ctx, db, etherscanKey, source.ChainIDBase, plt, chain, cfg.FeeCollector, toBlock); err != nil {
+ log.Printf("collector: %s/%s native ETH on Base: %v", plt, chain, err)
+ }
+ case "bsc":
+ if err := collectNativeBSC(ctx, db, plt, chain, cfg.FeeCollector, toBlock); err != nil {
+ log.Printf("collector: %s/%s native BNB: %v", plt, chain, err)
+ }
+ case "robinhood":
+ if err := collectNativeBlockscout(ctx, db, plt, chain, cfg.FeeCollector, toBlock); err != nil {
+ log.Printf("collector: %s/%s native ETH (Blockscout): %v", plt, chain, err)
+ }
+ }
+ }
+ return nil
+}
+
+func collectERC20(ctx context.Context, db *store.DB, rpc, plt, chain, collector, token string, toBlock uint64) error {
+ cursor, _, err := db.GetCursor(ctx, chain, plt, token)
+ if err != nil {
+ return fmt.Errorf("get cursor: %w", err)
+ }
+ if cursor >= toBlock {
+ return nil
+ }
+ decimals := platform.TokenDecimals[token]
+
+ transfers, lastBlock, err := source.GetERC20TransfersAdaptive(ctx, rpc, token, collector, cursor+1, toBlock)
+ if err != nil {
+ return err
+ }
+
+ events := make([]store.EVMEvent, 0, len(transfers))
+ for _, t := range transfers {
+ var bt time.Time
+ if !t.BlockTime.IsZero() {
+ bt = t.BlockTime
+ _ = db.CacheBlockTime(ctx, chain, t.BlockNum, bt)
+ } else {
+ var err error
+ bt, err = resolveBlockTime(ctx, db, rpc, chain, t.BlockNum)
+ if err != nil {
+ continue
+ }
+ }
+ events = append(events, store.EVMEvent{
+ Chain: chain, TxHash: t.TxHash, BlockNum: t.BlockNum, BlockTime: bt,
+ Platform: plt, Asset: token, AmountRaw: t.Amount,
+ Decimals: decimals, EventKey: source.LogIndexKey(t.LogIndex),
+ })
+ }
+ if err := db.UpsertEvents(ctx, events); err != nil {
+ return err
+ }
+ if lastBlock > cursor {
+ _ = db.SaveCursor(ctx, chain, plt, token, lastBlock)
+ }
+ if len(events) > 0 {
+ log.Printf("collector: %s/%s ERC20 %d events block=%d", plt, chain, len(events), lastBlock)
+ }
+ return nil
+}
+
+func collectNativeEtherscan(ctx context.Context, db *store.DB, apiKey, chainID, plt, chain, collector string, toBlock uint64) error {
+ cursor, _, err := db.GetCursor(ctx, chain, plt, "native")
+ if err != nil {
+ return fmt.Errorf("get cursor: %w", err)
+ }
+
+ internalTxs, lastInt, err := source.GetEtherscanInternalTxs(ctx, apiKey, chainID, collector, cursor)
+ if err != nil {
+ return err
+ }
+ normalTxs, lastNorm, err := source.GetEtherscanNormalTxs(ctx, apiKey, chainID, collector, cursor)
+ if err != nil {
+ return err
+ }
+
+ rpc := chainRPC[chain]
+ var events []store.EVMEvent
+ for _, tx := range append(internalTxs, normalTxs...) {
+ if tx.BlockNum > toBlock {
+ continue
+ }
+ var bt time.Time
+ if !tx.BlockTime.IsZero() {
+ bt = tx.BlockTime
+ _ = db.CacheBlockTime(ctx, chain, tx.BlockNum, bt)
+ } else {
+ var err error
+ bt, err = resolveBlockTime(ctx, db, rpc, chain, tx.BlockNum)
+ if err != nil {
+ continue
+ }
+ }
+ events = append(events, store.EVMEvent{
+ Chain: chain, TxHash: tx.TxHash, BlockNum: tx.BlockNum, BlockTime: bt,
+ Platform: plt, Asset: "native", AmountRaw: tx.Amount,
+ Decimals: 18, EventKey: tx.EventKey,
+ })
+ }
+ if err := db.UpsertEvents(ctx, events); err != nil {
+ return err
+ }
+ if high := max64(lastInt, lastNorm); high > cursor {
+ _ = db.SaveCursor(ctx, chain, plt, "native", high)
+ }
+ if len(events) > 0 {
+ log.Printf("collector: %s/%s native ETH %d events", plt, chain, len(events))
+ }
+ return nil
+}
+
+func collectNativeBlockscout(ctx context.Context, db *store.DB, plt, chain, collector string, toBlock uint64) error {
+ cursor, _, err := db.GetCursor(ctx, chain, plt, "native")
+ if err != nil {
+ return fmt.Errorf("get cursor: %w", err)
+ }
+ if cursor >= toBlock {
+ return nil
+ }
+ rpc := chainRPC[chain]
+
+ internalTxs, lastInt, err := source.GetBlockscoutInternalTxs(ctx, source.BlockscoutRobinhood, collector, cursor)
+ if err != nil {
+ return err
+ }
+ normalTxs, lastNorm, err := source.GetBlockscoutNormalTxs(ctx, source.BlockscoutRobinhood, collector, cursor)
+ if err != nil {
+ return err
+ }
+
+ var events []store.EVMEvent
+ for _, tx := range append(internalTxs, normalTxs...) {
+ if tx.BlockNum > toBlock {
+ continue
+ }
+ var bt time.Time
+ if !tx.BlockTime.IsZero() {
+ bt = tx.BlockTime
+ _ = db.CacheBlockTime(ctx, chain, tx.BlockNum, bt)
+ } else {
+ bt, err = resolveBlockTime(ctx, db, rpc, chain, tx.BlockNum)
+ if err != nil {
+ continue
+ }
+ }
+ events = append(events, store.EVMEvent{
+ Chain: chain, TxHash: tx.TxHash, BlockNum: tx.BlockNum, BlockTime: bt,
+ Platform: plt, Asset: "native", AmountRaw: tx.Amount,
+ Decimals: 18, EventKey: tx.EventKey,
+ })
+ }
+ if err := db.UpsertEvents(ctx, events); err != nil {
+ return err
+ }
+ if high := max64(lastInt, lastNorm); high > cursor {
+ _ = db.SaveCursor(ctx, chain, plt, "native", high)
+ }
+ if len(events) > 0 {
+ log.Printf("collector: %s/%s native ETH (Blockscout) %d events block=%d", plt, chain, len(events), max64(lastInt, lastNorm))
+ }
+ return nil
+}
+
+func collectNativeBSC(ctx context.Context, db *store.DB, plt, chain, collector string, toBlock uint64) error {
+ cursor, _, err := db.GetCursor(ctx, chain, plt, "native")
+ if err != nil {
+ return fmt.Errorf("get cursor: %w", err)
+ }
+ if cursor >= toBlock {
+ return nil
+ }
+ rpc := chainRPC[chain]
+
+ transfers, err := source.GetNativeTransfers(ctx, rpc, collector, cursor+1, toBlock)
+ if err != nil {
+ return err
+ }
+
+ var events []store.EVMEvent
+ var highBlock uint64
+ for _, t := range transfers {
+ var bt time.Time
+ if !t.BlockTime.IsZero() {
+ bt = t.BlockTime
+ _ = db.CacheBlockTime(ctx, chain, t.BlockNum, bt)
+ } else {
+ var err error
+ bt, err = resolveBlockTime(ctx, db, rpc, chain, t.BlockNum)
+ if err != nil {
+ continue
+ }
+ }
+ events = append(events, store.EVMEvent{
+ Chain: chain, TxHash: t.TxHash, BlockNum: t.BlockNum, BlockTime: bt,
+ Platform: plt, Asset: "native", AmountRaw: t.Amount,
+ Decimals: 18, EventKey: t.EventKey,
+ })
+ if t.BlockNum > highBlock {
+ highBlock = t.BlockNum
+ }
+ }
+ if err := db.UpsertEvents(ctx, events); err != nil {
+ return err
+ }
+ if highBlock > cursor {
+ _ = db.SaveCursor(ctx, chain, plt, "native", highBlock)
+ }
+ if len(events) > 0 {
+ log.Printf("collector: %s/%s native BNB %d events block=%d", plt, chain, len(events), highBlock)
+ }
+ return nil
+}
+
+func resolveBlockTime(ctx context.Context, db *store.DB, rpc, chain string, blockNum uint64) (time.Time, error) {
+ if t, ok := db.GetBlockTime(ctx, chain, blockNum); ok {
+ return t, nil
+ }
+ t, err := source.BlockTime(ctx, rpc, blockNum)
+ if err != nil {
+ return time.Time{}, err
+ }
+ _ = db.CacheBlockTime(ctx, chain, blockNum, t)
+ return t, nil
+}
+
+func finalityBlock(ctx context.Context, rpc, chain string) (uint64, error) {
+ switch chain {
+ case "ethereum", "base":
+ return source.ResolveBlockTag(ctx, rpc, "finalized")
+ case "bsc":
+ head, err := source.HeadBlock(ctx, rpc)
+ if err != nil {
+ return 0, err
+ }
+ if head > 20 {
+ return head - 20, nil
+ }
+ return 0, nil
+ default:
+ return source.HeadBlock(ctx, rpc)
+ }
+}
+
+func bscRPC() string {
+ if v := os.Getenv("BSC_RPC_URL"); v != "" {
+ return v
+ }
+ key := os.Getenv("NODEREAL_BSC_KEY")
+ if key == "" {
+ log.Fatal("collector: NODEREAL_BSC_KEY or BSC_RPC_URL is required")
+ }
+ return "https://bsc-mainnet.nodereal.io/v1/" + key
+}
+
+func envOrDefault(key, def string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return def
+}
+
+func mustEnv(key string) string {
+ v := os.Getenv(key)
+ if v == "" {
+ log.Fatalf("missing env: %s", key)
+ }
+ return v
+}
+
+func max64(a, b uint64) uint64 {
+ if a > b {
+ return a
+ }
+ return b
+}
diff --git a/harnesses/evm-exec/cmd/materializer/main.go b/harnesses/evm-exec/cmd/materializer/main.go
new file mode 100644
index 00000000..ece79c69
--- /dev/null
+++ b/harnesses/evm-exec/cmd/materializer/main.go
@@ -0,0 +1,36 @@
+package main
+
+import (
+ "context"
+ "log"
+ "os"
+ "time"
+
+ "github.com/ChainBench/OpenChainBench/harnesses/evm-exec/internal/store"
+)
+
+func main() {
+ ctx := context.Background()
+ db, err := store.New(ctx, mustEnv("DATABASE_URL"))
+ if err != nil {
+ log.Fatalf("materializer: %v", err)
+ }
+ defer db.Close()
+
+ for {
+ if err := db.Materialize(ctx); err != nil {
+ log.Printf("materializer: %v", err)
+ } else {
+ log.Printf("materializer: done")
+ }
+ time.Sleep(5 * time.Minute)
+ }
+}
+
+func mustEnv(key string) string {
+ v := os.Getenv(key)
+ if v == "" {
+ log.Fatalf("missing env: %s", key)
+ }
+ return v
+}
diff --git a/harnesses/evm-exec/docker-compose.evm-exec.yml b/harnesses/evm-exec/docker-compose.evm-exec.yml
new file mode 100644
index 00000000..de33a5f0
--- /dev/null
+++ b/harnesses/evm-exec/docker-compose.evm-exec.yml
@@ -0,0 +1,36 @@
+# Fragment to merge into /opt/ocb/docker-compose.yml on VPS.
+# Requires: ocb-postgres (from docker-compose.apps.yml).
+# Env: /run/ocb/.env.evm-exec
+# DATABASE_URL, ETHERSCAN_API_KEY, NODEREAL_BSC_KEY
+# Optional: ETH_RPC_URL (default https://eth.drpc.org)
+# BASE_RPC_URL (default https://base.drpc.org)
+# BSC_RPC_URL (overrides NODEREAL_BSC_KEY)
+
+services:
+ evm-exec-collector:
+ build: /opt/ocb/harnesses/evm-exec
+ container_name: ocb-evm-exec-collector
+ restart: unless-stopped
+ env_file: /run/ocb/.env.evm-exec
+ command: ["/app/collector"]
+ networks: [web]
+ depends_on:
+ postgres:
+ condition: service_healthy
+ logging:
+ driver: json-file
+ options: { max-size: "20m", max-file: "5" }
+
+ evm-exec-materializer:
+ build: /opt/ocb/harnesses/evm-exec
+ container_name: ocb-evm-exec-materializer
+ restart: unless-stopped
+ env_file: /run/ocb/.env.evm-exec
+ command: ["/app/materializer"]
+ networks: [web]
+ depends_on:
+ postgres:
+ condition: service_healthy
+ logging:
+ driver: json-file
+ options: { max-size: "10m", max-file: "3" }
diff --git a/harnesses/evm-exec/go.mod b/harnesses/evm-exec/go.mod
new file mode 100644
index 00000000..437438a5
--- /dev/null
+++ b/harnesses/evm-exec/go.mod
@@ -0,0 +1,14 @@
+module github.com/ChainBench/OpenChainBench/harnesses/evm-exec
+
+go 1.24.0
+
+require github.com/jackc/pgx/v5 v5.7.2
+
+require (
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ golang.org/x/crypto v0.31.0 // indirect
+ golang.org/x/sync v0.10.0 // indirect
+ golang.org/x/text v0.21.0 // indirect
+)
diff --git a/harnesses/evm-exec/go.sum b/harnesses/evm-exec/go.sum
new file mode 100644
index 00000000..731b5dfd
--- /dev/null
+++ b/harnesses/evm-exec/go.sum
@@ -0,0 +1,28 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
+github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
+golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/harnesses/evm-exec/internal/platform/platforms.go b/harnesses/evm-exec/internal/platform/platforms.go
new file mode 100644
index 00000000..098100b9
--- /dev/null
+++ b/harnesses/evm-exec/internal/platform/platforms.go
@@ -0,0 +1,199 @@
+package platform
+
+import (
+ "strings"
+)
+
+// EVMPlatform describes what to monitor for one platform on one chain.
+type EVMPlatform struct {
+ FeeCollector string // lowercase 42-char 0x address
+ NativeEnabled bool // collect native asset (ETH / BNB)
+ ERC20Tokens []string // lowercase ERC-20 contracts to monitor
+ BootstrapDays int // how many days back to seed cursor on first run
+}
+
+// USDC contract addresses, lowercase.
+const (
+ USDC_ETH = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" // 6 decimals
+ USDC_BSC = "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d" // 18 decimals (Binance-Peg)
+ USDC_BASE = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" // 6 decimals
+ USDG_ROBINHOOD = "0x5fc5360d0400a0fd4f2af552add042d716f1d168" // 6 decimals, Robinhood Chain stablecoin
+)
+
+// TokenDecimals maps known token addresses to their decimal places.
+var TokenDecimals = map[string]int{
+ USDC_ETH: 6,
+ USDC_BSC: 18,
+ USDC_BASE: 6,
+ USDG_ROBINHOOD: 6,
+}
+
+// NativeSymbol returns the native asset symbol for a chain.
+func NativeSymbol(chain string) string {
+ if chain == "bsc" {
+ return "BNB"
+ }
+ return "ETH"
+}
+
+// BlocksPerDay is the approximate number of blocks produced per day per chain.
+var BlocksPerDay = map[string]uint64{
+ "ethereum": 7_200,
+ "bsc": 28_800,
+ "base": 43_200,
+ "robinhood": 864_000, // ~10 blocks/second (Arbitrum Orbit)
+}
+
+// PlatformConfig maps platform → chain → configuration.
+var PlatformConfig = map[string]map[string]EVMPlatform{
+ // pump.fun Terminal (acquired Terminal.fun/Padre Oct 2025) — confirmed via DeFiLlama
+ "pumpfun": {
+ "ethereum": {
+ FeeCollector: "0xa74fa823bc8617fa320a966b3d11b0f722ef09ee",
+ NativeEnabled: true,
+ ERC20Tokens: []string{USDC_ETH},
+ BootstrapDays: 30,
+ },
+ "bsc": {
+ FeeCollector: "0x2b0a28a0a9197f8af5d1b8371c048e92dd78b640",
+ NativeEnabled: true,
+ ERC20Tokens: []string{USDC_BSC},
+ BootstrapDays: 30,
+ },
+ "base": {
+ FeeCollector: "0x16388de42c5829fd0e88c8eb001ef43bfc93f177",
+ NativeEnabled: true, // Etherscan V2 chainid=8453, same ETHERSCAN_API_KEY
+ ERC20Tokens: []string{USDC_BASE},
+ BootstrapDays: 30,
+ },
+ },
+ // Maestro — confirmed via DeFiLlama fees/maestro adapter, same address all EVM chains
+ "maestro": {
+ "ethereum": {
+ FeeCollector: "0xb0999731f7c2581844658a9d2ced1be0077b7397",
+ NativeEnabled: true,
+ ERC20Tokens: []string{USDC_ETH},
+ BootstrapDays: 30,
+ },
+ "bsc": {
+ FeeCollector: "0xb0999731f7c2581844658a9d2ced1be0077b7397",
+ NativeEnabled: true,
+ ERC20Tokens: []string{USDC_BSC},
+ BootstrapDays: 30,
+ },
+ "base": {
+ FeeCollector: "0xb0999731f7c2581844658a9d2ced1be0077b7397",
+ NativeEnabled: true, // Etherscan V2 chainid=8453
+ ERC20Tokens: []string{USDC_BASE},
+ BootstrapDays: 30,
+ },
+ },
+ // Banana Gun — these addresses are routers, not pure fee wallets.
+ // Native disabled on all chains: internal ETH/BNB = trade principal, not fees.
+ // USDC inflows are also router throughput; evmKey=null in frontend so nothing is displayed.
+ // Proper fix: eth_getLogs on topic 0x72015ace… for actual fee events.
+ "banana-gun": {
+ "ethereum": {
+ FeeCollector: "0x3328f7f4a1d1c57c35df56bbf0c9dcafca309c49",
+ NativeEnabled: false,
+ ERC20Tokens: []string{USDC_ETH},
+ BootstrapDays: 30,
+ },
+ "bsc": {
+ FeeCollector: "0x461efe0100be0682545972ebfc8b4a13253bd602",
+ NativeEnabled: false,
+ ERC20Tokens: []string{USDC_BSC},
+ BootstrapDays: 30,
+ },
+ "base": {
+ FeeCollector: "0x1fba6b0bbae2b74586fba407fb45bd4788b7b130",
+ NativeEnabled: false, // was incorrectly true; same router logic applies on Base
+ ERC20Tokens: []string{USDC_BASE},
+ BootstrapDays: 30,
+ },
+ },
+ "gmgn": {
+ "ethereum": {
+ FeeCollector: "0xb8159ba378904f803639d274cec79f788931c9c8",
+ NativeEnabled: true,
+ ERC20Tokens: []string{USDC_ETH},
+ BootstrapDays: 30,
+ },
+ "bsc": {
+ FeeCollector: "0xb8159ba378904f803639d274cec79f788931c9c8",
+ NativeEnabled: true,
+ ERC20Tokens: []string{USDC_BSC},
+ BootstrapDays: 30,
+ },
+ "base": {
+ FeeCollector: "0xb8159ba378904f803639d274cec79f788931c9c8",
+ NativeEnabled: true, // Etherscan V2 chainid=8453
+ ERC20Tokens: []string{USDC_BASE},
+ BootstrapDays: 30,
+ },
+ },
+ // Axiom — confirmed via DeFiLlama fees/axiom adapter (feeReceiver field, start 2026-01-25)
+ "axiom": {
+ "bsc": {
+ FeeCollector: "0xdec29d79e8cdf009d2fa33e0558cb5648481cac3",
+ NativeEnabled: true,
+ ERC20Tokens: []string{USDC_BSC},
+ BootstrapDays: 30,
+ },
+ },
+ // GMGN + Maestro on Robinhood Chain (Arbitrum Orbit L2, chainId=4663)
+ // Same fee-collector addresses as ETH/BSC; stablecoin is USDG (Robinhood's native stable).
+ // Native ETH collected via Blockscout (robinhoodchain.blockscout.com/api, txlistinternal).
+ "gmgn-robinhood": {
+ "robinhood": {
+ FeeCollector: "0xb8159ba378904f803639d274cec79f788931c9c8",
+ NativeEnabled: true,
+ ERC20Tokens: []string{USDG_ROBINHOOD},
+ BootstrapDays: 30,
+ },
+ },
+ "maestro-robinhood": {
+ "robinhood": {
+ FeeCollector: "0xb0999731f7c2581844658a9d2ced1be0077b7397",
+ NativeEnabled: true,
+ ERC20Tokens: []string{USDG_ROBINHOOD},
+ BootstrapDays: 30,
+ },
+ },
+}
+
+// Coverage returns "full" if native is enabled for this platform+chain, else "stable-only".
+func Coverage(platform, chain string) string {
+ chains, ok := PlatformConfig[platform]
+ if !ok {
+ return "stable-only"
+ }
+ cfg, ok := chains[chain]
+ if !ok {
+ return "stable-only"
+ }
+ if cfg.NativeEnabled {
+ return "full"
+ }
+ return "stable-only"
+}
+
+func init() {
+ for plt, chains := range PlatformConfig {
+ for chain, cfg := range chains {
+ mustAddr(cfg.FeeCollector, plt+"/"+chain+"/FeeCollector")
+ for _, t := range cfg.ERC20Tokens {
+ mustAddr(t, plt+"/"+chain+"/ERC20Token")
+ }
+ }
+ }
+ for addr := range TokenDecimals {
+ mustAddr(addr, "TokenDecimals key")
+ }
+}
+
+func mustAddr(a, label string) {
+ if len(a) != 42 || !strings.HasPrefix(a, "0x") || a != strings.ToLower(a) {
+ panic("invalid address in platform config [" + label + "]: " + a)
+ }
+}
diff --git a/harnesses/evm-exec/internal/source/blockscout.go b/harnesses/evm-exec/internal/source/blockscout.go
new file mode 100644
index 00000000..06ca775f
--- /dev/null
+++ b/harnesses/evm-exec/internal/source/blockscout.go
@@ -0,0 +1,289 @@
+package source
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "math/big"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+// Blockscout Etherscan-compatible API for chains that have a Blockscout explorer
+// but are not supported by Etherscan V2.
+const (
+ BlockscoutRobinhood = "https://robinhoodchain.blockscout.com/api"
+)
+
+// blockscoutTx matches the Blockscout txlistinternal result shape.
+type blockscoutTx struct {
+ TransactionHash string `json:"transactionHash"`
+ BlockNumber string `json:"blockNumber"`
+ TimeStamp string `json:"timeStamp"`
+ Value string `json:"value"`
+ To string `json:"to"`
+ IsError string `json:"isError"`
+ Index string `json:"index"` // trace position within the tx (event key)
+}
+
+// blockscoutNormalTx matches the Blockscout txlist (normal/external tx) result shape.
+// Field names differ from txlistinternal: hash vs transactionHash, no index.
+type blockscoutNormalTx struct {
+ Hash string `json:"hash"`
+ BlockNumber string `json:"blockNumber"`
+ TimeStamp string `json:"timeStamp"`
+ Value string `json:"value"`
+ To string `json:"to"`
+ IsError string `json:"isError"`
+}
+
+// GetBlockscoutInternalTxs fetches internal ETH transfers to address via Blockscout.
+// apiURL is the chain-specific Blockscout API base (e.g. blockscoutRobinhood).
+func GetBlockscoutInternalTxs(ctx context.Context, apiURL, address string, startBlock uint64) ([]NativeTx, uint64, error) {
+ const offset = 1000
+ const maxPage = 20 // 20k results max before we advance the window
+
+ addrLower := strings.ToLower(address)
+ var all []NativeTx
+ highestBlock := startBlock
+ curStart := startBlock
+
+ for {
+ var windowTxs []NativeTx
+ hitCap := false
+
+ for page := 1; page <= maxPage; page++ {
+ time.Sleep(300 * time.Millisecond)
+
+ params := url.Values{
+ "module": {"account"},
+ "action": {"txlistinternal"},
+ "address": {address},
+ "startblock": {fmt.Sprintf("%d", curStart)},
+ "endblock": {"99999999"},
+ "page": {fmt.Sprintf("%d", page)},
+ "offset": {fmt.Sprintf("%d", offset)},
+ "sort": {"asc"},
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL+"?"+params.Encode(), nil)
+ if err != nil {
+ return all, highestBlock, err
+ }
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return all, highestBlock, fmt.Errorf("blockscout page %d: %w", page, err)
+ }
+
+ var out struct {
+ Status string `json:"status"`
+ Message string `json:"message"`
+ Result json.RawMessage `json:"result"`
+ }
+ decErr := json.NewDecoder(resp.Body).Decode(&out)
+ resp.Body.Close()
+ if decErr != nil {
+ return all, highestBlock, fmt.Errorf("blockscout decode: %w", decErr)
+ }
+
+ // Blockscout status "0" = error/empty, "1" = full results, "2" = partial (still valid).
+ if out.Status == "0" {
+ break
+ }
+ if out.Status != "1" && out.Status != "2" {
+ var msg string
+ json.Unmarshal(out.Result, &msg) //nolint:errcheck
+ return all, highestBlock, fmt.Errorf("blockscout: %s %s", out.Message, msg)
+ }
+
+ var txs []blockscoutTx
+ if err := json.Unmarshal(out.Result, &txs); err != nil {
+ break
+ }
+
+ for _, tx := range txs {
+ if tx.IsError == "1" || tx.Value == "" || tx.Value == "0" {
+ continue
+ }
+ if strings.ToLower(tx.To) != addrLower {
+ continue
+ }
+ amt, err := new(big.Int).SetString(tx.Value, 10)
+ if !err || amt.Sign() <= 0 {
+ continue
+ }
+ blockNum, parseErr := parseDecU64(tx.BlockNumber)
+ if parseErr != nil {
+ continue
+ }
+ var bt time.Time
+ if ts, tsErr := parseDecU64(tx.TimeStamp); tsErr == nil && ts > 0 {
+ bt = time.Unix(int64(ts), 0).UTC()
+ }
+ ntx := NativeTx{
+ TxHash: strings.ToLower(tx.TransactionHash),
+ BlockNum: blockNum,
+ BlockTime: bt,
+ Amount: amt,
+ EventKey: tx.Index,
+ }
+ windowTxs = append(windowTxs, ntx)
+ if blockNum > highestBlock {
+ highestBlock = blockNum
+ }
+ }
+
+ if len(txs) < offset {
+ break
+ }
+ if page == maxPage {
+ hitCap = true
+ break
+ }
+ }
+
+ all = append(all, windowTxs...)
+
+ if !hitCap || len(windowTxs) == 0 {
+ break
+ }
+
+ var lastBlock uint64
+ for _, tx := range windowTxs {
+ if tx.BlockNum > lastBlock {
+ lastBlock = tx.BlockNum
+ }
+ }
+ if lastBlock <= curStart {
+ break
+ }
+ curStart = lastBlock
+ }
+
+ return all, highestBlock, nil
+}
+
+// GetBlockscoutNormalTxs fetches direct ETH transfers (tx.value > 0, to == address) via txlist.
+// Complements GetBlockscoutInternalTxs: direct EOA sends don't appear in txlistinternal.
+func GetBlockscoutNormalTxs(ctx context.Context, apiURL, address string, startBlock uint64) ([]NativeTx, uint64, error) {
+ const offset = 1000
+ const maxPage = 20
+
+ addrLower := strings.ToLower(address)
+ var all []NativeTx
+ highestBlock := startBlock
+ curStart := startBlock
+
+ for {
+ var windowTxs []NativeTx
+ hitCap := false
+
+ for page := 1; page <= maxPage; page++ {
+ time.Sleep(300 * time.Millisecond)
+
+ params := url.Values{
+ "module": {"account"},
+ "action": {"txlist"},
+ "address": {address},
+ "startblock": {fmt.Sprintf("%d", curStart)},
+ "endblock": {"99999999"},
+ "page": {fmt.Sprintf("%d", page)},
+ "offset": {fmt.Sprintf("%d", offset)},
+ "sort": {"asc"},
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL+"?"+params.Encode(), nil)
+ if err != nil {
+ return all, highestBlock, err
+ }
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return all, highestBlock, fmt.Errorf("blockscout txlist page %d: %w", page, err)
+ }
+
+ var out struct {
+ Status string `json:"status"`
+ Message string `json:"message"`
+ Result json.RawMessage `json:"result"`
+ }
+ decErr := json.NewDecoder(resp.Body).Decode(&out)
+ resp.Body.Close()
+ if decErr != nil {
+ return all, highestBlock, fmt.Errorf("blockscout txlist decode: %w", decErr)
+ }
+
+ if out.Status == "0" {
+ break
+ }
+ if out.Status != "1" && out.Status != "2" {
+ break
+ }
+
+ var txs []blockscoutNormalTx
+ if err := json.Unmarshal(out.Result, &txs); err != nil {
+ break
+ }
+
+ for _, tx := range txs {
+ if tx.IsError == "1" || tx.Value == "" || tx.Value == "0" {
+ continue
+ }
+ if strings.ToLower(tx.To) != addrLower {
+ continue
+ }
+ amt, ok := new(big.Int).SetString(tx.Value, 10)
+ if !ok || amt.Sign() <= 0 {
+ continue
+ }
+ blockNum, parseErr := parseDecU64(tx.BlockNumber)
+ if parseErr != nil {
+ continue
+ }
+ var bt time.Time
+ if ts, tsErr := parseDecU64(tx.TimeStamp); tsErr == nil && ts > 0 {
+ bt = time.Unix(int64(ts), 0).UTC()
+ }
+ ntx := NativeTx{
+ TxHash: strings.ToLower(tx.Hash),
+ BlockNum: blockNum,
+ BlockTime: bt,
+ Amount: amt,
+ EventKey: "top", // direct tx value, not a trace
+ }
+ windowTxs = append(windowTxs, ntx)
+ if blockNum > highestBlock {
+ highestBlock = blockNum
+ }
+ }
+
+ if len(txs) < offset {
+ break
+ }
+ if page == maxPage {
+ hitCap = true
+ break
+ }
+ }
+
+ all = append(all, windowTxs...)
+
+ if !hitCap || len(windowTxs) == 0 {
+ break
+ }
+
+ var lastBlock uint64
+ for _, tx := range windowTxs {
+ if tx.BlockNum > lastBlock {
+ lastBlock = tx.BlockNum
+ }
+ }
+ if lastBlock <= curStart {
+ break
+ }
+ curStart = lastBlock
+ }
+
+ return all, highestBlock, nil
+}
diff --git a/harnesses/evm-exec/internal/source/blocktime.go b/harnesses/evm-exec/internal/source/blocktime.go
new file mode 100644
index 00000000..83386ed5
--- /dev/null
+++ b/harnesses/evm-exec/internal/source/blocktime.go
@@ -0,0 +1,110 @@
+package source
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+)
+
+var httpClient = &http.Client{Timeout: 20 * time.Second}
+
+// HeadBlock returns the current head block number via eth_blockNumber.
+func HeadBlock(ctx context.Context, rpcURL string) (uint64, error) {
+ body, _ := json.Marshal(map[string]any{
+ "jsonrpc": "2.0", "id": 1,
+ "method": "eth_blockNumber", "params": []any{},
+ })
+ var out struct {
+ Result string `json:"result"`
+ Error *struct{ Message string } `json:"error"`
+ }
+ if err := RpcPost(ctx, rpcURL, body, &out); err != nil {
+ return 0, err
+ }
+ if out.Error != nil {
+ return 0, fmt.Errorf("eth_blockNumber: %s", out.Error.Message)
+ }
+ return ParseHex64(out.Result)
+}
+
+// BlockTime fetches a block's unix timestamp via eth_getBlockByNumber(n, false).
+func BlockTime(ctx context.Context, rpcURL string, blockNum uint64) (time.Time, error) {
+ body, _ := json.Marshal(map[string]any{
+ "jsonrpc": "2.0", "id": 1,
+ "method": "eth_getBlockByNumber",
+ "params": []any{fmt.Sprintf("0x%x", blockNum), false},
+ })
+ var out struct {
+ Result *struct {
+ Timestamp string `json:"timestamp"`
+ } `json:"result"`
+ Error *struct{ Message string } `json:"error"`
+ }
+ if err := RpcPost(ctx, rpcURL, body, &out); err != nil {
+ return time.Time{}, err
+ }
+ if out.Error != nil {
+ return time.Time{}, fmt.Errorf("eth_getBlockByNumber: %s", out.Error.Message)
+ }
+ if out.Result == nil {
+ return time.Time{}, fmt.Errorf("null block %d", blockNum)
+ }
+ ts, err := ParseHex64(out.Result.Timestamp)
+ if err != nil {
+ return time.Time{}, err
+ }
+ return time.Unix(int64(ts), 0).UTC(), nil
+}
+
+// ResolveBlockTag resolves an eth block tag ("finalized", "latest") to a block number.
+func ResolveBlockTag(ctx context.Context, rpcURL, tag string) (uint64, error) {
+ body, _ := json.Marshal(map[string]any{
+ "jsonrpc": "2.0", "id": 1,
+ "method": "eth_getBlockByNumber",
+ "params": []any{tag, false},
+ })
+ var out struct {
+ Result *struct {
+ Number string `json:"number"`
+ } `json:"result"`
+ Error *struct{ Message string } `json:"error"`
+ }
+ if err := RpcPost(ctx, rpcURL, body, &out); err != nil {
+ return 0, err
+ }
+ if out.Error != nil {
+ return 0, fmt.Errorf("eth_getBlockByNumber %s: %s", tag, out.Error.Message)
+ }
+ if out.Result == nil {
+ return 0, fmt.Errorf("null block for tag %q", tag)
+ }
+ return ParseHex64(out.Result.Number)
+}
+
+// RpcPost marshals body, POSTs to rpcURL with Content-Type: application/json, decodes into dst.
+func RpcPost(ctx context.Context, rpcURL string, body []byte, dst any) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, rpcURL, bytes.NewReader(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("HTTP %d from %s", resp.StatusCode, rpcURL)
+ }
+ return json.NewDecoder(resp.Body).Decode(dst)
+}
+
+// ParseHex64 parses a 0x-prefixed or bare hex string to uint64.
+func ParseHex64(s string) (uint64, error) {
+ return strconv.ParseUint(strings.TrimPrefix(s, "0x"), 16, 64)
+}
diff --git a/harnesses/evm-exec/internal/source/etherscan.go b/harnesses/evm-exec/internal/source/etherscan.go
new file mode 100644
index 00000000..7790bed8
--- /dev/null
+++ b/harnesses/evm-exec/internal/source/etherscan.go
@@ -0,0 +1,210 @@
+package source
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "math/big"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+const (
+ etherscanV2 = "https://api.etherscan.io/v2/api"
+ ChainIDEthereum = "1"
+ ChainIDBase = "8453" // Base via Etherscan V2 (same key as Ethereum, no separate BaseScan key)
+)
+
+// NativeTx is an ETH transfer (internal or normal) to a monitored address.
+type NativeTx struct {
+ TxHash string
+ BlockNum uint64
+ BlockTime time.Time // from Etherscan timeStamp, avoids eth_getBlockByNumber
+ Amount *big.Int // wei, never divided
+ EventKey string // traceId for internal txs, "" for normal value txs
+}
+
+type etherscanTx struct {
+ Hash string `json:"hash"`
+ BlockNumber string `json:"blockNumber"`
+ TimeStamp string `json:"timeStamp"` // unix seconds as decimal string
+ Value string `json:"value"`
+ To string `json:"to"`
+ IsError string `json:"isError"`
+ TraceID string `json:"traceId"`
+}
+
+// GetEtherscanInternalTxs fetches ETH internal transfers to address starting from startBlock.
+func GetEtherscanInternalTxs(ctx context.Context, apiKey, chainID, address string, startBlock uint64) ([]NativeTx, uint64, error) {
+ return fetchEtherscanTxs(ctx, apiKey, chainID, address, startBlock, "txlistinternal", func(tx etherscanTx) (NativeTx, bool) {
+ if tx.IsError == "1" || tx.Value == "0" || tx.Value == "" {
+ return NativeTx{}, false
+ }
+ amt, err := ParseDecAmount(tx.Value)
+ if err != nil || amt.Sign() <= 0 {
+ return NativeTx{}, false
+ }
+ blockNum, err := parseDecU64(tx.BlockNumber)
+ if err != nil {
+ return NativeTx{}, false
+ }
+ var bt time.Time
+ if ts, err2 := parseDecU64(tx.TimeStamp); err2 == nil && ts > 0 {
+ bt = time.Unix(int64(ts), 0).UTC()
+ }
+ return NativeTx{TxHash: strings.ToLower(tx.Hash), BlockNum: blockNum, BlockTime: bt, Amount: amt, EventKey: tx.TraceID}, true
+ })
+}
+
+// GetEtherscanNormalTxs fetches plain ETH value transfers to address starting from startBlock.
+func GetEtherscanNormalTxs(ctx context.Context, apiKey, chainID, address string, startBlock uint64) ([]NativeTx, uint64, error) {
+ addrLower := strings.ToLower(address)
+ return fetchEtherscanTxs(ctx, apiKey, chainID, address, startBlock, "txlist", func(tx etherscanTx) (NativeTx, bool) {
+ if tx.IsError == "1" || tx.Value == "0" || tx.Value == "" {
+ return NativeTx{}, false
+ }
+ if strings.ToLower(tx.To) != addrLower {
+ return NativeTx{}, false
+ }
+ amt, err := ParseDecAmount(tx.Value)
+ if err != nil || amt.Sign() <= 0 {
+ return NativeTx{}, false
+ }
+ blockNum, err := parseDecU64(tx.BlockNumber)
+ if err != nil {
+ return NativeTx{}, false
+ }
+ var bt time.Time
+ if ts, err2 := parseDecU64(tx.TimeStamp); err2 == nil && ts > 0 {
+ bt = time.Unix(int64(ts), 0).UTC()
+ }
+ return NativeTx{TxHash: strings.ToLower(tx.Hash), BlockNum: blockNum, BlockTime: bt, Amount: amt, EventKey: ""}, true
+ })
+}
+
+type filterFn func(etherscanTx) (NativeTx, bool)
+
+// fetchEtherscanTxs handles Etherscan pagination including the 10k-result cap.
+func fetchEtherscanTxs(ctx context.Context, apiKey, chainID, address string, startBlock uint64, action string, filter filterFn) ([]NativeTx, uint64, error) {
+ const (
+ offset = 1000
+ maxPage = 10
+ endBlock = 99999999
+ )
+
+ var all []NativeTx
+ highestBlock := startBlock
+ curStart := startBlock
+
+ for {
+ var windowTxs []NativeTx
+ hitCap := false
+ page := 1
+
+ for page <= maxPage {
+ time.Sleep(250 * time.Millisecond)
+
+ batch, err := etherscanPage(ctx, apiKey, chainID, address, action, curStart, endBlock, page, offset)
+ if err != nil {
+ return all, highestBlock, fmt.Errorf("etherscan %s page %d: %w", action, page, err)
+ }
+
+ for _, tx := range batch {
+ if ntx, ok := filter(tx); ok {
+ windowTxs = append(windowTxs, ntx)
+ if ntx.BlockNum > highestBlock {
+ highestBlock = ntx.BlockNum
+ }
+ }
+ }
+
+ if len(batch) < offset {
+ break // last page
+ }
+ if page == maxPage {
+ hitCap = true
+ break
+ }
+ page++
+ }
+
+ all = append(all, windowTxs...)
+
+ if !hitCap || len(windowTxs) == 0 {
+ break
+ }
+
+ // Advance past what we've seen and loop.
+ var lastBlock uint64
+ for _, tx := range windowTxs {
+ if tx.BlockNum > lastBlock {
+ lastBlock = tx.BlockNum
+ }
+ }
+ if lastBlock <= curStart {
+ break
+ }
+ curStart = lastBlock
+ }
+
+ return all, highestBlock, nil
+}
+
+func etherscanPage(ctx context.Context, apiKey, chainID, address, action string, startBlock uint64, endBlock, page, offset int) ([]etherscanTx, error) {
+ apiURL := etherscanV2
+ params := url.Values{
+ "module": {"account"},
+ "action": {action},
+ "address": {address},
+ "startblock": {fmt.Sprintf("%d", startBlock)},
+ "endblock": {fmt.Sprintf("%d", endBlock)},
+ "page": {fmt.Sprintf("%d", page)},
+ "offset": {fmt.Sprintf("%d", offset)},
+ "sort": {"asc"},
+ "apikey": {apiKey},
+ }
+ if chainID != "" {
+ params.Set("chainid", chainID)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL+"?"+params.Encode(), nil)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var out struct {
+ Status string `json:"status"`
+ Message string `json:"message"`
+ Result json.RawMessage `json:"result"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ return nil, fmt.Errorf("etherscan decode: %w", err)
+ }
+ if out.Status == "0" && strings.Contains(out.Message, "No transactions") {
+ return nil, nil
+ }
+ if out.Status != "1" {
+ var msg string
+ json.Unmarshal(out.Result, &msg) //nolint:errcheck
+ return nil, fmt.Errorf("etherscan %s: %s %s", action, out.Message, msg)
+ }
+
+ var txs []etherscanTx
+ if err := json.Unmarshal(out.Result, &txs); err != nil {
+ return nil, fmt.Errorf("etherscan result decode: %w", err)
+ }
+ return txs, nil
+}
+
+func parseDecU64(s string) (uint64, error) {
+ var n uint64
+ _, err := fmt.Sscan(s, &n)
+ return n, err
+}
diff --git a/harnesses/evm-exec/internal/source/logs.go b/harnesses/evm-exec/internal/source/logs.go
new file mode 100644
index 00000000..c1e1f95f
--- /dev/null
+++ b/harnesses/evm-exec/internal/source/logs.go
@@ -0,0 +1,176 @@
+package source
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "math/big"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const transferTopic0 = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
+
+// ERC20Transfer is one token Transfer event emitted to a recipient.
+type ERC20Transfer struct {
+ TxHash string
+ BlockNum uint64
+ LogIndex uint64
+ BlockTime time.Time // from blockTimestamp in getLogs response; zero if not present
+ Amount *big.Int // raw token units (never divided in Go)
+}
+
+// RPCError is a JSON-RPC level error; used to detect range-too-large codes.
+type RPCError struct {
+ Code int
+ Message string
+}
+
+func (e *RPCError) Error() string { return fmt.Sprintf("RPC %d: %s", e.Code, e.Message) }
+
+// IsRangeError returns true when the RPC error means the block range is too wide.
+func IsRangeError(err error) bool {
+ rpcErr, ok := err.(*RPCError)
+ if !ok {
+ return false
+ }
+ // -32005: range too large (Alchemy/Infura), -32602: invalid params (geth),
+ // -32614: range limit exceeded (base mainnet.base.org)
+ return rpcErr.Code == -32005 || rpcErr.Code == -32602 || rpcErr.Code == -32614
+}
+
+// GetERC20Transfers fetches Transfer events from [fromBlock, toBlock] for one window.
+func GetERC20Transfers(ctx context.Context, rpcURL, tokenContract, recipient string, fromBlock, toBlock uint64) ([]ERC20Transfer, error) {
+ // topic2: 32-byte left-padded address (12 zero bytes + 20 addr bytes).
+ topic2 := "0x" + strings.Repeat("0", 24) + strings.TrimPrefix(strings.ToLower(recipient), "0x")
+
+ body, _ := json.Marshal(map[string]any{
+ "jsonrpc": "2.0", "id": 1,
+ "method": "eth_getLogs",
+ "params": []any{map[string]any{
+ "fromBlock": fmt.Sprintf("0x%x", fromBlock),
+ "toBlock": fmt.Sprintf("0x%x", toBlock),
+ "address": tokenContract,
+ "topics": []any{transferTopic0, nil, topic2},
+ }},
+ })
+
+ var out struct {
+ Result []struct {
+ TxHash string `json:"transactionHash"`
+ BlockNumber string `json:"blockNumber"`
+ BlockTimestamp string `json:"blockTimestamp"` // hex unix seconds; present on Base, absent on ETH/BSC
+ LogIndex string `json:"logIndex"`
+ Data string `json:"data"`
+ Topics []string `json:"topics"`
+ } `json:"result"`
+ Error *struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+ if err := RpcPost(ctx, rpcURL, body, &out); err != nil {
+ return nil, fmt.Errorf("eth_getLogs: %w", err)
+ }
+ if out.Error != nil {
+ return nil, &RPCError{Code: out.Error.Code, Message: out.Error.Message}
+ }
+
+ var transfers []ERC20Transfer
+ for _, log := range out.Result {
+ if len(log.Data) < 2 || len(log.Topics) < 3 {
+ continue
+ }
+ blockNum, err := ParseHex64(log.BlockNumber)
+ if err != nil {
+ continue
+ }
+ logIdx, err := ParseHex64(log.LogIndex)
+ if err != nil {
+ continue
+ }
+ amount, ok := new(big.Int).SetString(strings.TrimPrefix(log.Data, "0x"), 16)
+ if !ok || amount.Sign() <= 0 {
+ continue
+ }
+ var bt time.Time
+ if ts, err2 := ParseHex64(log.BlockTimestamp); err2 == nil && ts > 0 {
+ bt = time.Unix(int64(ts), 0).UTC()
+ }
+ transfers = append(transfers, ERC20Transfer{
+ TxHash: strings.ToLower(log.TxHash),
+ BlockNum: blockNum,
+ LogIndex: logIdx,
+ BlockTime: bt,
+ Amount: amount,
+ })
+ }
+ return transfers, nil
+}
+
+// GetERC20TransfersAdaptive fetches all ERC-20 transfers from fromBlock to toBlock
+// using adaptive range sizing (halves on range error, grows on success).
+func GetERC20TransfersAdaptive(ctx context.Context, rpcURL, tokenContract, recipient string, fromBlock, toBlock uint64) ([]ERC20Transfer, uint64, error) {
+ const maxRange = uint64(10_000)
+ rangeSize := maxRange
+ var all []ERC20Transfer
+ cur := fromBlock
+
+ for cur <= toBlock {
+ end := cur + rangeSize - 1
+ if end > toBlock {
+ end = toBlock
+ }
+
+ batch, err := GetERC20Transfers(ctx, rpcURL, tokenContract, recipient, cur, end)
+ if err != nil {
+ if IsRangeError(err) && rangeSize > 100 {
+ rangeSize /= 2
+ continue
+ }
+ select {
+ case <-ctx.Done():
+ return all, cur - 1, ctx.Err()
+ case <-time.After(3 * time.Second):
+ }
+ batch, err = GetERC20Transfers(ctx, rpcURL, tokenContract, recipient, cur, end)
+ if err != nil {
+ return all, cur - 1, fmt.Errorf("eth_getLogs [%d,%d]: %w", cur, end, err)
+ }
+ }
+
+ all = append(all, batch...)
+ if rangeSize < maxRange {
+ rangeSize = rangeSize * 125 / 100
+ if rangeSize > maxRange {
+ rangeSize = maxRange
+ }
+ }
+ cur = end + 1
+ }
+ return all, toBlock, nil
+}
+
+// ParseDecAmount parses a decimal-string wei amount (Etherscan format) to *big.Int.
+func ParseDecAmount(s string) (*big.Int, error) {
+ v, ok := new(big.Int).SetString(s, 10)
+ if !ok {
+ return nil, fmt.Errorf("bad decimal amount: %q", s)
+ }
+ return v, nil
+}
+
+// ParseHexAmount parses a 0x-prefixed hex wei amount (NodeReal format) to *big.Int.
+func ParseHexAmount(s string) (*big.Int, error) {
+ v, ok := new(big.Int).SetString(strings.TrimPrefix(s, "0x"), 16)
+ if !ok {
+ return nil, fmt.Errorf("bad hex amount: %q", s)
+ }
+ return v, nil
+}
+
+// LogIndexKey returns the event_key string for an ERC-20 log.
+func LogIndexKey(logIndex uint64) string {
+ return strconv.FormatUint(logIndex, 10)
+}
diff --git a/harnesses/evm-exec/internal/source/nodereal.go b/harnesses/evm-exec/internal/source/nodereal.go
new file mode 100644
index 00000000..466a7429
--- /dev/null
+++ b/harnesses/evm-exec/internal/source/nodereal.go
@@ -0,0 +1,133 @@
+package source
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "math/big"
+ "strings"
+ "time"
+)
+
+// AssetTransfer is one native-asset (BNB/ETH) transfer from NodeReal nr_getAssetTransfers.
+type AssetTransfer struct {
+ TxHash string
+ BlockNum uint64
+ BlockTime time.Time // from NodeReal blockTimeStamp (avoids separate RPC call)
+ Amount *big.Int // wei, never divided
+ EventKey string // position index within (tx_hash) group for uniqueness
+}
+
+type nrTransfer struct {
+ BlockNum string `json:"blockNum"`
+ Hash string `json:"hash"`
+ Value string `json:"value"`
+ Category string `json:"category"`
+ BlockTimeStamp int64 `json:"blockTimeStamp"` // unix seconds, present when withMetadata:true
+}
+
+// GetNativeTransfers fetches all native asset (BNB) transfers to toAddress in [fromBlock, toBlock]
+// using NodeReal nr_getAssetTransfers with full pagination via pageKey.
+func GetNativeTransfers(ctx context.Context, rpcURL, toAddress string, fromBlock, toBlock uint64) ([]AssetTransfer, error) {
+ var allRaw []nrTransfer
+ pageKey := ""
+
+ for {
+ params := map[string]any{
+ "fromBlock": fmt.Sprintf("0x%x", fromBlock),
+ "toBlock": fmt.Sprintf("0x%x", toBlock),
+ "toAddress": toAddress,
+ "category": []string{"external", "internal"},
+ "withMetadata": true,
+ "excludeZeroValue": true,
+ "maxCount": "0x3e8", // 1000
+ }
+ if pageKey != "" {
+ params["pageKey"] = pageKey
+ }
+
+ body, _ := json.Marshal(map[string]any{
+ "jsonrpc": "2.0", "id": 1,
+ "method": "nr_getAssetTransfers",
+ "params": []any{params},
+ })
+
+ var out struct {
+ Result *struct {
+ Transfers []nrTransfer `json:"transfers"`
+ PageKey string `json:"pageKey"`
+ } `json:"result"`
+ Error *struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+
+ var lastErr error
+ for attempt := range 3 {
+ if attempt > 0 {
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-time.After(time.Duration(attempt*attempt) * time.Second):
+ }
+ }
+ if err := RpcPost(ctx, rpcURL, body, &out); err != nil {
+ lastErr = err
+ continue
+ }
+ lastErr = nil
+ break
+ }
+ if lastErr != nil {
+ return nil, fmt.Errorf("nr_getAssetTransfers: %w", lastErr)
+ }
+ if out.Error != nil {
+ return nil, fmt.Errorf("nr_getAssetTransfers RPC %d: %s", out.Error.Code, out.Error.Message)
+ }
+ if out.Result == nil {
+ return nil, fmt.Errorf("nr_getAssetTransfers: null result")
+ }
+
+ allRaw = append(allRaw, out.Result.Transfers...)
+ pageKey = out.Result.PageKey
+ if pageKey == "" {
+ break
+ }
+ }
+
+ // Assign event_key as position within each tx_hash group (handles multicall).
+ txCount := make(map[string]int, len(allRaw))
+ result := make([]AssetTransfer, 0, len(allRaw))
+
+ for _, t := range allRaw {
+ if t.Value == "" || t.Value == "0x0" {
+ continue
+ }
+ amt, err := ParseHexAmount(t.Value)
+ if err != nil || amt.Sign() <= 0 {
+ continue
+ }
+ blockNum, err := ParseHex64(t.BlockNum)
+ if err != nil {
+ continue
+ }
+ hash := strings.ToLower(t.Hash)
+ idx := txCount[hash]
+ txCount[hash]++
+
+ var blockTime time.Time
+ if t.BlockTimeStamp > 0 {
+ blockTime = time.Unix(t.BlockTimeStamp, 0).UTC()
+ }
+
+ result = append(result, AssetTransfer{
+ TxHash: hash,
+ BlockNum: blockNum,
+ BlockTime: blockTime,
+ Amount: amt,
+ EventKey: fmt.Sprintf("%d", idx),
+ })
+ }
+ return result, nil
+}
diff --git a/harnesses/evm-exec/internal/store/store.go b/harnesses/evm-exec/internal/store/store.go
new file mode 100644
index 00000000..35ad6595
--- /dev/null
+++ b/harnesses/evm-exec/internal/store/store.go
@@ -0,0 +1,199 @@
+package store
+
+import (
+ "context"
+ "fmt"
+ "math/big"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type DB struct {
+ pool *pgxpool.Pool
+}
+
+func New(ctx context.Context, connStr string) (*DB, error) {
+ pool, err := pgxpool.New(ctx, connStr)
+ if err != nil {
+ return nil, fmt.Errorf("store: connect: %w", err)
+ }
+ return &DB{pool: pool}, nil
+}
+
+func (db *DB) Close() { db.pool.Close() }
+
+// EVMEvent is one fee inflow to a platform's fee collector.
+type EVMEvent struct {
+ Chain string
+ TxHash string
+ BlockNum uint64
+ BlockTime time.Time
+ Platform string
+ Asset string // "native" or token contract (lowercase)
+ AmountRaw *big.Int // wei or raw token units; never divided in Go
+ Decimals int
+ EventKey string // log_index / traceId / transfer index / ""
+}
+
+// UpsertEvent inserts an EVM fee event; skips silently on (chain,tx_hash,asset,event_key) conflict.
+func (db *DB) UpsertEvent(ctx context.Context, e EVMEvent) error {
+ _, err := db.pool.Exec(ctx, `
+ INSERT INTO evm_exec_events
+ (chain, tx_hash, block_num, block_time, platform, asset, amount_raw, decimals, event_key)
+ VALUES ($1,$2,$3,$4,$5,$6,$7::NUMERIC,$8,$9)
+ ON CONFLICT (chain, tx_hash, asset, event_key) DO NOTHING`,
+ e.Chain, e.TxHash, e.BlockNum, e.BlockTime,
+ e.Platform, e.Asset, e.AmountRaw.String(), e.Decimals, e.EventKey,
+ )
+ if err != nil {
+ return fmt.Errorf("store: upsert %s/%s: %w", e.Chain, e.TxHash, err)
+ }
+ return nil
+}
+
+// UpsertEvents bulk-upserts in a single transaction.
+func (db *DB) UpsertEvents(ctx context.Context, events []EVMEvent) error {
+ if len(events) == 0 {
+ return nil
+ }
+ tx, err := db.pool.Begin(ctx)
+ if err != nil {
+ return fmt.Errorf("store: begin: %w", err)
+ }
+ defer tx.Rollback(ctx)
+
+ for _, e := range events {
+ _, err := tx.Exec(ctx, `
+ INSERT INTO evm_exec_events
+ (chain, tx_hash, block_num, block_time, platform, asset, amount_raw, decimals, event_key)
+ VALUES ($1,$2,$3,$4,$5,$6,$7::NUMERIC,$8,$9)
+ ON CONFLICT (chain, tx_hash, asset, event_key) DO NOTHING`,
+ e.Chain, e.TxHash, e.BlockNum, e.BlockTime,
+ e.Platform, e.Asset, e.AmountRaw.String(), e.Decimals, e.EventKey,
+ )
+ if err != nil {
+ return fmt.Errorf("store: upsert %s/%s: %w", e.Chain, e.TxHash, err)
+ }
+ }
+ return tx.Commit(ctx)
+}
+
+// GetCursor returns the last processed block for (chain, platform, asset).
+// Returns (0, false, nil) when no cursor exists yet.
+func (db *DB) GetCursor(ctx context.Context, chain, platform, asset string) (uint64, bool, error) {
+ var lastBlock uint64
+ err := db.pool.QueryRow(ctx,
+ `SELECT last_block FROM evm_exec_cursors WHERE chain=$1 AND platform=$2 AND asset=$3`,
+ chain, platform, asset,
+ ).Scan(&lastBlock)
+ if err != nil {
+ return 0, false, nil
+ }
+ return lastBlock, true, nil
+}
+
+// SaveCursor upserts the last processed block.
+func (db *DB) SaveCursor(ctx context.Context, chain, platform, asset string, lastBlock uint64) error {
+ _, err := db.pool.Exec(ctx, `
+ INSERT INTO evm_exec_cursors (chain, platform, asset, last_block, updated_at)
+ VALUES ($1,$2,$3,$4,now())
+ ON CONFLICT (chain, platform, asset) DO UPDATE SET
+ last_block = EXCLUDED.last_block, updated_at = now()`,
+ chain, platform, asset, lastBlock,
+ )
+ return err
+}
+
+// SeedCursorIfAbsent inserts a bootstrap cursor only when none exists (DO NOTHING on conflict).
+func (db *DB) SeedCursorIfAbsent(ctx context.Context, chain, platform, asset string, bootstrapBlock uint64) error {
+ _, err := db.pool.Exec(ctx, `
+ INSERT INTO evm_exec_cursors (chain, platform, asset, last_block, updated_at)
+ VALUES ($1,$2,$3,$4,now())
+ ON CONFLICT (chain, platform, asset) DO NOTHING`,
+ chain, platform, asset, bootstrapBlock,
+ )
+ return err
+}
+
+// GetBlockTime returns a cached block timestamp, if present.
+func (db *DB) GetBlockTime(ctx context.Context, chain string, blockNum uint64) (time.Time, bool) {
+ var t time.Time
+ err := db.pool.QueryRow(ctx,
+ `SELECT block_time FROM evm_block_times WHERE chain=$1 AND block_num=$2`,
+ chain, blockNum,
+ ).Scan(&t)
+ return t, err == nil
+}
+
+// CacheBlockTime writes a block timestamp to the local cache table.
+func (db *DB) CacheBlockTime(ctx context.Context, chain string, blockNum uint64, t time.Time) error {
+ _, err := db.pool.Exec(ctx, `
+ INSERT INTO evm_block_times (chain, block_num, block_time)
+ VALUES ($1,$2,$3)
+ ON CONFLICT (chain, block_num) DO NOTHING`,
+ chain, blockNum, t,
+ )
+ return err
+}
+
+// Materialize recomputes hourly revenue facts from the last 31 days of events.
+func (db *DB) Materialize(ctx context.Context) error {
+ _, err := db.pool.Exec(ctx, `
+ INSERT INTO evm_exec_facts
+ (chain, platform, bucket_start, revenue_stable, revenue_native, native_symbol, updated_at)
+ SELECT
+ chain,
+ platform,
+ date_trunc('hour', block_time) AS bucket_start,
+ COALESCE(SUM(amount_raw::numeric / (10::numeric ^ decimals)) FILTER (WHERE asset <> 'native'), 0),
+ COALESCE(SUM(amount_raw::numeric / (10::numeric ^ decimals)) FILTER (WHERE asset = 'native'), 0),
+ CASE WHEN chain = 'bsc' THEN 'BNB' ELSE 'ETH' END,
+ now()
+ FROM evm_exec_events
+ WHERE block_time > now() - INTERVAL '31 days'
+ GROUP BY 1, 2, 3
+ ON CONFLICT (chain, platform, bucket_start) DO UPDATE SET
+ revenue_stable = EXCLUDED.revenue_stable,
+ revenue_native = EXCLUDED.revenue_native,
+ native_symbol = EXCLUDED.native_symbol,
+ updated_at = now()`,
+ )
+ return err
+}
+
+// RevenueRow is one (chain, platform) 24h revenue aggregate.
+type RevenueRow struct {
+ Chain string
+ Platform string
+ RevenueStable float64
+ RevenueNative float64
+ NativeSymbol string
+}
+
+// GetRevenue24h returns aggregated revenue for the last 24h per (chain, platform).
+func (db *DB) GetRevenue24h(ctx context.Context) ([]RevenueRow, error) {
+ rows, err := db.pool.Query(ctx, `
+ SELECT chain, platform,
+ COALESCE(SUM(revenue_stable), 0),
+ COALESCE(SUM(revenue_native), 0),
+ COALESCE(MAX(native_symbol), '')
+ FROM evm_exec_facts
+ WHERE bucket_start >= now() - INTERVAL '24 hours'
+ GROUP BY chain, platform`,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var out []RevenueRow
+ for rows.Next() {
+ var r RevenueRow
+ if err := rows.Scan(&r.Chain, &r.Platform, &r.RevenueStable, &r.RevenueNative, &r.NativeSymbol); err != nil {
+ return nil, err
+ }
+ out = append(out, r)
+ }
+ return out, rows.Err()
+}
diff --git a/harnesses/evm-exec/migrations/004_evm_exec.sql b/harnesses/evm-exec/migrations/004_evm_exec.sql
new file mode 100644
index 00000000..ac7c879c
--- /dev/null
+++ b/harnesses/evm-exec/migrations/004_evm_exec.sql
@@ -0,0 +1,46 @@
+CREATE TABLE IF NOT EXISTS evm_exec_events (
+ id BIGSERIAL PRIMARY KEY,
+ chain TEXT NOT NULL,
+ tx_hash TEXT NOT NULL,
+ block_num BIGINT NOT NULL,
+ block_time TIMESTAMPTZ NOT NULL,
+ platform TEXT NOT NULL,
+ asset TEXT NOT NULL,
+ amount_raw NUMERIC(78,0) NOT NULL,
+ decimals INT NOT NULL,
+ -- intra-tx discriminator: log_index for ERC-20, traceId for Etherscan internal,
+ -- position index for NodeReal transfers, "" for plain value txs.
+ event_key TEXT NOT NULL,
+ UNIQUE (chain, tx_hash, asset, event_key)
+);
+
+CREATE INDEX IF NOT EXISTS idx_evm_events_lookup
+ ON evm_exec_events (platform, chain, block_time DESC);
+
+CREATE TABLE IF NOT EXISTS evm_block_times (
+ chain TEXT NOT NULL,
+ block_num BIGINT NOT NULL,
+ block_time TIMESTAMPTZ NOT NULL,
+ PRIMARY KEY (chain, block_num)
+);
+
+-- No DEFAULT on last_block: the collector seeds from bootstrap on first run.
+CREATE TABLE IF NOT EXISTS evm_exec_cursors (
+ chain TEXT NOT NULL,
+ platform TEXT NOT NULL,
+ asset TEXT NOT NULL,
+ last_block BIGINT NOT NULL,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (chain, platform, asset)
+);
+
+CREATE TABLE IF NOT EXISTS evm_exec_facts (
+ chain TEXT NOT NULL,
+ platform TEXT NOT NULL,
+ bucket_start TIMESTAMPTZ NOT NULL,
+ revenue_stable NUMERIC NOT NULL DEFAULT 0,
+ revenue_native NUMERIC NOT NULL DEFAULT 0,
+ native_symbol TEXT,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (chain, platform, bucket_start)
+);
diff --git a/harnesses/memecoin-platforms/.env.example b/harnesses/memecoin-platforms/.env.example
new file mode 100644
index 00000000..dbb65e2d
--- /dev/null
+++ b/harnesses/memecoin-platforms/.env.example
@@ -0,0 +1,3 @@
+DUNE_API_KEY=your_dune_api_key
+DUNE_QUERY_ID= # leave empty on first run; harness prints the ID and exits
+MOBULA_API_KEY=your_mobula_api_key
diff --git a/harnesses/memecoin-platforms/Dockerfile b/harnesses/memecoin-platforms/Dockerfile
new file mode 100644
index 00000000..0f7bd539
--- /dev/null
+++ b/harnesses/memecoin-platforms/Dockerfile
@@ -0,0 +1,22 @@
+FROM golang:1.24-alpine AS builder
+
+WORKDIR /build
+
+COPY go.mod go.sum ./
+RUN go mod download
+
+COPY . .
+
+RUN CGO_ENABLED=0 GOOS=linux go build -o /memecoin-platforms ./cmd/monitor
+
+FROM alpine:latest
+
+RUN apk --no-cache add ca-certificates
+
+WORKDIR /app
+
+COPY --from=builder /memecoin-platforms .
+
+EXPOSE 2112
+
+CMD ["./memecoin-platforms"]
diff --git a/harnesses/memecoin-platforms/cmd/monitor/defillama.go b/harnesses/memecoin-platforms/cmd/monitor/defillama.go
new file mode 100644
index 00000000..32fa0d1c
--- /dev/null
+++ b/harnesses/memecoin-platforms/cmd/monitor/defillama.go
@@ -0,0 +1,51 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+)
+
+type defillamaClient struct {
+ http *http.Client
+}
+
+func newDefillamaClient() *defillamaClient {
+ return &defillamaClient{http: &http.Client{Timeout: 15 * time.Second}}
+}
+
+// fetchFomoFees returns Fomo's 24h Solana fees in USD from DeFiLlama.
+// Fomo combines on-chain USDC inflows + off-chain relay fees via a private
+// Dune table (dune.tryfomo.fomo_relay_fees) that only DeFiLlama can read.
+func (c *defillamaClient) fetchFomoFees() (float64, error) {
+ req, err := http.NewRequest(http.MethodGet, "https://api.llama.fi/summary/fees/fomo", nil)
+ if err != nil {
+ return 0, err
+ }
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return 0, fmt.Errorf("http: %w", err)
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return 0, err
+ }
+ if resp.StatusCode >= 400 {
+ return 0, fmt.Errorf("status %d", resp.StatusCode)
+ }
+ var out struct {
+ ChainBreakdown map[string]struct {
+ Total24h float64 `json:"total24h"`
+ } `json:"chainBreakdown"`
+ }
+ if err := json.Unmarshal(body, &out); err != nil {
+ return 0, fmt.Errorf("parse: %w", err)
+ }
+ if bd, ok := out.ChainBreakdown["Solana"]; ok {
+ return bd.Total24h, nil
+ }
+ return 0, nil
+}
diff --git a/harnesses/memecoin-platforms/cmd/monitor/dune.go b/harnesses/memecoin-platforms/cmd/monitor/dune.go
new file mode 100644
index 00000000..853eb815
--- /dev/null
+++ b/harnesses/memecoin-platforms/cmd/monitor/dune.go
@@ -0,0 +1,292 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// feesSQL is the Dune Trino SQL embedded here for auto-create.
+// Mirrors harnesses/memecoin-platforms/queries/platform_fees.sql.
+const feesSQL = `
+WITH
+
+wallet_platform AS (
+ SELECT address, platform FROM (VALUES
+ ('BB5dnY55FXS1e1NXqZDwCzgdYJdMCj3B92PU6Q5Fb6DT','gmgn'),
+ ('7sHXjs1j7sDJGVSMSPjD1b4v3FD6uRSvRWfhRdfv5BiA','gmgn'),
+ ('HeZVpHj9jLwTVtMMbzQRf6mLtFPkWNSg11o68qrbUBa3','gmgn'),
+ ('ByRRgnZenY6W2sddo1VJzX9o4sMU4gPDUkcmgrpGBxRy','gmgn'),
+ ('DXfkEGoo6WFsdL7x6gLZ7r6Hw2S6HrtrAQVPWYx2A1s9','gmgn'),
+ ('3t9EKmRiAUcQUYzTZpNojzeGP1KBAVEEbDNmy6wECQpK','gmgn'),
+ ('DymeoWc5WLNiQBaoLuxrxDnDRvLgGZ1QGsEoCAM7Jsrx','gmgn'),
+ ('dBhdrmwBkRa66XxBuAK4WZeZnsZ6bHeHCCLXa3a8bTJ','gmgn'),
+ ('6TxjC5wJzuuZgTtnTMipwwULEbMPx5JPW3QwWkdTGnrn','gmgn'),
+ ('7LCZckF6XXGQ1hDY6HFXBKWAtiUgL9QY5vj1C4Bn1Qjj','axiom'),
+ ('4V65jvcDG9DSQioUVqVPiUcUY9v6sb6HKtMnsxSKEz5S','axiom'),
+ ('CeA3sPZfWWToFEBmw5n1Y93tnV66Vmp8LacLzsVprgxZ','axiom'),
+ ('AaG6of1gbj1pbDumvbSiTuJhRCRkkUNaWVxijSbWvTJW','axiom'),
+ ('7oi1L8U9MRu5zDz5syFahsiLUric47LzvJBQX6r827ws','axiom'),
+ ('9kPrgLggBJ69tx1czYAbp7fezuUmL337BsqQTKETUEhP','axiom'),
+ ('DKyUs1xXMDy8Z11zNsLnUg3dy9HZf6hYZidB6WodcaGy','axiom'),
+ ('4FobGn5ZWYquoJkxMzh2VUAWvV36xMgxQ3M7uG1pGGhd','axiom'),
+ ('76sxKrPtgoJHDJvxwFHqb3cAXWfRHFLe3VpKcLCAHSEf','axiom'),
+ ('H2cDR3EkJjtTKDQKk8SJS48du9mhsdzQhy8xJx5UMqQK','axiom'),
+ ('8m5GkL7nVy95G4YVUbs79z873oVKqg2afgKRmqxsiiRm','axiom'),
+ ('4kuG6NsAFJNwqEkac8GFDMMheCGKUPEbaRVHHyFHSwWz','axiom'),
+ ('8vFGAKdwpn4hk7kc1cBgfWZzpyW3MEMDATDzVZhddeQb','axiom'),
+ ('86Vh4XGLW2b6nvWbRyDs4ScgMXbuvRCHT7WbUT3RFxKG','axiom'),
+ ('DZfEurFKFtSbdWZsKSDTqpqsQgvXxmESpvRtXkAdgLwM','axiom'),
+ ('5L2QKqDn5ukJSWGyqR4RPvFvwnBabKWqAqMzH4heaQNB','axiom'),
+ ('DYVeNgXGLAhZdeLMMYnCw1nPnMxkBN7fJnNpHmizTrrF','axiom'),
+ ('Hbj6XdxX6eV4nfbYTseysibp4zZJtVRRPn2J3BhGRuK9','axiom'),
+ ('846ah7iBSu9ApuCyEhA5xpnjHHX7d4QJKetWLbwzmJZ8','axiom'),
+ ('5BqYhuD4q1YD3DMAYkc1FeTu9vqQVYYdfBAmkZjamyZg','axiom'),
+ ('HrTf9CzXR1dRH4Sof5QrpmGWwpwAf3qZzwCsEjQpXcSq','fomo'),
+ ('9yMwSPk9mrXSN7yDHUuZurAh1sjbJsfpUqjZ7SvVtdco','trojan'),
+ ('92Med3qeK7duC5iiYsHX38H2f2twJfRsSx93oNrza2VH','trojan'),
+ ('2jwHNxavSoMZMEDbT1eV9PcPt5dDcayCqM6MkgaPpmWQ','trojan'),
+ ('65gDv7pZQCZELsNpNYSFEBtNFpWZAbxmRFB6BGMqFkHH','trojan'),
+ ('BWgb8wR1FEGiu1jCDSKuHKf752W27b4iN6SvoNCiK4qp','trojan'),
+ ('8jgg7moFJkHyTtAv9M6RBSPMp2oXeXhuiUMKW8YbYCWn','trojan'),
+ ('AVUCZyuT35YSuj4RH7fwiyPu82Djn2Hfg7y2ND2XcnZH','photon'),
+ ('MaestroUL88UBnZr3wfoN7hqmNWFi3ZYCGqZoJJHE36','maestro'),
+ ('FRMxAnZgkW58zbYcE7Bxqsg99VWpJh6sMP5xLzAWNabN','maestro'),
+ ('CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM','pump-fun'),
+ ('62qc2CNXwrYqQScmEdiZFFAnJR262PxWEuNQtxfafNgV','pump-fun'),
+ ('FWsW1xNtWscwNmKv6wVsU1iTzRN6wmmk3MjxRP5tT7hz','pump-fun'),
+ ('7hTckgnGnLQR6sdH7YkqFTAA7VwTfYFaZ6EhEsU3saCX','pump-fun'),
+ ('AVmoTthdrX6tKt4nDjco2D775W2YK3sDhxPcMmzUAmTY','pump-fun'),
+ ('9rPYyANsfQZw3DnDmKE3YCQF5E8oD89UXoHn9JFEhJUz','pump-fun'),
+ ('G5UZAVbAf46s7cKWoyKu8kYTip9DGTpbLZ2qa9Aq69dP','pump-fun'),
+ ('7VtfL8fvgNfhz17qKRMjzQEXgbdpnHHHQRh54R9jP2RJ','pump-fun'),
+ ('GesfTA3X2arioaHp8bbKdjG9vJtskViWACZoYvxp4twS','pump-fun')
+ ) AS t(address, platform)
+),
+
+native_sol AS (
+ SELECT wp.platform, a.tx_id, CAST(a.post_balance - a.pre_balance AS DOUBLE) / 1e9 AS inflow, a.block_time
+ FROM solana.account_activity a
+ JOIN wallet_platform wp ON a.address = wp.address
+ WHERE a.block_time >= NOW() - INTERVAL '1' DAY
+ AND a.token_mint_address IS NULL
+ AND a.post_balance > a.pre_balance
+),
+
+usdc_inflows AS (
+ SELECT wp.platform, a.tx_id, GREATEST(a.post_token_balance - a.pre_token_balance, 0) / 1e6 AS inflow, a.block_time
+ FROM solana.account_activity a
+ JOIN wallet_platform wp ON a.token_balance_owner = wp.address
+ WHERE a.block_time >= NOW() - INTERVAL '1' DAY
+ AND a.token_mint_address = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
+ AND a.post_token_balance > a.pre_token_balance
+),
+
+wsol_inflows AS (
+ SELECT wp.platform, a.tx_id, GREATEST(a.post_token_balance - a.pre_token_balance, 0) / 1e9 AS inflow, a.block_time
+ FROM solana.account_activity a
+ JOIN wallet_platform wp ON a.token_balance_owner = wp.address
+ WHERE a.block_time >= NOW() - INTERVAL '1' DAY
+ AND a.token_mint_address = 'So11111111111111111111111111111111111111112'
+ AND a.post_token_balance > a.pre_token_balance
+),
+
+combined AS (
+ SELECT platform, 0.0 AS usdc_inflow, 0.0 AS wsol_inflow, inflow AS sol_inflow, block_time FROM native_sol
+ UNION ALL
+ SELECT platform, inflow, 0.0, 0.0, block_time FROM usdc_inflows
+ UNION ALL
+ SELECT platform, 0.0, inflow, 0.0, block_time FROM wsol_inflows
+),
+
+fee_tx_ids AS (
+ SELECT platform, tx_id FROM native_sol
+ UNION
+ SELECT platform, tx_id FROM usdc_inflows
+ UNION
+ SELECT platform, tx_id FROM wsol_inflows
+),
+
+fee_paying_vol AS (
+ SELECT sub.platform, SUM(sub.max_swap) AS fee_paying_volume_usd_24h
+ FROM (
+ SELECT ft.platform, ft.tx_id, MAX(COALESCE(ABS(t.amount_usd), 0)) AS max_swap
+ FROM fee_tx_ids ft
+ JOIN dex_solana.trades t ON t.tx_id = ft.tx_id
+ WHERE t.block_time >= NOW() - INTERVAL '1' DAY
+ GROUP BY ft.platform, ft.tx_id
+ ) sub
+ GROUP BY sub.platform
+)
+
+SELECT
+ fees.platform,
+ fees.usdc_fees_24h,
+ fees.wsol_fees_24h,
+ fees.sol_fees_24h,
+ fees.data_freshness,
+ COALESCE(fpv.fee_paying_volume_usd_24h, 0) AS fee_paying_volume_usd_24h
+FROM (
+ SELECT
+ platform,
+ COALESCE(SUM(usdc_inflow), 0) AS usdc_fees_24h,
+ COALESCE(SUM(wsol_inflow), 0) AS wsol_fees_24h,
+ COALESCE(SUM(sol_inflow), 0) AS sol_fees_24h,
+ MAX(block_time) AS data_freshness
+ FROM combined
+ GROUP BY platform
+) fees
+LEFT JOIN fee_paying_vol fpv ON fpv.platform = fees.platform
+ORDER BY fees.platform
+`
+
+const duneBase = "https://api.dune.com/api/v1"
+
+type duneClient struct {
+ apiKey string
+ http *http.Client
+}
+
+type duneRow struct {
+ Platform string `json:"platform"`
+ USDCFees24h float64 `json:"usdc_fees_24h"`
+ WSOLFees24h float64 `json:"wsol_fees_24h"`
+ SOLFees24h float64 `json:"sol_fees_24h"`
+ DataFreshness string `json:"data_freshness"`
+ FeePayingVolumeUSD24h float64 `json:"fee_paying_volume_usd_24h"`
+}
+
+func isNoExecutionError(err error) bool {
+ if err == nil {
+ return false
+ }
+ s := err.Error()
+ return strings.HasPrefix(s, "HTTP 40") && strings.Contains(s, "No execution")
+}
+
+func newDuneClient(apiKey string) *duneClient {
+ return &duneClient{
+ apiKey: apiKey,
+ http: &http.Client{Timeout: 30 * time.Second},
+ }
+}
+
+func (d *duneClient) req(method, path string, body any) (*http.Response, error) {
+ var r io.Reader
+ if body != nil {
+ b, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ r = bytes.NewReader(b)
+ }
+ req, err := http.NewRequest(method, duneBase+path, r)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("X-Dune-Api-Key", d.apiKey)
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ return d.http.Do(req)
+}
+
+func (d *duneClient) createQuery() (string, error) {
+ payload := map[string]any{
+ "name": "OCB: Memecoin Platform Fees 24h",
+ "query_sql": feesSQL,
+ "is_private": false,
+ "parameters": []any{},
+ }
+ resp, err := d.req("POST", "/query", payload)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ b, _ := io.ReadAll(resp.Body)
+ return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, b)
+ }
+ var out struct {
+ QueryID int `json:"query_id"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("%d", out.QueryID), nil
+}
+
+func (d *duneClient) updateQuery(queryID string) error {
+ payload := map[string]any{
+ "query_sql": feesSQL,
+ }
+ resp, err := d.req("PATCH", "/query/"+queryID, payload)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ b, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("HTTP %d: %s", resp.StatusCode, b)
+ }
+ return nil
+}
+
+func (d *duneClient) execute(queryID string) (string, error) {
+ resp, err := d.req("POST", "/query/"+queryID+"/execute", map[string]any{})
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ b, _ := io.ReadAll(resp.Body)
+ return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, b)
+ }
+ var out struct {
+ ExecutionID string `json:"execution_id"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ return "", err
+ }
+ return out.ExecutionID, nil
+}
+
+func (d *duneClient) executionState(execID string) (string, error) {
+ resp, err := d.req("GET", "/execution/"+execID+"/status", nil)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ var out struct {
+ State string `json:"state"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ return "", err
+ }
+ return out.State, nil
+}
+
+func (d *duneClient) latestResult(queryID string) ([]duneRow, error) {
+ resp, err := d.req("GET", "/query/"+queryID+"/results", nil)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ b, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, b)
+ }
+ var out struct {
+ Result struct {
+ Rows []duneRow `json:"rows"`
+ } `json:"result"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ return nil, err
+ }
+ return out.Result.Rows, nil
+}
diff --git a/harnesses/memecoin-platforms/cmd/monitor/lighthouse.go b/harnesses/memecoin-platforms/cmd/monitor/lighthouse.go
new file mode 100644
index 00000000..7c947c40
--- /dev/null
+++ b/harnesses/memecoin-platforms/cmd/monitor/lighthouse.go
@@ -0,0 +1,80 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+const lighthouseURL = "https://api.mobula.io/api/2/market/lighthouse"
+
+type lighthouseClient struct {
+ apiKey string
+ http *http.Client
+}
+
+func newLighthouseClient(apiKey string) *lighthouseClient {
+ return &lighthouseClient{
+ apiKey: apiKey,
+ http: &http.Client{Timeout: 15 * time.Second},
+ }
+}
+
+// fetchVolumes returns platform name -> volume_usd_24h from the Mobula lighthouse.
+func (c *lighthouseClient) fetchVolumes() (map[string]float64, error) {
+ req, err := http.NewRequest(http.MethodGet, lighthouseURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("build req: %w", err)
+ }
+ req.Header.Set("Authorization", c.apiKey)
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("http: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
+ if err != nil {
+ return nil, fmt.Errorf("read: %w", err)
+ }
+ if resp.StatusCode >= 400 {
+ return nil, fmt.Errorf("status %d: %.200s", resp.StatusCode, body)
+ }
+
+ type entry struct {
+ Name string `json:"name"`
+ VolumeUSD struct {
+ H24 float64 `json:"24h"`
+ } `json:"volumeUSD"`
+ }
+ var out struct {
+ Data struct {
+ ByPlatform []entry `json:"byPlatform"`
+ ByLaunchpad []entry `json:"byLaunchpad"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(body, &out); err != nil {
+ return nil, fmt.Errorf("parse: %w", err)
+ }
+
+ volumes := make(map[string]float64)
+ for _, p := range out.Data.ByPlatform {
+ if p.Name == "" {
+ continue
+ }
+ volumes[strings.ToLower(p.Name)] = p.VolumeUSD.H24
+ }
+ // pump.fun bonding curve is under byLaunchpad as "PumpFun"
+ for _, p := range out.Data.ByLaunchpad {
+ key := strings.ToLower(p.Name)
+ if key == "pumpfun" {
+ volumes["pump-fun"] = p.VolumeUSD.H24
+ }
+ }
+ return volumes, nil
+}
diff --git a/harnesses/memecoin-platforms/cmd/monitor/main.go b/harnesses/memecoin-platforms/cmd/monitor/main.go
new file mode 100644
index 00000000..c266ce8c
--- /dev/null
+++ b/harnesses/memecoin-platforms/cmd/monitor/main.go
@@ -0,0 +1,236 @@
+// memecoin-platforms -- Bench 203
+//
+// Computes per-platform take rates for Solana memecoin trading bots by combining:
+// - Dune Analytics: fee wallet inflows (USDC + WSOL + native SOL) -> fees_usd_24h
+// - Mobula lighthouse: platform trading volume -> volume_usd_24h
+// - take_rate = fees_usd_24h / volume_usd_24h * 100
+//
+// Required env vars:
+//
+// DUNE_API_KEY - Dune Analytics API key
+// DUNE_QUERY_ID - Dune query ID (leave empty on first run; harness prints it and exits)
+// MOBULA_API_KEY - Mobula API key
+//
+// Metrics on :2112/metrics:
+//
+// memecoin_platform_fee_rate_pct{platform}
+// memecoin_platform_fees_usd_24h{platform}
+// memecoin_platform_volume_usd_24h{platform}
+// memecoin_platform_health
+// memecoin_last_poll_timestamp_seconds
+package main
+
+import (
+ "fmt"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+)
+
+const (
+ fetchInterval = 15 * time.Minute
+ refreshInterval = 6 * time.Hour
+ solPriceRefresh = 5 * time.Minute
+)
+
+func main() {
+ fmt.Println("=== memecoin-platforms harness ===")
+ fmt.Println("OpenChainBench Bench 203 -- Memecoin platform fee rates via Dune + Mobula lighthouse.")
+
+ duneKey := os.Getenv("DUNE_API_KEY")
+ if duneKey == "" {
+ fmt.Fprintln(os.Stderr, "[fatal] DUNE_API_KEY not set")
+ os.Exit(1)
+ }
+ mobulaKey := os.Getenv("MOBULA_API_KEY")
+ if mobulaKey == "" {
+ fmt.Fprintln(os.Stderr, "[fatal] MOBULA_API_KEY not set")
+ os.Exit(1)
+ }
+
+ queryID := os.Getenv("DUNE_QUERY_ID")
+ dune := newDuneClient(duneKey)
+
+ if queryID == "" {
+ fmt.Println("[init] DUNE_QUERY_ID not set, creating Dune query...")
+ id, err := dune.createQuery()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "[fatal] failed to create Dune query: %v\n", err)
+ os.Exit(1)
+ }
+ fmt.Printf("[init] Dune query created: %s\n", id)
+ fmt.Printf("[init] Set DUNE_QUERY_ID=%s and restart the harness.\n", id)
+ os.Exit(0)
+ }
+
+ lighthouse := newLighthouseClient(mobulaKey)
+ llama := newDefillamaClient()
+
+ startSolPriceRefresher(solPriceRefresh)
+
+ go func() {
+ if err := startMetricsServer(":2112"); err != nil {
+ fmt.Printf("[fatal] metrics server: %v\n", err)
+ os.Exit(1)
+ }
+ }()
+
+ sig := make(chan os.Signal, 1)
+ signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
+
+ // Sync Dune query SQL to the embedded feesSQL constant, then trigger a fresh execution.
+ // This ensures new SQL columns (e.g. fee_paying_volume_usd_24h) are picked up automatically.
+ if err := dune.updateQuery(queryID); err != nil {
+ fmt.Printf("[init] Dune query update failed (continuing with cached SQL): %v\n", err)
+ } else {
+ fmt.Println("[init] Dune query SQL synced")
+ }
+
+ // On startup: fetch cached Dune result + current lighthouse data immediately,
+ // then trigger a fresh Dune execution in the background.
+ runPoll(dune, lighthouse, llama, queryID)
+ go func() {
+ execID, err := dune.execute(queryID)
+ if err != nil {
+ fmt.Printf("[refresh] execute failed: %v\n", err)
+ return
+ }
+ pollUntilDone(dune, lighthouse, llama, queryID, execID)
+ }()
+
+ fetchTick := time.NewTicker(fetchInterval)
+ refreshTick := time.NewTicker(refreshInterval)
+ defer fetchTick.Stop()
+ defer refreshTick.Stop()
+
+ for {
+ select {
+ case <-sig:
+ fmt.Println("[shutdown] received signal")
+ return
+ case <-fetchTick.C:
+ runPoll(dune, lighthouse, llama, queryID)
+ case <-refreshTick.C:
+ execID, err := dune.execute(queryID)
+ if err != nil {
+ fmt.Printf("[refresh] execute failed: %v\n", err)
+ continue
+ }
+ go pollUntilDone(dune, lighthouse, llama, queryID, execID)
+ }
+ }
+}
+
+func runPoll(dune *duneClient, lh *lighthouseClient, llama *defillamaClient, queryID string) {
+ rows, err := dune.latestResult(queryID)
+ if err != nil {
+ if isNoExecutionError(err) {
+ // After a SQL update, Dune creates a new version with no execution yet.
+ // The background goroutine will execute and call runPoll when done.
+ fmt.Printf("[fetch] dune: no execution for current query version, waiting for background refresh\n")
+ } else {
+ fmt.Printf("[fetch] dune failed: %v\n", err)
+ platformHealth.Set(0)
+ }
+ return
+ }
+
+ volumes, err := lh.fetchVolumes()
+ if err != nil {
+ fmt.Printf("[fetch] lighthouse failed: %v\n", err)
+ platformHealth.Set(0)
+ return
+ }
+
+ solUSD := getSolPrice()
+
+ // Aggregate rows per platform (Dune may return multiple rows).
+ type totals struct {
+ feesUSD float64
+ feePayingVolumeUSD float64
+ }
+ byPlatform := make(map[string]*totals)
+ var latestBlockTime string
+ for _, r := range rows {
+ if r.Platform == "" {
+ continue
+ }
+ t, ok := byPlatform[r.Platform]
+ if !ok {
+ t = &totals{}
+ byPlatform[r.Platform] = t
+ }
+ t.feesUSD += r.USDCFees24h + r.WSOLFees24h*solUSD + r.SOLFees24h*solUSD
+ t.feePayingVolumeUSD += r.FeePayingVolumeUSD24h
+ if r.DataFreshness > latestBlockTime {
+ latestBlockTime = r.DataFreshness
+ }
+ }
+ if latestBlockTime != "" {
+ s := latestBlockTime
+ if len(s) > 19 {
+ s = s[:19]
+ }
+ for _, layout := range []string{"2006-01-02T15:04:05", "2006-01-02 15:04:05"} {
+ if ts, err := time.Parse(layout, s); err == nil {
+ duneDataFreshness.Set(float64(ts.Unix()))
+ break
+ }
+ }
+ }
+
+ // Fomo fees: on-chain USDC sweeps + off-chain relay fees only accessible
+ // via DeFiLlama (private Dune table dune.tryfomo.fomo_relay_fees).
+ fomoFees, err := llama.fetchFomoFees()
+ if err != nil {
+ fmt.Printf("[fetch] fomo/defillama failed: %v\n", err)
+ } else if fomoFees > 0 {
+ byPlatform["fomo"] = &totals{feesUSD: fomoFees}
+ }
+
+ for platform, t := range byPlatform {
+ mobulaVol := volumes[platform]
+ feesUSD24h.WithLabelValues(platform).Set(t.feesUSD)
+ volumeUSD24h.WithLabelValues(platform).Set(mobulaVol)
+ feePayingVolumeUSD24h.WithLabelValues(platform).Set(t.feePayingVolumeUSD)
+
+ rate := 0.0
+ if mobulaVol > 0 {
+ rate = t.feesUSD / mobulaVol * 100
+ }
+ feeRatePct.WithLabelValues(platform).Set(rate)
+
+ if t.feePayingVolumeUSD > 0 {
+ feePayingRatePct.WithLabelValues(platform).Set(t.feesUSD / t.feePayingVolumeUSD * 100)
+ if mobulaVol > 0 {
+ coveragePct.WithLabelValues(platform).Set(t.feePayingVolumeUSD / mobulaVol * 100)
+ }
+ }
+ }
+
+ lastPollTime.SetToCurrentTime()
+ platformHealth.Set(1)
+ fmt.Printf("[fetch] updated %d platform(s), sol=$%.2f\n", len(byPlatform), solUSD)
+}
+
+func pollUntilDone(dune *duneClient, lh *lighthouseClient, llama *defillamaClient, queryID, execID string) {
+ for range 30 {
+ time.Sleep(30 * time.Second)
+ state, err := dune.executionState(execID)
+ if err != nil {
+ fmt.Printf("[poll] state check failed: %v\n", err)
+ return
+ }
+ switch state {
+ case "QUERY_STATE_COMPLETED":
+ fmt.Printf("[poll] execution %s complete\n", execID)
+ runPoll(dune, lh, llama, queryID)
+ return
+ case "QUERY_STATE_FAILED", "QUERY_STATE_CANCELLED", "QUERY_STATE_EXPIRED":
+ fmt.Printf("[poll] execution %s ended with state %s\n", execID, state)
+ return
+ }
+ }
+ fmt.Printf("[poll] execution %s timed out waiting\n", execID)
+}
diff --git a/harnesses/memecoin-platforms/cmd/monitor/metrics.go b/harnesses/memecoin-platforms/cmd/monitor/metrics.go
new file mode 100644
index 00000000..87225b2e
--- /dev/null
+++ b/harnesses/memecoin-platforms/cmd/monitor/metrics.go
@@ -0,0 +1,73 @@
+package main
+
+import (
+ "net/http"
+
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+)
+
+var (
+ feeRatePct = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "memecoin_platform_fee_rate_pct",
+ Help: "Take rate using Mobula total volume: fees_usd_24h / mobula_volume_usd_24h * 100 (lower bound; denominator includes non-fee trades)",
+ }, []string{"platform"})
+
+ feePayingRatePct = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "memecoin_platform_fee_paying_rate_pct",
+ Help: "Take rate using fee-paying volume only: fees_usd_24h / fee_paying_volume_usd_24h * 100 (comparable across platforms)",
+ }, []string{"platform"})
+
+ coveragePct = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "memecoin_platform_coverage_pct",
+ Help: "Fee-paying volume / Mobula total volume * 100: fraction of attributed volume that generated a fee inflow",
+ }, []string{"platform"})
+
+ feesUSD24h = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "memecoin_platform_fees_usd_24h",
+ Help: "Raw USD fees collected by this platform in the last 24h (Dune)",
+ }, []string{"platform"})
+
+ volumeUSD24h = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "memecoin_platform_volume_usd_24h",
+ Help: "Trading volume in USD in the last 24h (Mobula lighthouse, total attributed volume)",
+ }, []string{"platform"})
+
+ feePayingVolumeUSD24h = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "memecoin_platform_fee_paying_volume_usd_24h",
+ Help: "Volume of trades that generated a fee inflow in the last 24h (Dune: largest swap per fee-receiving tx)",
+ }, []string{"platform"})
+
+ platformHealth = prometheus.NewGauge(prometheus.GaugeOpts{
+ Name: "memecoin_platform_health",
+ Help: "1.0 when both Dune and lighthouse data are fresh",
+ })
+
+ lastPollTime = prometheus.NewGauge(prometheus.GaugeOpts{
+ Name: "memecoin_last_poll_timestamp_seconds",
+ Help: "Unix timestamp of the last successful poll",
+ })
+
+ duneDataFreshness = prometheus.NewGauge(prometheus.GaugeOpts{
+ Name: "memecoin_dune_data_freshness_seconds",
+ Help: "Unix timestamp of the most recent block_time seen in the Dune query results. Gap from now() indicates solana.account_activity table lag.",
+ })
+)
+
+func init() {
+ prometheus.MustRegister(
+ feeRatePct, feePayingRatePct, coveragePct,
+ feesUSD24h, volumeUSD24h, feePayingVolumeUSD24h,
+ platformHealth, lastPollTime, duneDataFreshness,
+ )
+}
+
+func startMetricsServer(addr string) error {
+ mux := http.NewServeMux()
+ mux.Handle("/metrics", promhttp.Handler())
+ mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("OK"))
+ })
+ return http.ListenAndServe(addr, mux)
+}
diff --git a/harnesses/memecoin-platforms/cmd/monitor/solprice.go b/harnesses/memecoin-platforms/cmd/monitor/solprice.go
new file mode 100644
index 00000000..2a057a73
--- /dev/null
+++ b/harnesses/memecoin-platforms/cmd/monitor/solprice.go
@@ -0,0 +1,85 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "sync/atomic"
+ "time"
+ "math"
+)
+
+const coingeckoURL = "https://api.coingecko.com/api/v3/simple/price?ids=solana&vs_currencies=usd"
+
+// solPriceBits stores the current SOL/USD price as a uint64 (math.Float64bits).
+var solPriceBits atomic.Uint64
+
+func init() {
+ setSolPrice(150.0)
+}
+
+func setSolPrice(usd float64) {
+ solPriceBits.Store(math.Float64bits(usd))
+}
+
+func getSolPrice() float64 {
+ return math.Float64frombits(solPriceBits.Load())
+}
+
+func updateSolPrice(client *http.Client) {
+ price, err := fetchSolPrice(client)
+ if err != nil {
+ fmt.Printf("[solprice] fetch failed: %v (keeping %.2f)\n", err, getSolPrice())
+ return
+ }
+ setSolPrice(price)
+ fmt.Printf("[solprice] updated: $%.2f\n", price)
+}
+
+func fetchSolPrice(client *http.Client) (float64, error) {
+ req, err := http.NewRequest(http.MethodGet, coingeckoURL, nil)
+ if err != nil {
+ return 0, err
+ }
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return 0, fmt.Errorf("coingecko: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
+ if err != nil {
+ return 0, fmt.Errorf("read: %w", err)
+ }
+ if resp.StatusCode >= 400 {
+ return 0, fmt.Errorf("status %d: %.100s", resp.StatusCode, body)
+ }
+
+ var out struct {
+ Solana struct {
+ USD float64 `json:"usd"`
+ } `json:"solana"`
+ }
+ if err := json.Unmarshal(body, &out); err != nil {
+ return 0, fmt.Errorf("parse: %w", err)
+ }
+ if out.Solana.USD <= 0 {
+ return 0, fmt.Errorf("invalid price: %v", out.Solana.USD)
+ }
+ return out.Solana.USD, nil
+}
+
+func startSolPriceRefresher(interval time.Duration) {
+ client := &http.Client{Timeout: 10 * time.Second}
+ updateSolPrice(client)
+ go func() {
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for range ticker.C {
+ updateSolPrice(client)
+ }
+ }()
+}
diff --git a/harnesses/memecoin-platforms/go.mod b/harnesses/memecoin-platforms/go.mod
new file mode 100644
index 00000000..f7eb7f34
--- /dev/null
+++ b/harnesses/memecoin-platforms/go.mod
@@ -0,0 +1,19 @@
+module memecoin-platforms
+
+go 1.24.0
+
+toolchain go1.24.4
+
+require github.com/prometheus/client_golang v1.20.5
+
+require (
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/klauspost/compress v1.17.9 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/prometheus/client_model v0.6.1 // indirect
+ github.com/prometheus/common v0.55.0 // indirect
+ github.com/prometheus/procfs v0.15.1 // indirect
+ golang.org/x/sys v0.40.0 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
+)
diff --git a/harnesses/memecoin-platforms/go.sum b/harnesses/memecoin-platforms/go.sum
new file mode 100644
index 00000000..309d820f
--- /dev/null
+++ b/harnesses/memecoin-platforms/go.sum
@@ -0,0 +1,24 @@
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
+github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
+github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
+github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
+github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
+github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
+github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
+github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
+github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
+golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
+golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
diff --git a/harnesses/memecoin-platforms/queries/platform_fees.sql b/harnesses/memecoin-platforms/queries/platform_fees.sql
new file mode 100644
index 00000000..9f440be3
--- /dev/null
+++ b/harnesses/memecoin-platforms/queries/platform_fees.sql
@@ -0,0 +1,144 @@
+-- OCB Bench 203: Memecoin Platform Fees (Dune Trino SQL)
+-- Monitors known fee wallets for native SOL, USDC, and WSOL inflows.
+-- Key: native SOL uses address column, SPL tokens use token_balance_owner column.
+--
+-- Columns: platform, usdc_fees_24h, wsol_fees_24h, sol_fees_24h, data_freshness,
+-- fee_paying_volume_usd_24h
+-- fee_paying_volume_usd_24h = sum of the largest swap (amount_usd) in each tx that
+-- generated a fee inflow; provides a self-consistent denominator so take_rate is
+-- comparable across platforms (vs. Mobula total volume which includes non-fee trades).
+-- data_freshness = MAX(block_time) across all matched rows; alerts if table lags
+-- and the nominal 24h window silently shrinks (e.g. table only refreshed 20h ago).
+
+WITH
+
+wallet_platform AS (
+ SELECT address, platform FROM (VALUES
+ ('BB5dnY55FXS1e1NXqZDwCzgdYJdMCj3B92PU6Q5Fb6DT','gmgn'),
+ ('7sHXjs1j7sDJGVSMSPjD1b4v3FD6uRSvRWfhRdfv5BiA','gmgn'),
+ ('HeZVpHj9jLwTVtMMbzQRf6mLtFPkWNSg11o68qrbUBa3','gmgn'),
+ ('ByRRgnZenY6W2sddo1VJzX9o4sMU4gPDUkcmgrpGBxRy','gmgn'),
+ ('DXfkEGoo6WFsdL7x6gLZ7r6Hw2S6HrtrAQVPWYx2A1s9','gmgn'),
+ ('3t9EKmRiAUcQUYzTZpNojzeGP1KBAVEEbDNmy6wECQpK','gmgn'),
+ ('DymeoWc5WLNiQBaoLuxrxDnDRvLgGZ1QGsEoCAM7Jsrx','gmgn'),
+ ('dBhdrmwBkRa66XxBuAK4WZeZnsZ6bHeHCCLXa3a8bTJ','gmgn'),
+ ('6TxjC5wJzuuZgTtnTMipwwULEbMPx5JPW3QwWkdTGnrn','gmgn'),
+ ('7LCZckF6XXGQ1hDY6HFXBKWAtiUgL9QY5vj1C4Bn1Qjj','axiom'),
+ ('4V65jvcDG9DSQioUVqVPiUcUY9v6sb6HKtMnsxSKEz5S','axiom'),
+ ('CeA3sPZfWWToFEBmw5n1Y93tnV66Vmp8LacLzsVprgxZ','axiom'),
+ ('AaG6of1gbj1pbDumvbSiTuJhRCRkkUNaWVxijSbWvTJW','axiom'),
+ ('7oi1L8U9MRu5zDz5syFahsiLUric47LzvJBQX6r827ws','axiom'),
+ ('9kPrgLggBJ69tx1czYAbp7fezuUmL337BsqQTKETUEhP','axiom'),
+ ('DKyUs1xXMDy8Z11zNsLnUg3dy9HZf6hYZidB6WodcaGy','axiom'),
+ ('4FobGn5ZWYquoJkxMzh2VUAWvV36xMgxQ3M7uG1pGGhd','axiom'),
+ ('76sxKrPtgoJHDJvxwFHqb3cAXWfRHFLe3VpKcLCAHSEf','axiom'),
+ ('H2cDR3EkJjtTKDQKk8SJS48du9mhsdzQhy8xJx5UMqQK','axiom'),
+ ('8m5GkL7nVy95G4YVUbs79z873oVKqg2afgKRmqxsiiRm','axiom'),
+ ('4kuG6NsAFJNwqEkac8GFDMMheCGKUPEbaRVHHyFHSwWz','axiom'),
+ ('8vFGAKdwpn4hk7kc1cBgfWZzpyW3MEMDATDzVZhddeQb','axiom'),
+ ('86Vh4XGLW2b6nvWbRyDs4ScgMXbuvRCHT7WbUT3RFxKG','axiom'),
+ ('DZfEurFKFtSbdWZsKSDTqpqsQgvXxmESpvRtXkAdgLwM','axiom'),
+ ('5L2QKqDn5ukJSWGyqR4RPvFvwnBabKWqAqMzH4heaQNB','axiom'),
+ ('DYVeNgXGLAhZdeLMMYnCw1nPnMxkBN7fJnNpHmizTrrF','axiom'),
+ ('Hbj6XdxX6eV4nfbYTseysibp4zZJtVRRPn2J3BhGRuK9','axiom'),
+ ('846ah7iBSu9ApuCyEhA5xpnjHHX7d4QJKetWLbwzmJZ8','axiom'),
+ ('5BqYhuD4q1YD3DMAYkc1FeTu9vqQVYYdfBAmkZjamyZg','axiom'),
+ ('HrTf9CzXR1dRH4Sof5QrpmGWwpwAf3qZzwCsEjQpXcSq','fomo'),
+ ('9yMwSPk9mrXSN7yDHUuZurAh1sjbJsfpUqjZ7SvVtdco','trojan'),
+ ('92Med3qeK7duC5iiYsHX38H2f2twJfRsSx93oNrza2VH','trojan'),
+ ('2jwHNxavSoMZMEDbT1eV9PcPt5dDcayCqM6MkgaPpmWQ','trojan'),
+ ('65gDv7pZQCZELsNpNYSFEBtNFpWZAbxmRFB6BGMqFkHH','trojan'),
+ ('BWgb8wR1FEGiu1jCDSKuHKf752W27b4iN6SvoNCiK4qp','trojan'),
+ ('8jgg7moFJkHyTtAv9M6RBSPMp2oXeXhuiUMKW8YbYCWn','trojan'),
+ ('AVUCZyuT35YSuj4RH7fwiyPu82Djn2Hfg7y2ND2XcnZH','photon'),
+ ('MaestroUL88UBnZr3wfoN7hqmNWFi3ZYCGqZoJJHE36','maestro'),
+ ('FRMxAnZgkW58zbYcE7Bxqsg99VWpJh6sMP5xLzAWNabN','maestro'),
+ ('CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM','pump-fun'),
+ ('62qc2CNXwrYqQScmEdiZFFAnJR262PxWEuNQtxfafNgV','pump-fun'),
+ ('FWsW1xNtWscwNmKv6wVsU1iTzRN6wmmk3MjxRP5tT7hz','pump-fun'),
+ ('7hTckgnGnLQR6sdH7YkqFTAA7VwTfYFaZ6EhEsU3saCX','pump-fun'),
+ ('AVmoTthdrX6tKt4nDjco2D775W2YK3sDhxPcMmzUAmTY','pump-fun'),
+ ('9rPYyANsfQZw3DnDmKE3YCQF5E8oD89UXoHn9JFEhJUz','pump-fun'),
+ ('G5UZAVbAf46s7cKWoyKu8kYTip9DGTpbLZ2qa9Aq69dP','pump-fun'),
+ ('7VtfL8fvgNfhz17qKRMjzQEXgbdpnHHHQRh54R9jP2RJ','pump-fun'),
+ ('GesfTA3X2arioaHp8bbKdjG9vJtskViWACZoYvxp4twS','pump-fun')
+ ) AS t(address, platform)
+),
+
+native_sol AS (
+ SELECT wp.platform, a.tx_id, CAST(a.post_balance - a.pre_balance AS DOUBLE) / 1e9 AS inflow, a.block_time
+ FROM solana.account_activity a
+ JOIN wallet_platform wp ON a.address = wp.address
+ WHERE a.block_time >= NOW() - INTERVAL '1' DAY
+ AND a.token_mint_address IS NULL
+ AND a.post_balance > a.pre_balance
+),
+
+usdc_inflows AS (
+ SELECT wp.platform, a.tx_id, GREATEST(a.post_token_balance - a.pre_token_balance, 0) / 1e6 AS inflow, a.block_time
+ FROM solana.account_activity a
+ JOIN wallet_platform wp ON a.token_balance_owner = wp.address
+ WHERE a.block_time >= NOW() - INTERVAL '1' DAY
+ AND a.token_mint_address = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
+ AND a.post_token_balance > a.pre_token_balance
+),
+
+wsol_inflows AS (
+ SELECT wp.platform, a.tx_id, GREATEST(a.post_token_balance - a.pre_token_balance, 0) / 1e9 AS inflow, a.block_time
+ FROM solana.account_activity a
+ JOIN wallet_platform wp ON a.token_balance_owner = wp.address
+ WHERE a.block_time >= NOW() - INTERVAL '1' DAY
+ AND a.token_mint_address = 'So11111111111111111111111111111111111111112'
+ AND a.post_token_balance > a.pre_token_balance
+),
+
+combined AS (
+ SELECT platform, 0.0 AS usdc_inflow, 0.0 AS wsol_inflow, inflow AS sol_inflow, block_time FROM native_sol
+ UNION ALL
+ SELECT platform, inflow, 0.0, 0.0, block_time FROM usdc_inflows
+ UNION ALL
+ SELECT platform, 0.0, inflow, 0.0, block_time FROM wsol_inflows
+),
+
+-- All (platform, tx_id) pairs where a fee inflow was received (deduplicated)
+fee_tx_ids AS (
+ SELECT platform, tx_id FROM native_sol
+ UNION
+ SELECT platform, tx_id FROM usdc_inflows
+ UNION
+ SELECT platform, tx_id FROM wsol_inflows
+),
+
+-- For each fee-generating tx, take the largest swap trade (max amount_usd per tx)
+-- to avoid double-counting multi-hop swaps in the same transaction.
+fee_paying_vol AS (
+ SELECT sub.platform, SUM(sub.max_swap) AS fee_paying_volume_usd_24h
+ FROM (
+ SELECT ft.platform, ft.tx_id, MAX(COALESCE(ABS(t.amount_usd), 0)) AS max_swap
+ FROM fee_tx_ids ft
+ JOIN dex_solana.trades t ON t.tx_id = ft.tx_id
+ WHERE t.block_time >= NOW() - INTERVAL '1' DAY
+ GROUP BY ft.platform, ft.tx_id
+ ) sub
+ GROUP BY sub.platform
+)
+
+SELECT
+ fees.platform,
+ fees.usdc_fees_24h,
+ fees.wsol_fees_24h,
+ fees.sol_fees_24h,
+ fees.data_freshness,
+ COALESCE(fpv.fee_paying_volume_usd_24h, 0) AS fee_paying_volume_usd_24h
+FROM (
+ SELECT
+ platform,
+ COALESCE(SUM(usdc_inflow), 0) AS usdc_fees_24h,
+ COALESCE(SUM(wsol_inflow), 0) AS wsol_fees_24h,
+ COALESCE(SUM(sol_inflow), 0) AS sol_fees_24h,
+ MAX(block_time) AS data_freshness
+ FROM combined
+ GROUP BY platform
+) fees
+LEFT JOIN fee_paying_vol fpv ON fpv.platform = fees.platform
+ORDER BY fees.platform
diff --git a/harnesses/metadata-coverage/cmd/script/defined_auth.go b/harnesses/metadata-coverage/cmd/script/defined_auth.go
index 717e75ad..40ca7c73 100644
--- a/harnesses/metadata-coverage/cmd/script/defined_auth.go
+++ b/harnesses/metadata-coverage/cmd/script/defined_auth.go
@@ -58,6 +58,33 @@ func decodeJWTExpiration(token string) (time.Time, error) {
return time.Unix(claims.Exp, 0), nil
}
+// tryTokenService fetches a fresh JWE from the Paris-box sidecar.
+func tryTokenService(baseURL string) (string, error) {
+ client := &http.Client{Timeout: 5 * time.Second}
+ for _, path := range []string{"/token", "/jwt", "/"} {
+ resp, err := client.Get(baseURL + path)
+ if err != nil {
+ continue
+ }
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if resp.StatusCode != 200 {
+ continue
+ }
+ var parsed struct {
+ Token string `json:"token"`
+ }
+ if err := json.Unmarshal(body, &parsed); err == nil && parsed.Token != "" {
+ return parsed.Token, nil
+ }
+ s := strings.TrimSpace(string(body))
+ if len(s) > 50 {
+ return s, nil
+ }
+ }
+ return "", fmt.Errorf("no working path on sidecar")
+}
+
// GetDefinedJWTToken returns a cached JWT token or generates a new one if expired.
// If CODEX_JWT env var is set, it is returned directly (bypasses session-cookie flow).
func GetDefinedJWTToken(sessionCookie string) (string, error) {
@@ -84,6 +111,17 @@ func GetDefinedJWTToken(sessionCookie string) (string, error) {
return globalTokenCache.token, nil
}
+ // Sidecar (Paris box, fresh every 8 min via Tor+utls, bypasses Kasada)
+ if svcURL := os.Getenv("DEFINED_TOKEN_SERVICE_URL"); svcURL != "" {
+ if tok, err := tryTokenService(svcURL); err == nil && tok != "" {
+ fmt.Printf("[DEFINED-AUTH] Got token from sidecar (len=%d)\n", len(tok))
+ globalTokenCache.token = tok
+ globalTokenCache.expiresAt = time.Now().Add(8 * time.Minute)
+ globalTokenCache.lastRefresh = time.Now()
+ return tok, nil
+ }
+ }
+
// Generate new token
token, err := generateDefinedJWTToken(sessionCookie)
if err != nil {
diff --git a/harnesses/mev-protect-rpc/cmd/script/probe.go b/harnesses/mev-protect-rpc/cmd/script/probe.go
index 25920a4d..0afd3358 100644
--- a/harnesses/mev-protect-rpc/cmd/script/probe.go
+++ b/harnesses/mev-protect-rpc/cmd/script/probe.go
@@ -192,7 +192,7 @@ func call(ctx context.Context, client *http.Client, url, method string, params [
start := time.Now()
resp, err := client.Do(req)
- lat := float64(time.Since(start).Milliseconds())
+ lat := float64(time.Since(start).Nanoseconds()) / 1e6
if err != nil {
if ctx.Err() != nil {
return lat, "canceled"
diff --git a/harnesses/perp-protocol-longevity/cmd/script/defillama.go b/harnesses/perp-protocol-longevity/cmd/script/defillama.go
index e54b5962..c07becb2 100644
--- a/harnesses/perp-protocol-longevity/cmd/script/defillama.go
+++ b/harnesses/perp-protocol-longevity/cmd/script/defillama.go
@@ -65,6 +65,9 @@ func enrichFromDefiLlama(reg []venueRecord) []venueRecord {
if t.Before(reg[i].Launched) {
continue
}
+ if h.Amount <= 0 {
+ continue // null/zero amount = no confirmed financial loss
+ }
if h.ReturnedFunds != nil && *h.ReturnedFunds >= h.Amount {
continue
}
diff --git a/harnesses/pm-rate-limits/cmd/script/codex_auth.go b/harnesses/pm-rate-limits/cmd/script/codex_auth.go
index a4e1fd39..2b170cd7 100644
--- a/harnesses/pm-rate-limits/cmd/script/codex_auth.go
+++ b/harnesses/pm-rate-limits/cmd/script/codex_auth.go
@@ -78,6 +78,18 @@ func GetCodexJWT(sessionCookie string) (string, error) {
if codexTokenCache.token != "" && time.Now().Before(codexTokenCache.expiresAt.Add(-1*time.Hour)) {
return codexTokenCache.token, nil
}
+
+ // Sidecar (Paris box, fresh every 8 min via Tor+utls, bypasses Kasada)
+ if svcURL := os.Getenv("DEFINED_TOKEN_SERVICE_URL"); svcURL != "" {
+ if tok, err := tryTokenService(svcURL); err == nil && tok != "" {
+ log.Printf("[codex-auth] Got token from sidecar (len=%d)", len(tok))
+ codexTokenCache.token = tok
+ codexTokenCache.expiresAt = time.Now().Add(8 * time.Minute)
+ codexTokenCache.lastRefresh = time.Now()
+ return tok, nil
+ }
+ }
+
tok, err := mintCodexJWT(sessionCookie)
if err != nil {
return "", err
@@ -101,6 +113,32 @@ func InvalidateCodexJWT() {
log.Printf("[codex-auth] JWT cache invalidated")
}
+func tryTokenService(baseURL string) (string, error) {
+ client := &http.Client{Timeout: 5 * time.Second}
+ for _, path := range []string{"/token", "/jwt", "/"} {
+ resp, err := client.Get(baseURL + path)
+ if err != nil {
+ continue
+ }
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if resp.StatusCode != 200 {
+ continue
+ }
+ var parsed struct {
+ Token string `json:"token"`
+ }
+ if err := json.Unmarshal(body, &parsed); err == nil && parsed.Token != "" {
+ return parsed.Token, nil
+ }
+ s := strings.TrimSpace(string(body))
+ if len(s) > 50 {
+ return s, nil
+ }
+ }
+ return "", fmt.Errorf("no working path on sidecar")
+}
+
func mintCodexJWT(sessionCookie string) (string, error) {
// CRITICAL: route through HTTP_PROXY / HTTPS_PROXY (Webshare). Direct
// Railway IPs get stuck in Vercel's bot-ban loop after a few mints.
diff --git a/harnesses/rpc-capabilities/cmd/script/probe.go b/harnesses/rpc-capabilities/cmd/script/probe.go
index 3856911e..b27502c5 100644
--- a/harnesses/rpc-capabilities/cmd/script/probe.go
+++ b/harnesses/rpc-capabilities/cmd/script/probe.go
@@ -122,7 +122,7 @@ func callLatestBlock(ctx context.Context, url string) (block uint64, hash string
start := time.Now()
resp, err := client.Do(req)
- latencyMs = float64(time.Since(start).Milliseconds())
+ latencyMs = float64(time.Since(start).Nanoseconds()) / 1e6
if err != nil {
if ctx.Err() != nil || strings.Contains(err.Error(), "deadline exceeded") || strings.Contains(err.Error(), "Timeout") {
@@ -309,7 +309,7 @@ func callLatestSlot(ctx context.Context, url string) (slot uint64, result string
start := time.Now()
resp, err := client.Do(req)
- latencyMs = float64(time.Since(start).Milliseconds())
+ latencyMs = float64(time.Since(start).Nanoseconds()) / 1e6
if err != nil {
if ctx.Err() != nil || strings.Contains(err.Error(), "deadline exceeded") || strings.Contains(err.Error(), "Timeout") {
@@ -375,7 +375,7 @@ func callSubstrateHeader(ctx context.Context, url string) (block uint64, hash st
start := time.Now()
resp, err := client.Do(req)
- latencyMs = float64(time.Since(start).Milliseconds())
+ latencyMs = float64(time.Since(start).Nanoseconds()) / 1e6
if err != nil {
if ctx.Err() != nil || strings.Contains(err.Error(), "deadline exceeded") || strings.Contains(err.Error(), "Timeout") {
@@ -449,7 +449,7 @@ func callCosmosStatus(ctx context.Context, url string) (block uint64, hash strin
start := time.Now()
resp, err := client.Do(req)
- latencyMs = float64(time.Since(start).Milliseconds())
+ latencyMs = float64(time.Since(start).Nanoseconds()) / 1e6
if err != nil {
if ctx.Err() != nil || strings.Contains(err.Error(), "deadline exceeded") || strings.Contains(err.Error(), "Timeout") {
diff --git a/harnesses/solana-dex-volume/Dockerfile b/harnesses/solana-dex-volume/Dockerfile
new file mode 100644
index 00000000..c620d261
--- /dev/null
+++ b/harnesses/solana-dex-volume/Dockerfile
@@ -0,0 +1,15 @@
+FROM golang:1.24-alpine AS builder
+
+WORKDIR /app
+RUN apk add --no-cache git
+
+COPY go.mod go.sum ./
+COPY cmd ./cmd
+RUN go mod download && CGO_ENABLED=0 GOOS=linux go build -o /app/harness ./cmd/script
+
+FROM debian:bookworm-slim
+WORKDIR /app
+RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
+COPY --from=builder /app/harness /app/harness
+EXPOSE 2112
+CMD ["/app/harness"]
diff --git a/harnesses/solana-dex-volume/cmd/script/main.go b/harnesses/solana-dex-volume/cmd/script/main.go
new file mode 100644
index 00000000..e04d2049
--- /dev/null
+++ b/harnesses/solana-dex-volume/cmd/script/main.go
@@ -0,0 +1,175 @@
+// solana-dex-volume -- Bench 205
+//
+// Polls the DeFiLlama DEX API every 30 minutes for each tracked platform
+// and exposes per-platform 24h/7d volume and protocol revenue as Prometheus gauges.
+//
+// No API key required. Endpoint: https://api.llama.fi/summary/dexs/{slug}
+//
+// Metrics on :2112/metrics:
+//
+// defillama_dex_volume_24h_usd{platform}
+// defillama_dex_volume_7d_usd{platform}
+// defillama_dex_fees_24h_usd{platform} (protocol revenue, ?dataType=dailyRevenue)
+// defillama_dex_fees_7d_usd{platform}
+// defillama_dex_take_rate{platform} (fees24h / volume24h)
+// defillama_dex_health{platform}
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/promauto"
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+)
+
+const (
+ pollInterval = 30 * time.Minute
+ baseURL = "https://api.llama.fi"
+)
+
+var platforms = []struct {
+ slug string
+ label string
+}{
+ {"pump.fun", "pump-fun"},
+ {"gmgn", "gmgn"},
+ {"axiom", "axiom"},
+ {"fomo-wallet", "fomo"},
+ {"trojan", "trojan"},
+ {"photon", "photon"},
+ {"bullx", "bullx"},
+}
+
+var (
+ volume24h = promauto.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "defillama_dex_volume_24h_usd",
+ Help: "24h DEX trading volume in USD from DeFiLlama",
+ }, []string{"platform"})
+
+ volume7d = promauto.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "defillama_dex_volume_7d_usd",
+ Help: "7-day DEX trading volume in USD from DeFiLlama",
+ }, []string{"platform"})
+
+ fees24h = promauto.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "defillama_dex_fees_24h_usd",
+ Help: "24h protocol revenue in USD from DeFiLlama (dailyRevenue dataType)",
+ }, []string{"platform"})
+
+ fees7d = promauto.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "defillama_dex_fees_7d_usd",
+ Help: "7-day protocol revenue in USD from DeFiLlama (dailyRevenue dataType)",
+ }, []string{"platform"})
+
+ takeRate = promauto.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "defillama_dex_take_rate",
+ Help: "Protocol revenue as fraction of trading volume (fees24h / volume24h)",
+ }, []string{"platform"})
+
+ health = promauto.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "defillama_dex_health",
+ Help: "1 if last DeFiLlama fetch succeeded, 0 otherwise",
+ }, []string{"platform"})
+)
+
+type llamaResponse struct {
+ Total24h float64 `json:"total24h"`
+ Total7d float64 `json:"total7d"`
+}
+
+func fetch(endpoint, slug, query string) (llamaResponse, error) {
+ url := fmt.Sprintf("%s/summary/%s/%s", baseURL, endpoint, slug)
+ if query != "" {
+ url += "?" + query
+ }
+ resp, err := http.Get(url)
+ if err != nil {
+ return llamaResponse{}, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ return llamaResponse{}, fmt.Errorf("HTTP %d", resp.StatusCode)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return llamaResponse{}, err
+ }
+ var r llamaResponse
+ if err := json.Unmarshal(body, &r); err != nil {
+ return llamaResponse{}, err
+ }
+ return r, nil
+}
+
+func runOnce() {
+ for _, p := range platforms {
+ vol, err := fetch("dexs", p.slug, "")
+ if err != nil {
+ fmt.Printf("[poll] volume %s: %v\n", p.slug, err)
+ health.WithLabelValues(p.label).Set(0)
+ continue
+ }
+
+ rev, err := fetch("fees", p.slug, "dataType=dailyRevenue")
+ if err != nil {
+ fmt.Printf("[poll] fees %s: %v (volume ok)\n", p.slug, err)
+ }
+
+ volume24h.WithLabelValues(p.label).Set(vol.Total24h)
+ volume7d.WithLabelValues(p.label).Set(vol.Total7d)
+ fees24h.WithLabelValues(p.label).Set(rev.Total24h)
+ fees7d.WithLabelValues(p.label).Set(rev.Total7d)
+ if vol.Total24h > 0 {
+ takeRate.WithLabelValues(p.label).Set(rev.Total24h / vol.Total24h)
+ }
+ health.WithLabelValues(p.label).Set(1)
+ fmt.Printf("[poll] %s: vol24=$%.0f vol7d=$%.0f rev24=$%.0f rate=%.4f\n",
+ p.label, vol.Total24h, vol.Total7d, rev.Total24h, rev.Total24h/max(vol.Total24h, 1))
+ }
+}
+
+func max(a, b float64) float64 {
+ if a > b {
+ return a
+ }
+ return b
+}
+
+func main() {
+ fmt.Println("=== solana-dex-volume harness (Bench 205) ===")
+
+ go func() {
+ http.Handle("/metrics", promhttp.Handler())
+ http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
+ fmt.Println("[srv] metrics on :2112")
+ if err := http.ListenAndServe(":2112", nil); err != nil {
+ fmt.Fprintf(os.Stderr, "[fatal] %v\n", err)
+ os.Exit(1)
+ }
+ }()
+
+ sig := make(chan os.Signal, 1)
+ signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
+
+ runOnce()
+
+ tick := time.NewTicker(pollInterval)
+ defer tick.Stop()
+
+ for {
+ select {
+ case <-sig:
+ return
+ case <-tick.C:
+ runOnce()
+ }
+ }
+}
diff --git a/harnesses/solana-dex-volume/go.mod b/harnesses/solana-dex-volume/go.mod
new file mode 100644
index 00000000..8655e4bf
--- /dev/null
+++ b/harnesses/solana-dex-volume/go.mod
@@ -0,0 +1,18 @@
+module solana-dex-volume
+
+go 1.24.0
+
+require github.com/prometheus/client_golang v1.23.2
+
+require (
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/kr/text v0.2.0 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.66.1 // indirect
+ github.com/prometheus/procfs v0.16.1 // indirect
+ go.yaml.in/yaml/v2 v2.4.2 // indirect
+ golang.org/x/sys v0.35.0 // indirect
+ google.golang.org/protobuf v1.36.8 // indirect
+)
diff --git a/harnesses/solana-dex-volume/go.sum b/harnesses/solana-dex-volume/go.sum
new file mode 100644
index 00000000..d6b8ca98
--- /dev/null
+++ b/harnesses/solana-dex-volume/go.sum
@@ -0,0 +1,46 @@
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
+github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
+github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
+github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
+go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
+golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
+golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
+google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/harnesses/solana-exec/cmd/api/main.go b/harnesses/solana-exec/cmd/api/main.go
index 3fcf7cac..79c3853a 100644
--- a/harnesses/solana-exec/cmd/api/main.go
+++ b/harnesses/solana-exec/cmd/api/main.go
@@ -3,10 +3,13 @@ package main
import (
"context"
"encoding/json"
+ "fmt"
+ "io"
"log"
"net/http"
"os"
"sort"
+ "sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
@@ -26,6 +29,8 @@ func main() {
w.Write([]byte(`{"status":"ok"}`))
})
mux.HandleFunc("/api/exec-leaderboard", corsJSON(handleExecLeaderboard(pool)))
+ mux.HandleFunc("/api/evm-revenue", corsJSON(handleEVMRevenue(pool)))
+ mux.HandleFunc("/metrics", handleMetrics(pool))
addr := ":2116"
log.Printf("exec-api listening on %s", addr)
@@ -41,6 +46,7 @@ type WindowStats struct {
P50CUPriceMicro float64 `json:"p50CUPriceMicro"`
P95CUPriceMicro float64 `json:"p95CUPriceMicro"`
AvgPlatformFeeLamports float64 `json:"avgPlatformFeeLamports"`
+ SumPlatformFeeLamports int64 `json:"sumPlatformFeeLamports"`
JitoRate float64 `json:"jitoRate"`
AvgCUConsumed float64 `json:"avgCUConsumed"`
}
@@ -62,48 +68,70 @@ func handleExecLeaderboard(pool *pgxpool.Pool) http.HandlerFunc {
defer cancel()
rows, err := pool.Query(ctx, `
+ WITH cu AS (
+ SELECT platform,
+ percentile_cont(0.5) WITHIN GROUP (ORDER BY cu_price_micro)
+ FILTER (WHERE block_time >= now() - INTERVAL '24 hours') AS h24_p50,
+ percentile_cont(0.95) WITHIN GROUP (ORDER BY cu_price_micro)
+ FILTER (WHERE block_time >= now() - INTERVAL '24 hours') AS h24_p95,
+ percentile_cont(0.5) WITHIN GROUP (ORDER BY cu_price_micro)
+ FILTER (WHERE block_time >= now() - INTERVAL '7 days') AS d7_p50,
+ percentile_cont(0.95) WITHIN GROUP (ORDER BY cu_price_micro)
+ FILTER (WHERE block_time >= now() - INTERVAL '7 days') AS d7_p95,
+ percentile_cont(0.5) WITHIN GROUP (ORDER BY cu_price_micro)
+ FILTER (WHERE block_time >= now() - INTERVAL '30 days') AS d30_p50,
+ percentile_cont(0.95) WITHIN GROUP (ORDER BY cu_price_micro)
+ FILTER (WHERE block_time >= now() - INTERVAL '30 days') AS d30_p95
+ FROM solana_exec_cu_samples
+ GROUP BY platform
+ )
SELECT
- platform,
+ f.platform,
-- weighted averages: SUM(avg×count)/SUM(count) avoids skewing by small off-peak buckets
- SUM(avg_priority_fee_lamports * tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '24 hours')
- / NULLIF(SUM(tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '24 hours'), 0) AS h24_prio,
- AVG(p50_cu_price_micro) FILTER (WHERE bucket_start >= now() - INTERVAL '24 hours') AS h24_p50,
- AVG(p95_cu_price_micro) FILTER (WHERE bucket_start >= now() - INTERVAL '24 hours') AS h24_p95,
- SUM(avg_platform_fee_lamports * tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '24 hours')
- / NULLIF(SUM(tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '24 hours'), 0) AS h24_pfee,
- SUM(jito_rate * tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '24 hours')
- / NULLIF(SUM(tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '24 hours'), 0) AS h24_jito,
- SUM(avg_cu_consumed * tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '24 hours')
- / NULLIF(SUM(tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '24 hours'), 0) AS h24_cu,
- SUM(tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '24 hours') AS h24_count,
-
- SUM(avg_priority_fee_lamports * tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '7 days')
- / NULLIF(SUM(tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '7 days'), 0) AS d7_prio,
- AVG(p50_cu_price_micro) FILTER (WHERE bucket_start >= now() - INTERVAL '7 days') AS d7_p50,
- AVG(p95_cu_price_micro) FILTER (WHERE bucket_start >= now() - INTERVAL '7 days') AS d7_p95,
- SUM(avg_platform_fee_lamports * tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '7 days')
- / NULLIF(SUM(tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '7 days'), 0) AS d7_pfee,
- SUM(jito_rate * tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '7 days')
- / NULLIF(SUM(tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '7 days'), 0) AS d7_jito,
- SUM(avg_cu_consumed * tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '7 days')
- / NULLIF(SUM(tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '7 days'), 0) AS d7_cu,
- SUM(tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '7 days') AS d7_count,
-
- SUM(avg_priority_fee_lamports * tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '30 days')
- / NULLIF(SUM(tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '30 days'), 0) AS d30_prio,
- AVG(p50_cu_price_micro) FILTER (WHERE bucket_start >= now() - INTERVAL '30 days') AS d30_p50,
- AVG(p95_cu_price_micro) FILTER (WHERE bucket_start >= now() - INTERVAL '30 days') AS d30_p95,
- SUM(avg_platform_fee_lamports * tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '30 days')
- / NULLIF(SUM(tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '30 days'), 0) AS d30_pfee,
- SUM(jito_rate * tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '30 days')
- / NULLIF(SUM(tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '30 days'), 0) AS d30_jito,
- SUM(avg_cu_consumed * tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '30 days')
- / NULLIF(SUM(tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '30 days'), 0) AS d30_cu,
- SUM(tx_count) FILTER (WHERE bucket_start >= now() - INTERVAL '30 days') AS d30_count,
-
- MAX(bucket_start)::text AS latest_bucket
- FROM solana_exec_facts
- GROUP BY platform`,
+ SUM(f.avg_priority_fee_lamports * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '24 hours')
+ / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '24 hours'), 0) AS h24_prio,
+ MAX(c.h24_p50) AS h24_p50,
+ MAX(c.h24_p95) AS h24_p95,
+ SUM(f.avg_platform_fee_lamports * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '24 hours')
+ / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '24 hours'), 0) AS h24_pfee,
+ COALESCE(SUM(f.sum_platform_fee_lamports) FILTER (WHERE f.bucket_start >= now() - INTERVAL '24 hours'), 0) AS h24_sum_pfee,
+ SUM(f.jito_rate * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '24 hours')
+ / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '24 hours'), 0) AS h24_jito,
+ SUM(f.avg_cu_consumed * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '24 hours')
+ / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '24 hours'), 0) AS h24_cu,
+ SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '24 hours') AS h24_count,
+
+ SUM(f.avg_priority_fee_lamports * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '7 days')
+ / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '7 days'), 0) AS d7_prio,
+ MAX(c.d7_p50) AS d7_p50,
+ MAX(c.d7_p95) AS d7_p95,
+ SUM(f.avg_platform_fee_lamports * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '7 days')
+ / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '7 days'), 0) AS d7_pfee,
+ COALESCE(SUM(f.sum_platform_fee_lamports) FILTER (WHERE f.bucket_start >= now() - INTERVAL '7 days'), 0) AS d7_sum_pfee,
+ SUM(f.jito_rate * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '7 days')
+ / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '7 days'), 0) AS d7_jito,
+ SUM(f.avg_cu_consumed * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '7 days')
+ / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '7 days'), 0) AS d7_cu,
+ SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '7 days') AS d7_count,
+
+ SUM(f.avg_priority_fee_lamports * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '30 days')
+ / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '30 days'), 0) AS d30_prio,
+ MAX(c.d30_p50) AS d30_p50,
+ MAX(c.d30_p95) AS d30_p95,
+ SUM(f.avg_platform_fee_lamports * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '30 days')
+ / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '30 days'), 0) AS d30_pfee,
+ COALESCE(SUM(f.sum_platform_fee_lamports) FILTER (WHERE f.bucket_start >= now() - INTERVAL '30 days'), 0) AS d30_sum_pfee,
+ SUM(f.jito_rate * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '30 days')
+ / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '30 days'), 0) AS d30_jito,
+ SUM(f.avg_cu_consumed * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '30 days')
+ / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '30 days'), 0) AS d30_cu,
+ SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '30 days') AS d30_count,
+
+ MAX(f.bucket_start)::text AS latest_bucket
+ FROM solana_exec_facts f
+ LEFT JOIN cu c USING (platform)
+ WHERE f.bucket_start >= now() - INTERVAL '31 days'
+ GROUP BY f.platform`,
)
if err != nil {
log.Printf("exec-api: query: %v", err)
@@ -116,17 +144,20 @@ func handleExecLeaderboard(pool *pgxpool.Pool) http.HandlerFunc {
for rows.Next() {
var plt, latestBkt string
var h24Prio, h24P50, h24P95, h24Pfee, h24Jito, h24Cu *float64
+ var h24SumPfee int64
var h24Count *int64
var d7Prio, d7P50, d7P95, d7Pfee, d7Jito, d7Cu *float64
+ var d7SumPfee int64
var d7Count *int64
var d30Prio, d30P50, d30P95, d30Pfee, d30Jito, d30Cu *float64
+ var d30SumPfee int64
var d30Count *int64
if err := rows.Scan(
&plt,
- &h24Prio, &h24P50, &h24P95, &h24Pfee, &h24Jito, &h24Cu, &h24Count,
- &d7Prio, &d7P50, &d7P95, &d7Pfee, &d7Jito, &d7Cu, &d7Count,
- &d30Prio, &d30P50, &d30P95, &d30Pfee, &d30Jito, &d30Cu, &d30Count,
+ &h24Prio, &h24P50, &h24P95, &h24Pfee, &h24SumPfee, &h24Jito, &h24Cu, &h24Count,
+ &d7Prio, &d7P50, &d7P95, &d7Pfee, &d7SumPfee, &d7Jito, &d7Cu, &d7Count,
+ &d30Prio, &d30P50, &d30P95, &d30Pfee, &d30SumPfee, &d30Jito, &d30Cu, &d30Count,
&latestBkt,
); err != nil {
log.Printf("exec-api: scan: %v", err)
@@ -137,9 +168,9 @@ func handleExecLeaderboard(pool *pgxpool.Pool) http.HandlerFunc {
Platform: plt,
LatestBkt: latestBkt,
Windows: map[string]WindowStats{
- "24h": buildWindow(h24Prio, h24P50, h24P95, h24Pfee, h24Jito, h24Cu, h24Count),
- "7d": buildWindow(d7Prio, d7P50, d7P95, d7Pfee, d7Jito, d7Cu, d7Count),
- "30d": buildWindow(d30Prio, d30P50, d30P95, d30Pfee, d30Jito, d30Cu, d30Count),
+ "24h": buildWindow(h24Prio, h24P50, h24P95, h24Pfee, h24SumPfee, h24Jito, h24Cu, h24Count),
+ "7d": buildWindow(d7Prio, d7P50, d7P95, d7Pfee, d7SumPfee, d7Jito, d7Cu, d7Count),
+ "30d": buildWindow(d30Prio, d30P50, d30P95, d30Pfee, d30SumPfee, d30Jito, d30Cu, d30Count),
},
}
platforms = append(platforms, p)
@@ -159,7 +190,7 @@ func handleExecLeaderboard(pool *pgxpool.Pool) http.HandlerFunc {
}
}
-func buildWindow(prio, p50, p95, pfee, jito, cu *float64, count *int64) WindowStats {
+func buildWindow(prio, p50, p95, pfee *float64, sumPfee int64, jito, cu *float64, count *int64) WindowStats {
ws := WindowStats{}
if count != nil {
ws.TxCount = *count
@@ -176,6 +207,7 @@ func buildWindow(prio, p50, p95, pfee, jito, cu *float64, count *int64) WindowSt
if pfee != nil {
ws.AvgPlatformFeeLamports = *pfee
}
+ ws.SumPlatformFeeLamports = sumPfee
if jito != nil {
ws.JitoRate = *jito
}
@@ -200,3 +232,308 @@ func mustEnv(key string) string {
}
return v
}
+
+// ---- Prometheus /metrics endpoint ----
+
+type latestBucketRow struct {
+ platform string
+ jitoRate float64
+ p50CUPriceMicro float64
+ p95CUPriceMicro float64
+ avgPriorityFeeLamports float64
+ avgPlatformFeeLamports float64
+ txCount int64
+}
+
+func handleMetrics(pool *pgxpool.Pool) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
+ defer cancel()
+
+ // Latest hourly bucket per platform.
+ latestRows, err := pool.Query(ctx, `
+ SELECT DISTINCT ON (platform)
+ platform, jito_rate, p50_cu_price_micro, p95_cu_price_micro,
+ avg_priority_fee_lamports, avg_platform_fee_lamports, tx_count
+ FROM solana_exec_facts
+ ORDER BY platform, bucket_start DESC`)
+ if err != nil {
+ log.Printf("metrics: latest query: %v", err)
+ http.Error(w, "internal", http.StatusInternalServerError)
+ return
+ }
+ defer latestRows.Close()
+
+ var latest []latestBucketRow
+ for latestRows.Next() {
+ var row latestBucketRow
+ if err := latestRows.Scan(
+ &row.platform,
+ &row.jitoRate,
+ &row.p50CUPriceMicro,
+ &row.p95CUPriceMicro,
+ &row.avgPriorityFeeLamports,
+ &row.avgPlatformFeeLamports,
+ &row.txCount,
+ ); err != nil {
+ log.Printf("metrics: scan: %v", err)
+ continue
+ }
+ latest = append(latest, row)
+ }
+ latestRows.Close()
+
+ // 24h tx counts.
+ countRows, err := pool.Query(ctx, `
+ SELECT platform, SUM(tx_count) AS count_24h
+ FROM solana_exec_facts
+ WHERE bucket_start >= now() - INTERVAL '24 hours'
+ GROUP BY platform`)
+ if err != nil {
+ log.Printf("metrics: count query: %v", err)
+ http.Error(w, "internal", http.StatusInternalServerError)
+ return
+ }
+ defer countRows.Close()
+
+ counts24h := make(map[string]int64)
+ for countRows.Next() {
+ var plt string
+ var cnt int64
+ if err := countRows.Scan(&plt, &cnt); err != nil {
+ continue
+ }
+ counts24h[plt] = cnt
+ }
+ countRows.Close()
+
+ w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
+
+ fmt.Fprint(w, "# HELP solana_exec_jito_rate Fraction of transactions with a Jito tip, latest hourly bucket\n")
+ fmt.Fprint(w, "# TYPE solana_exec_jito_rate gauge\n")
+ for _, row := range latest {
+ fmt.Fprintf(w, "solana_exec_jito_rate{platform=%q} %g\n", row.platform, row.jitoRate)
+ }
+
+ fmt.Fprint(w, "# HELP solana_exec_cu_price_p50_micro Median compute unit price microlamports/CU, latest hourly bucket\n")
+ fmt.Fprint(w, "# TYPE solana_exec_cu_price_p50_micro gauge\n")
+ for _, row := range latest {
+ fmt.Fprintf(w, "solana_exec_cu_price_p50_micro{platform=%q} %g\n", row.platform, row.p50CUPriceMicro)
+ }
+
+ fmt.Fprint(w, "# HELP solana_exec_cu_price_p95_micro p95 compute unit price microlamports/CU, latest hourly bucket\n")
+ fmt.Fprint(w, "# TYPE solana_exec_cu_price_p95_micro gauge\n")
+ for _, row := range latest {
+ fmt.Fprintf(w, "solana_exec_cu_price_p95_micro{platform=%q} %g\n", row.platform, row.p95CUPriceMicro)
+ }
+
+ fmt.Fprint(w, "# HELP solana_exec_priority_fee_lamports Average priority fee in lamports, latest hourly bucket\n")
+ fmt.Fprint(w, "# TYPE solana_exec_priority_fee_lamports gauge\n")
+ for _, row := range latest {
+ fmt.Fprintf(w, "solana_exec_priority_fee_lamports{platform=%q} %g\n", row.platform, row.avgPriorityFeeLamports)
+ }
+
+ fmt.Fprint(w, "# HELP solana_exec_platform_fee_lamports Average platform fee in lamports, latest hourly bucket\n")
+ fmt.Fprint(w, "# TYPE solana_exec_platform_fee_lamports gauge\n")
+ for _, row := range latest {
+ fmt.Fprintf(w, "solana_exec_platform_fee_lamports{platform=%q} %g\n", row.platform, row.avgPlatformFeeLamports)
+ }
+
+ fmt.Fprint(w, "# HELP solana_exec_tx_count_24h Total transaction count over the last 24 hours\n")
+ fmt.Fprint(w, "# TYPE solana_exec_tx_count_24h gauge\n")
+ for plt, cnt := range counts24h {
+ fmt.Fprintf(w, "solana_exec_tx_count_24h{platform=%q} %d\n", plt, cnt)
+ }
+ }
+}
+
+// ---- EVM revenue endpoint ----
+
+// evmCoverage is the static coverage map derived from the evm-exec platform config.
+// "full" = native + stable; "stable-only" = only ERC-20 USDC/USDG tracked.
+// Mirrors platform.Coverage() in evm-exec/internal/platform/platforms.go.
+var evmCoverage = map[string]map[string]string{
+ "pumpfun": {"ethereum": "full", "bsc": "full", "base": "full"},
+ "gmgn": {"ethereum": "full", "bsc": "full", "base": "full"},
+ "maestro": {"ethereum": "full", "bsc": "full", "base": "full"},
+ "axiom": {"bsc": "full"},
+ "gmgn-robinhood": {"robinhood": "full"},
+ "maestro-robinhood": {"robinhood": "full"},
+ // banana-gun: evmKey=null in frontend config; EVM data intentionally not displayed
+ // (router addresses receive trade principal, not fees; pending eth_getLogs on topic 0x72015ace…)
+}
+
+// coinGeckoIDs maps chain name → CoinGecko asset ID for native price lookup.
+var coinGeckoIDs = map[string]string{
+ "ethereum": "ethereum",
+ "bsc": "binancecoin",
+ "base": "ethereum",
+ "robinhood": "ethereum",
+}
+
+// nativeSymbols maps chain → native asset ticker.
+var nativeSymbols = map[string]string{
+ "ethereum": "ETH",
+ "bsc": "BNB",
+ "base": "ETH",
+ "robinhood": "ETH",
+}
+
+type priceCache struct {
+ mu sync.Mutex
+ prices map[string]float64 // coingecko id → USD price
+ fetchedAt time.Time
+}
+
+var globalPrices = &priceCache{prices: make(map[string]float64)}
+
+func (p *priceCache) get(ctx context.Context) map[string]float64 {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if time.Since(p.fetchedAt) < 15*time.Minute && len(p.prices) > 0 {
+ out := make(map[string]float64, len(p.prices))
+ for k, v := range p.prices {
+ out[k] = v
+ }
+ return out
+ }
+ reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ req, err := http.NewRequestWithContext(reqCtx, http.MethodGet,
+ "https://api.coingecko.com/api/v3/simple/price?ids=ethereum,binancecoin&vs_currencies=usd", nil)
+ if err != nil {
+ return nil
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ var raw map[string]map[string]float64
+ if err := json.Unmarshal(body, &raw); err != nil {
+ return nil
+ }
+ for id, vs := range raw {
+ if usd, ok := vs["usd"]; ok {
+ p.prices[id] = usd
+ }
+ }
+ p.fetchedAt = time.Now()
+ out := make(map[string]float64, len(p.prices))
+ for k, v := range p.prices {
+ out[k] = v
+ }
+ return out
+}
+
+type NativeRevenue struct {
+ Symbol string `json:"symbol"`
+ Amount float64 `json:"amount"`
+ USD *float64 `json:"usd"`
+}
+
+type EVMChainRevenue struct {
+ Stable24h float64 `json:"stable24h"`
+ Stable7d float64 `json:"stable7d"`
+ Stable30d float64 `json:"stable30d"`
+ Native *NativeRevenue `json:"native"` // 24h only; historical prices not stored
+ Coverage string `json:"coverage"`
+}
+
+type EVMPlatformRow struct {
+ Platform string `json:"platform"`
+ Chains map[string]EVMChainRevenue `json:"chains"`
+}
+
+type EVMRevenueResponse struct {
+ UpdatedAt string `json:"updatedAt"`
+ Platforms []EVMPlatformRow `json:"platforms"`
+}
+
+func handleEVMRevenue(pool *pgxpool.Pool) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
+ defer cancel()
+
+ rows, err := pool.Query(ctx, `
+ SELECT chain, platform,
+ COALESCE(SUM(revenue_stable) FILTER (WHERE bucket_start >= now() - INTERVAL '24 hours'), 0),
+ COALESCE(SUM(revenue_stable) FILTER (WHERE bucket_start >= now() - INTERVAL '7 days'), 0),
+ COALESCE(SUM(revenue_stable) FILTER (WHERE bucket_start >= now() - INTERVAL '30 days'), 0),
+ COALESCE(SUM(revenue_native) FILTER (WHERE bucket_start >= now() - INTERVAL '24 hours'), 0),
+ COALESCE(MAX(native_symbol), '')
+ FROM evm_exec_facts
+ WHERE bucket_start >= now() - INTERVAL '30 days'
+ GROUP BY chain, platform`)
+ if err != nil {
+ log.Printf("evm-revenue: query: %v", err)
+ http.Error(w, "internal", http.StatusInternalServerError)
+ return
+ }
+ defer rows.Close()
+
+ prices := globalPrices.get(r.Context())
+
+ // Group by platform.
+ byPlatform := make(map[string]map[string]EVMChainRevenue)
+ for rows.Next() {
+ var chain, plt, nativeSym string
+ var stable24h, stable7d, stable30d, native float64
+ if err := rows.Scan(&chain, &plt, &stable24h, &stable7d, &stable30d, &native, &nativeSym); err != nil {
+ continue
+ }
+ if byPlatform[plt] == nil {
+ byPlatform[plt] = make(map[string]EVMChainRevenue)
+ }
+
+ coverage := "stable-only"
+ if m, ok := evmCoverage[plt]; ok {
+ if c, ok := m[chain]; ok {
+ coverage = c
+ }
+ }
+
+ var nat *NativeRevenue
+ if native > 0 || coverage == "full" {
+ sym := nativeSym
+ if sym == "" {
+ sym = nativeSymbols[chain]
+ }
+ nr := &NativeRevenue{Symbol: sym, Amount: native}
+ if cgID := coinGeckoIDs[chain]; cgID != "" && prices != nil {
+ if price, ok := prices[cgID]; ok {
+ usd := native * price
+ nr.USD = &usd
+ }
+ }
+ nat = nr
+ }
+
+ byPlatform[plt][chain] = EVMChainRevenue{
+ Stable24h: stable24h,
+ Stable7d: stable7d,
+ Stable30d: stable30d,
+ Native: nat,
+ Coverage: coverage,
+ }
+ }
+ if err := rows.Err(); err != nil {
+ log.Printf("evm-revenue: rows: %v", err)
+ }
+
+ var platforms []EVMPlatformRow
+ for plt, chains := range byPlatform {
+ platforms = append(platforms, EVMPlatformRow{Platform: plt, Chains: chains})
+ }
+ sort.Slice(platforms, func(i, j int) bool {
+ return platforms[i].Platform < platforms[j].Platform
+ })
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(EVMRevenueResponse{
+ UpdatedAt: time.Now().UTC().Format(time.RFC3339),
+ Platforms: platforms,
+ })
+ }
+}
diff --git a/harnesses/solana-exec/cmd/collector/main.go b/harnesses/solana-exec/cmd/collector/main.go
index 909c7774..56cf537a 100644
--- a/harnesses/solana-exec/cmd/collector/main.go
+++ b/harnesses/solana-exec/cmd/collector/main.go
@@ -5,6 +5,8 @@ import (
"fmt"
"log"
"os"
+ "sort"
+ "strings"
"time"
"github.com/ChainBench/OpenChainBench/harnesses/solana-exec/internal/helius"
@@ -24,13 +26,17 @@ func main() {
}
defer db.Close()
- h := helius.New(mustEnv("HELIUS_API_KEY"))
+ rpcURL := os.Getenv("SOLANA_RPC_URL")
+ if rpcURL == "" {
+ rpcURL = "https://api.mainnet-beta.solana.com"
+ }
+ h := helius.New(rpcURL)
log.Printf("collector: monitoring %d platforms", len(platform.FeeAccounts))
for {
- for plt, feeAccount := range platform.FeeAccounts {
- if err := collect(ctx, db, h, plt, feeAccount); err != nil {
+ for plt, feeAccounts := range platform.FeeAccounts {
+ if err := collect(ctx, db, h, plt, feeAccounts); err != nil {
log.Printf("collector: %s: %v", plt, err)
}
}
@@ -38,76 +44,130 @@ func main() {
}
}
-func collect(ctx context.Context, db *store.DB, h *helius.Client, plt, feeAccount string) error {
- cursor, err := db.GetCursor(ctx, plt)
- if err != nil {
- return fmt.Errorf("get cursor: %w", err)
+type acctData struct {
+ cursorKey string
+ cursor store.Cursor
+ sigs []helius.SigEntry // newest-first from RPC
+}
+
+func collect(ctx context.Context, db *store.DB, h *helius.Client, plt string, feeAccounts []string) error {
+ // SPL token mint for fee detection; empty = SOL (native lamports).
+ feeToken := platform.FeeToken[plt]
+
+ // feeSet is used to detect fee transfers in NativeTransfers / postTokenBalances.owner.
+ // For SPL-token platforms (e.g. FOMO), FeeAccounts holds the ATA (for sig discovery)
+ // but postTokenBalances.owner is the wallet — so use FeeOwners when present.
+ feeOwners := platform.FeeOwners[plt]
+ if feeOwners == nil {
+ feeOwners = feeAccounts
+ }
+ feeSet := make(map[string]bool, len(feeOwners))
+ for _, fa := range feeOwners {
+ feeSet[fa] = true
}
- const sigLimit = 100
- // Paginate through ALL signatures newer than cursor (newest-first per page).
- // Each page uses `before=oldestSigInPreviousPage` to walk backwards until
- // we exhaust the window. This guarantees complete coverage regardless of volume.
- var sigs []helius.SigEntry
- before := ""
- for {
- batch, err := h.GetSignaturesForAddress(ctx, feeAccount, sigLimit, cursor.LastSig, before)
+ // Paginate each fee account independently with its own cursor so a crash
+ // between accounts doesn't cause re-processing of already-committed data.
+ perAcct := make([]acctData, 0, len(feeAccounts))
+
+ for _, feeAccount := range feeAccounts {
+ cursorKey := plt + ":" + feeAccount
+ cursor, err := db.GetCursor(ctx, cursorKey)
if err != nil {
- return fmt.Errorf("get sigs (before=%s): %w", before, err)
+ return fmt.Errorf("get cursor %s: %w", feeAccount, err)
}
- sigs = append(sigs, batch...)
- if len(batch) < sigLimit {
- break // last page
+
+ const sigLimit = 1000
+ const maxPages = 300 // cap backfill at 300k sigs per account (~20 days for busy wallets)
+ var sigs []helius.SigEntry
+ before := ""
+ for page := 0; page < maxPages; page++ {
+ batch, err := h.GetSignaturesForAddress(ctx, feeAccount, sigLimit, cursor.LastSig, before)
+ if err != nil {
+ return fmt.Errorf("get sigs %s (before=%s): %w", feeAccount, before, err)
+ }
+ sigs = append(sigs, batch...)
+ if len(batch) < sigLimit {
+ break
+ }
+ before = batch[len(batch)-1].Signature
+ time.Sleep(100 * time.Millisecond)
+ }
+ if len(sigs) > sigLimit {
+ log.Printf("collector: %s: %s: paginated %d sigs", plt, feeAccount, len(sigs))
}
- before = batch[len(batch)-1].Signature
- time.Sleep(300 * time.Millisecond) // respect Helius free-tier rate limit between pages
- }
- if len(sigs) == 0 {
- return nil
- }
- if len(sigs) > sigLimit {
- log.Printf("collector: %s: paginated %d raw sigs (%d pages)", plt, len(sigs), (len(sigs)+sigLimit-1)/sigLimit)
- }
- // Reverse to process oldest-first so cursor is always the true watermark.
- reversed := make([]helius.SigEntry, len(sigs))
- for i, s := range sigs {
- reversed[len(sigs)-1-i] = s
+ perAcct = append(perAcct, acctData{cursorKey: cursorKey, cursor: cursor, sigs: sigs})
}
- // Pre-filter failed txs to avoid wasting enhanced-API credits.
- // Failed txs don't generate platform fees; priority-fee stats should reflect
- // the cost of successful trades only.
- var sigStrs []string
- for _, s := range reversed {
- if s.Err == nil {
- sigStrs = append(sigStrs, s.Signature)
+ // Merge sigs from all accounts, de-duplicate by signature.
+ // A single tx can touch multiple fee accounts of the same platform (rare but possible).
+ seen := make(map[string]struct{})
+ var allSigs []helius.SigEntry
+ for _, acct := range perAcct {
+ for _, s := range acct.sigs {
+ if _, dup := seen[s.Signature]; dup {
+ continue
+ }
+ seen[s.Signature] = struct{}{}
+ allSigs = append(allSigs, s)
}
}
- // Count successful sigs per hour bucket from raw pagination (full volume, no enhanced API cost).
+ if len(allSigs) == 0 {
+ return nil
+ }
+
+ // Count raw hourly volume from the merged+deduped sig set so a tx touching
+ // multiple fee accounts of the same platform is counted exactly once.
+ // from_cursor is a compound of all account cursors for idempotency on retry.
+ fromCursor := compoundCursor(perAcct)
hourBuckets := make(map[time.Time]int64)
- for _, s := range reversed {
+ for _, s := range allSigs {
if s.Err != nil || s.BlockTime == 0 {
continue
}
bucket := time.Unix(s.BlockTime, 0).UTC().Truncate(time.Hour)
hourBuckets[bucket]++
}
- if err := db.UpsertRawCounts(ctx, plt, hourBuckets); err != nil {
+ if err := db.UpsertRawCounts(ctx, plt, fromCursor, hourBuckets); err != nil {
return fmt.Errorf("upsert raw counts: %w", err)
}
+ // Pre-filter failed txs; only successful trades generate platform fees.
+ var sigStrs []string
+ for _, s := range allSigs {
+ if s.Err == nil {
+ sigStrs = append(sigStrs, s.Signature)
+ }
+ }
+
+ // All sigs failed: still save cursors so we don't re-scan next poll.
if len(sigStrs) == 0 {
- // All sigs in this batch were failed txs; advance cursor and skip.
- newest := sigs[0]
- return db.SaveCursor(ctx, plt, newest.Signature, newest.Slot)
+ return saveCursors(ctx, db, perAcct)
+ }
+
+ // Sort by slot descending before striding so the sample is temporally uniform
+ // across multi-account platforms (e.g. Axiom 20 accounts — merge order is arbitrary).
+ sort.Slice(allSigs, func(i, j int) bool { return allSigs[i].Slot > allSigs[j].Slot })
+
+ // Rebuild sigStrs from the sorted order after filtering failed txs.
+ sigStrs = sigStrs[:0]
+ for _, s := range allSigs {
+ if s.Err == nil {
+ sigStrs = append(sigStrs, s.Signature)
+ }
}
- // Cap enhanced API at 100 sigs per poll (Helius free-tier budget: ~432K CUs/month).
- // Fee quality metrics are sampled; tx_count comes from raw counts above.
+ // Cap enhanced API at 100 sigs; stride-sample across the full window so the
+ // sample represents the whole poll period, not just the most recent transactions.
if len(sigStrs) > 100 {
- sigStrs = sigStrs[:100]
+ step := len(sigStrs) / 100
+ sampled := make([]string, 0, 100)
+ for i := 0; i < len(sigStrs) && len(sampled) < 100; i += step {
+ sampled = append(sampled, sigStrs[i])
+ }
+ sigStrs = sampled
}
txs, err := h.GetEnhancedTransactions(ctx, sigStrs)
@@ -128,20 +188,52 @@ func collect(ctx context.Context, db *store.DB, h *helius.Client, plt, feeAccoun
var platformFeeLamports, jitoTipLamports int64
isJito := false
- for _, xfer := range tx.NativeTransfers {
- if xfer.ToUserAccount == feeAccount {
- platformFeeLamports += xfer.Amount
+
+ if feeToken == "" {
+ // SOL-fee platform: detect via native SOL transfers.
+ // Skip any tx where a fee wallet is also a sender — these are internal sweeps
+ // (e.g. Axiom consolidating 20 wallets) that would inflate the average fee.
+ hasFeeWalletSender := false
+ for _, sender := range tx.SenderAccounts {
+ if feeSet[sender] {
+ hasFeeWalletSender = true
+ break
+ }
+ }
+ if !hasFeeWalletSender {
+ for _, xfer := range tx.NativeTransfers {
+ if feeSet[xfer.ToUserAccount] {
+ platformFeeLamports += xfer.Amount
+ }
+ }
+ }
+ for _, xfer := range tx.NativeTransfers {
+ if platform.JitoTipAccounts[xfer.ToUserAccount] {
+ jitoTipLamports += xfer.Amount
+ isJito = true
+ }
}
- if platform.JitoTipAccounts[xfer.ToUserAccount] {
- jitoTipLamports += xfer.Amount
- isJito = true
+ } else {
+ // SPL-token-fee platform (e.g. FOMO uses USDC): detect via token transfers.
+ // platformFeeLamports stores raw token units (1 USDC = 1_000_000).
+ for _, xfer := range tx.TokenTransfers {
+ if feeSet[xfer.ToOwner] && xfer.Mint == feeToken {
+ platformFeeLamports += xfer.Amount
+ }
+ }
+ // Jito detection is SOL-based regardless of fee token.
+ for _, xfer := range tx.NativeTransfers {
+ if platform.JitoTipAccounts[xfer.ToUserAccount] {
+ jitoTipLamports += xfer.Amount
+ isJito = true
+ }
}
}
- var cuPriceMicro int64
- if tx.ComputeUnitsConsumed > 0 && priorityFee > 0 {
- cuPriceMicro = priorityFee * 1_000_000 / tx.ComputeUnitsConsumed
- }
+ // CU price comes directly from SetComputeUnitPrice (disc 0x03), already in
+ // micro-lamports per CU — no division needed. Only set when the instruction
+ // is present; 0 means base fee only, not a zero-price priority tx.
+ cuPriceMicro := tx.CUPriceDeclared
events = append(events, store.ExecEvent{
Sig: tx.Signature,
@@ -162,13 +254,48 @@ func collect(ctx context.Context, db *store.DB, h *helius.Client, plt, feeAccoun
return fmt.Errorf("upsert: %w", err)
}
- // Advance cursor to the newest sig (first in original order = last in reversed).
- newest := sigs[0]
- if err := db.SaveCursor(ctx, plt, newest.Signature, newest.Slot); err != nil {
- return fmt.Errorf("save cursor: %w", err)
+ cuSamples := make([]store.CUSample, 0, len(events))
+ for _, e := range events {
+ if e.CUPriceMicro > 0 {
+ cuSamples = append(cuSamples, store.CUSample{Sig: e.Sig, BlockTime: e.BlockTime, CUPriceMicro: e.CUPriceMicro})
+ }
+ }
+ if err := db.InsertCUSamples(ctx, plt, cuSamples); err != nil {
+ return fmt.Errorf("insert cu samples: %w", err)
+ }
+
+ if err := saveCursors(ctx, db, perAcct); err != nil {
+ return err
+ }
+
+ log.Printf("collector: %s: ingested %d events", plt, len(events))
+ return nil
+}
+
+// compoundCursor returns a deterministic string that encodes the cursor state for
+// all accounts in this poll, used as the from_cursor idempotency key in raw_counts.
+func compoundCursor(perAcct []acctData) string {
+ if len(perAcct) == 0 {
+ return ""
+ }
+ parts := make([]string, 0, len(perAcct))
+ for _, a := range perAcct {
+ parts = append(parts, a.cursorKey+"="+a.cursor.LastSig)
}
+ return strings.Join(parts, ",")
+}
- log.Printf("collector: %s: ingested %d txs (newest slot %d)", plt, len(events), newest.Slot)
+// saveCursors advances each fee account's cursor to its newest observed sig.
+func saveCursors(ctx context.Context, db *store.DB, perAcct []acctData) error {
+ for _, acct := range perAcct {
+ if len(acct.sigs) == 0 {
+ continue
+ }
+ newest := acct.sigs[0] // RPC returns newest-first
+ if err := db.SaveCursor(ctx, acct.cursorKey, newest.Signature, newest.Slot); err != nil {
+ return fmt.Errorf("save cursor %s: %w", acct.cursorKey, err)
+ }
+ }
return nil
}
diff --git a/harnesses/solana-exec/cmd/materializer/main.go b/harnesses/solana-exec/cmd/materializer/main.go
index 98fc753a..63e0c462 100644
--- a/harnesses/solana-exec/cmd/materializer/main.go
+++ b/harnesses/solana-exec/cmd/materializer/main.go
@@ -27,6 +27,12 @@ func main() {
log.Printf("materializer: %s: done", plt)
}
}
+ if err := db.PurgeCUSamples(ctx); err != nil {
+ log.Printf("materializer: purge cu samples: %v", err)
+ }
+ if err := db.PurgeEvents(ctx); err != nil {
+ log.Printf("materializer: purge events: %v", err)
+ }
time.Sleep(5 * time.Minute)
}
}
diff --git a/harnesses/solana-exec/docker-compose.solana-exec.yml b/harnesses/solana-exec/docker-compose.solana-exec.yml
index 63d252d0..7a956401 100644
--- a/harnesses/solana-exec/docker-compose.solana-exec.yml
+++ b/harnesses/solana-exec/docker-compose.solana-exec.yml
@@ -1,5 +1,6 @@
# Fragment to merge into /opt/ocb/docker-compose.yml on VPS.
-# Requires: ocb-postgres (from docker-compose.apps.yml), HELIUS_API_KEY in env.
+# Requires: ocb-postgres (from docker-compose.apps.yml).
+# Optional: SOLANA_RPC_URL (defaults to api.mainnet-beta.solana.com).
services:
solana-exec-collector:
diff --git a/harnesses/solana-exec/internal/helius/client.go b/harnesses/solana-exec/internal/helius/client.go
index 45bc6c17..58b0743a 100644
--- a/harnesses/solana-exec/internal/helius/client.go
+++ b/harnesses/solana-exec/internal/helius/client.go
@@ -3,29 +3,37 @@ package helius
import (
"bytes"
"context"
+ "encoding/binary"
"encoding/json"
"fmt"
+ "math/big"
"net/http"
+ "strconv"
+ "strings"
+ "sync"
"time"
)
-// Client wraps the Helius RPC + enhanced-transactions APIs.
+// Client wraps the Solana JSON-RPC API.
+// All calls use standard Solana JSON-RPC — no third-party credits required.
type Client struct {
httpClient *http.Client
- apiKey string
- rpcURL string // https://mainnet.helius-rpc.com/?api-key=KEY
- enhURL string // https://api.helius.xyz/v0/transactions?api-key=KEY
+ rpcURL string
}
-func New(apiKey string) *Client {
+// New creates a client using the given Solana RPC endpoint.
+func New(rpcURL string) *Client {
return &Client{
httpClient: &http.Client{Timeout: 30 * time.Second},
- apiKey: apiKey,
- rpcURL: fmt.Sprintf("https://mainnet.helius-rpc.com/?api-key=%s", apiKey),
- enhURL: fmt.Sprintf("https://api.helius.xyz/v0/transactions?api-key=%s", apiKey),
+ rpcURL: rpcURL,
}
}
+// NewWithRPC kept for call-site compat; apiKey is ignored.
+func NewWithRPC(_, rpcURL string) *Client {
+ return New(rpcURL)
+}
+
// SigEntry is one result from getSignaturesForAddress.
type SigEntry struct {
Signature string `json:"signature"`
@@ -36,8 +44,7 @@ type SigEntry struct {
// GetSignaturesForAddress fetches up to `limit` finalized signatures for
// `address`, newest-first. `until` is the exclusive upper bound (cursor);
-// `before` is the exclusive lower bound used for pagination (pass "" for
-// the first page).
+// `before` is the exclusive lower bound for pagination (pass "" for first page).
func (c *Client) GetSignaturesForAddress(ctx context.Context, address string, limit int, until, before string) ([]SigEntry, error) {
params := map[string]any{
"limit": limit,
@@ -57,90 +64,412 @@ func (c *Client) GetSignaturesForAddress(ctx context.Context, address string, li
"params": []any{address, params},
})
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.rpcURL, bytes.NewReader(body))
- if err != nil {
- return nil, err
- }
- req.Header.Set("Content-Type", "application/json")
+ var lastErr error
+ for attempt := range 3 {
+ if attempt > 0 {
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-time.After(time.Duration(attempt*attempt) * time.Second):
+ }
+ }
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.rpcURL, bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("helius RPC HTTP %d", resp.StatusCode)
- }
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ lastErr = err
+ continue
+ }
+ if resp.StatusCode == http.StatusTooManyRequests {
+ resp.Body.Close()
+ lastErr = fmt.Errorf("RPC HTTP 429")
+ continue
+ }
+ if resp.StatusCode != http.StatusOK {
+ resp.Body.Close()
+ return nil, fmt.Errorf("RPC HTTP %d", resp.StatusCode)
+ }
- var out struct {
- Result []SigEntry `json:"result"`
- Error *struct {
- Code int `json:"code"`
- Message string `json:"message"`
- } `json:"error"`
+ var out struct {
+ Result []SigEntry `json:"result"`
+ Error *struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ resp.Body.Close()
+ return nil, fmt.Errorf("RPC decode: %w", err)
+ }
+ resp.Body.Close()
+ if out.Error != nil {
+ return nil, fmt.Errorf("RPC error %d: %s", out.Error.Code, out.Error.Message)
+ }
+ return out.Result, nil
}
- if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
- return nil, fmt.Errorf("helius RPC decode: %w", err)
- }
- if out.Error != nil {
- return nil, fmt.Errorf("helius RPC error %d: %s", out.Error.Code, out.Error.Message)
- }
- return out.Result, nil
+ return nil, fmt.Errorf("RPC: %w (after 3 attempts)", lastErr)
}
-// NativeTransfer is a SOL transfer extracted by Helius.
+// NativeTransfer is a SOL transfer derived from pre/post balance diff.
type NativeTransfer struct {
- FromUserAccount string `json:"fromUserAccount"`
- ToUserAccount string `json:"toUserAccount"`
- Amount int64 `json:"amount"` // lamports
+ FromUserAccount string
+ ToUserAccount string
+ Amount int64 // lamports
+}
+
+// TokenTransfer is an SPL token transfer derived from pre/post token balance diff.
+// Amount is in the token's raw units (e.g. 1 USDC = 1_000_000).
+type TokenTransfer struct {
+ ToOwner string // wallet that owns the destination token account
+ Mint string // SPL token mint address
+ Amount int64 // raw token units (positive = received)
}
-// EnhancedTx is the Helius-parsed transaction.
+// EnhancedTx is a parsed Solana transaction.
type EnhancedTx struct {
- Signature string `json:"signature"`
- Slot uint64 `json:"slot"`
- Timestamp int64 `json:"timestamp"` // unix seconds
- Fee int64 `json:"fee"` // total fee lamports
- FeePayer string `json:"feePayer"`
- NativeTransfers []NativeTransfer `json:"nativeTransfers"`
- TransactionError any `json:"transactionError"`
- ComputeUnitsConsumed int64 `json:"computeUnitsConsumed"`
+ Signature string
+ Slot uint64
+ Timestamp int64 // unix seconds
+ Fee int64 // total fee lamports
+ FeePayer string
+ NativeTransfers []NativeTransfer
+ TokenTransfers []TokenTransfer
+ TransactionError any
+ ComputeUnitsConsumed int64
+ // CULimit from SetComputeUnitLimit (disc 0x02). 0 = instruction absent.
+ CULimit int64
+ // CUPriceDeclared is the micro-lamports/CU from SetComputeUnitPrice (disc 0x03).
+ // This is the authoritative price — use it directly, no division needed.
+ // 0 = no priority fee set (base fee only).
+ CUPriceDeclared int64
+ // SenderAccounts is the set of accounts whose SOL balance decreased in this tx.
+ // Used by the collector to detect internal fee-wallet sweeps and skip them.
+ SenderAccounts []string
}
-// GetEnhancedTransactions fetches enhanced parsed transactions for up to 100
-// signatures in a single request. Helius caps the batch at 100.
+const b58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
+
+// decodeBase58 decodes a base58-encoded Solana instruction data string to bytes.
+func decodeBase58(s string) []byte {
+ var n big.Int
+ for _, c := range s {
+ idx := strings.IndexRune(b58Alphabet, c)
+ if idx < 0 {
+ return nil
+ }
+ n.Mul(&n, big.NewInt(58))
+ n.Add(&n, big.NewInt(int64(idx)))
+ }
+ decoded := n.Bytes()
+ leading := 0
+ for _, c := range s {
+ if c != '1' {
+ break
+ }
+ leading++
+ }
+ out := make([]byte, leading+len(decoded))
+ copy(out[leading:], decoded)
+ return out
+}
+
+const computeBudgetProgram = "ComputeBudget111111111111111111111111111111"
+
+// GetEnhancedTransactions fetches and parses up to 100 transactions in parallel
+// using standard Solana JSON-RPC getTransaction — no third-party credits needed.
+// NativeTransfers and TokenTransfers are derived from pre/post balance diffs.
func (c *Client) GetEnhancedTransactions(ctx context.Context, sigs []string) ([]EnhancedTx, error) {
if len(sigs) == 0 {
return nil, nil
}
if len(sigs) > 100 {
- return nil, fmt.Errorf("helius: enhanced batch max 100 sigs, got %d", len(sigs))
+ return nil, fmt.Errorf("batch max 100 sigs, got %d", len(sigs))
+ }
+
+ type result struct {
+ tx EnhancedTx
+ ok bool
+ }
+ results := make([]result, len(sigs))
+
+ sem := make(chan struct{}, 10)
+ var wg sync.WaitGroup
+ for i, sig := range sigs {
+ i, sig := i, sig
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ sem <- struct{}{}
+ defer func() { <-sem }()
+ tx, err := c.getTransaction(ctx, sig)
+ if err != nil {
+ return
+ }
+ results[i] = result{tx: tx, ok: true}
+ }()
+ }
+ wg.Wait()
+
+ txs := make([]EnhancedTx, 0, len(sigs))
+ for _, r := range results {
+ if r.ok {
+ txs = append(txs, r.tx)
+ }
}
+ return txs, nil
+}
+
+// accountKey handles both legacy (bare string) and v0 (object with pubkey field) formats.
+type accountKey struct{ Pubkey string }
+func (a *accountKey) UnmarshalJSON(b []byte) error {
+ var s string
+ if json.Unmarshal(b, &s) == nil {
+ a.Pubkey = s
+ return nil
+ }
+ var obj struct {
+ Pubkey string `json:"pubkey"`
+ }
+ if err := json.Unmarshal(b, &obj); err != nil {
+ return err
+ }
+ a.Pubkey = obj.Pubkey
+ return nil
+}
+
+func (c *Client) getTransaction(ctx context.Context, sig string) (EnhancedTx, error) {
body, _ := json.Marshal(map[string]any{
- "transactions": sigs,
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "getTransaction",
+ "params": []any{sig, map[string]any{
+ "encoding": "json",
+ "commitment": "finalized",
+ "maxSupportedTransactionVersion": 0,
+ }},
})
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.enhURL, bytes.NewReader(body))
- if err != nil {
- return nil, err
+ type tokenBalanceEntry struct {
+ AccountIndex int `json:"accountIndex"`
+ Mint string `json:"mint"`
+ Owner string `json:"owner"`
+ UITokenAmount struct {
+ Amount string `json:"amount"` // raw integer as string
+ } `json:"uiTokenAmount"`
}
- req.Header.Set("Content-Type", "application/json")
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, err
+ var out struct {
+ Result *struct {
+ Slot uint64 `json:"slot"`
+ BlockTime *int64 `json:"blockTime"`
+ Transaction struct {
+ Message struct {
+ AccountKeys []accountKey `json:"accountKeys"`
+ Instructions []struct {
+ ProgramIDIndex int `json:"programIdIndex"`
+ Data string `json:"data"` // base58-encoded
+ } `json:"instructions"`
+ } `json:"message"`
+ Signatures []string `json:"signatures"`
+ } `json:"transaction"`
+ Meta *struct {
+ Err any `json:"err"`
+ Fee int64 `json:"fee"`
+ PreBalances []int64 `json:"preBalances"`
+ PostBalances []int64 `json:"postBalances"`
+ ComputeUnitsConsumed *int64 `json:"computeUnitsConsumed"`
+ PreTokenBalances []tokenBalanceEntry `json:"preTokenBalances"`
+ PostTokenBalances []tokenBalanceEntry `json:"postTokenBalances"`
+ LoadedAddresses *struct {
+ Writable []string `json:"writable"`
+ Readonly []string `json:"readonly"`
+ } `json:"loadedAddresses"`
+ } `json:"meta"`
+ } `json:"result"`
+ Error *struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ } `json:"error"`
}
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("helius enhanced HTTP %d", resp.StatusCode)
+ var lastErr error
+ for attempt := range 3 {
+ if attempt > 0 {
+ select {
+ case <-ctx.Done():
+ return EnhancedTx{}, ctx.Err()
+ case <-time.After(time.Duration(attempt*attempt) * time.Second):
+ }
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.rpcURL, bytes.NewReader(body))
+ if err != nil {
+ return EnhancedTx{}, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ lastErr = err
+ continue
+ }
+ if resp.StatusCode == http.StatusTooManyRequests {
+ resp.Body.Close()
+ lastErr = fmt.Errorf("getTransaction HTTP 429")
+ continue
+ }
+ if resp.StatusCode != http.StatusOK {
+ resp.Body.Close()
+ return EnhancedTx{}, fmt.Errorf("getTransaction HTTP %d", resp.StatusCode)
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ resp.Body.Close()
+ lastErr = fmt.Errorf("decode: %w", err)
+ continue
+ }
+ resp.Body.Close()
+ lastErr = nil
+ break
+ }
+ if lastErr != nil {
+ return EnhancedTx{}, fmt.Errorf("getTransaction: %w (after 3 attempts)", lastErr)
+ }
+ if out.Error != nil {
+ return EnhancedTx{}, fmt.Errorf("RPC error %d: %s", out.Error.Code, out.Error.Message)
}
+ if out.Result == nil || out.Result.Meta == nil {
+ return EnhancedTx{}, fmt.Errorf("null result for %s", sig)
+ }
+
+ r := out.Result
+ m := r.Meta
- var txs []EnhancedTx
- if err := json.NewDecoder(resp.Body).Decode(&txs); err != nil {
- return nil, fmt.Errorf("helius enhanced decode: %w", err)
+ // Full account key list: static keys + v0 loaded addresses (writable then readonly).
+ accounts := make([]accountKey, len(r.Transaction.Message.AccountKeys))
+ copy(accounts, r.Transaction.Message.AccountKeys)
+ if m.LoadedAddresses != nil {
+ for _, addr := range m.LoadedAddresses.Writable {
+ accounts = append(accounts, accountKey{Pubkey: addr})
+ }
+ for _, addr := range m.LoadedAddresses.Readonly {
+ accounts = append(accounts, accountKey{Pubkey: addr})
+ }
}
- return txs, nil
+
+ // NativeTransfers: accounts whose SOL balance increased received SOL.
+ // SenderAccounts: accounts whose SOL balance decreased (used for sweep detection).
+ var nativeTransfers []NativeTransfer
+ var senderAccounts []string
+ for i, key := range accounts {
+ if i >= len(m.PreBalances) || i >= len(m.PostBalances) {
+ break
+ }
+ diff := m.PostBalances[i] - m.PreBalances[i]
+ if diff > 0 {
+ nativeTransfers = append(nativeTransfers, NativeTransfer{ToUserAccount: key.Pubkey, Amount: diff})
+ } else if diff < 0 {
+ senderAccounts = append(senderAccounts, key.Pubkey)
+ }
+ }
+
+ // TokenTransfers: net SPL token gain per (owner, mint).
+ // Sum pre and post separately so wallets with multiple ATAs for the same mint
+ // are handled correctly — the diff is the net change, not a per-account delta.
+ type ownerMint struct{ owner, mint string }
+ preTokenAmt := make(map[ownerMint]int64, len(m.PreTokenBalances))
+ for _, tb := range m.PreTokenBalances {
+ if tb.Owner == "" {
+ continue
+ }
+ amt, _ := strconv.ParseInt(tb.UITokenAmount.Amount, 10, 64)
+ preTokenAmt[ownerMint{tb.Owner, tb.Mint}] += amt
+ }
+ postTokenAmt := make(map[ownerMint]int64, len(m.PostTokenBalances))
+ for _, tb := range m.PostTokenBalances {
+ if tb.Owner == "" {
+ continue // owner field absent on very old txs
+ }
+ amt, _ := strconv.ParseInt(tb.UITokenAmount.Amount, 10, 64)
+ postTokenAmt[ownerMint{tb.Owner, tb.Mint}] += amt
+ }
+ var tokenTransfers []TokenTransfer
+ for key, postAmt := range postTokenAmt {
+ diff := postAmt - preTokenAmt[key]
+ if diff > 0 {
+ tokenTransfers = append(tokenTransfers, TokenTransfer{
+ ToOwner: key.owner,
+ Mint: key.mint,
+ Amount: diff,
+ })
+ }
+ }
+
+ var cuConsumed int64
+ if m.ComputeUnitsConsumed != nil {
+ cuConsumed = *m.ComputeUnitsConsumed
+ }
+
+ // Parse ComputeBudget instructions for CU price and limit.
+ // Discriminator enum (borsh u8):
+ // 0x02 = SetComputeUnitLimit payload: u32 LE [1:5]
+ // 0x03 = SetComputeUnitPrice payload: u64 LE [1:9] (micro-lamports per CU)
+ var cuLimit, cuPriceDeclared int64
+ for _, ix := range r.Transaction.Message.Instructions {
+ if ix.ProgramIDIndex < 0 || ix.ProgramIDIndex >= len(accounts) {
+ continue
+ }
+ if accounts[ix.ProgramIDIndex].Pubkey != computeBudgetProgram {
+ continue
+ }
+ data := decodeBase58(ix.Data)
+ if len(data) == 0 {
+ continue
+ }
+ switch data[0] {
+ case 2:
+ if len(data) >= 5 {
+ cuLimit = int64(binary.LittleEndian.Uint32(data[1:5]))
+ }
+ case 3:
+ if len(data) >= 9 {
+ cuPriceDeclared = int64(binary.LittleEndian.Uint64(data[1:9]))
+ }
+ }
+ }
+
+ var feePayer string
+ if len(accounts) > 0 {
+ feePayer = accounts[0].Pubkey
+ }
+
+ var blockTime int64
+ if r.BlockTime != nil {
+ blockTime = *r.BlockTime
+ }
+
+ var sig0 string
+ if len(r.Transaction.Signatures) > 0 {
+ sig0 = r.Transaction.Signatures[0]
+ }
+
+ return EnhancedTx{
+ Signature: sig0,
+ Slot: r.Slot,
+ Timestamp: blockTime,
+ Fee: m.Fee,
+ FeePayer: feePayer,
+ NativeTransfers: nativeTransfers,
+ TokenTransfers: tokenTransfers,
+ TransactionError: m.Err,
+ ComputeUnitsConsumed: cuConsumed,
+ CULimit: cuLimit,
+ CUPriceDeclared: cuPriceDeclared,
+ SenderAccounts: senderAccounts,
+ }, nil
}
diff --git a/harnesses/solana-exec/internal/platform/platforms.go b/harnesses/solana-exec/internal/platform/platforms.go
index 16864d1d..a1ec8b7a 100644
--- a/harnesses/solana-exec/internal/platform/platforms.go
+++ b/harnesses/solana-exec/internal/platform/platforms.go
@@ -1,13 +1,106 @@
package platform
-// FeeAccounts maps platform name → its on-chain fee-receiver account.
-// These are the accounts that receive platform fees from user trades.
-// Adding a new platform = one line here.
-var FeeAccounts = map[string]string{
- "pump.fun": "CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM",
- // TODO: add FOMO, Axiom, GMGN once their fee accounts are confirmed
- // Discover by inspecting any known tx from each platform and finding
- // the SOL recipient that is not the user, not Jito, not rent.
+// FeeAccounts maps platform name → its on-chain fee-receiver accounts.
+// Multi-account platforms (GMGN, Axiom) list all wallets; the collector
+// paginates each independently and aggregates before sampling.
+// Sources: DefiLlama dimension-adapters, verified 2026-08.
+var FeeAccounts = map[string][]string{
+ // 8 bonding-curve fee recipients (round-robin) + Mayhem wallet.
+ // Sources: DeFiLlama fees/pumpdotfun adapter (master, 2026-08).
+ "pump.fun": {
+ "CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM",
+ "62qc2CNXwrYqQScmEdiZFFAnJR262PxWEuNQtxfafNgV",
+ "FWsW1xNtWscwNmKv6wVsU1iTzRN6wmmk3MjxRP5tT7hz",
+ "7hTckgnGnLQR6sdH7YkqFTAA7VwTfYFaZ6EhEsU3saCX",
+ "AVmoTthdrX6tKt4nDjco2D775W2YK3sDhxPcMmzUAmTY",
+ "9rPYyANsfQZw3DnDmKE3YCQF5E8oD89UXoHn9JFEhJUz",
+ "G5UZAVbAf46s7cKWoyKu8kYTip9DGTpbLZ2qa9Aq69dP",
+ "7VtfL8fvgNfhz17qKRMjzQEXgbdpnHHHQRh54R9jP2RJ",
+ "GesfTA3X2arioaHp8bbKdjG9vJtskViWACZoYvxp4twS", // Mayhem mode
+ },
+ "photon": {
+ "AVUCZyuT35YSuj4RH7fwiyPu82Djn2Hfg7y2ND2XcnZH",
+ },
+ "bullx": {
+ "9RYJ3qr5eU5xAooqVcbmdeusjcViL5Nkiq7Gske3tiKq", // current (post 2024-11-16)
+ "F4hJ3Ee3c5UuaorKAMfELBjYCjiiLH75haZTKqTywRP3", // legacy
+ },
+ "gmgn": {
+ // Nine pure fee-collector wallets verified on-chain (2026-06).
+ // No transfers among themselves → positive SOL diff = fee, no double-count.
+ "BB5dnY55FXS1e1NXqZDwCzgdYJdMCj3B92PU6Q5Fb6DT",
+ "7sHXjs1j7sDJGVSMSPjD1b4v3FD6uRSvRWfhRdfv5BiA",
+ "HeZVpHj9jLwTVtMMbzQRf6mLtFPkWNSg11o68qrbUBa3",
+ "ByRRgnZenY6W2sddo1VJzX9o4sMU4gPDUkcmgrpGBxRy",
+ "DXfkEGoo6WFsdL7x6gLZ7r6Hw2S6HrtrAQVPWYx2A1s9",
+ "3t9EKmRiAUcQUYzTZpNojzeGP1KBAVEEbDNmy6wECQpK",
+ "DymeoWc5WLNiQBaoLuxrxDnDRvLgGZ1QGsEoCAM7Jsrx",
+ "dBhdrmwBkRa66XxBuAK4WZeZnsZ6bHeHCCLXa3a8bTJ",
+ "6TxjC5wJzuuZgTtnTMipwwULEbMPx5JPW3QwWkdTGnrn",
+ },
+ "axiom": {
+ "7LCZckF6XXGQ1hDY6HFXBKWAtiUgL9QY5vj1C4Bn1Qjj",
+ "4V65jvcDG9DSQioUVqVPiUcUY9v6sb6HKtMnsxSKEz5S",
+ "CeA3sPZfWWToFEBmw5n1Y93tnV66Vmp8LacLzsVprgxZ",
+ "AaG6of1gbj1pbDumvbSiTuJhRCRkkUNaWVxijSbWvTJW",
+ "7oi1L8U9MRu5zDz5syFahsiLUric47LzvJBQX6r827ws",
+ "9kPrgLggBJ69tx1czYAbp7fezuUmL337BsqQTKETUEhP",
+ "DKyUs1xXMDy8Z11zNsLnUg3dy9HZf6hYZidB6WodcaGy",
+ "4FobGn5ZWYquoJkxMzh2VUAWvV36xMgxQ3M7uG1pGGhd",
+ "76sxKrPtgoJHDJvxwFHqb3cAXWfRHFLe3VpKcLCAHSEf",
+ "H2cDR3EkJjtTKDQKk8SJS48du9mhsdzQhy8xJx5UMqQK",
+ "8m5GkL7nVy95G4YVUbs79z873oVKqg2afgKRmqxsiiRm",
+ "4kuG6NsAFJNwqEkac8GFDMMheCGKUPEbaRVHHyFHSwWz",
+ "8vFGAKdwpn4hk7kc1cBgfWZzpyW3MEMDATDzVZhddeQb",
+ "86Vh4XGLW2b6nvWbRyDs4ScgMXbuvRCHT7WbUT3RFxKG",
+ "DZfEurFKFtSbdWZsKSDTqpqsQgvXxmESpvRtXkAdgLwM",
+ "5L2QKqDn5ukJSWGyqR4RPvFvwnBabKWqAqMzH4heaQNB",
+ "DYVeNgXGLAhZdeLMMYnCw1nPnMxkBN7fJnNpHmizTrrF",
+ "Hbj6XdxX6eV4nfbYTseysibp4zZJtVRRPn2J3BhGRuK9",
+ "846ah7iBSu9ApuCyEhA5xpnjHHX7d4QJKetWLbwzmJZ8",
+ "5BqYhuD4q1YD3DMAYkc1FeTu9vqQVYYdfBAmkZjamyZg",
+ },
+ "fomo": {
+ // FOMO collects fees in USDC. The fee wallet's USDC ATA is monitored for sigs
+ // (getSignaturesForAddress on the wallet returns 0 overlap with ATA — confirmed
+ // live on 2026-08). Fee detection uses FeeOwners below (the wallet address)
+ // because postTokenBalances.owner = wallet, not ATA.
+ "HrTf9CzXR1dRH4Sof5QrpmGWwpwAf3qZzwCsEjQpXcSq", // USDC ATA of fee wallet
+ },
+ // Trojan — confirmed via DeFiLlama dexs/trojan/index.ts (master, 2026-08)
+ "trojan": {
+ "9yMwSPk9mrXSN7yDHUuZurAh1sjbJsfpUqjZ7SvVtdco", // primary bot fee wallet
+ "92Med3qeK7duC5iiYsHX38H2f2twJfRsSx93oNrza2VH", // terminal
+ "2jwHNxavSoMZMEDbT1eV9PcPt5dDcayCqM6MkgaPpmWQ",
+ "65gDv7pZQCZELsNpNYSFEBtNFpWZAbxmRFB6BGMqFkHH",
+ "BWgb8wR1FEGiu1jCDSKuHKf752W27b4iN6SvoNCiK4qp",
+ "8jgg7moFJkHyTtAv9M6RBSPMp2oXeXhuiUMKW8YbYCWn",
+ },
+ // Banana Gun — confirmed via DeFiLlama fees/banana-gun-trading.ts (master, 2026-08)
+ "banana-gun": {
+ "47hEzz83VFR23rLTEeVm9A7eFzjJwjvdupPPmX3cePqF",
+ "4BBNEVRgrxVKv9f7pMNE788XM1tt379X9vNjpDH2KCL7",
+ "8r2hZoDfk5hDWJ1sDujAi2Qr45ZyZw5EQxAXiMZWLKh2",
+ },
+ // Maestro — confirmed via DeFiLlama fees/maestro.ts (master, 2026-08)
+ "maestro": {
+ "MaestroUL88UBnZr3wfoN7hqmNWFi3ZYCGqZoJJHE36",
+ "FRMxAnZgkW58zbYcE7Bxqsg99VWpJh6sMP5xLzAWNabN",
+ },
+}
+
+// FeeToken maps platform name → SPL token mint used for platform fee collection.
+// Absent (empty string) = SOL (native lamports). Set for USDC-fee platforms.
+// USDC mint: EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v
+var FeeToken = map[string]string{
+ "fomo": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
+}
+
+// FeeOwners maps platform → wallet addresses to match against postTokenBalances.owner
+// for SPL-token fee detection. Only needed when FeeAccounts holds ATA addresses
+// (which differ from the wallet). If absent, feeSet falls back to FeeAccounts.
+var FeeOwners = map[string][]string{
+ "fomo": {"R4rNJHaffSUotNmqSKNEfDcJE8A7zJUkaoM5Jkd7cYX"},
}
// JitoTipAccounts is the authoritative set of 8 Jito tip wallets.
diff --git a/harnesses/solana-exec/internal/store/store.go b/harnesses/solana-exec/internal/store/store.go
index 1564a23d..6185da79 100644
--- a/harnesses/solana-exec/internal/store/store.go
+++ b/harnesses/solana-exec/internal/store/store.go
@@ -69,7 +69,7 @@ func (db *DB) UpsertEvents(ctx context.Context, events []ExecEvent) error {
return tx.Commit(ctx)
}
-// Cursor holds the last processed signature for a platform.
+// Cursor holds the last processed signature for a fee account.
type Cursor struct {
LastSig string
Slot uint64
@@ -98,8 +98,9 @@ func (db *DB) SaveCursor(ctx context.Context, platform, lastSig string, slot uin
}
// UpsertRawCounts stores hourly successful-tx counts from raw sig pagination.
-// counts maps bucket_start (hour-truncated UTC) → total successful sigs that hour.
-func (db *DB) UpsertRawCounts(ctx context.Context, platform string, counts map[time.Time]int64) error {
+// fromCursor is cursor.LastSig at poll start — makes each row idempotent:
+// same (platform, bucket_start, fromCursor) on retry → DO NOTHING, no double-count.
+func (db *DB) UpsertRawCounts(ctx context.Context, platform, fromCursor string, counts map[time.Time]int64) error {
if len(counts) == 0 {
return nil
}
@@ -110,12 +111,10 @@ func (db *DB) UpsertRawCounts(ctx context.Context, platform string, counts map[t
defer tx.Rollback(ctx)
for bucket, count := range counts {
_, err := tx.Exec(ctx, `
- INSERT INTO solana_exec_raw_counts (platform, bucket_start, success_count, updated_at)
- VALUES ($1, $2, $3, now())
- ON CONFLICT (platform, bucket_start) DO UPDATE SET
- success_count = solana_exec_raw_counts.success_count + EXCLUDED.success_count,
- updated_at = now()`,
- platform, bucket, count,
+ INSERT INTO solana_exec_raw_counts (platform, bucket_start, from_cursor, success_count, updated_at)
+ VALUES ($1, $2, $3, $4, now())
+ ON CONFLICT (platform, bucket_start, from_cursor) DO NOTHING`,
+ platform, bucket, fromCursor, count,
)
if err != nil {
return fmt.Errorf("store: upsert raw count %v: %w", bucket, err)
@@ -124,6 +123,49 @@ func (db *DB) UpsertRawCounts(ctx context.Context, platform string, counts map[t
return tx.Commit(ctx)
}
+// CUSample is a single compute-unit price observation from the enhanced API sample.
+type CUSample struct {
+ Sig string
+ BlockTime time.Time
+ CUPriceMicro int64
+}
+
+// InsertCUSamples stores CU price samples idempotently keyed on (platform, sig).
+func (db *DB) InsertCUSamples(ctx context.Context, platform string, samples []CUSample) error {
+ if len(samples) == 0 {
+ return nil
+ }
+ tx, err := db.pool.Begin(ctx)
+ if err != nil {
+ return fmt.Errorf("store: begin: %w", err)
+ }
+ defer tx.Rollback(ctx)
+ for _, s := range samples {
+ _, err := tx.Exec(ctx,
+ `INSERT INTO solana_exec_cu_samples (platform, sig, block_time, cu_price_micro)
+ VALUES ($1,$2,$3,$4)
+ ON CONFLICT (platform, sig) DO NOTHING`,
+ platform, s.Sig, s.BlockTime, s.CUPriceMicro,
+ )
+ if err != nil {
+ return fmt.Errorf("store: insert cu sample %s: %w", s.Sig, err)
+ }
+ }
+ return tx.Commit(ctx)
+}
+
+// PurgeCUSamples deletes samples older than 35 days to bound table growth.
+func (db *DB) PurgeCUSamples(ctx context.Context) error {
+ _, err := db.pool.Exec(ctx, `DELETE FROM solana_exec_cu_samples WHERE block_time < now() - INTERVAL '35 days'`)
+ return err
+}
+
+// PurgeEvents deletes sampled events older than 90 days.
+func (db *DB) PurgeEvents(ctx context.Context) error {
+ _, err := db.pool.Exec(ctx, `DELETE FROM solana_exec_events WHERE block_time < now() - INTERVAL '90 days'`)
+ return err
+}
+
// Materialize recomputes hourly facts for the given platform.
// tx_count comes from solana_exec_raw_counts (full volume); fee metrics come
// from the sampled solana_exec_events (representative quality metrics).
@@ -132,7 +174,7 @@ func (db *DB) Materialize(ctx context.Context, platform string) error {
INSERT INTO solana_exec_facts
(platform, bucket_start, tx_count,
avg_priority_fee_lamports, p50_cu_price_micro, p95_cu_price_micro,
- avg_platform_fee_lamports, jito_rate, avg_cu_consumed, computed_at)
+ avg_platform_fee_lamports, sum_platform_fee_lamports, jito_rate, avg_cu_consumed, computed_at)
SELECT
e.platform,
date_trunc('hour', e.block_time) AS bucket_start,
@@ -141,13 +183,22 @@ func (db *DB) Materialize(ctx context.Context, platform string) error {
COALESCE(percentile_cont(0.5) WITHIN GROUP (ORDER BY e.cu_price_micro) FILTER (WHERE e.cu_price_micro > 0), 0) AS p50_cu_price_micro,
COALESCE(percentile_cont(0.95) WITHIN GROUP (ORDER BY e.cu_price_micro) FILTER (WHERE e.cu_price_micro > 0), 0) AS p95_cu_price_micro,
AVG(e.platform_fee_lamports) AS avg_platform_fee_lamports,
+ -- Scale sampled fee sum by raw/sampled ratio so the total reflects full volume.
+ -- Without scaling, sum would be ~1/N of reality for high-volume platforms.
+ CASE
+ WHEN r.success_count IS NOT NULL AND COUNT(*) > 0
+ THEN (SUM(e.platform_fee_lamports)::numeric * r.success_count / COUNT(*))::bigint
+ ELSE SUM(e.platform_fee_lamports)
+ END AS sum_platform_fee_lamports,
AVG(CASE WHEN e.is_jito_bundle THEN 1.0 ELSE 0.0 END) AS jito_rate,
AVG(e.cu_consumed) AS avg_cu_consumed,
now()
FROM solana_exec_events e
- LEFT JOIN solana_exec_raw_counts r
- ON r.platform = e.platform
- AND r.bucket_start = date_trunc('hour', e.block_time)
+ LEFT JOIN (
+ SELECT platform, bucket_start, SUM(success_count) AS success_count
+ FROM solana_exec_raw_counts
+ GROUP BY platform, bucket_start
+ ) r ON r.platform = e.platform AND r.bucket_start = date_trunc('hour', e.block_time)
WHERE e.platform = $1
GROUP BY e.platform, date_trunc('hour', e.block_time), r.success_count
ON CONFLICT (platform, bucket_start) DO UPDATE SET
@@ -156,6 +207,7 @@ func (db *DB) Materialize(ctx context.Context, platform string) error {
p50_cu_price_micro = EXCLUDED.p50_cu_price_micro,
p95_cu_price_micro = EXCLUDED.p95_cu_price_micro,
avg_platform_fee_lamports = EXCLUDED.avg_platform_fee_lamports,
+ sum_platform_fee_lamports = EXCLUDED.sum_platform_fee_lamports,
jito_rate = EXCLUDED.jito_rate,
avg_cu_consumed = EXCLUDED.avg_cu_consumed,
computed_at = now()`,
diff --git a/harnesses/solana-exec/migrations/003_cu_samples.sql b/harnesses/solana-exec/migrations/003_cu_samples.sql
new file mode 100644
index 00000000..38d89388
--- /dev/null
+++ b/harnesses/solana-exec/migrations/003_cu_samples.sql
@@ -0,0 +1,16 @@
+BEGIN;
+
+-- Raw CU price samples from the 100-sig enhanced API poll each cycle.
+-- Gives real percentile_cont on actual data points instead of AVG(hourly_p50).
+-- Retention: 35 days (purged by materializer).
+CREATE TABLE IF NOT EXISTS solana_exec_cu_samples (
+ id BIGSERIAL PRIMARY KEY,
+ platform TEXT NOT NULL,
+ block_time TIMESTAMPTZ NOT NULL,
+ cu_price_micro BIGINT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS solana_exec_cu_samples_platform_time
+ ON solana_exec_cu_samples (platform, block_time);
+
+COMMIT;
diff --git a/harnesses/solana-exec/migrations/003_multi_platform.sql b/harnesses/solana-exec/migrations/003_multi_platform.sql
new file mode 100644
index 00000000..bf5528ee
--- /dev/null
+++ b/harnesses/solana-exec/migrations/003_multi_platform.sql
@@ -0,0 +1,29 @@
+BEGIN;
+
+-- Add from_cursor to raw_counts for idempotency: same poll window = same from_cursor = DO NOTHING on retry.
+-- Drop and recreate: table is ephemeral (rolling window, recomputable) so truncate is safe.
+TRUNCATE solana_exec_raw_counts;
+ALTER TABLE solana_exec_raw_counts
+ ADD COLUMN IF NOT EXISTS from_cursor TEXT NOT NULL DEFAULT '';
+ALTER TABLE solana_exec_raw_counts
+ DROP CONSTRAINT IF EXISTS solana_exec_raw_counts_pkey;
+ALTER TABLE solana_exec_raw_counts
+ ADD PRIMARY KEY (platform, bucket_start, from_cursor);
+
+-- Per-account cursors: key was platform, now platform:feeAccount.
+-- Existing single-account cursors (key = platform name) stay valid for pump.fun.
+-- No migration needed; old key will not match new compound keys — polls restart from tip for new accounts.
+
+-- CU price samples for rolling percentile metrics (35-day retention).
+CREATE TABLE IF NOT EXISTS solana_exec_cu_samples (
+ platform TEXT NOT NULL,
+ sig TEXT NOT NULL,
+ block_time TIMESTAMPTZ NOT NULL,
+ cu_price_micro BIGINT NOT NULL,
+ PRIMARY KEY (platform, sig)
+);
+
+CREATE INDEX IF NOT EXISTS solana_exec_cu_samples_bt
+ ON solana_exec_cu_samples (platform, block_time DESC);
+
+COMMIT;
diff --git a/harnesses/solana-exec/migrations/004_raw_counts_idempotent.sql b/harnesses/solana-exec/migrations/004_raw_counts_idempotent.sql
new file mode 100644
index 00000000..66ab942a
--- /dev/null
+++ b/harnesses/solana-exec/migrations/004_raw_counts_idempotent.sql
@@ -0,0 +1,17 @@
+BEGIN;
+
+-- Make raw_counts idempotent on crash/restart.
+-- Problem: UpsertRawCounts accumulated (success_count + EXCLUDED.success_count).
+-- On a crash between UpsertRawCounts and SaveCursor, the next boot re-processes
+-- the same sigs and double-counts. Fix: key by (platform, bucket_start, from_cursor)
+-- so each poll's contribution is a distinct row. ON CONFLICT DO NOTHING = fully idempotent.
+
+ALTER TABLE solana_exec_raw_counts DROP CONSTRAINT solana_exec_raw_counts_pkey;
+
+-- from_cursor = cursor.LastSig at the start of the poll that produced this row.
+-- Existing rows get '' which is safe — they predate this schema change and won't be re-inserted.
+ALTER TABLE solana_exec_raw_counts ADD COLUMN IF NOT EXISTS from_cursor TEXT NOT NULL DEFAULT '';
+
+ALTER TABLE solana_exec_raw_counts ADD PRIMARY KEY (platform, bucket_start, from_cursor);
+
+COMMIT;
diff --git a/harnesses/solana-exec/migrations/005_events_purge_and_cu_sig.sql b/harnesses/solana-exec/migrations/005_events_purge_and_cu_sig.sql
new file mode 100644
index 00000000..2594ba92
--- /dev/null
+++ b/harnesses/solana-exec/migrations/005_events_purge_and_cu_sig.sql
@@ -0,0 +1,18 @@
+BEGIN;
+
+-- Bound events table growth: materializer will purge rows older than 90 days.
+-- Facts table already holds all aggregated metrics; raw events are only needed
+-- for re-materialization within the rolling window.
+-- No schema change needed — purge is done in application code.
+
+-- Make cu_samples idempotent on crash/restart by keying on (platform, sig).
+-- Without this, a crash between UpsertEvents and SaveCursor causes the next
+-- boot to re-call InsertCUSamples for the same 100 sigs, duplicating them
+-- and slightly skewing percentiles.
+ALTER TABLE solana_exec_cu_samples ADD COLUMN IF NOT EXISTS sig TEXT;
+
+CREATE UNIQUE INDEX IF NOT EXISTS solana_exec_cu_samples_platform_sig
+ ON solana_exec_cu_samples (platform, sig)
+ WHERE sig IS NOT NULL;
+
+COMMIT;
diff --git a/harnesses/solana-exec/migrations/006_sum_platform_fee.sql b/harnesses/solana-exec/migrations/006_sum_platform_fee.sql
new file mode 100644
index 00000000..65218cc6
--- /dev/null
+++ b/harnesses/solana-exec/migrations/006_sum_platform_fee.sql
@@ -0,0 +1,8 @@
+BEGIN;
+
+-- Exact per-bucket fee sum, populated by Materialize().
+-- Replaces the extrapolation txCount × avg_platform_fee when events are complete.
+ALTER TABLE solana_exec_facts
+ ADD COLUMN IF NOT EXISTS sum_platform_fee_lamports BIGINT NOT NULL DEFAULT 0;
+
+COMMIT;
diff --git a/harnesses/solana-launchpad-wars/Dockerfile b/harnesses/solana-launchpad-wars/Dockerfile
new file mode 100644
index 00000000..379e2f91
--- /dev/null
+++ b/harnesses/solana-launchpad-wars/Dockerfile
@@ -0,0 +1,15 @@
+FROM golang:1.24-alpine AS builder
+
+WORKDIR /app
+RUN apk add --no-cache git
+
+COPY go.mod ./
+COPY cmd ./cmd
+RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -o /app/harness ./cmd/script
+
+FROM debian:bookworm-slim
+WORKDIR /app
+RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
+COPY --from=builder /app/harness /app/harness
+EXPOSE 2112
+CMD ["/app/harness"]
diff --git a/harnesses/solana-launchpad-wars/cmd/script/main.go b/harnesses/solana-launchpad-wars/cmd/script/main.go
new file mode 100644
index 00000000..a7307869
--- /dev/null
+++ b/harnesses/solana-launchpad-wars/cmd/script/main.go
@@ -0,0 +1,71 @@
+// solana-launchpad-wars -- Bench 200
+//
+// Polls the Mobula lighthouse endpoint every 15 minutes and exposes
+// per-launchpad 24h volume, fees and trade counts as Prometheus gauges.
+//
+// Metrics on :2112/metrics:
+//
+// solana_launchpad_volume_24h_usd{launchpad}
+// solana_launchpad_fees_24h_usd{launchpad}
+// solana_launchpad_trades_24h{launchpad}
+// solana_launchpad_health{launchpad}
+package main
+
+import (
+ "fmt"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+)
+
+const pollInterval = 15 * time.Minute
+
+func main() {
+ fmt.Println("=== solana-launchpad-wars harness ===")
+ fmt.Println("OpenChainBench Bench 200 -- Solana launchpad leaderboard via Mobula lighthouse.")
+
+ apiKey := os.Getenv("MOBULA_API_KEY")
+ if apiKey == "" {
+ fmt.Fprintln(os.Stderr, "[fatal] MOBULA_API_KEY not set")
+ os.Exit(1)
+ }
+
+ client := newMobulaClient(apiKey)
+
+ go func() {
+ if err := startMetricsServer(":2112"); err != nil {
+ fmt.Printf("[fatal] metrics server: %v\n", err)
+ os.Exit(1)
+ }
+ }()
+
+ sig := make(chan os.Signal, 1)
+ signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
+
+ runOnce(client)
+
+ tick := time.NewTicker(pollInterval)
+ defer tick.Stop()
+
+ for {
+ select {
+ case <-sig:
+ fmt.Println("[shutdown] received signal")
+ return
+ case <-tick.C:
+ runOnce(client)
+ }
+ }
+}
+
+func runOnce(c *mobulaClient) {
+ data, err := c.fetchLighthouse()
+ if err != nil {
+ fmt.Printf("[poll] lighthouse fetch failed: %v\n", err)
+ return
+ }
+ publishLighthouse(data)
+ fmt.Printf("[poll] updated: %d launchpads, %d platforms\n",
+ len(data.ByLaunchpad), len(data.ByPlatform))
+}
diff --git a/harnesses/solana-launchpad-wars/cmd/script/metrics.go b/harnesses/solana-launchpad-wars/cmd/script/metrics.go
new file mode 100644
index 00000000..54cb19cb
--- /dev/null
+++ b/harnesses/solana-launchpad-wars/cmd/script/metrics.go
@@ -0,0 +1,108 @@
+package main
+
+import (
+ "net/http"
+ "regexp"
+ "strings"
+
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+)
+
+var (
+ launchpadVol24h = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "solana_launchpad_volume_24h_usd",
+ Help: "24h trading volume in USD (Mobula lighthouse byLaunchpad).",
+ }, []string{"launchpad"})
+
+ launchpadFees24h = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "solana_launchpad_fees_24h_usd",
+ Help: "24h protocol fees in USD (Mobula lighthouse byLaunchpad).",
+ }, []string{"launchpad"})
+
+ launchpadTrades24h = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "solana_launchpad_trades_24h",
+ Help: "24h trade count (Mobula lighthouse byLaunchpad).",
+ }, []string{"launchpad"})
+
+ launchpadHealth = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "solana_launchpad_health",
+ Help: "1 if this launchpad returned data in the last lighthouse poll.",
+ }, []string{"launchpad"})
+
+ platformVol24h = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "solana_platform_volume_24h_usd",
+ Help: "24h trading volume in USD routed through this Solana trading platform.",
+ }, []string{"platform"})
+
+ platformFees24h = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "solana_platform_fees_24h_usd",
+ Help: "24h platform fees in USD collected by this Solana trading platform.",
+ }, []string{"platform"})
+
+ platformTrades24h = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "solana_platform_trades_24h",
+ Help: "24h trade count attributed to this Solana trading platform (Mobula lighthouse).",
+ }, []string{"platform"})
+
+ platformHealth = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "solana_platform_health",
+ Help: "1 if this platform returned data in the last lighthouse poll.",
+ }, []string{"platform"})
+)
+
+func init() {
+ prometheus.MustRegister(
+ launchpadVol24h,
+ launchpadFees24h,
+ launchpadTrades24h,
+ launchpadHealth,
+ platformVol24h,
+ platformFees24h,
+ platformTrades24h,
+ platformHealth,
+ )
+}
+
+var nonAlnum = regexp.MustCompile(`[^a-z0-9]+`)
+
+func slugify(name string) string {
+ s := strings.ToLower(name)
+ s = nonAlnum.ReplaceAllString(s, "-")
+ return strings.Trim(s, "-")
+}
+
+func publishLighthouse(data *lighthouseData) {
+ for _, e := range data.ByLaunchpad {
+ slug := slugify(e.Name)
+ launchpadVol24h.WithLabelValues(slug).Set(e.VolumeUSD.H24)
+ launchpadFees24h.WithLabelValues(slug).Set(e.FeesPaidUSD.H24)
+ launchpadTrades24h.WithLabelValues(slug).Set(e.Trades.H24)
+ launchpadHealth.WithLabelValues(slug).Set(1)
+ // pump.fun trades via its own frontend are in byLaunchpad; publish as platform
+ // so bench 201 can compare it against GMGN, Axiom, Fomo etc. (same attribution logic).
+ if slug == "pumpfun" {
+ platformVol24h.WithLabelValues("pump-fun").Set(e.VolumeUSD.H24)
+ platformFees24h.WithLabelValues("pump-fun").Set(e.FeesPaidUSD.H24)
+ platformTrades24h.WithLabelValues("pump-fun").Set(e.Trades.H24)
+ platformHealth.WithLabelValues("pump-fun").Set(1)
+ }
+ }
+ for _, e := range data.ByPlatform {
+ slug := slugify(e.Name)
+ platformVol24h.WithLabelValues(slug).Set(e.VolumeUSD.H24)
+ platformFees24h.WithLabelValues(slug).Set(e.FeesPaidUSD.H24)
+ platformTrades24h.WithLabelValues(slug).Set(e.Trades.H24)
+ platformHealth.WithLabelValues(slug).Set(1)
+ }
+}
+
+func startMetricsServer(addr string) error {
+ mux := http.NewServeMux()
+ mux.Handle("/metrics", promhttp.Handler())
+ mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("OK"))
+ })
+ return http.ListenAndServe(addr, mux)
+}
diff --git a/harnesses/solana-launchpad-wars/cmd/script/mobula.go b/harnesses/solana-launchpad-wars/cmd/script/mobula.go
new file mode 100644
index 00000000..a458aa5b
--- /dev/null
+++ b/harnesses/solana-launchpad-wars/cmd/script/mobula.go
@@ -0,0 +1,73 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+)
+
+const lighthouseURL = "https://api.mobula.io/api/2/market/lighthouse"
+
+type mobulaClient struct {
+ apiKey string
+ http *http.Client
+}
+
+func newMobulaClient(apiKey string) *mobulaClient {
+ return &mobulaClient{
+ apiKey: apiKey,
+ http: &http.Client{Timeout: 15 * time.Second},
+ }
+}
+
+type windowValues struct {
+ Min15 float64 `json:"15min"`
+ H1 float64 `json:"1h"`
+ H6 float64 `json:"6h"`
+ H24 float64 `json:"24h"`
+}
+
+type lighthouseEntry struct {
+ Name string `json:"name"`
+ VolumeUSD windowValues `json:"volumeUSD"`
+ FeesPaidUSD windowValues `json:"feesPaidUSD"`
+ Trades windowValues `json:"trades"`
+}
+
+type lighthouseData struct {
+ ByLaunchpad []lighthouseEntry `json:"byLaunchpad"`
+ ByPlatform []lighthouseEntry `json:"byPlatform"`
+}
+
+func (c *mobulaClient) fetchLighthouse() (*lighthouseData, error) {
+ req, err := http.NewRequest(http.MethodGet, lighthouseURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("build req: %w", err)
+ }
+ req.Header.Set("Authorization", c.apiKey)
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("http: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
+ if err != nil {
+ return nil, fmt.Errorf("read: %w", err)
+ }
+ if resp.StatusCode >= 400 {
+ return nil, fmt.Errorf("status %d: %.200s", resp.StatusCode, body)
+ }
+
+ var out struct {
+ Data lighthouseData `json:"data"`
+ }
+ if err := json.Unmarshal(body, &out); err != nil {
+ return nil, fmt.Errorf("parse: %w", err)
+ }
+ return &out.Data, nil
+}
diff --git a/harnesses/solana-launchpad-wars/go.mod b/harnesses/solana-launchpad-wars/go.mod
new file mode 100644
index 00000000..6a1707dd
--- /dev/null
+++ b/harnesses/solana-launchpad-wars/go.mod
@@ -0,0 +1,18 @@
+module solana-launchpad-wars
+
+go 1.24.0
+
+require github.com/prometheus/client_golang v1.23.2
+
+require (
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/kr/text v0.2.0 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.66.1 // indirect
+ github.com/prometheus/procfs v0.16.1 // indirect
+ go.yaml.in/yaml/v2 v2.4.2 // indirect
+ golang.org/x/sys v0.35.0 // indirect
+ google.golang.org/protobuf v1.36.8 // indirect
+)
diff --git a/harnesses/solana-launchpad-wars/go.sum b/harnesses/solana-launchpad-wars/go.sum
new file mode 100644
index 00000000..d6b8ca98
--- /dev/null
+++ b/harnesses/solana-launchpad-wars/go.sum
@@ -0,0 +1,46 @@
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
+github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
+github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
+github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
+go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
+golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
+golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
+google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/harnesses/solana-unique-traders/Dockerfile b/harnesses/solana-unique-traders/Dockerfile
new file mode 100644
index 00000000..f88e4a0d
--- /dev/null
+++ b/harnesses/solana-unique-traders/Dockerfile
@@ -0,0 +1,16 @@
+FROM golang:1.24-alpine AS builder
+
+WORKDIR /app
+RUN apk add --no-cache git
+
+COPY go.mod ./
+COPY cmd ./cmd
+COPY queries ./queries
+RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -o /app/harness ./cmd/script
+
+FROM debian:bookworm-slim
+WORKDIR /app
+RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
+COPY --from=builder /app/harness /app/harness
+EXPOSE 2112
+CMD ["/app/harness"]
diff --git a/harnesses/solana-unique-traders/cmd/script/dune.go b/harnesses/solana-unique-traders/cmd/script/dune.go
new file mode 100644
index 00000000..8d8dc00e
--- /dev/null
+++ b/harnesses/solana-unique-traders/cmd/script/dune.go
@@ -0,0 +1,220 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+)
+
+// querySQL counts unique swap transactions per Solana trading platform in 24h.
+//
+// Terminals (GMGN, Axiom, Fomo, Trojan, Photon, Maestro): fee wallet inflow
+// detection via solana.account_activity. SOL branch matches address = fee wallet
+// with post_balance > pre_balance. USDC/WSOL branch matches token_balance_owner
+// = fee wallet. Since terminals charge fees on every trade, this captures all
+// real swap activity. Fomo uses R4rN... (owner wallet) for token_balance_owner.
+//
+// pump.fun: switched from fee wallet to dex_solana.trades (project IN
+// ('pumpdotfun','pumpswap')). After pump.fun cut bonding-curve fees to 0% on
+// 2026-08-07, its fee wallets still received micro-SOL inflows on every tx
+// (rent/account-creation side-effects), inflating the count by ~60% with noise.
+// dex_solana.trades indexes actual swap events: 'pumpdotfun' = bonding curve,
+// 'pumpswap' = graduated AMM. Validated: 6.0M unique tx vs 9.7M via fee wallet.
+const querySQL = `
+WITH fee_wallets AS (
+ SELECT address, platform FROM (VALUES
+ ('BB5dnY55FXS1e1NXqZDwCzgdYJdMCj3B92PU6Q5Fb6DT','gmgn'),
+ ('7sHXjs1j7sDJGVSMSPjD1b4v3FD6uRSvRWfhRdfv5BiA','gmgn'),
+ ('HeZVpHj9jLwTVtMMbzQRf6mLtFPkWNSg11o68qrbUBa3','gmgn'),
+ ('ByRRgnZenY6W2sddo1VJzX9o4sMU4gPDUkcmgrpGBxRy','gmgn'),
+ ('DXfkEGoo6WFsdL7x6gLZ7r6Hw2S6HrtrAQVPWYx2A1s9','gmgn'),
+ ('3t9EKmRiAUcQUYzTZpNojzeGP1KBAVEEbDNmy6wECQpK','gmgn'),
+ ('DymeoWc5WLNiQBaoLuxrxDnDRvLgGZ1QGsEoCAM7Jsrx','gmgn'),
+ ('dBhdrmwBkRa66XxBuAK4WZeZnsZ6bHeHCCLXa3a8bTJ','gmgn'),
+ ('6TxjC5wJzuuZgTtnTMipwwULEbMPx5JPW3QwWkdTGnrn','gmgn'),
+ ('7LCZckF6XXGQ1hDY6HFXBKWAtiUgL9QY5vj1C4Bn1Qjj','axiom'),
+ ('4V65jvcDG9DSQioUVqVPiUcUY9v6sb6HKtMnsxSKEz5S','axiom'),
+ ('CeA3sPZfWWToFEBmw5n1Y93tnV66Vmp8LacLzsVprgxZ','axiom'),
+ ('AaG6of1gbj1pbDumvbSiTuJhRCRkkUNaWVxijSbWvTJW','axiom'),
+ ('7oi1L8U9MRu5zDz5syFahsiLUric47LzvJBQX6r827ws','axiom'),
+ ('9kPrgLggBJ69tx1czYAbp7fezuUmL337BsqQTKETUEhP','axiom'),
+ ('DKyUs1xXMDy8Z11zNsLnUg3dy9HZf6hYZidB6WodcaGy','axiom'),
+ ('4FobGn5ZWYquoJkxMzh2VUAWvV36xMgxQ3M7uG1pGGhd','axiom'),
+ ('76sxKrPtgoJHDJvxwFHqb3cAXWfRHFLe3VpKcLCAHSEf','axiom'),
+ ('H2cDR3EkJjtTKDQKk8SJS48du9mhsdzQhy8xJx5UMqQK','axiom'),
+ ('8m5GkL7nVy95G4YVUbs79z873oVKqg2afgKRmqxsiiRm','axiom'),
+ ('4kuG6NsAFJNwqEkac8GFDMMheCGKUPEbaRVHHyFHSwWz','axiom'),
+ ('8vFGAKdwpn4hk7kc1cBgfWZzpyW3MEMDATDzVZhddeQb','axiom'),
+ ('86Vh4XGLW2b6nvWbRyDs4ScgMXbuvRCHT7WbUT3RFxKG','axiom'),
+ ('DZfEurFKFtSbdWZsKSDTqpqsQgvXxmESpvRtXkAdgLwM','axiom'),
+ ('5L2QKqDn5ukJSWGyqR4RPvFvwnBabKWqAqMzH4heaQNB','axiom'),
+ ('DYVeNgXGLAhZdeLMMYnCw1nPnMxkBN7fJnNpHmizTrrF','axiom'),
+ ('Hbj6XdxX6eV4nfbYTseysibp4zZJtVRRPn2J3BhGRuK9','axiom'),
+ ('846ah7iBSu9ApuCyEhA5xpnjHHX7d4QJKetWLbwzmJZ8','axiom'),
+ ('5BqYhuD4q1YD3DMAYkc1FeTu9vqQVYYdfBAmkZjamyZg','axiom'),
+ ('R4rNJHaffSUotNmqSKNEfDcJE8A7zJUkaoM5Jkd7cYX','fomo'),
+ ('9yMwSPk9mrXSN7yDHUuZurAh1sjbJsfpUqjZ7SvVtdco','trojan'),
+ ('92Med3qeK7duC5iiYsHX38H2f2twJfRsSx93oNrza2VH','trojan'),
+ ('2jwHNxavSoMZMEDbT1eV9PcPt5dDcayCqM6MkgaPpmWQ','trojan'),
+ ('65gDv7pZQCZELsNpNYSFEBtNFpWZAbxmRFB6BGMqFkHH','trojan'),
+ ('BWgb8wR1FEGiu1jCDSKuHKf752W27b4iN6SvoNCiK4qp','trojan'),
+ ('8jgg7moFJkHyTtAv9M6RBSPMp2oXeXhuiUMKW8YbYCWn','trojan'),
+ ('AVUCZyuT35YSuj4RH7fwiyPu82Djn2Hfg7y2ND2XcnZH','photon'),
+ ('MaestroUL88UBnZr3wfoN7hqmNWFi3ZYCGqZoJJHE36','maestro'),
+ ('FRMxAnZgkW58zbYcE7Bxqsg99VWpJh6sMP5xLzAWNabN','maestro')
+ ) AS t(address, platform)
+),
+fee_activity AS (
+ SELECT fw.platform, a.tx_id
+ FROM solana.account_activity a
+ JOIN fee_wallets fw ON a.address = fw.address
+ WHERE a.block_time >= NOW() - INTERVAL '1' DAY
+ AND a.token_mint_address IS NULL
+ AND a.post_balance > a.pre_balance
+ UNION
+ SELECT fw.platform, a.tx_id
+ FROM solana.account_activity a
+ JOIN fee_wallets fw ON a.token_balance_owner = fw.address
+ WHERE a.block_time >= NOW() - INTERVAL '1' DAY
+ AND a.token_mint_address = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
+ AND a.post_token_balance > a.pre_token_balance
+ UNION
+ SELECT fw.platform, a.tx_id
+ FROM solana.account_activity a
+ JOIN fee_wallets fw ON a.token_balance_owner = fw.address
+ WHERE a.block_time >= NOW() - INTERVAL '1' DAY
+ AND a.token_mint_address = 'So11111111111111111111111111111111111111112'
+ AND a.post_token_balance > a.pre_token_balance
+ UNION
+ SELECT 'pump-fun' AS platform, tx_id
+ FROM dex_solana.trades
+ WHERE project IN ('pumpdotfun', 'pumpswap')
+ AND block_time >= NOW() - INTERVAL '1' DAY
+)
+SELECT platform, COUNT(DISTINCT tx_id) AS unique_traders_24h
+FROM fee_activity
+GROUP BY platform
+ORDER BY unique_traders_24h DESC
+`
+
+const duneBase = "https://api.dune.com/api/v1"
+
+type duneClient struct {
+ apiKey string
+ http *http.Client
+}
+
+type duneRow struct {
+ Platform string `json:"platform"`
+ UniqueTraders24h float64 `json:"unique_traders_24h"`
+}
+
+func newDuneClient(apiKey string) *duneClient {
+ return &duneClient{
+ apiKey: apiKey,
+ http: &http.Client{Timeout: 30 * time.Second},
+ }
+}
+
+func (d *duneClient) req(method, path string, body any) (*http.Response, error) {
+ var r io.Reader
+ if body != nil {
+ b, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ r = bytes.NewReader(b)
+ }
+ req, err := http.NewRequest(method, duneBase+path, r)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("X-Dune-Api-Key", d.apiKey)
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ return d.http.Do(req)
+}
+
+func (d *duneClient) createQuery() (string, error) {
+ payload := map[string]any{
+ "name": "OCB: Solana Unique Traders 24h by Platform",
+ "query_sql": querySQL,
+ "is_private": false,
+ "parameters": []any{},
+ }
+ resp, err := d.req("POST", "/query", payload)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ body, _ := io.ReadAll(resp.Body)
+ return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ var out struct {
+ QueryID int `json:"query_id"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("%d", out.QueryID), nil
+}
+
+func (d *duneClient) execute(queryID string) (string, error) {
+ resp, err := d.req("POST", "/query/"+queryID+"/execute", map[string]any{})
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ body, _ := io.ReadAll(resp.Body)
+ return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ var out struct {
+ ExecutionID string `json:"execution_id"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ return "", err
+ }
+ return out.ExecutionID, nil
+}
+
+func (d *duneClient) executionState(execID string) (string, error) {
+ resp, err := d.req("GET", "/execution/"+execID+"/status", nil)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ var out struct {
+ State string `json:"state"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ return "", err
+ }
+ return out.State, nil
+}
+
+func (d *duneClient) latestResult(queryID string) ([]duneRow, error) {
+ resp, err := d.req("GET", "/query/"+queryID+"/results", nil)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ var out struct {
+ Result struct {
+ Rows []duneRow `json:"rows"`
+ } `json:"result"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ return nil, err
+ }
+ return out.Result.Rows, nil
+}
diff --git a/harnesses/solana-unique-traders/cmd/script/main.go b/harnesses/solana-unique-traders/cmd/script/main.go
new file mode 100644
index 00000000..793cee93
--- /dev/null
+++ b/harnesses/solana-unique-traders/cmd/script/main.go
@@ -0,0 +1,128 @@
+// solana-unique-traders -- Bench 207
+//
+// Fetches the Dune query result (unique daily traders per Solana platform)
+// and exposes it as Prometheus gauges. Triggers a fresh Dune execution every
+// hour; serves the latest cached result every 15 minutes in between.
+//
+// Required env vars:
+// DUNE_API_KEY - Dune Analytics API key
+// DUNE_QUERY_ID - ID of the saved Dune query (see queries/unique_traders.sql)
+// If not set, the harness creates the query automatically and exits.
+//
+// Metrics on :2112/metrics:
+// solana_platform_unique_traders_24h{platform}
+// solana_platform_trader_health{platform}
+package main
+
+import (
+ "fmt"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+)
+
+const (
+ fetchInterval = 15 * time.Minute
+ refreshInterval = 1 * time.Hour
+)
+
+func main() {
+ fmt.Println("=== solana-unique-traders harness ===")
+ fmt.Println("OpenChainBench Bench 207 -- Unique daily traders by Solana platform via Dune.")
+
+ apiKey := os.Getenv("DUNE_API_KEY")
+ if apiKey == "" {
+ fmt.Fprintln(os.Stderr, "[fatal] DUNE_API_KEY not set")
+ os.Exit(1)
+ }
+
+ queryID := os.Getenv("DUNE_QUERY_ID")
+ client := newDuneClient(apiKey)
+
+ if queryID == "" {
+ fmt.Println("[init] DUNE_QUERY_ID not set, creating Dune query...")
+ id, err := client.createQuery()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "[fatal] failed to create Dune query: %v\n", err)
+ os.Exit(1)
+ }
+ fmt.Printf("[init] Dune query created: %s\n", id)
+ fmt.Printf("[init] Set DUNE_QUERY_ID=%s and restart the harness.\n", id)
+ os.Exit(0)
+ }
+
+ go func() {
+ if err := startMetricsServer(":2112"); err != nil {
+ fmt.Printf("[fatal] metrics server: %v\n", err)
+ os.Exit(1)
+ }
+ }()
+
+ sig := make(chan os.Signal, 1)
+ signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
+
+ // On startup: fetch latest cached result immediately, then trigger a fresh execution.
+ runFetch(client, queryID)
+ go func() {
+ execID, err := client.execute(queryID)
+ if err != nil {
+ fmt.Printf("[refresh] execute failed: %v\n", err)
+ return
+ }
+ pollUntilDone(client, queryID, execID)
+ }()
+
+ fetchTick := time.NewTicker(fetchInterval)
+ refreshTick := time.NewTicker(refreshInterval)
+ defer fetchTick.Stop()
+ defer refreshTick.Stop()
+
+ for {
+ select {
+ case <-sig:
+ fmt.Println("[shutdown] received signal")
+ return
+ case <-fetchTick.C:
+ runFetch(client, queryID)
+ case <-refreshTick.C:
+ execID, err := client.execute(queryID)
+ if err != nil {
+ fmt.Printf("[refresh] execute failed: %v\n", err)
+ continue
+ }
+ go pollUntilDone(client, queryID, execID)
+ }
+ }
+}
+
+func runFetch(c *duneClient, queryID string) {
+ rows, err := c.latestResult(queryID)
+ if err != nil {
+ fmt.Printf("[fetch] failed: %v\n", err)
+ return
+ }
+ publishRows(rows)
+ fmt.Printf("[fetch] updated %d platform(s)\n", len(rows))
+}
+
+func pollUntilDone(c *duneClient, queryID, execID string) {
+ for range 30 {
+ time.Sleep(30 * time.Second)
+ state, err := c.executionState(execID)
+ if err != nil {
+ fmt.Printf("[poll] state check failed: %v\n", err)
+ return
+ }
+ switch state {
+ case "QUERY_STATE_COMPLETED":
+ fmt.Printf("[poll] execution %s complete\n", execID)
+ runFetch(c, queryID)
+ return
+ case "QUERY_STATE_FAILED", "QUERY_STATE_CANCELLED", "QUERY_STATE_EXPIRED":
+ fmt.Printf("[poll] execution %s ended with state %s\n", execID, state)
+ return
+ }
+ }
+ fmt.Printf("[poll] execution %s timed out waiting\n", execID)
+}
diff --git a/harnesses/solana-unique-traders/cmd/script/metrics.go b/harnesses/solana-unique-traders/cmd/script/metrics.go
new file mode 100644
index 00000000..86b285a8
--- /dev/null
+++ b/harnesses/solana-unique-traders/cmd/script/metrics.go
@@ -0,0 +1,44 @@
+package main
+
+import (
+ "net/http"
+
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+)
+
+var (
+ uniqueTraders24h = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "solana_platform_unique_traders_24h",
+ Help: "Unique wallet addresses that traded through this Solana platform in the last 24h (Dune Analytics, fee-wallet attribution).",
+ }, []string{"platform"})
+
+ traderHealth = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "solana_platform_trader_health",
+ Help: "1 if this platform returned unique-trader data in the last Dune poll.",
+ }, []string{"platform"})
+)
+
+func init() {
+ prometheus.MustRegister(uniqueTraders24h, traderHealth)
+}
+
+func publishRows(rows []duneRow) {
+ for _, r := range rows {
+ if r.Platform == "" {
+ continue
+ }
+ uniqueTraders24h.WithLabelValues(r.Platform).Set(r.UniqueTraders24h)
+ traderHealth.WithLabelValues(r.Platform).Set(1)
+ }
+}
+
+func startMetricsServer(addr string) error {
+ mux := http.NewServeMux()
+ mux.Handle("/metrics", promhttp.Handler())
+ mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("OK"))
+ })
+ return http.ListenAndServe(addr, mux)
+}
diff --git a/harnesses/solana-unique-traders/go.mod b/harnesses/solana-unique-traders/go.mod
new file mode 100644
index 00000000..d5e7f65d
--- /dev/null
+++ b/harnesses/solana-unique-traders/go.mod
@@ -0,0 +1,18 @@
+module solana-unique-traders
+
+go 1.24.0
+
+require github.com/prometheus/client_golang v1.23.2
+
+require (
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/kr/text v0.2.0 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.66.1 // indirect
+ github.com/prometheus/procfs v0.16.1 // indirect
+ go.yaml.in/yaml/v2 v2.4.2 // indirect
+ golang.org/x/sys v0.35.0 // indirect
+ google.golang.org/protobuf v1.36.8 // indirect
+)
diff --git a/harnesses/solana-unique-traders/go.sum b/harnesses/solana-unique-traders/go.sum
new file mode 100644
index 00000000..d6b8ca98
--- /dev/null
+++ b/harnesses/solana-unique-traders/go.sum
@@ -0,0 +1,46 @@
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
+github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
+github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
+github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
+go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
+golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
+golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
+google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/harnesses/solana-unique-traders/queries/unique_traders.sql b/harnesses/solana-unique-traders/queries/unique_traders.sql
new file mode 100644
index 00000000..da82fafb
--- /dev/null
+++ b/harnesses/solana-unique-traders/queries/unique_traders.sql
@@ -0,0 +1,143 @@
+-- Unique daily traders by Solana trading platform
+-- Source: pump_fun_solana.trades (pump.fun) + solana.account_activity joined with solana.transactions (all others)
+-- Attribution: fee wallet presence in transaction account keys
+-- Window: rolling 24 hours
+-- Used by: OpenChainBench Bench 207 (solana-unique-traders)
+
+WITH pump_fun AS (
+ SELECT
+ 'pump-fun' AS platform,
+ COUNT(DISTINCT trader_id) AS unique_traders_24h
+ FROM pump_fun_solana.trades
+ WHERE block_time >= NOW() - INTERVAL '1' DAY
+),
+
+fee_activity AS (
+ SELECT
+ CASE
+ WHEN address IN (
+ 'BB5dnY55FXS1e1NXqZDwCzgdYJdMCj3B92PU6Q5Fb6DT',
+ '7sHXjs1j7sDJGVSMSPjD1b4v3FD6uRSvRWfhRdfv5BiA',
+ 'HeZVpHj9jLwTVtMMbzQRf6mLtFPkWNSg11o68qrbUBa3',
+ 'ByRRgnZenY6W2sddo1VJzX9o4sMU4gPDUkcmgrpGBxRy',
+ 'DXfkEGoo6WFsdL7x6gLZ7r6Hw2S6HrtrAQVPWYx2A1s9',
+ '3t9EKmRiAUcQUYzTZpNojzeGP1KBAVEEbDNmy6wECQpK',
+ 'DymeoWc5WLNiQBaoLuxrxDnDRvLgGZ1QGsEoCAM7Jsrx',
+ 'dBhdrmwBkRa66XxBuAK4WZeZnsZ6bHeHCCLXa3a8bTJ',
+ '6TxjC5wJzuuZgTtnTMipwwULEbMPx5JPW3QwWkdTGnrn'
+ ) THEN 'gmgn'
+ WHEN address IN (
+ '7LCZckF6XXGQ1hDY6HFXBKWAtiUgL9QY5vj1C4Bn1Qjj',
+ '4V65jvcDG9DSQioUVqVPiUcUY9v6sb6HKtMnsxSKEz5S',
+ 'CeA3sPZfWWToFEBmw5n1Y93tnV66Vmp8LacLzsVprgxZ',
+ 'AaG6of1gbj1pbDumvbSiTuJhRCRkkUNaWVxijSbWvTJW',
+ '7oi1L8U9MRu5zDz5syFahsiLUric47LzvJBQX6r827ws',
+ '9kPrgLggBJ69tx1czYAbp7fezuUmL337BsqQTKETUEhP',
+ 'DKyUs1xXMDy8Z11zNsLnUg3dy9HZf6hYZidB6WodcaGy',
+ '4FobGn5ZWYquoJkxMzh2VUAWvV36xMgxQ3M7uG1pGGhd',
+ '76sxKrPtgoJHDJvxwFHqb3cAXWfRHFLe3VpKcLCAHSEf',
+ 'H2cDR3EkJjtTKDQKk8SJS48du9mhsdzQhy8xJx5UMqQK',
+ '8m5GkL7nVy95G4YVUbs79z873oVKqg2afgKRmqxsiiRm',
+ '4kuG6NsAFJNwqEkac8GFDMMheCGKUPEbaRVHHyFHSwWz',
+ '8vFGAKdwpn4hk7kc1cBgfWZzpyW3MEMDATDzVZhddeQb',
+ '86Vh4XGLW2b6nvWbRyDs4ScgMXbuvRCHT7WbUT3RFxKG',
+ 'DZfEurFKFtSbdWZsKSDTqpqsQgvXxmESpvRtXkAdgLwM',
+ '5L2QKqDn5ukJSWGyqR4RPvFvwnBabKWqAqMzH4heaQNB',
+ 'DYVeNgXGLAhZdeLMMYnCw1nPnMxkBN7fJnNpHmizTrrF',
+ 'Hbj6XdxX6eV4nfbYTseysibp4zZJtVRRPn2J3BhGRuK9',
+ '846ah7iBSu9ApuCyEhA5xpnjHHX7d4QJKetWLbwzmJZ8',
+ '5BqYhuD4q1YD3DMAYkc1FeTu9vqQVYYdfBAmkZjamyZg'
+ ) THEN 'axiom'
+ WHEN address IN (
+ 'HrTf9CzXR1dRH4Sof5QrpmGWwpwAf3qZzwCsEjQpXcSq'
+ ) THEN 'fomo'
+ WHEN address IN (
+ '9yMwSPk9mrXSN7yDHUuZurAh1sjbJsfpUqjZ7SvVtdco',
+ '92Med3qeK7duC5iiYsHX38H2f2twJfRsSx93oNrza2VH',
+ '2jwHNxavSoMZMEDbT1eV9PcPt5dDcayCqM6MkgaPpmWQ',
+ '65gDv7pZQCZELsNpNYSFEBtNFpWZAbxmRFB6BGMqFkHH',
+ 'BWgb8wR1FEGiu1jCDSKuHKf752W27b4iN6SvoNCiK4qp',
+ '8jgg7moFJkHyTtAv9M6RBSPMp2oXeXhuiUMKW8YbYCWn'
+ ) THEN 'trojan'
+ WHEN address IN (
+ 'AVUCZyuT35YSuj4RH7fwiyPu82Djn2Hfg7y2ND2XcnZH'
+ ) THEN 'photon'
+ WHEN address IN (
+ 'MaestroUL88UBnZr3wfoN7hqmNWFi3ZYCGqZoJJHE36',
+ 'FRMxAnZgkW58zbYcE7Bxqsg99VWpJh6sMP5xLzAWNabN'
+ ) THEN 'maestro'
+ END AS platform,
+ tx_id
+ FROM solana.account_activity
+ WHERE block_time >= NOW() - INTERVAL '1' DAY
+ AND address IN (
+ -- GMGN (9 wallets)
+ 'BB5dnY55FXS1e1NXqZDwCzgdYJdMCj3B92PU6Q5Fb6DT',
+ '7sHXjs1j7sDJGVSMSPjD1b4v3FD6uRSvRWfhRdfv5BiA',
+ 'HeZVpHj9jLwTVtMMbzQRf6mLtFPkWNSg11o68qrbUBa3',
+ 'ByRRgnZenY6W2sddo1VJzX9o4sMU4gPDUkcmgrpGBxRy',
+ 'DXfkEGoo6WFsdL7x6gLZ7r6Hw2S6HrtrAQVPWYx2A1s9',
+ '3t9EKmRiAUcQUYzTZpNojzeGP1KBAVEEbDNmy6wECQpK',
+ 'DymeoWc5WLNiQBaoLuxrxDnDRvLgGZ1QGsEoCAM7Jsrx',
+ 'dBhdrmwBkRa66XxBuAK4WZeZnsZ6bHeHCCLXa3a8bTJ',
+ '6TxjC5wJzuuZgTtnTMipwwULEbMPx5JPW3QwWkdTGnrn',
+ -- AXIOM (20 wallets)
+ '7LCZckF6XXGQ1hDY6HFXBKWAtiUgL9QY5vj1C4Bn1Qjj',
+ '4V65jvcDG9DSQioUVqVPiUcUY9v6sb6HKtMnsxSKEz5S',
+ 'CeA3sPZfWWToFEBmw5n1Y93tnV66Vmp8LacLzsVprgxZ',
+ 'AaG6of1gbj1pbDumvbSiTuJhRCRkkUNaWVxijSbWvTJW',
+ '7oi1L8U9MRu5zDz5syFahsiLUric47LzvJBQX6r827ws',
+ '9kPrgLggBJ69tx1czYAbp7fezuUmL337BsqQTKETUEhP',
+ 'DKyUs1xXMDy8Z11zNsLnUg3dy9HZf6hYZidB6WodcaGy',
+ '4FobGn5ZWYquoJkxMzh2VUAWvV36xMgxQ3M7uG1pGGhd',
+ '76sxKrPtgoJHDJvxwFHqb3cAXWfRHFLe3VpKcLCAHSEf',
+ 'H2cDR3EkJjtTKDQKk8SJS48du9mhsdzQhy8xJx5UMqQK',
+ '8m5GkL7nVy95G4YVUbs79z873oVKqg2afgKRmqxsiiRm',
+ '4kuG6NsAFJNwqEkac8GFDMMheCGKUPEbaRVHHyFHSwWz',
+ '8vFGAKdwpn4hk7kc1cBgfWZzpyW3MEMDATDzVZhddeQb',
+ '86Vh4XGLW2b6nvWbRyDs4ScgMXbuvRCHT7WbUT3RFxKG',
+ 'DZfEurFKFtSbdWZsKSDTqpqsQgvXxmESpvRtXkAdgLwM',
+ '5L2QKqDn5ukJSWGyqR4RPvFvwnBabKWqAqMzH4heaQNB',
+ 'DYVeNgXGLAhZdeLMMYnCw1nPnMxkBN7fJnNpHmizTrrF',
+ 'Hbj6XdxX6eV4nfbYTseysibp4zZJtVRRPn2J3BhGRuK9',
+ '846ah7iBSu9ApuCyEhA5xpnjHHX7d4QJKetWLbwzmJZ8',
+ '5BqYhuD4q1YD3DMAYkc1FeTu9vqQVYYdfBAmkZjamyZg',
+ -- FOMO (1 wallet, USDC ATA)
+ 'HrTf9CzXR1dRH4Sof5QrpmGWwpwAf3qZzwCsEjQpXcSq',
+ -- TROJAN (6 wallets)
+ '9yMwSPk9mrXSN7yDHUuZurAh1sjbJsfpUqjZ7SvVtdco',
+ '92Med3qeK7duC5iiYsHX38H2f2twJfRsSx93oNrza2VH',
+ '2jwHNxavSoMZMEDbT1eV9PcPt5dDcayCqM6MkgaPpmWQ',
+ '65gDv7pZQCZELsNpNYSFEBtNFpWZAbxmRFB6BGMqFkHH',
+ 'BWgb8wR1FEGiu1jCDSKuHKf752W27b4iN6SvoNCiK4qp',
+ '8jgg7moFJkHyTtAv9M6RBSPMp2oXeXhuiUMKW8YbYCWn',
+ -- PHOTON (1 wallet)
+ 'AVUCZyuT35YSuj4RH7fwiyPu82Djn2Hfg7y2ND2XcnZH',
+ -- MAESTRO (2 wallets)
+ 'MaestroUL88UBnZr3wfoN7hqmNWFi3ZYCGqZoJJHE36',
+ 'FRMxAnZgkW58zbYcE7Bxqsg99VWpJh6sMP5xLzAWNabN'
+ )
+),
+
+fee_with_signer AS (
+ SELECT
+ fa.platform,
+ t.fee_payer
+ FROM fee_activity fa
+ JOIN solana.transactions t ON t.id = fa.tx_id
+ WHERE t.success = true
+ AND fa.platform IS NOT NULL
+),
+
+fee_counts AS (
+ SELECT
+ platform,
+ COUNT(DISTINCT fee_payer) AS unique_traders_24h
+ FROM fee_with_signer
+ GROUP BY platform
+)
+
+SELECT platform, unique_traders_24h FROM pump_fun
+UNION ALL
+SELECT platform, unique_traders_24h FROM fee_counts
+ORDER BY unique_traders_24h DESC
diff --git a/harnesses/ws-head-latency/cmd/script/aggregator.go b/harnesses/ws-head-latency/cmd/script/aggregator.go
index 87e384bc..89a4a029 100644
--- a/harnesses/ws-head-latency/cmd/script/aggregator.go
+++ b/harnesses/ws-head-latency/cmd/script/aggregator.go
@@ -147,7 +147,7 @@ func (a *aggregator) settle(height int64) {
}
}
for _, p := range providers {
- lagMs := float64(e.arrivals[p].Sub(t0).Milliseconds())
+ lagMs := float64(e.arrivals[p].Sub(t0).Nanoseconds()) / 1e6
headLagHist.WithLabelValues(p, a.chain).Observe(lagMs)
}
headWins.WithLabelValues(winner, a.chain).Inc()
diff --git a/next.config.ts b/next.config.ts
index cf1c571b..3e70417c 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -262,6 +262,11 @@ const nextConfig: NextConfig = {
destination: "/benchmarks/pm-resolution-delay/:path*",
permanent: true,
},
+ {
+ source: "/apps/exec",
+ destination: "/apps",
+ permanent: true,
+ },
...chainRedirects,
...rpcClusterRedirects,
];
diff --git a/public/logos/banana-gun.png b/public/logos/banana-gun.png
new file mode 100644
index 00000000..740eca24
Binary files /dev/null and b/public/logos/banana-gun.png differ
diff --git a/public/logos/bullx.png b/public/logos/bullx.png
new file mode 100644
index 00000000..a822fee4
Binary files /dev/null and b/public/logos/bullx.png differ
diff --git a/public/logos/dex-screener.png b/public/logos/dex-screener.png
new file mode 100644
index 00000000..a86efa35
Binary files /dev/null and b/public/logos/dex-screener.png differ
diff --git a/public/logos/gmgn.jpg b/public/logos/gmgn.jpg
new file mode 100644
index 00000000..079d613c
Binary files /dev/null and b/public/logos/gmgn.jpg differ
diff --git a/public/logos/maestro.jpg b/public/logos/maestro.jpg
new file mode 100644
index 00000000..070bff5e
Binary files /dev/null and b/public/logos/maestro.jpg differ
diff --git a/public/logos/moonshot.svg b/public/logos/moonshot.svg
new file mode 100644
index 00000000..6e67b8ab
--- /dev/null
+++ b/public/logos/moonshot.svg
@@ -0,0 +1,29 @@
+
diff --git a/public/logos/phantom.svg b/public/logos/phantom.svg
new file mode 100644
index 00000000..9cc90aa9
--- /dev/null
+++ b/public/logos/phantom.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/public/logos/photon.webp b/public/logos/photon.webp
new file mode 100644
index 00000000..38238ce0
Binary files /dev/null and b/public/logos/photon.webp differ
diff --git a/public/logos/pump-fun.jpg b/public/logos/pump-fun.jpg
new file mode 100644
index 00000000..710d65cc
Binary files /dev/null and b/public/logos/pump-fun.jpg differ
diff --git a/public/logos/pumpfun.svg b/public/logos/pumpfun.svg
new file mode 100644
index 00000000..c264f37f
--- /dev/null
+++ b/public/logos/pumpfun.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/public/logos/trojan.jpg b/public/logos/trojan.jpg
new file mode 100644
index 00000000..72f4d9bd
Binary files /dev/null and b/public/logos/trojan.jpg differ
diff --git a/src/app/alternatives/[slug]/page.tsx b/src/app/alternatives/[slug]/page.tsx
index a7b35462..05dfd25f 100644
--- a/src/app/alternatives/[slug]/page.tsx
+++ b/src/app/alternatives/[slug]/page.tsx
@@ -4,7 +4,6 @@ import Link from "next/link";
import { ArrowLeft, ArrowUpRight } from "lucide-react";
import { Pill } from "@/components/pill";
import { TimeSeriesChart } from "@/components/time-series-chart";
-import { LedgerTable } from "@/components/ledger-table";
import { CountLeaderboard } from "@/components/count-leaderboard";
import { fmtUnit, unitSuffix, fmtValue } from "@/lib/format";
import { computeFieldStats } from "@/lib/stats";
@@ -23,6 +22,7 @@ import { isRegion } from "@/lib/brand";
import { PERP_VENUE_META, benchRowsForVenue } from "@/lib/perp-venue-context";
import { fetchPerpCohort } from "@/lib/perp-stats";
import { PerpVenueBenchCards } from "@/components/perp-venue-bench-cards";
+import { LedgerTable } from "@/components/ledger-table";
export const dynamic = "force-dynamic";
@@ -50,13 +50,36 @@ export async function generateMetadata({
const title =
alt.seo_title ??
`${alt.target_product} alternatives. live benchmark · OpenChainBench`;
- const description = alt.seo_description ?? alt.intro.slice(0, 200);
+ const description = capDescription(
+ alt.seo_description ?? alt.intro,
+ 158,
+ );
const url = `${SITE.url}/alternatives/${alt.slug}`;
+ const ogImage = `${SITE.url}/api/og/${alt.benchmark}`;
+ // Noindex when the underlying bench has no live data yet — same rule as
+ // /benchmarks/[slug]. Avoids indexing a "Not measured yet" page that
+ // undercuts the bench canonical once data arrives.
+ const isDraft = alt.bench.status === "draft" || alt.status === "draft";
return {
title,
description,
alternates: { canonical: url },
- openGraph: { title, description, type: "article", url },
+ ...(isDraft ? { robots: { index: false, follow: true } } : {}),
+ openGraph: {
+ title,
+ description,
+ type: "article",
+ url,
+ siteName: SITE.name,
+ images: [ogImage],
+ },
+ twitter: {
+ card: "summary_large_image",
+ site: SITE.twitter,
+ title,
+ description,
+ images: [ogImage],
+ },
};
}
@@ -268,8 +291,8 @@ export default async function AlternativePage({
)}
{/* Top alternatives cards - explicit list with internal links to
- product pages. The bench leaderboard below carries the full data;
- this section frames the answer for skim-readers and search. */}
+ product pages. Excludes the target product itself so every card
+ is a genuine alternative, not a restatement of the subject. */}
{topAlternatives.length > 0 && (
@@ -312,20 +335,24 @@ export default async function AlternativePage({
)}
- {/* Reuse bench rendering. count vs latency split, same as the
- /benchmarks/[slug] page. */}
+ {/* Count benches: leaderboard is the primary view; no latency stats. */}
{!isDraft && bench.unit === "count" && (
- <>
+
-
- Product ledger
-
-
- >
+
)}
+ {/* Latency / rate / USD benches: full ranked table + trend chart.
+ LedgerTable gives readers the complete ranked list on this page
+ (better search-intent match than a CTA-only pattern) while
+ /benchmarks/ remains canonical for the interactive bar chart,
+ distribution view, and per-chain drilldowns. */}
{!isDraft && bench.unit !== "count" && (
<>
+
+
+
+
{bench.metric} · last 24 hours
-
-
- Product ledger · sorted by p50
-
-
>
)}
-
- Same data as{" "}
-
- /benchmarks/{bench.slug}
+ {/* Link to canonical benchmark for interactive views not present here:
+ ranked bar chart, distribution, per-chain drilldowns, chart export. */}
+ {!isDraft && (
+
+
+ Interactive bar chart, distribution view, per-chain drilldowns and
+ chart export on the benchmark page.
+
+
+ Open benchmark
+
+
+
+ )}
+
+
+ Measurements sourced from{" "}
+
+ {bench.title}
- {alt.chain ? ` (filtered to ${alt.chain})` : ""}, refreshed every minute.
- OpenChainBench is community-run; methodology is open.
+ {alt.chain ? ` (filtered to ${alt.chain})` : ""}. Methodology and
+ raw data on the benchmark page.
- Gross fees = all fees collected by the protocol before any distribution.{" "}
- Net value captured = burn + treasury + token holder − emissions.
- Builder/interface fees (paid to frontend operators) are excluded.
- Methodology versions and allocation params are on-chain verifiable.
+
- A pair is published when all four conditions hold. Both
- providers run in the same OpenChainBench benchmark for at
- least seven consecutive days. Each provider has at least 1000
- samples in the measurement window. The head to head query has
- observable third party search demand. Both providers have a
- public /products/[slug] page on OCB. The full
- pair ledger is versioned in the public repo so the
- methodology is externally verifiable.
+ Auto-generated pairs require: both providers in the same
+ benchmark for seven consecutive days, at least 1000 samples
+ per provider, observable third-party search demand, and a
+ public /products/[slug] page on OCB. Editorially
+ curated pairs (like this one) may publish early when search
+ demand is high and data is accruing — panels with fewer than
+ 100 samples are shown as provisional. The full pair ledger is
+ versioned in the public repo.
diff --git a/src/app/hyperliquid/[slug]/page.tsx b/src/app/hyperliquid/[slug]/page.tsx
index de10af5e..a72cf878 100644
--- a/src/app/hyperliquid/[slug]/page.tsx
+++ b/src/app/hyperliquid/[slug]/page.tsx
@@ -6,6 +6,7 @@ import {
fetchHlCohort,
fetchHlHistory,
isHlBuilderSlug,
+ type HlBuilderStats,
type HlCohortRow,
type HlHistoryFrontendCompact,
} from "@/lib/hl-builder-stats";
@@ -14,6 +15,8 @@ import { HlBuilderDashboard } from "@/components/hl-builder-dashboard";
import { ProviderLogo } from "@/components/provider-logo";
import { pageMetadata } from "@/lib/page-metadata";
import { safeJsonLd } from "@/lib/jsonld";
+import { RelatedProvidersSection } from "@/components/related-providers-section";
+import { BenchAppearancesSection } from "@/components/bench-appearances-section";
/**
* Per-frontend detail page for the Hyperliquid grid overview. Server
@@ -109,6 +112,19 @@ export default async function HlFrontendPage({
if (!history || !frontend) {
if (!(await isHlBuilderSlug(slug))) notFound();
const cohortRow = cohort?.rows.find((r) => r.slug === slug);
+ // If we have live builder stats (Prom snapshot), show the dashboard
+ // without the history-dependent sections (firstDay, peakFees, peer group).
+ if (hlStats) {
+ const name = cohortRow?.name ?? titleCaseSlug(slug);
+ return (
+
+ );
+ }
return ;
}
@@ -291,6 +307,10 @@ export default async function HlFrontendPage({
)}
+
+
+
+
Source: local hl node tailing every Hyperliquid mainnet fill. This
page reads the same rolling-30d gauges as the /hyperliquid hub;
@@ -311,6 +331,82 @@ function titleCaseSlug(slug: string): string {
.join(" ");
}
+/** Full dashboard rendered when builder stats are available but the
+ * 12-month history blob doesn't include this frontend yet (e.g. fees
+ * were 0 when the blob was last built). Shows all live KPIs and charts
+ * from hlStats; skips history-dependent sections (firstDay, peak, peers). */
+function HlBuilderNoHistory({
+ slug,
+ name,
+ stats,
+ cohortRow,
+}: {
+ slug: string;
+ name: string;
+ stats: HlBuilderStats;
+ cohortRow: HlCohortRow | undefined;
+}) {
+ const breadcrumbLd = {
+ "@context": "https://schema.org",
+ "@type": "BreadcrumbList",
+ itemListElement: [
+ { "@type": "ListItem", position: 1, name: "Home", item: "https://openchainbench.com/" },
+ { "@type": "ListItem", position: 2, name: "Hyperliquid", item: "https://openchainbench.com/hyperliquid" },
+ { "@type": "ListItem", position: 3, name, item: `https://openchainbench.com/hyperliquid/${slug}` },
+ ],
+ };
+ return (
+
+
+
+
+
Hyperliquid frontend
+
+
+
{name}
+
+
+ {slug}
+
+
+
+ {fmtUSDShort(stats.revenue30d)}
+
+ rolling 30d builder fees
+
+ )}
+
+
+
+
+
+ );
+}
+
/** Lightweight placeholder rendered with HTTP 200 when a builder is in
* the spec catalog but temporarily absent from the history blob. Shows
* whatever cohort data is still resolvable so the page carries real
diff --git a/src/app/rpc/page.tsx b/src/app/rpc/page.tsx
index 78b3a755..9d95b78d 100644
--- a/src/app/rpc/page.tsx
+++ b/src/app/rpc/page.tsx
@@ -109,9 +109,10 @@ export default async function RpcHubPage() {
Every free, no-key public RPC endpoint, measured per chain with
- the same probe: one identical eth_blockNumber call
- every 15 seconds from 3 regions (N. Virginia, Amsterdam,
- Singapore). The matrix below folds the per-chain leaderboards
+ the same probe: one identical{" "}
+ eth_getBlockByNumber("latest", false) call with a
+ rotating request id (defeats CDN body-keyed caches) every 15
+ seconds from 3 regions (N. Virginia, Amsterdam, Singapore). The matrix below folds the per-chain leaderboards
into one view: fastest provider per chain, fastest per region,
and which gateway covers your whole multichain stack. Headline
numbers are 24h p50 round-trip latency; methodology and
@@ -229,8 +230,8 @@ export default async function RpcHubPage() {
Methodology
Each chain row aggregates that chain's dedicated bench: an
- identical JSON-RPC POST (eth_blockNumber or the
- chain's equivalent head call) sent every 15 seconds to
+ identical JSON-RPC POST (eth_getBlockByNumber("latest", false) with
+ rotating request id, or the chain's equivalent head call) sent every 15 seconds to
every free, no-key public endpoint from us-east, eu-west and
Singapore. Headline figures are the 50th percentile of
client-side round-trip latency over the trailing 24 hours,
diff --git a/src/components/bench-appearances-section.tsx b/src/components/bench-appearances-section.tsx
new file mode 100644
index 00000000..c50c7fc3
--- /dev/null
+++ b/src/components/bench-appearances-section.tsx
@@ -0,0 +1,118 @@
+import Link from "next/link";
+import { getProviders } from "@/lib/providers";
+import { CATEGORY_COLOR } from "@/lib/category-colors";
+import { fmtUnit } from "@/lib/format";
+
+interface Props {
+ providerSlug: string;
+}
+
+export async function BenchAppearancesSection({ providerSlug }: Props) {
+ const profiles = await getProviders();
+ const p = profiles.find(
+ (pr) => pr.slug.toLowerCase() === providerSlug.toLowerCase(),
+ );
+ if (!p || p.appearances.length === 0) return null;
+
+ const sorted = [...p.appearances].sort((a, b) => {
+ if (a.rank !== b.rank) return a.rank - b.rank;
+ return a.benchmark.title.localeCompare(b.benchmark.title);
+ });
+
+ return (
+
+