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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions harnesses/apps/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ 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
RUN CGO_ENABLED=0 go build -o /app/api ./cmd/api

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 --from=builder /app/api /app/api
COPY migrations/ /app/migrations/
174 changes: 174 additions & 0 deletions harnesses/apps/cmd/api/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package main

import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"time"

"github.com/jackc/pgx/v5/pgxpool"
)

func main() {
ctx := context.Background()
pool, err := pgxpool.New(ctx, mustEnv("DATABASE_URL"))
if err != nil {
log.Fatalf("apps-api: connect: %v", err)
}
defer pool.Close()

mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
})
mux.HandleFunc("/api/leaderboard", corsJSON(handleLeaderboard(pool)))

addr := ":2115"
log.Printf("apps-api listening on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("apps-api: serve: %v", err)
}
}

type WindowMetrics struct {
Gross float64 `json:"gross"`
Burn float64 `json:"burn"`
LP float64 `json:"lp"`
Spot float64 `json:"spot"`
}

type ProtocolRow struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Category string `json:"category"`
LatestDay string `json:"latestDay"`
Windows map[string]WindowMetrics `json:"windows"`
}

type LeaderboardResponse struct {
UpdatedAt string `json:"updatedAt"`
Protocols []ProtocolRow `json:"protocols"`
}

var deploymentMeta = map[string]struct{ Name, Slug, Category string }{
"hyperliquid:hypercore": {Name: "Hyperliquid", Slug: "hyperliquid", Category: "perps"},
"dydx-v4:dydx-chain": {Name: "dYdX", Slug: "dydx", Category: "perps"},
}

func handleLeaderboard(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
deployment_id,
beneficiary,
component,
SUM(CASE WHEN bucket_start >= now() - INTERVAL '24 hours' THEN amount_usd ELSE 0 END) AS h24,
SUM(CASE WHEN bucket_start >= now() - INTERVAL '7 days' THEN amount_usd ELSE 0 END) AS d7,
SUM(CASE WHEN bucket_start >= now() - INTERVAL '30 days' THEN amount_usd ELSE 0 END) AS d30,
SUM(amount_usd) AS all_time,
MAX(bucket_start)::text AS latest_day
FROM fee_facts
WHERE methodology_version = 1
GROUP BY deployment_id, beneficiary, component
ORDER BY deployment_id, beneficiary, component`,
)
if err != nil {
log.Printf("leaderboard query: %v", err)
http.Error(w, "internal", http.StatusInternalServerError)
return
}
defer rows.Close()

type key struct{ id, beneficiary, component string }
type vals struct{ h24, d7, d30, allTime float64; latestDay string }
data := map[key]vals{}

for rows.Next() {
var k key
var v vals
if err := rows.Scan(&k.id, &k.beneficiary, &k.component, &v.h24, &v.d7, &v.d30, &v.allTime, &v.latestDay); err != nil {
continue
}
data[k] = v
}

// Aggregate per deployment into WindowMetrics.
depMap := map[string]*ProtocolRow{}
for k, v := range data {
row, ok := depMap[k.id]
if !ok {
meta := deploymentMeta[k.id]
row = &ProtocolRow{
ID: k.id,
Name: meta.Name,
Slug: meta.Slug,
Category: meta.Category,
Windows: map[string]WindowMetrics{},
}
depMap[k.id] = row
}
if v.latestDay > row.LatestDay {
row.LatestDay = v.latestDay
}
addToWindow := func(winKey string, amount float64) {
wm := row.Windows[winKey]
wm.Gross += amount
switch k.beneficiary {
case "burn":
wm.Burn += amount
case "lp":
wm.LP += amount
}
if k.component == "spot_fee" {
wm.Spot += amount
}
row.Windows[winKey] = wm
}
addToWindow("24h", v.h24)
addToWindow("7d", v.d7)
addToWindow("30d", v.d30)
addToWindow("allTime", v.allTime)
}

protocols := make([]ProtocolRow, 0, len(depMap))
for _, row := range depMap {
protocols = append(protocols, *row)
}
// Sort by 30d gross descending.
for i := 1; i < len(protocols); i++ {
for j := i; j > 0 && protocols[j].Windows["30d"].Gross > protocols[j-1].Windows["30d"].Gross; j-- {
protocols[j], protocols[j-1] = protocols[j-1], protocols[j]
}
}

resp := LeaderboardResponse{
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
Protocols: protocols,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
}

func corsJSON(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Cache-Control", "public, max-age=60, stale-while-revalidate=300")
h(w, r)
}
}

func mustEnv(key string) string {
v := os.Getenv(key)
if v == "" {
log.Fatalf("missing env: %s", key)
}
return v
}
57 changes: 57 additions & 0 deletions src/app/apps/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { Metadata } from "next";
import { pageMetadata } from "@/lib/page-metadata";
import { safeJsonLd, buildBreadcrumbJsonLd } from "@/lib/jsonld";
import { SITE } from "@/data/site";
import { fetchAppsLeaderboard, type ProtocolRow } from "@/lib/apps-leaderboard";
import { AppsLeaderboardTable } from "@/components/apps-leaderboard-table";

const DESCRIPTION =
"Protocol revenue leaderboard for on-chain apps: fees captured by treasury, token holders, and LPs. Reproducible methodology, refreshed daily, sources public.";

export const metadata: Metadata = pageMetadata({
path: "/apps",
title: "Protocol Revenue Leaderboard 2026 — OpenChainBench",
description: DESCRIPTION,
});

export const revalidate = 300;

export default async function AppsHubPage() {
const data = await fetchAppsLeaderboard();

const breadcrumb = {
"@context": "https://schema.org",
...buildBreadcrumbJsonLd([
{ name: "Home", item: SITE.url },
{ name: "Apps", item: `${SITE.url}/apps` },
]),
};

return (
<article className="mx-auto max-w-[900px] px-4 sm:px-6 py-10 sm:py-14">
<script
type="application/ld+json"
// biome-ignore lint/security/noDangerouslySetInnerHtml: serialized via safeJsonLd
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumb) }}
/>

<h1 className="display text-3xl sm:text-4xl text-ink leading-[1.05]">
Protocol revenue.
</h1>
<p className="mt-4 max-w-2xl text-base text-ink-soft leading-snug">
{DESCRIPTION}
</p>

<div className="mt-10">
<AppsLeaderboardTable protocols={data?.protocols ?? []} updatedAt={data?.updatedAt ?? null} />
</div>

<p className="mt-8 text-xs text-ink-muted leading-relaxed max-w-2xl">
<strong>Gross fees</strong> = all fees collected by the protocol before any distribution.{" "}
<strong>Net value captured</strong> = burn + treasury + token holder − emissions.
Builder/interface fees (paid to frontend operators) are excluded.
Methodology versions and allocation params are on-chain verifiable.
</p>
</article>
);
}
114 changes: 114 additions & 0 deletions src/components/apps-leaderboard-table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"use client";

import { useState } from "react";
import type { ProtocolRow, WindowMetrics } from "@/lib/apps-leaderboard";

const WINDOWS = [
{ key: "24h", label: "24h" },
{ key: "7d", label: "7d" },
{ key: "30d", label: "30d" },
{ key: "allTime", label: "All time" },
];

function fmt(n: number): string {
if (n >= 1e9) return `$${(n / 1e9).toFixed(2)}B`;
if (n >= 1e6) return `$${(n / 1e6).toFixed(2)}M`;
if (n >= 1e3) return `$${(n / 1e3).toFixed(1)}K`;
return `$${Math.round(n).toLocaleString()}`;
}

function Dot() {
return <span className="text-ink-muted">—</span>;
}

export function AppsLeaderboardTable({
protocols,
updatedAt,
}: {
protocols: ProtocolRow[];
updatedAt: string | null;
}) {
const [window, setWindow] = useState("30d");

if (protocols.length === 0) {
return (
<div className="py-12 text-center text-ink-muted text-sm">
No data yet — collector is warming up.
</div>
);
}

const sorted = [...protocols].sort(
(a, b) =>
(b.windows[window]?.gross ?? 0) - (a.windows[window]?.gross ?? 0),
);

return (
<div>
<div className="flex items-center justify-between mb-4 gap-4 flex-wrap">
<div className="flex gap-1">
{WINDOWS.map((w) => (
<button
key={w.key}
type="button"
onClick={() => setWindow(w.key)}
className={`px-3 py-1 rounded text-sm font-mono transition-colors ${
window === w.key
? "bg-ink text-bg"
: "text-ink-soft hover:text-ink"
}`}
>
{w.label}
</button>
))}
</div>
{updatedAt && (
<span className="text-xs text-ink-muted font-mono">
Updated {new Date(updatedAt).toLocaleString()}
</span>
)}
</div>

<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-ink/10">
<th className="text-left py-2 pr-4 font-medium text-ink-soft w-8">#</th>
<th className="text-left py-2 pr-4 font-medium text-ink-soft">Protocol</th>
<th className="text-right py-2 pr-4 font-medium text-ink-soft">Gross fees</th>
<th className="text-right py-2 pr-4 font-medium text-ink-soft hidden sm:table-cell">Burn / AF</th>
<th className="text-right py-2 pr-4 font-medium text-ink-soft hidden sm:table-cell">LP</th>
<th className="text-right py-2 font-medium text-ink-soft hidden md:table-cell">Latest data</th>
</tr>
</thead>
<tbody>
{sorted.map((p, i) => {
const wm: WindowMetrics | undefined = p.windows[window];
return (
<tr key={p.id} className="border-b border-ink/5 hover:bg-ink/2">
<td className="py-3 pr-4 text-ink-muted font-mono text-xs">{i + 1}</td>
<td className="py-3 pr-4">
<div className="font-medium text-ink">{p.name}</div>
<div className="text-xs text-ink-muted capitalize">{p.category}</div>
</td>
<td className="py-3 pr-4 text-right font-mono">
{wm?.gross ? fmt(wm.gross) : <Dot />}
</td>
<td className="py-3 pr-4 text-right font-mono text-ink-soft hidden sm:table-cell">
{wm?.burn ? fmt(wm.burn) : <Dot />}
</td>
<td className="py-3 pr-4 text-right font-mono text-ink-soft hidden sm:table-cell">
{wm?.lp ? fmt(wm.lp) : <Dot />}
</td>
<td className="py-3 text-right text-xs text-ink-muted font-mono hidden md:table-cell">
{p.latestDay ? p.latestDay.slice(0, 10) : <Dot />}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
34 changes: 34 additions & 0 deletions src/lib/apps-leaderboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export type WindowMetrics = {
gross: number;
burn: number;
lp: number;
spot: number;
};

export type ProtocolRow = {
id: string;
name: string;
slug: string;
category: string;
latestDay: string;
windows: Record<string, WindowMetrics>;
};

export type LeaderboardResponse = {
updatedAt: string;
protocols: ProtocolRow[];
};

const APPS_API = "https://apps.openchainbench.com";

export async function fetchAppsLeaderboard(): Promise<LeaderboardResponse | null> {
try {
const res = await fetch(`${APPS_API}/api/leaderboard`, {
next: { revalidate: 300 },
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
Loading