From 9b8af733ce71790fa1537aa72bc5ede1ee20a664 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:20:25 +0200 Subject: [PATCH] feat: Gains trade history via REST API (replace on-chain logs) - fetchGainsTrades() hits backend-global.gains.trade/api/personal-trading-history - paginates up to 1000 trades, filters collateralIndex=3 (USDC) - GainsWalletData now has tradingFees, fundingFees, borrowingFees, netCostUsdc, recentTrades - Gains wallet side shows fee/funding/borrowing/net breakdown (like HL) - GainsTradeTable component with per-trade pair/action/fee/pnl - Remove rpcCall/getLatestBlock/fetchGainsLogs (on-chain FeesProcessed) --- src/app/api/fee-compare/route.ts | 176 +++++++++++++------------- src/components/fee-compare-client.tsx | 175 ++++++++++++++++++++++++- 2 files changed, 263 insertions(+), 88 deletions(-) diff --git a/src/app/api/fee-compare/route.ts b/src/app/api/fee-compare/route.ts index 8465bf9a..7cdcf3c5 100644 --- a/src/app/api/fee-compare/route.ts +++ b/src/app/api/fee-compare/route.ts @@ -6,11 +6,8 @@ export const runtime = "nodejs"; export const maxDuration = 30; const HL_API = "https://api.hyperliquid.xyz/info"; -const ARB_RPC = "https://arb1.arbitrum.io/rpc"; -const GAINS_DIAMOND_ARB = "0xFF162c694eAA571f685030649814282eA457f169"; const GAINS_VARS_URL = "https://backend-arbitrum.gains.trade/trading-variables"; -const FEES_PROCESSED_TOPIC = - "0x71555a7cc983000fe069574303ed2e47aa16417d297441f6d5e314bd6c58b2fe"; +const GAINS_HISTORY_API = "https://backend-global.gains.trade"; const GMX_SUBSQUID = "https://gmx.squids.live/gmx-synthetics-arbitrum:prod/api/graphql"; const DYDX_INDEXER = "https://indexer.dydx.trade"; @@ -19,7 +16,6 @@ const HL_TAKER_FALLBACK = 0.00035; const WALLET_RE = /^0x[0-9a-fA-F]{40}$/; const DYDX_ADDRESS_RE = /^dydx1[a-z0-9]{38}$/; -const BLOCKS_PER_DAY = 43200; const MAX_DISPLAY_FILLS = 50; const RATE_CACHE_TTL_MS = 60 * 60 * 1000; @@ -70,11 +66,24 @@ type HlFundingEvent = { delta: { usdc: string }; }; -type GainsLog = { - topics: string[]; - data: string; - blockNumber: string; - transactionHash: string; +type GainsApiTrade = { + id: number; + date: string; + pair: string; + action: string; + price: number; + size: number; + leverage: number; + pnl_net: number; + collateralIndex: number; + meta?: { + tradeFeesData?: { realizedTradingFeesCollateral?: number }; + uiRealizedPnlData?: { + realizedTradingFeesCollateral?: number; + realizedFundingFeesCollateral?: number; + realizedNewBorrowingFeesCollateral?: number; + }; + }; }; type GainsTradingVars = { @@ -106,8 +115,21 @@ type HlWalletData = { type GainsWalletData = { events: number; feesUsdc: number; + fundingFeesUsdc: number; + borrowingFeesUsdc: number; + netCostUsdc: number; positionSizeUsdc: number; avgFeeRateBps: number; + recentTrades: Array<{ + date: string; + pair: string; + action: string; + notional: number; + tradingFee: number; + fundingFee: number; + borrowingFee: number; + pnl_net: number; + }>; }; type GmxWalletData = { @@ -308,53 +330,6 @@ async function resolveRate(slug: string): Promise<{ rate: number; note: string; return { rate: 0.0005, note: "Documented rate", rateIsLive: false }; } -async function rpcCall(method: string, params: unknown[]): Promise { - const res = await fetch(ARB_RPC, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), - signal: AbortSignal.timeout(15000), - }); - const d = (await res.json()) as { result?: unknown; error?: { message: string } }; - if (d.error) throw new Error(`RPC ${method}: ${d.error.message}`); - return d.result; -} - -async function getLatestBlock(): Promise { - const hex = (await rpcCall("eth_blockNumber", [])) as string; - return parseInt(hex, 16); -} - -async function fetchGainsLogs(wallet: string, fromBlock: number, toBlock: number) { - const walletPadded = - "0x" + wallet.replace("0x", "").toLowerCase().padStart(64, "0"); - const logs = (await rpcCall("eth_getLogs", [ - { - address: GAINS_DIAMOND_ARB, - topics: [FEES_PROCESSED_TOPIC, null, walletPadded], - fromBlock: "0x" + fromBlock.toString(16), - toBlock: "0x" + toBlock.toString(16), - }, - ])) as GainsLog[]; - - return logs - .map((log) => { - const collateralIndex = parseInt(log.topics[1] ?? "0x0", 16); - const data = log.data.replace("0x", ""); - if (data.length < 192) return null; - const posSize = BigInt("0x" + data.slice(0, 64)); - const orderType = parseInt(data.slice(64, 128), 16); - const totalFees = BigInt("0x" + data.slice(128, 192)); - return { collateralIndex, orderType, posSize, totalFees }; - }) - .filter(Boolean) as Array<{ - collateralIndex: number; - orderType: number; - posSize: bigint; - totalFees: bigint; - }>; -} - async function fetchHlFills(wallet: string): Promise { const res = await fetch(HL_API, { method: "POST", @@ -462,6 +437,31 @@ async function fetchDydxFills(dydxAddress: string): Promise { }; } +async function fetchGainsTrades(wallet: string, cutoffMs: number): Promise { + const startDate = new Date(cutoffMs).toISOString(); + const all: GainsApiTrade[] = []; + let cursor: number | undefined; + let pages = 0; + const MAX_PAGES = 10; + + do { + const params = new URLSearchParams({ chainId: "42161", limit: "100", startDate }); + if (cursor !== undefined) params.set("cursor", String(cursor)); + + const res = await fetch( + `${GAINS_HISTORY_API}/api/personal-trading-history/${wallet}?${params}`, + { signal: AbortSignal.timeout(12000) } + ); + if (!res.ok) break; + const body = (await res.json()) as { data: GainsApiTrade[]; pagination: { hasMore: boolean; nextCursor?: number } }; + all.push(...body.data); + cursor = body.pagination.hasMore ? body.pagination.nextCursor : undefined; + pages++; + } while (cursor !== undefined && pages < MAX_PAGES); + + return all; +} + function buildHlWalletData( recentFills: HlFill[], hlFundingTotal: number @@ -596,12 +596,7 @@ export async function GET(req: Request) { let hlFillsData: HlFill[] = []; let hlFundingData: HlFundingEvent[] = []; - let gainsLogsData: Array<{ - collateralIndex: number; - orderType: number; - posSize: bigint; - totalFees: bigint; - }> = []; + let gainsTradesData: GainsApiTrade[] = []; let gmxWalletData: GmxWalletData | null = null; let dydxWalletData: DydxWalletData | null = null; @@ -620,13 +615,7 @@ export async function GET(req: Request) { } if (venueA === "gains" || venueB === "gains") { fetches.push( - getLatestBlock().then(async (latestBlock) => { - const fromBlock = Math.max( - 0, - latestBlock - Math.ceil(days * BLOCKS_PER_DAY) - ); - gainsLogsData = await fetchGainsLogs(wallet, fromBlock, latestBlock); - }) + fetchGainsTrades(wallet, cutoffMs).then((d) => { gainsTradesData = d; }).catch(() => {}) ); } if (venueA === "gmx-v2" || venueB === "gmx-v2") { @@ -674,22 +663,39 @@ export async function GET(req: Request) { : fill.notional * otherRate, })); } else if (fetchEvmWallet && slug === "gains") { - const usdcLogs = gainsLogsData.filter((l) => l.collateralIndex === 3); - const feesUsdc = usdcLogs.reduce( - (s, l) => s + Number(l.totalFees) / 1e6, - 0 - ); - const sizeUsdc = usdcLogs.reduce( - (s, l) => s + Number(l.posSize) / 1e6, - 0 - ); - const gd: GainsWalletData = { - events: usdcLogs.length, + const CLOSE_ACTIONS = new Set(["TradeClosedMarket", "TradeClosedTP", "TradeClosedSL", "TradeClosedLIQ"]); + const usdcTrades = gainsTradesData.filter((t) => t.collateralIndex === 3); + let feesUsdc = 0; + let fundingFeesUsdc = 0; + let borrowingFeesUsdc = 0; + let notionalUsd = 0; + const recentTrades: GainsWalletData["recentTrades"] = []; + + for (const t of usdcTrades) { + const tradingFee = t.meta?.tradeFeesData?.realizedTradingFeesCollateral ?? 0; + const isClose = CLOSE_ACTIONS.has(t.action); + const fundingFee = isClose ? (t.meta?.uiRealizedPnlData?.realizedFundingFeesCollateral ?? 0) : 0; + const borrowingFee = isClose ? (t.meta?.uiRealizedPnlData?.realizedNewBorrowingFeesCollateral ?? 0) : 0; + feesUsdc += tradingFee; + fundingFeesUsdc += fundingFee; + borrowingFeesUsdc += borrowingFee; + notionalUsd += t.size * t.leverage; + if (recentTrades.length < 50) { + recentTrades.push({ date: t.date, pair: t.pair, action: t.action, notional: t.size * t.leverage, tradingFee, fundingFee, borrowingFee, pnl_net: t.pnl_net }); + } + } + + const netCostUsdc = feesUsdc + fundingFeesUsdc + borrowingFeesUsdc; + walletData = { + events: usdcTrades.length, feesUsdc, - positionSizeUsdc: sizeUsdc, - avgFeeRateBps: sizeUsdc > 0 ? (feesUsdc / sizeUsdc) * 10000 : 0, - }; - walletData = gd; + fundingFeesUsdc, + borrowingFeesUsdc, + netCostUsdc, + positionSizeUsdc: notionalUsd, + avgFeeRateBps: notionalUsd > 0 ? (feesUsdc / notionalUsd) * 10000 : 0, + recentTrades, + } satisfies GainsWalletData; } else if (fetchEvmWallet && slug === "gmx-v2" && gmxWalletData) { walletData = gmxWalletData; } else if (fetchDydxWallet && slug === "dydx" && dydxWalletData) { diff --git a/src/components/fee-compare-client.tsx b/src/components/fee-compare-client.tsx index 847ca02b..da9b1138 100644 --- a/src/components/fee-compare-client.tsx +++ b/src/components/fee-compare-client.tsx @@ -75,8 +75,21 @@ type HlWalletData = { type GainsWalletData = { events: number; feesUsdc: number; + fundingFeesUsdc: number; + borrowingFeesUsdc: number; + netCostUsdc: number; positionSizeUsdc: number; avgFeeRateBps: number; + recentTrades: Array<{ + date: string; + pair: string; + action: string; + notional: number; + tradingFee: number; + fundingFee: number; + borrowingFee: number; + pnl_net: number; + }>; }; type GmxWalletData = { @@ -137,7 +150,8 @@ type FeeCompareResult = { function walletFees(slug: string, w: AnyWallet): number { if (slug === "hyperliquid") return (w as HlWalletData).netCostUsd; - return (w as GainsWalletData | GmxWalletData | DydxWalletData).feesUsdc; + if (slug === "gains") return (w as GainsWalletData).netCostUsdc; + return (w as GmxWalletData | DydxWalletData).feesUsdc; } function walletVolume(slug: string, w: AnyWallet): number { @@ -633,7 +647,54 @@ function WalletSide({ ); })()} - {venue.slug !== "hyperliquid" && ( + {venue.slug === "gains" && (() => { + const gW = w as GainsWalletData; + const hasFunding = Math.abs(gW.fundingFeesUsdc) > 0.5; + const hasBorrowing = gW.borrowingFeesUsdc > 0.5; + return ( +
+
+

Volume

+

{fmtUsd(volume)}

+
+
+
+

Trading fees

+

{fmtUsd(gW.feesUsdc)}

+
+ {hasFunding && ( +
+

+ Funding {gW.fundingFeesUsdc > 0 ? "paid" : "received"} +

+

0 ? "text-red-400" : "text-emerald-500"}`}> + {gW.fundingFeesUsdc > 0 + ? `−${fmtUsd(gW.fundingFeesUsdc)}` + : `+${fmtUsd(Math.abs(gW.fundingFeesUsdc))}`} +

+
+ )} + {hasBorrowing && ( +
+

Borrowing fees

+

+ −{fmtUsd(gW.borrowingFeesUsdc)} +

+
+ )} + {(hasFunding || hasBorrowing) && ( +
+

Net cost

+

+ {fmtUsd(gW.netCostUsdc)} +

+
+ )} +
+
+ ); + })()} + {venue.slug !== "hyperliquid" && venue.slug !== "gains" && (

Volume

{fmtUsd(volume)}

@@ -898,6 +959,106 @@ function HlTradeTable({ ); } +// ────────────────────────────────────────────────────────────────────── +// GainsTradeTable +// ────────────────────────────────────────────────────────────────────── + +function GainsTradeTable({ + trades, + venueName, +}: { + trades: GainsWalletData["recentTrades"]; + venueName: string; +}) { + const [showAll, setShowAll] = useState(false); + const PREVIEW = 10; + const rows = showAll ? trades : trades.slice(0, PREVIEW); + + if (trades.length === 0) return null; + + function actionLabel(action: string) { + return action + .replace("Trade", "") + .replace("Market", "") + .replace("Opened", "Open ") + .replace("Closed", "Close ") + .trim(); + } + + return ( +
+
+
+ +

{venueName} trade history

+
+

{trades.length} actions

+
+
+ + + + + + + + + + + + + {rows.map((t, i) => ( + + + + + + + + + ))} + +
DateMarketActionNotionalFeePnL
+ {new Date(t.date).toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })} + {t.pair.replace("/USD", "")} + + {actionLabel(t.action)} + + {fmtUsd(t.notional)}{fmtUsd(t.tradingFee)} + {t.action.includes("Closed") ? ( + t.pnl_net > 0.01 ? ( + +{fmtUsd(t.pnl_net)} + ) : t.pnl_net < -0.01 ? ( + {fmtUsd(t.pnl_net)} + ) : ( + $0 + ) + ) : ( + open + )} +
+
+ {trades.length > PREVIEW && ( + + )} +
+ ); +} + // ────────────────────────────────────────────────────────────────────── // Footnote // ────────────────────────────────────────────────────────────────────── @@ -915,7 +1076,7 @@ function Footnote({ venueA, venueB }: { venueA: VenueResult; venueB: VenueResult )} {hasGains && ( <> - Gains: on-chain FeesProcessed events (Arbitrum). + Gains: trade history from backend-global.gains.trade REST API (Arbitrum). Rates fetched live per-coin from backend-arbitrum.gains.trade.{" "} )} @@ -955,6 +1116,8 @@ function Results({ result }: { result: FeeCompareResult }) { venueB.slug === "hyperliquid" && venueB.wallet ? (venueB.wallet as HlWalletData) : null; + const gainsVenueA = venueA.slug === "gains" && venueA.wallet ? (venueA.wallet as GainsWalletData) : null; + const gainsVenueB = venueB.slug === "gains" && venueB.wallet ? (venueB.wallet as GainsWalletData) : null; return (
@@ -982,6 +1145,12 @@ function Results({ result }: { result: FeeCompareResult }) { otherVenueName={venueA.name} /> )} + {gainsVenueA && gainsVenueA.recentTrades.length > 0 && ( + + )} + {gainsVenueB && gainsVenueB.recentTrades.length > 0 && ( + + )}
);