From b7dff257dc5dc10678dce9bc20e740bae7b20b71 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:59:47 +0200 Subject: [PATCH 1/2] feat(apps): switch HL collector to node fills, add hl-fills-agg Replace DeFiLlama proxy with first-party Hyperliquid node fills. - Reads fee/builderFee from node_fills_by_block on SGP (15.235.224.14) - hl-fills-agg.py aggregates daily totals, runs as pm2 on SGP:2116 - net_usdc = gross - builder = amount flowing to AF + HLP + deployers - Fills archive starts 2026-06-01, backfill runs automatically --- .../apps/internal/collect/rest/hyperliquid.go | 162 ++++++++---------- harnesses/apps/scripts/hl-fills-agg.py | 156 +++++++++++++++++ 2 files changed, 232 insertions(+), 86 deletions(-) create mode 100644 harnesses/apps/scripts/hl-fills-agg.py diff --git a/harnesses/apps/internal/collect/rest/hyperliquid.go b/harnesses/apps/internal/collect/rest/hyperliquid.go index 4bbaf207..dd4d35d3 100644 --- a/harnesses/apps/internal/collect/rest/hyperliquid.go +++ b/harnesses/apps/internal/collect/rest/hyperliquid.go @@ -4,33 +4,38 @@ import ( "context" "encoding/json" "fmt" + "math" "net/http" "time" "github.com/ChainBench/OpenChainBench/harnesses/apps/internal/spec" ) -const defilllamaHL = "https://api.llama.fi/summary/fees/hyperliquid" +// fillsAggURL is the hl-fills-agg service running on the SGP node alongside +// the Hyperliquid non-validating node. It reads hourly node_fills_by_block +// files and returns daily fee totals (gross, builder, net) in USDC. +const fillsAggURL = "http://15.235.224.14:2116" + +// fillsEpoch is the earliest date available in the node fills archive. +const fillsEpoch = "20260601" type HyperliquidCollector struct { client *http.Client } func NewHyperliquid() *HyperliquidCollector { - return &HyperliquidCollector{client: &http.Client{Timeout: 30 * time.Second}} + return &HyperliquidCollector{client: &http.Client{Timeout: 60 * time.Second}} } -func (c *HyperliquidCollector) Name() string { return "hyperliquid-defillama" } - -// hlDayBreakdown holds the per-category fees for one day as returned by DeFiLlama. -// Perps → taker_fee / burn (goes to AF which buys+burns HYPE) -// HLP → taker_fee / lp (HLP vault earns from market making) -// Spot → spot_fee / burn (spot orderbook fees, mostly AF) -type hlDayBreakdown struct { - Ts int64 // unix seconds (DeFiLlama bucket start) - Perps float64 // "Hyperliquid Perps" - HLP float64 // "Hyperliquid HLP" - Spot float64 // "Hyperliquid Spot Orderbook" +func (c *HyperliquidCollector) Name() string { return "hyperliquid-node-fills" } + +type hlDailySummary struct { + Date string `json:"date"` // "YYYYMMDD" + GrossUSDC float64 `json:"gross_usdc"` // all fees paid (taker + maker) + BuilderUSDC float64 `json:"builder_usdc"` // third-party builder codes cut + NetUSDC float64 `json:"net_usdc"` // gross - builder → goes to AF + HLP + deployers + Fills int64 `json:"fills"` + Hours int `json:"hours"` } func (c *HyperliquidCollector) Collect( @@ -41,56 +46,71 @@ func (c *HyperliquidCollector) Collect( ) (spec.Cursor, error) { cursor := from - days, err := c.fetchBreakdown(ctx) + // Determine the start date string. + // cursor.Height encodes the unix timestamp (seconds) of the last processed + // day's midnight UTC. Zero means start from the fills epoch. + var startDate string + if from.Height == 0 { + startDate = fillsEpoch + } else { + startDate = time.Unix(int64(from.Height), 0).UTC().Format("20060102") + } + endDate := to.Ts.UTC().Format("20060102") + + days, err := c.fetchDailySummaries(ctx, startDate, endDate) if err != nil { - return cursor, fmt.Errorf("hyperliquid: defillama: %w", err) + return cursor, fmt.Errorf("hyperliquid: fills-agg: %w", err) } for _, d := range days { - // Use unix timestamp (seconds) as synthetic height for cursor tracking. - h := uint64(d.Ts) - if h < from.Height { + ts, err := time.Parse("20060102", d.Date) + if err != nil { continue } - if h >= to.Height { - break - } + ts = ts.UTC() - ts := time.Unix(d.Ts, 0).UTC() + h := uint64(ts.Unix()) + if h < from.Height { + continue + } - // Skip days with zero fees (typically the incomplete current day at the tail). - if d.Perps == 0 && d.HLP == 0 && d.Spot == 0 { + // Skip partial days (< 23 hours) — they're incomplete. + // Today's day will be partial and should not be persisted. + if d.Hours < 23 { continue } - // DeFiLlama returns whole-dollar amounts. - // Store as raw USD integer strings with Decimals=0. - emit := func(component, beneficiary string, amount float64) { - if amount <= 0 { - return - } - out <- spec.FeeEvent{ - DeploymentID: deploymentID, - EventKey: fmt.Sprintf("hl:defillama:%d:%s:%s", d.Ts, component, beneficiary), - Ts: ts, - Height: h, - Component: component, - Beneficiary: beneficiary, - Token: "USD", - AmountRaw: fmt.Sprintf("%d", int64(amount)), - Decimals: 0, - Market: "all", - Finality: spec.FinalityFinal, - Source: "defillama-hyperliquid", - Meta: map[string]string{ - "note": "Phase 1 proxy via DeFiLlama. Will be replaced by S3 fills in Phase 2.", - }, - } + if d.NetUSDC <= 0 { + continue } - emit("taker_fee", "burn", d.Perps) - emit("taker_fee", "lp", d.HLP) - emit("spot_fee", "burn", d.Spot) + // net_usdc = all fills fees minus builder codes. + // This is the total amount that flows to AF + HLP + deployers. + // We classify as taker_fee/burn since the vast majority (~97-99%) + // goes to the Assistance Fund (which burns HYPE), per the HL docs. + // The HLP vault share and deployer share are not separable per-fill + // and are small relative to the total. + amountMicro := int64(math.Round(d.NetUSDC * 1e6)) + out <- spec.FeeEvent{ + DeploymentID: deploymentID, + EventKey: fmt.Sprintf("hl:node-fills:%s:net", d.Date), + Ts: ts, + Height: h, + Component: "taker_fee", + Beneficiary: "burn", + Token: "USDC", + AmountRaw: fmt.Sprintf("%d", amountMicro), + Decimals: 6, + Market: "all", + Finality: spec.FinalityFinal, + Source: "hyperliquid-node-fills", + Meta: map[string]string{ + "gross_usdc": fmt.Sprintf("%.2f", d.GrossUSDC), + "builder_usdc": fmt.Sprintf("%.2f", d.BuilderUSDC), + "fills": fmt.Sprintf("%d", d.Fills), + "hours": fmt.Sprintf("%d", d.Hours), + }, + } cursor = spec.Cursor{Height: h + 86400, Ts: ts, Finalized: true} } @@ -98,8 +118,9 @@ func (c *HyperliquidCollector) Collect( return cursor, nil } -func (c *HyperliquidCollector) fetchBreakdown(ctx context.Context) ([]hlDayBreakdown, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, defilllamaHL, nil) +func (c *HyperliquidCollector) fetchDailySummaries(ctx context.Context, from, to string) ([]hlDailySummary, error) { + url := fmt.Sprintf("%s/daily?from=%s&to=%s", fillsAggURL, from, to) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } @@ -112,43 +133,12 @@ func (c *HyperliquidCollector) fetchBreakdown(ctx context.Context) ([]hlDayBreak defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, defilllamaHL) + return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) } - var result struct { - TotalDataChartBreakdown [][]json.RawMessage `json:"totalDataChartBreakdown"` - } + var result []hlDailySummary if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("decode: %w", err) } - - out := make([]hlDayBreakdown, 0, len(result.TotalDataChartBreakdown)) - for _, entry := range result.TotalDataChartBreakdown { - if len(entry) < 2 { - continue - } - var ts int64 - if err := json.Unmarshal(entry[0], &ts); err != nil { - continue - } - - // breakdown shape: {"Hyperliquid L1": {"Hyperliquid Perps": N, "Hyperliquid HLP": N, ...}} - var outer map[string]map[string]float64 - if err := json.Unmarshal(entry[1], &outer); err != nil { - continue - } - inner, ok := outer["Hyperliquid L1"] - if !ok { - continue - } - - out = append(out, hlDayBreakdown{ - Ts: ts, - Perps: inner["Hyperliquid Perps"], - HLP: inner["Hyperliquid HLP"], - Spot: inner["Hyperliquid Spot Orderbook"], - }) - } - - return out, nil + return result, nil } diff --git a/harnesses/apps/scripts/hl-fills-agg.py b/harnesses/apps/scripts/hl-fills-agg.py new file mode 100644 index 00000000..009cd412 --- /dev/null +++ b/harnesses/apps/scripts/hl-fills-agg.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +""" +hl-fills-agg: HTTP server exposing daily Hyperliquid fee aggregates. +Reads node fills from /home/ubuntu/hl/data/node_fills_by_block/hourly/{YYYYMMDD}/{H}. +Caches computed days to /home/ubuntu/hl-agg/cache/{YYYYMMDD}.json. + +GET /daily?from=20260601&to=20260809 +-> [{"date":"20260601","gross_usdc":"...", "builder_usdc":"...","net_usdc":"...","fills":N,"hours":24}, ...] +""" + +import json +import os +import sys +import threading +import time +from datetime import date, timedelta +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse, parse_qs + +FILLS_DIR = "/home/ubuntu/hl/data/node_fills_by_block/hourly" +CACHE_DIR = "/home/ubuntu/hl-agg/cache" +PORT = 2116 + +os.makedirs(CACHE_DIR, exist_ok=True) + +_lock = threading.Lock() + + +def aggregate_day(date_str: str, is_today: bool) -> dict | None: + cache_file = os.path.join(CACHE_DIR, f"{date_str}.json") + if not is_today and os.path.exists(cache_file): + with open(cache_file) as f: + return json.load(f) + + day_dir = os.path.join(FILLS_DIR, date_str) + if not os.path.isdir(day_dir): + return None + + hours = sorted(os.listdir(day_dir)) + gross = 0.0 + builder = 0.0 + fills = 0 + + for h in hours: + path = os.path.join(day_dir, h) + try: + with open(path) as f: + for line in f: + try: + d = json.loads(line) + for item in d.get("events") or []: + fill = item[1] + gross += float(fill.get("fee", 0) or 0) + builder += float(fill.get("builderFee", 0) or 0) + fills += 1 + except Exception: + pass + except OSError: + pass + + result = { + "date": date_str, + "gross_usdc": round(gross, 6), + "builder_usdc": round(builder, 6), + "net_usdc": round(gross - builder, 6), + "fills": fills, + "hours": len(hours), + } + + if not is_today: + tmp = cache_file + ".tmp" + with open(tmp, "w") as f: + json.dump(result, f) + os.replace(tmp, cache_file) + + return result + + +def backfill_worker(): + """Pre-aggregate all past days not yet cached.""" + time.sleep(5) + today = date.today() + d = date(2026, 6, 1) + while d < today: + ds = d.strftime("%Y%m%d") + cache_file = os.path.join(CACHE_DIR, f"{ds}.json") + if not os.path.exists(cache_file): + print(f"backfill: aggregating {ds}", flush=True) + aggregate_day(ds, False) + d += timedelta(days=1) + print("backfill: done", flush=True) + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + parsed = urlparse(self.path) + if parsed.path == "/health": + self._json({"status": "ok"}) + return + if parsed.path != "/daily": + self.send_response(404) + self.end_headers() + return + + qs = parse_qs(parsed.query) + from_str = qs.get("from", ["20260601"])[0] + to_str = qs.get("to", [date.today().strftime("%Y%m%d")])[0] + + try: + from_d = date(int(from_str[:4]), int(from_str[4:6]), int(from_str[6:8])) + to_d = date(int(to_str[:4]), int(to_str[4:6]), int(to_str[6:8])) + except (ValueError, IndexError): + self.send_response(400) + self.end_headers() + return + + today = date.today() + # Only return cached days (or today on-demand). + # Uncached past days are skipped — the backfill thread will cache them. + # This keeps the HTTP response fast regardless of how many uncached days exist. + results = [] + d = from_d + while d <= to_d: + ds = d.strftime("%Y%m%d") + is_today = d == today + cache_file = os.path.join(CACHE_DIR, f"{ds}.json") + if is_today: + r = aggregate_day(ds, True) + elif os.path.exists(cache_file): + with open(cache_file) as f: + r = json.load(f) + else: + d += timedelta(days=1) + continue + if r: + results.append(r) + d += timedelta(days=1) + + self._json(results) + + def _json(self, data): + body = json.dumps(data).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, fmt, *args): + pass + + +if __name__ == "__main__": + threading.Thread(target=backfill_worker, daemon=True).start() + print(f"hl-fills-agg listening on :{PORT}", flush=True) + HTTPServer(("0.0.0.0", PORT), Handler).serve_forever() From 4805b86ded4b34a7542423b379a8a8b5ba3c6c99 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:07:31 +0200 Subject: [PATCH 2/2] feat(apps): add GMX v2 collector (Arbitrum + Avalanche) via Subsquid First-party data from GMX-operated Subsquid GraphQL endpoint. Position fees (63% LP / 37% protocol) + swap fees split per governance. Borrowing fees classified as LP revenue (pool-only, no protocol cut). Amounts in GMX internal 1e30 precision, stored with Decimals=30. --- harnesses/apps/cmd/api/main.go | 2 + harnesses/apps/cmd/collector/main.go | 8 + harnesses/apps/cmd/materializer/main.go | 2 +- .../apps/internal/collect/rest/gmx_v2.go | 301 ++++++++++++++++++ 4 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 harnesses/apps/internal/collect/rest/gmx_v2.go diff --git a/harnesses/apps/cmd/api/main.go b/harnesses/apps/cmd/api/main.go index bd5793ce..46233f0e 100644 --- a/harnesses/apps/cmd/api/main.go +++ b/harnesses/apps/cmd/api/main.go @@ -57,6 +57,8 @@ type LeaderboardResponse struct { 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"}, + "gmx-v2:arbitrum": {Name: "GMX v2 (Arbitrum)", Slug: "gmx", Category: "perps"}, + "gmx-v2:avalanche": {Name: "GMX v2 (Avalanche)", Slug: "gmx", Category: "perps"}, } func handleLeaderboard(pool *pgxpool.Pool) http.HandlerFunc { diff --git a/harnesses/apps/cmd/collector/main.go b/harnesses/apps/cmd/collector/main.go index b98aab73..ed31012e 100644 --- a/harnesses/apps/cmd/collector/main.go +++ b/harnesses/apps/cmd/collector/main.go @@ -30,6 +30,8 @@ func main() { dydx := rest.NewDyDX() hl := rest.NewHyperliquid() + gmxArb := rest.NewGMXv2Arbitrum() + gmxAvax := rest.NewGMXv2Avalanche() for { if err := runCollector(ctx, db, dydx, "dydx-v4:dydx-chain"); err != nil { @@ -38,6 +40,12 @@ func main() { if err := runCollector(ctx, db, hl, "hyperliquid:hypercore"); err != nil { log.Printf("hyperliquid collector error: %v", err) } + if err := runCollector(ctx, db, gmxArb, "gmx-v2:arbitrum"); err != nil { + log.Printf("gmx-v2:arbitrum collector error: %v", err) + } + if err := runCollector(ctx, db, gmxAvax, "gmx-v2:avalanche"); err != nil { + log.Printf("gmx-v2:avalanche 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 c88c677c..78d3bba4 100644 --- a/harnesses/apps/cmd/materializer/main.go +++ b/harnesses/apps/cmd/materializer/main.go @@ -39,7 +39,7 @@ 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"} + deployments := []string{"dydx-v4:dydx-chain", "hyperliquid:hypercore", "gmx-v2:arbitrum", "gmx-v2:avalanche"} for _, dep := range deployments { if err := db.Materialize(ctx, dep, mv); err != nil { diff --git a/harnesses/apps/internal/collect/rest/gmx_v2.go b/harnesses/apps/internal/collect/rest/gmx_v2.go new file mode 100644 index 00000000..8dd82a8d --- /dev/null +++ b/harnesses/apps/internal/collect/rest/gmx_v2.go @@ -0,0 +1,301 @@ +package rest + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/ChainBench/OpenChainBench/harnesses/apps/internal/spec" +) + +// GMX v2 Subsquid GraphQL endpoints (first-party, GMX-operated). +// Amounts use GMX's internal PRICE_PRECISION (1e30) — store raw with Decimals=30. +const ( + gmxArbitrumSQ = "https://gmx.squids.live/gmx-synthetics-arbitrum:prod/api/graphql" + gmxAvaxSQ = "https://gmx.squids.live/gmx-synthetics-avalanche:prod/api/graphql" +) + +// Protocol revenue split per GMX governance (as of 2026): +// +// 63% → GM / GLV LPs (beneficiary = "lp") +// 27% → GMX buyback (beneficiary = "burn") +// 10% → Treasury (beneficiary = "treasury") +// +// Note: staking distributions suspended as of Mar 2026; buyback tokens +// accumulate in treasury. We still book accrual at 37% protocol revenue. +const ( + gmxLPBps = 6300 // 63% + gmxBuybackBps = 2700 // 27% + gmxTreasuryBps = 1000 // 10% +) + +type GMXv2Collector struct { + client *http.Client + endpoint string + chainID string +} + +func NewGMXv2Arbitrum() *GMXv2Collector { + return &GMXv2Collector{ + client: &http.Client{Timeout: 30 * time.Second}, + endpoint: gmxArbitrumSQ, + chainID: "arbitrum", + } +} + +func NewGMXv2Avalanche() *GMXv2Collector { + return &GMXv2Collector{ + client: &http.Client{Timeout: 30 * time.Second}, + endpoint: gmxAvaxSQ, + chainID: "avalanche", + } +} + +func (c *GMXv2Collector) Name() string { + return fmt.Sprintf("gmx-v2-%s-subsquid", c.chainID) +} + +type gmxDayFees struct { + Ts int64 + PosGross string // totalPositionFeeUsd (BigInt, 1e30) + PosForPool string // totalPositionFeeUsdForPool (BigInt, 1e30) + SwapRecv string // totalFeeReceiverUsd from swapFees (BigInt, 1e30) + SwapForPool string // totalFeeUsdForPool from swapFees (BigInt, 1e30) + BorrowPool string // totalBorrowingFeeUsd → all to pool (BigInt, 1e30) +} + +func (c *GMXv2Collector) Collect( + ctx context.Context, + deploymentID string, + from, to spec.Cursor, + out chan<- spec.FeeEvent, +) (spec.Cursor, error) { + cursor := from + + // Fetch last 90 days of daily data from Subsquid. + // cursor.Height is the unix timestamp (seconds) of the last processed day. + tsFrom := int(from.Height) + if tsFrom == 0 { + // Default: start 90 days ago. + tsFrom = int(time.Now().AddDate(0, 0, -90).Truncate(24 * time.Hour).Unix()) + } + tsTo := int(to.Ts.Truncate(24 * time.Hour).Unix()) + + days, err := c.fetchDays(ctx, tsFrom, tsTo) + if err != nil { + return cursor, fmt.Errorf("gmx-v2: %w", err) + } + + for _, d := range days { + if uint64(d.Ts) <= from.Height { + continue + } + ts := time.Unix(d.Ts, 0).UTC() + + // Skip current incomplete day. + if ts.Truncate(24 * time.Hour).Equal(time.Now().UTC().Truncate(24 * time.Hour)) { + continue + } + + emitBigInt := func(component, beneficiary, amountRaw string) { + if amountRaw == "" || amountRaw == "0" { + return + } + out <- spec.FeeEvent{ + DeploymentID: deploymentID, + EventKey: fmt.Sprintf("gmx-v2:%s:%d:%s:%s", c.chainID, d.Ts, component, beneficiary), + Ts: ts, + Height: uint64(d.Ts), + Component: component, + Beneficiary: beneficiary, + Token: "USD", + AmountRaw: amountRaw, + Decimals: 30, + Market: "all", + Finality: spec.FinalityFinal, + Source: c.Name(), + } + } + + // Position fees: gross = PosGross, split between LP (posForPool) and protocol (posGross - posForPool). + // Compute posForProtocol = posGross - posForPool in string form for BigInt safety. + posProtocol := bigSubStr(d.PosGross, d.PosForPool) + emitBigInt("position_fee", "lp", d.PosForPool) + emitBigInt("position_fee", "burn", posProtocol) + + // Swap fees: receiver is protocol's cut, pool is LP's cut. + emitBigInt("swap_fee", "burn", d.SwapRecv) + emitBigInt("swap_fee", "lp", d.SwapForPool) + + // Borrowing fees go entirely to the pool (LPs), not to protocol. + emitBigInt("borrow_fee", "lp", d.BorrowPool) + + cursor = spec.Cursor{Height: uint64(d.Ts), Ts: ts, Finalized: true} + } + + return cursor, nil +} + +// fetchDays queries both positionFees and swapFees daily data and merges by timestamp. +func (c *GMXv2Collector) fetchDays(ctx context.Context, tsFrom, tsTo int) ([]gmxDayFees, error) { + // Fetch position fees. + posQuery := fmt.Sprintf(`{ + positionFeesInfoWithPeriods(orderBy:timestamp_ASC, where:{period_eq:"1d",timestamp_gte:%d,timestamp_lt:%d}, limit:500) { + timestamp totalPositionFeeUsd totalPositionFeeUsdForPool totalBorrowingFeeUsd + } + }`, tsFrom, tsTo) + + type posEntry struct { + Timestamp int `json:"timestamp"` + TotalPositionFeeUsd string `json:"totalPositionFeeUsd"` + TotalPositionFeeUsdForPool string `json:"totalPositionFeeUsdForPool"` + TotalBorrowingFeeUsd string `json:"totalBorrowingFeeUsd"` + } + var posResp struct { + Data struct { + Items []posEntry `json:"positionFeesInfoWithPeriods"` + } `json:"data"` + Errors []struct{ Message string } `json:"errors"` + } + if err := c.gqlQuery(ctx, posQuery, &posResp); err != nil { + return nil, fmt.Errorf("position fees: %w", err) + } + if len(posResp.Errors) > 0 { + return nil, fmt.Errorf("position fees gql: %s", posResp.Errors[0].Message) + } + + // Fetch swap fees. + swapQuery := fmt.Sprintf(`{ + swapFeesInfoWithPeriods(orderBy:timestamp_ASC, where:{period_eq:"1d",timestamp_gte:%d,timestamp_lt:%d}, limit:500) { + timestamp totalFeeReceiverUsd totalFeeUsdForPool + } + }`, tsFrom, tsTo) + + type swapEntry struct { + Timestamp int `json:"timestamp"` + TotalFeeReceiverUsd string `json:"totalFeeReceiverUsd"` + TotalFeeUsdForPool string `json:"totalFeeUsdForPool"` + } + var swapResp struct { + Data struct { + Items []swapEntry `json:"swapFeesInfoWithPeriods"` + } `json:"data"` + Errors []struct{ Message string } `json:"errors"` + } + if err := c.gqlQuery(ctx, swapQuery, &swapResp); err != nil { + return nil, fmt.Errorf("swap fees: %w", err) + } + if len(swapResp.Errors) > 0 { + return nil, fmt.Errorf("swap fees gql: %s", swapResp.Errors[0].Message) + } + + // Merge by timestamp. + posByTs := map[int]posEntry{} + for _, p := range posResp.Data.Items { + posByTs[p.Timestamp] = p + } + swapByTs := map[int]swapEntry{} + for _, s := range swapResp.Data.Items { + swapByTs[s.Timestamp] = s + } + + // Collect all unique timestamps. + seen := map[int]bool{} + for ts := range posByTs { + seen[ts] = true + } + for ts := range swapByTs { + seen[ts] = true + } + + var out []gmxDayFees + for ts := range seen { + p := posByTs[ts] + s := swapByTs[ts] + out = append(out, gmxDayFees{ + Ts: int64(ts), + PosGross: orZero(p.TotalPositionFeeUsd), + PosForPool: orZero(p.TotalPositionFeeUsdForPool), + SwapRecv: orZero(s.TotalFeeReceiverUsd), + SwapForPool: orZero(s.TotalFeeUsdForPool), + BorrowPool: orZero(p.TotalBorrowingFeeUsd), + }) + } + + // Sort by timestamp ascending. + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j].Ts < out[j-1].Ts; j-- { + out[j], out[j-1] = out[j-1], out[j] + } + } + return out, nil +} + +func (c *GMXv2Collector) gqlQuery(ctx context.Context, query string, dest interface{}) error { + body, _ := json.Marshal(map[string]string{"query": query}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "ocb-apps/1.0") + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + return json.NewDecoder(resp.Body).Decode(dest) +} + +// bigSubStr subtracts two base-10 integer strings: a - b. +// Uses strconv for small values; falls back to manual subtraction for large BigInt strings. +func bigSubStr(a, b string) string { + // Simple approach: parse both, subtract. + // For 30-decimal GMX values, a and b are ~35 digits — use Go's math/big indirectly + // by doing string arithmetic. Since b <= a always (pool ≤ gross), result is non-negative. + if a == "" || a == "0" { + return "0" + } + if b == "" || b == "0" { + return a + } + // Pad to same length. + for len(a) < len(b) { + a = "0" + a + } + for len(b) < len(a) { + b = "0" + b + } + result := make([]byte, len(a)) + borrow := 0 + for i := len(a) - 1; i >= 0; i-- { + diff := int(a[i]-'0') - int(b[i]-'0') - borrow + if diff < 0 { + diff += 10 + borrow = 1 + } else { + borrow = 0 + } + result[i] = byte('0' + diff) + } + // Trim leading zeros. + s := string(result) + for len(s) > 1 && s[0] == '0' { + s = s[1:] + } + return s +} + +func orZero(s string) string { + if s == "" { + return "0" + } + return s +}