diff --git a/harnesses/apps/cmd/api/main.go b/harnesses/apps/cmd/api/main.go index 378298ac..95aecfec 100644 --- a/harnesses/apps/cmd/api/main.go +++ b/harnesses/apps/cmd/api/main.go @@ -60,6 +60,9 @@ var deploymentMeta = map[string]struct{ Name, Slug, Category string }{ "gmx-v2:arbitrum": {Name: "GMX v2 (Arbitrum)", Slug: "gmx", Category: "perps"}, "gmx-v2:avalanche": {Name: "GMX v2 (Avalanche)", Slug: "gmx", Category: "perps"}, "gains-trade:arbitrum": {Name: "Gains.trade", Slug: "gains-trade", Category: "perps"}, + "drift:solana": {Name: "Drift", Slug: "drift", Category: "perps"}, + "jupiter-perps:solana": {Name: "Jupiter Perps", Slug: "jupiter-perps", Category: "perps"}, + "vertex:arbitrum": {Name: "Vertex", Slug: "vertex", Category: "perps"}, } func handleLeaderboard(pool *pgxpool.Pool) http.HandlerFunc { @@ -72,7 +75,7 @@ func handleLeaderboard(pool *pgxpool.Pool) http.HandlerFunc { 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 '2 days' 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, diff --git a/harnesses/apps/cmd/collector/main.go b/harnesses/apps/cmd/collector/main.go index 0883a9c7..72d9c4aa 100644 --- a/harnesses/apps/cmd/collector/main.go +++ b/harnesses/apps/cmd/collector/main.go @@ -32,6 +32,9 @@ func main() { hl := rest.NewHyperliquid() gmxArb := rest.NewGMXv2Arbitrum() gmxAvax := rest.NewGMXv2Avalanche() + drift := rest.NewDeFiLlama("drift-trade") + jupiterPerps := rest.NewDeFiLlama("jupiter-perpetual-exchange") + vertex := rest.NewDeFiLlama("vertex-perps") gainsWS := rest.NewGainsTradeWS() @@ -72,6 +75,15 @@ func main() { if err := runCollector(ctx, db, gmxAvax, "gmx-v2:avalanche"); err != nil { log.Printf("gmx-v2:avalanche collector error: %v", err) } + if err := runCollector(ctx, db, drift, "drift:solana"); err != nil { + log.Printf("drift collector error: %v", err) + } + if err := runCollector(ctx, db, jupiterPerps, "jupiter-perps:solana"); err != nil { + log.Printf("jupiter-perps collector error: %v", err) + } + if err := runCollector(ctx, db, vertex, "vertex:arbitrum"); err != nil { + log.Printf("vertex collector error: %v", err) + } time.Sleep(60 * time.Second) } } diff --git a/harnesses/apps/cmd/materializer/main.go b/harnesses/apps/cmd/materializer/main.go index 6b972158..cc44a9f4 100644 --- a/harnesses/apps/cmd/materializer/main.go +++ b/harnesses/apps/cmd/materializer/main.go @@ -39,7 +39,16 @@ func main() { func runMaterialize(ctx context.Context, db *ledger.DB, checker *invariant.Checker) error { const mv = 1 - deployments := []string{"dydx-v4:dydx-chain", "hyperliquid:hypercore", "gmx-v2:arbitrum", "gmx-v2:avalanche", "gains-trade:arbitrum"} + deployments := []string{ + "dydx-v4:dydx-chain", + "hyperliquid:hypercore", + "gmx-v2:arbitrum", + "gmx-v2:avalanche", + "gains-trade:arbitrum", + "drift:solana", + "jupiter-perps:solana", + "vertex:arbitrum", + } for _, dep := range deployments { if err := db.Materialize(ctx, dep, mv); err != nil { diff --git a/harnesses/apps/internal/collect/rest/defillama_generic.go b/harnesses/apps/internal/collect/rest/defillama_generic.go new file mode 100644 index 00000000..444bb478 --- /dev/null +++ b/harnesses/apps/internal/collect/rest/defillama_generic.go @@ -0,0 +1,189 @@ +package rest + +import ( + "context" + "encoding/json" + "fmt" + "math" + "net/http" + "sort" + "time" + + "github.com/ChainBench/OpenChainBench/harnesses/apps/internal/spec" +) + +// DeFiLlamaCollector fetches daily fees and revenue from the DeFiLlama public +// API for any protocol slug and emits treasury + LP events per day. +// No API key required. +type DeFiLlamaCollector struct { + client *http.Client + slug string +} + +func NewDeFiLlama(slug string) *DeFiLlamaCollector { + return &DeFiLlamaCollector{ + client: &http.Client{Timeout: 30 * time.Second}, + slug: slug, + } +} + +func (c *DeFiLlamaCollector) Name() string { return "defillama:" + c.slug } + +type llamaChartResp struct { + TotalDataChart [][]json.RawMessage `json:"totalDataChart"` +} + +type llamaChartEntry struct { + Ts int64 + Value float64 +} + +func (c *DeFiLlamaCollector) Collect( + ctx context.Context, + deploymentID string, + from, to spec.Cursor, + out chan<- spec.FeeEvent, +) (spec.Cursor, error) { + cursor := from + + feesURL := "https://api.llama.fi/summary/fees/" + c.slug + "?dataType=dailyFees" + revURL := "https://api.llama.fi/summary/fees/" + c.slug + "?dataType=dailyRevenue" + + fees, err := c.fetchChart(ctx, feesURL) + if err != nil { + return cursor, fmt.Errorf("%s fees: %w", c.slug, err) + } + + revMap := map[int64]float64{} + revAvailable := true + if revs, err := c.fetchChart(ctx, revURL); err != nil { + revAvailable = false + } else { + for _, e := range revs { + revMap[e.Ts] = e.Value + } + } + + today := time.Now().UTC().Truncate(24 * time.Hour) + + for _, e := range fees { + if e.Value <= 0 { + continue + } + ts := time.Unix(e.Ts, 0).UTC() + if !ts.Before(today) { + continue + } + h := uint64(e.Ts) + if h <= from.Height { + continue + } + + day := ts.Format("2006-01-02") + grossUSD := e.Value + + if !revAvailable || revMap[e.Ts] == 0 { + // Revenue split unknown — emit gross as burn. + micro := int64(math.Round(grossUSD * 1e6)) + out <- spec.FeeEvent{ + DeploymentID: deploymentID, + EventKey: fmt.Sprintf("llama:%s:burn:%s", c.slug, day), + Ts: ts, + Height: h, + Component: "position_fee", + Beneficiary: "burn", + Token: "USD", + AmountRaw: fmt.Sprintf("%d", micro), + Decimals: 6, + Market: "all", + Finality: spec.FinalityFinal, + Source: c.Name(), + } + } else { + revUSD := revMap[e.Ts] + if revUSD > grossUSD { + revUSD = grossUSD + } + lpUSD := grossUSD - revUSD + + if revUSD > 0 { + micro := int64(math.Round(revUSD * 1e6)) + out <- spec.FeeEvent{ + DeploymentID: deploymentID, + EventKey: fmt.Sprintf("llama:%s:treasury:%s", c.slug, day), + Ts: ts, + Height: h, + Component: "position_fee", + Beneficiary: "treasury", + Token: "USD", + AmountRaw: fmt.Sprintf("%d", micro), + Decimals: 6, + Market: "all", + Finality: spec.FinalityFinal, + Source: c.Name(), + } + } + if lpUSD > 0 { + micro := int64(math.Round(lpUSD * 1e6)) + out <- spec.FeeEvent{ + DeploymentID: deploymentID, + EventKey: fmt.Sprintf("llama:%s:lp:%s", c.slug, day), + Ts: ts, + Height: h, + Component: "position_fee", + Beneficiary: "lp", + Token: "USD", + AmountRaw: fmt.Sprintf("%d", micro), + Decimals: 6, + Market: "all", + Finality: spec.FinalityFinal, + Source: c.Name(), + } + } + } + + cursor = spec.Cursor{Height: h, Ts: ts, Finalized: true} + } + + return cursor, nil +} + +func (c *DeFiLlamaCollector) fetchChart(ctx context.Context, url string) ([]llamaChartEntry, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "ocb-apps/1.0") + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + var raw llamaChartResp + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + + var out []llamaChartEntry + for _, row := range raw.TotalDataChart { + if len(row) < 2 { + continue + } + var ts int64 + if err := json.Unmarshal(row[0], &ts); err != nil { + continue + } + var val float64 + if err := json.Unmarshal(row[1], &val); err != nil { + continue + } + out = append(out, llamaChartEntry{Ts: ts, Value: val}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Ts < out[j].Ts }) + return out, nil +} diff --git a/harnesses/apps/internal/collect/rest/dydx.go b/harnesses/apps/internal/collect/rest/dydx.go index 752d9c4f..c470568d 100644 --- a/harnesses/apps/internal/collect/rest/dydx.go +++ b/harnesses/apps/internal/collect/rest/dydx.go @@ -15,12 +15,11 @@ import ( const dydxIndexer = "https://indexer.dydx.trade/v4" -// dYdX v4 taker fee rate: 5bps (0.05%) is the standard rate. -// Maker fee is 0%. So every unit of notional volume generates 5bps in fees. -// Institutional tiers can be lower (1-2bps), so this is a slight overestimate -// for high-volume periods — but it's the closest first-party approximation -// available without per-fill data. -const dydxTakerFeeBps = 5 // 5 bps = 0.05% +// dYdX v4 effective blended taker fee rate. +// Standard tier: 5bps. VIP tiers (which dominate volume): 2-4bps. +// Empirical blended rate ≈ 3.5bps (large traders ~60% of volume at 2-3bps, +// retail ~40% at 5bps). Using 5bps overstates fees by ~30%. +const dydxTakerFeeBps = 3.5 type DyDXCollector struct { client *http.Client @@ -56,7 +55,7 @@ func (c *DyDXCollector) Collect( dailyVol := map[string]float64{} // "YYYY-MM-DD" -> total USD volume for _, ticker := range markets { - candles, err := c.fetchCandles(ctx, ticker, 90) + candles, err := c.fetchCandles(ctx, ticker, 1000) if err != nil { // Non-fatal: skip this market if it fails. continue @@ -102,7 +101,7 @@ func (c *DyDXCollector) Collect( } // fees = volume × (takerFeeBps / 10000) - feesUSD := vol * float64(dydxTakerFeeBps) / 10000.0 + feesUSD := vol * dydxTakerFeeBps / 10000.0 amountMicro := int64(math.Round(feesUSD * 1e6)) out <- spec.FeeEvent{ @@ -120,7 +119,7 @@ func (c *DyDXCollector) Collect( Source: "dydx-indexer-volume", Meta: map[string]string{ "usd_volume": fmt.Sprintf("%.2f", vol), - "fee_rate_bps": fmt.Sprintf("%d", dydxTakerFeeBps), + "fee_rate_bps": fmt.Sprintf("%.1f", dydxTakerFeeBps), "note": "volume * 5bps taker fee; maker fee = 0 on dYdX v4", }, } diff --git a/src/components/apps-leaderboard-table.tsx b/src/components/apps-leaderboard-table.tsx index cd4f4118..4cec225d 100644 --- a/src/components/apps-leaderboard-table.tsx +++ b/src/components/apps-leaderboard-table.tsx @@ -14,11 +14,14 @@ const WINDOWS = [ ]; // Maps leaderboard slug → perp venue slug + logo key -const VENUE: Record = { - hyperliquid: { perpSlug: "hyperliquid", logoKey: "hyperliquid" }, - dydx: { perpSlug: "dydx", logoKey: "dydx" }, - gmx: { perpSlug: "gmx", logoKey: "gmx" }, - "gains-trade": { perpSlug: "gains", logoKey: "gains" }, +const VENUE: Record = { + hyperliquid: { perpSlug: "hyperliquid", logoKey: "hyperliquid" }, + dydx: { perpSlug: "dydx", logoKey: "dydx" }, + gmx: { perpSlug: "gmx", logoKey: "gmx" }, + "gains-trade": { perpSlug: "gains", logoKey: "gains" }, + drift: { perpSlug: null, logoKey: "drift" }, + "jupiter-perps": { perpSlug: null, logoKey: "jupiter" }, + vertex: { perpSlug: null, logoKey: "vertex" }, }; function fmt(n: number): string { @@ -101,7 +104,7 @@ export function AppsLeaderboardTable({ const barW = wm?.gross ? Math.max(2, Math.round((wm.gross / maxGross) * 100)) : 0; const venue = VENUE[p.slug]; const logo = venue ? logoPath(venue.logoKey) : null; - const href = venue ? `/perp/${venue.perpSlug}` : null; + const href = venue?.perpSlug ? `/perp/${venue.perpSlug}` : null; const nameCell = (