From 33f9eb14206bbfcfc1f2c25055abe719b8be1cf2 Mon Sep 17 00:00:00 2001 From: neumattock <152253273+newmattock@users.noreply.github.com> Date: Tue, 26 May 2026 09:38:38 -0700 Subject: [PATCH] feat: add per-token volume chart --- app/analytics/page.tsx | 19 +- src/components/charts/PerTokenVolumeChart.tsx | 219 +++++++++++++++++ src/utils/__tests__/perTokenVolume.test.ts | 78 ++++++ src/utils/perTokenVolume.ts | 230 ++++++++++++++++++ src/utils/soroban.ts | 10 + vitest.config.ts | 2 +- 6 files changed, 554 insertions(+), 4 deletions(-) create mode 100644 src/components/charts/PerTokenVolumeChart.tsx create mode 100644 src/utils/__tests__/perTokenVolume.test.ts create mode 100644 src/utils/perTokenVolume.ts diff --git a/app/analytics/page.tsx b/app/analytics/page.tsx index 0461c12..a6c0f02 100644 --- a/app/analytics/page.tsx +++ b/app/analytics/page.tsx @@ -18,12 +18,12 @@ "use client"; import { useEffect, useState, useCallback, useRef } from "react"; -import type { Metadata } from "next"; import { NETWORK_NAME } from "@/constants"; import { getAllInvoices, Invoice } from "@/utils/soroban"; import AmountHistogram from "@/components/charts/AmountHistogram"; import FundingChart from "@/components/charts/FundingChart"; import DefaultRateChart from "@/components/charts/DefaultRateChart"; +import PerTokenVolumeChart from "@/components/charts/PerTokenVolumeChart"; import { ExportButton } from "@/components/ExportButton"; import AnimatedNumber from "@/components/AnimatedNumber"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; @@ -187,7 +187,8 @@ function useAnalyticsPolling(): UseAnalyticsReturn { // Initial fetch useEffect(() => { - fetch_(); + const timeout = window.setTimeout(fetch_, 0); + return () => window.clearTimeout(timeout); }, [fetch_]); // Polling every 5 minutes @@ -375,7 +376,7 @@ export default function AnalyticsPage() { ); } - const { summary, daily, indexed_at } = data!; + const { summary, indexed_at } = data!; const defaultRate = formatPercent( summary.total_defaulted, @@ -545,6 +546,18 @@ export default function AnalyticsPage() { + {/* ── Per-token Volume Breakdown ───────────────────────────────── */} +
+ + Token Volume + + + +
+ {/* ── Default Rate Trend ──────────────────────────────────────────────── */}
= 1_000_000) return `$${(value / 1_000_000).toFixed(2)}M`; + if (value >= 1_000) return `$${(value / 1_000).toFixed(1)}K`; + return `$${Math.round(value).toLocaleString()}`; +} + +function TokenVolumeTooltip({ active, payload, label }: TooltipProps) { + if (!active || !payload?.length) return null; + const row = payload[0].payload as PerTokenVolumeBucket; + + return ( +
+

+ Week of {label} +

+
+ {payload.map((entry) => ( +
+
+ + {entry.name} +
+ + {formatUsd(Number(entry.value))} + +
+ ))} +
+
+ USD-equivalent + {formatUsd(row.totalUsd)} +
+
+
+ ); +} + +export default function PerTokenVolumeChart() { + const [range, setRange] = useState("30D"); + const [rawStats, setRawStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + let cancelled = false; + + async function loadStats() { + setLoading(true); + setError(""); + try { + const stats = await getContractStats(); + if (!cancelled) setRawStats(stats); + } catch (err) { + if (!cancelled) { + setRawStats(null); + setError(err instanceof Error ? err.message : "Unable to load contract stats."); + } + } finally { + if (!cancelled) setLoading(false); + } + } + + loadStats(); + return () => { + cancelled = true; + }; + }, []); + + const { buckets, summary } = useMemo( + () => transformPerTokenVolumeStats(rawStats, range), + [rawStats, range], + ); + const isEmpty = !loading && buckets.length === 0; + + return ( +
+
+
+

+ Per-token Volume +

+

+ Weekly funded volume by supported token from contract stats +

+
+ +
+ {TIME_RANGES.map((option) => ( + + ))} +
+
+ +
+
+

+ Total USD-equiv +

+

+ {formatUsd(summary.totalUsd)} +

+
+ {(["USDC", "EURC", "XLM"] as const).map((token) => ( +
+
+ +

+ {token} +

+
+

+ {formatUsd(summary[token])} +

+
+ ))} +
+ + {error && ( +
+ Contract stats unavailable: {error} +
+ )} + +
+ {loading && ( +
+
+
+ )} + + {isEmpty ? ( +
+ + bar_chart + +

+ No per-token volume data for this period +

+
+ ) : ( + + + + + formatUsd(value).replace("$", "")} + /> + } /> + + + + + + )} +
+
+ ); +} diff --git a/src/utils/__tests__/perTokenVolume.test.ts b/src/utils/__tests__/perTokenVolume.test.ts new file mode 100644 index 0000000..b2b8d31 --- /dev/null +++ b/src/utils/__tests__/perTokenVolume.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { transformPerTokenVolumeStats } from "../perTokenVolume"; + +const NOW = new Date("2026-05-26T12:00:00Z"); + +describe("transformPerTokenVolumeStats", () => { + it("aggregates token rows into weekly USD-equivalent stacked buckets", () => { + const result = transformPerTokenVolumeStats( + { + oracle_prices: { USDC: 1, EURC: 1.1, XLM: 0.1 }, + per_token_weekly_volume: [ + { week: "2026-05-20", token: "USDC", amount: 1000 }, + { week: "2026-05-20", token: "EURC", amount: 500 }, + { week: "2026-05-20", token: "XLM", amount: 2000 }, + { week: "2026-05-13", token: "USDC", volume_usd: 250 }, + ], + }, + "30D", + NOW, + ); + + expect(result.buckets).toHaveLength(2); + expect(result.buckets[1]).toMatchObject({ + weekStart: "2026-05-18", + USDC: 1000, + EURC: 550, + XLM: 200, + totalUsd: 1750, + }); + expect(result.summary).toMatchObject({ + totalUsd: 2000, + USDC: 1250, + EURC: 550, + XLM: 200, + }); + }); + + it("filters buckets to the selected 30 or 90 day range", () => { + const raw = { + weeklyTokenVolume: [ + { weekStart: "2026-05-18", USDC: 100 }, + { weekStart: "2026-04-20", USDC: 200 }, + { weekStart: "2026-03-02", USDC: 300 }, + ], + }; + + expect(transformPerTokenVolumeStats(raw, "30D", NOW).buckets.map((b) => b.USDC)).toEqual([100]); + expect(transformPerTokenVolumeStats(raw, "90D", NOW).buckets.map((b) => b.USDC)).toEqual([ + 300, + 200, + 100, + ]); + }); + + it("falls back to flat per-token all-time fields when weekly rows are absent", () => { + const result = transformPerTokenVolumeStats( + { + indexed_at: "2026-05-26T09:00:00Z", + usdc_volume_funded: 1200, + eurc_volume_usd: 700, + xlm_volume: 1000, + prices_usd: { XLM: 0.15 }, + }, + "30D", + NOW, + ); + + expect(result.buckets).toHaveLength(1); + expect(result.buckets[0]).toMatchObject({ + weekStart: "2026-05-25", + USDC: 1200, + EURC: 700, + XLM: 150, + totalUsd: 2050, + }); + expect(result.summary.totalUsd).toBe(2050); + }); +}); diff --git a/src/utils/perTokenVolume.ts b/src/utils/perTokenVolume.ts new file mode 100644 index 0000000..1c7032c --- /dev/null +++ b/src/utils/perTokenVolume.ts @@ -0,0 +1,230 @@ +export type VolumeToken = "USDC" | "EURC" | "XLM"; +export type VolumeTimeRange = "30D" | "90D"; + +export interface PerTokenVolumeBucket { + weekStart: string; + label: string; + USDC: number; + EURC: number; + XLM: number; + totalUsd: number; +} + +export interface PerTokenVolumeSummary { + totalUsd: number; + USDC: number; + EURC: number; + XLM: number; +} + +export interface PerTokenVolumeResult { + buckets: PerTokenVolumeBucket[]; + summary: PerTokenVolumeSummary; +} + +const TOKENS: VolumeToken[] = ["USDC", "EURC", "XLM"]; +const DEFAULT_PRICES: Record = { + USDC: 1, + EURC: 1.08, + XLM: 0.12, +}; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function toNumber(value: unknown): number { + if (typeof value === "bigint") return Number(value); + if (typeof value === "number") return Number.isFinite(value) ? value : 0; + if (typeof value === "string") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} + +function normalizeToken(value: unknown): VolumeToken | null { + const symbol = String(value ?? "").toUpperCase(); + return TOKENS.includes(symbol as VolumeToken) ? (symbol as VolumeToken) : null; +} + +function pickNumber(record: Record, keys: string[]): number { + for (const key of keys) { + if (key in record) return toNumber(record[key]); + } + return 0; +} + +function getPrice(stats: Record, token: VolumeToken): number { + const priceContainers = [ + stats.oracle_prices, + stats.oraclePrices, + stats.prices_usd, + stats.pricesUsd, + ]; + + for (const container of priceContainers) { + if (!isRecord(container)) continue; + const price = toNumber(container[token] ?? container[token.toLowerCase()]); + if (price > 0) return price; + } + + return DEFAULT_PRICES[token]; +} + +function getWeekStart(dateValue: unknown): string { + const source = typeof dateValue === "string" ? dateValue : new Date().toISOString(); + const date = new Date(source); + if (Number.isNaN(date.getTime())) return new Date().toISOString().slice(0, 10); + + const day = date.getUTCDay(); + const distanceFromMonday = (day + 6) % 7; + date.setUTCDate(date.getUTCDate() - distanceFromMonday); + return date.toISOString().slice(0, 10); +} + +function formatWeekLabel(weekStart: string): string { + const date = new Date(`${weekStart}T00:00:00Z`); + return date.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }); +} + +function createBucket(weekStart: string): PerTokenVolumeBucket { + return { + weekStart, + label: formatWeekLabel(weekStart), + USDC: 0, + EURC: 0, + XLM: 0, + totalUsd: 0, + }; +} + +function readWeeklyRows(stats: Record): unknown[] { + const candidates = [ + stats.weekly_token_volume, + stats.weeklyTokenVolume, + stats.per_token_weekly_volume, + stats.perTokenWeeklyVolume, + stats.token_volumes_by_week, + stats.tokenVolumesByWeek, + ]; + for (const candidate of candidates) { + if (Array.isArray(candidate)) return candidate; + } + return []; +} + +function addWeeklyRow( + bucketMap: Map, + row: Record, + stats: Record, +) { + const weekStart = getWeekStart(row.week ?? row.week_start ?? row.weekStart ?? row.date); + const bucket = bucketMap.get(weekStart) ?? createBucket(weekStart); + + const explicitToken = normalizeToken(row.token ?? row.symbol); + if (explicitToken) { + const amount = pickNumber(row, ["amount", "volume", "volume_native", "nativeVolume"]); + const usd = pickNumber(row, ["usd", "volume_usd", "usd_equiv", "usdEquivalent"]); + bucket[explicitToken] += usd > 0 ? usd : amount * getPrice(stats, explicitToken); + } else { + for (const token of TOKENS) { + const amount = pickNumber(row, [ + token, + token.toLowerCase(), + `${token}_volume`, + `${token.toLowerCase()}_volume`, + `${token}_usd`, + `${token.toLowerCase()}_usd`, + ]); + bucket[token] += amount; + } + } + + bucket.totalUsd = TOKENS.reduce((sum, token) => sum + bucket[token], 0); + bucketMap.set(weekStart, bucket); +} + +function buildFlatAllTimeBucket(stats: Record): PerTokenVolumeBucket | null { + const weekStart = getWeekStart(stats.indexed_at ?? stats.updated_at ?? stats.timestamp); + const bucket = createBucket(weekStart); + + for (const token of TOKENS) { + const nativeVolume = pickNumber(stats, [ + `${token}_volume`, + `${token.toLowerCase()}_volume`, + `${token}_volume_funded`, + `${token.toLowerCase()}_volume_funded`, + `${token}_total_volume`, + `${token.toLowerCase()}_total_volume`, + ]); + const usdVolume = pickNumber(stats, [ + `${token}_volume_usd`, + `${token.toLowerCase()}_volume_usd`, + `${token}_usd_volume`, + `${token.toLowerCase()}_usd_volume`, + ]); + bucket[token] = usdVolume > 0 ? usdVolume : nativeVolume * getPrice(stats, token); + } + + bucket.totalUsd = TOKENS.reduce((sum, token) => sum + bucket[token], 0); + return bucket.totalUsd > 0 ? bucket : null; +} + +function filterBuckets( + buckets: PerTokenVolumeBucket[], + range: VolumeTimeRange, + now = new Date(), +): PerTokenVolumeBucket[] { + const days = range === "30D" ? 30 : 90; + const cutoff = new Date(now); + cutoff.setUTCDate(cutoff.getUTCDate() - days); + + return buckets + .filter((bucket) => new Date(`${bucket.weekStart}T00:00:00Z`) >= cutoff) + .sort((a, b) => a.weekStart.localeCompare(b.weekStart)); +} + +function summarize(buckets: PerTokenVolumeBucket[]): PerTokenVolumeSummary { + const summary: PerTokenVolumeSummary = { + totalUsd: 0, + USDC: 0, + EURC: 0, + XLM: 0, + }; + + for (const bucket of buckets) { + for (const token of TOKENS) { + summary[token] += bucket[token]; + } + summary.totalUsd += bucket.totalUsd; + } + + return summary; +} + +export function transformPerTokenVolumeStats( + rawStats: unknown, + range: VolumeTimeRange, + now = new Date(), +): PerTokenVolumeResult { + if (!isRecord(rawStats)) { + return { buckets: [], summary: summarize([]) }; + } + + const bucketMap = new Map(); + for (const row of readWeeklyRows(rawStats)) { + if (isRecord(row)) addWeeklyRow(bucketMap, row, rawStats); + } + + if (bucketMap.size === 0) { + const flatBucket = buildFlatAllTimeBucket(rawStats); + if (flatBucket) bucketMap.set(flatBucket.weekStart, flatBucket); + } + + const buckets = filterBuckets([...bucketMap.values()], range, now); + return { + buckets, + summary: summarize(buckets), + }; +} diff --git a/src/utils/soroban.ts b/src/utils/soroban.ts index 67d9969..44072ce 100644 --- a/src/utils/soroban.ts +++ b/src/utils/soroban.ts @@ -303,6 +303,16 @@ export async function getPayerScoresBatch( return map; } +export async function getContractStats(): Promise { + const callResult = await server.simulateTransaction( + buildReadTransaction(CONTRACT_ID, "get_contract_stats", []) + ); + if (!rpc.Api.isSimulationSuccess(callResult) || !callResult.result?.retval) { + throw new Error("Failed to fetch contract stats."); + } + return scValToNative(callResult.result.retval); +} + // ─── Write: fund invoice ────────────────────────────────────────────────────── export async function fundInvoice(funder: string, invoice_id: bigint) { diff --git a/vitest.config.ts b/vitest.config.ts index a222b59..f89dda0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ plugins: [react()], resolve: { alias: { - '@': path.resolve(__dirname, '.'), + '@': path.resolve(__dirname, 'src'), }, }, test: {