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 ──────────────────────────────────────────────── */}
{ - load(); + const timeout = window.setTimeout(load, 0); // Refresh every 30 s for real-time vote counts - const interval = setInterval(load, 30_000); - return () => clearInterval(interval); + const interval = window.setInterval(load, 30_000); + return () => { + window.clearTimeout(timeout); + window.clearInterval(interval); + }; }, [load]); useEffect(() => { diff --git a/src/components/TokenSelector.tsx b/src/components/TokenSelector.tsx index a157f15..e52bb44 100644 --- a/src/components/TokenSelector.tsx +++ b/src/components/TokenSelector.tsx @@ -43,11 +43,11 @@ function getTokenName(token: TokenLike): string { return token.name ?? token.symbol; } -function getTokenLogo(token: TokenLike): string { +function getTokenLogo(token: Pick): string { return token.logo ?? `/tokens/${token.symbol.toLowerCase()}.svg`; } -function getTokenIconLabel(token: TokenLike): string { +function getTokenIconLabel(token: Pick): string { return token.iconLabel ?? (token.symbol.replace(/[^A-Z0-9]/gi, "").slice(0, 2).toUpperCase() || "TK"); } diff --git a/src/components/charts/PerTokenVolumeChart.tsx b/src/components/charts/PerTokenVolumeChart.tsx new file mode 100644 index 0000000..2325f3a --- /dev/null +++ b/src/components/charts/PerTokenVolumeChart.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, + type TooltipProps, +} from "recharts"; +import { getContractStats } from "@/utils/soroban"; +import { + transformPerTokenVolumeStats, + type PerTokenVolumeBucket, + type VolumeTimeRange, +} from "@/utils/perTokenVolume"; + +const TIME_RANGES: VolumeTimeRange[] = ["30D", "90D"]; +const TOKEN_COLORS = { + USDC: "#2563eb", + EURC: "#eab308", + XLM: "#111827", +} as const; + +const CHART_TICK_STYLE = { + fill: "var(--color-on-surface-variant, #64748b)", + fontSize: 11, + fontFamily: "inherit", +}; + +const GRID_STROKE = "var(--color-outline-variant, #cbd5e1)"; + +function formatUsd(value: number): string { + if (value >= 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/evidence.ts b/src/utils/evidence.ts index 55396ad..c661f8f 100644 --- a/src/utils/evidence.ts +++ b/src/utils/evidence.ts @@ -6,7 +6,7 @@ export async function hashEvidence(text: string): Promise { if (typeof crypto !== "undefined" && crypto.subtle) { const encoded = new TextEncoder().encode(normalized); - const digest = await crypto.subtle.digest("SHA-256", encoded); + const digest = await crypto.subtle.digest("SHA-256", encoded.buffer as ArrayBuffer); return Array.from(new Uint8Array(digest)) .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); diff --git a/src/utils/federation.ts b/src/utils/federation.ts index 11922eb..d1b78c2 100644 --- a/src/utils/federation.ts +++ b/src/utils/federation.ts @@ -4,6 +4,11 @@ import { RPC_URL } from "@/constants"; const horizonServer = new rpc.Server(RPC_URL); const federationCache = new Map(); +interface AccountHomeDomain { + home_domain?: string; + homeDomain?: string; +} + export async function resolveFederatedAddress(address: string): Promise { if (!address) return address; const cached = federationCache.get(address); @@ -11,7 +16,8 @@ export async function resolveFederatedAddress(address: string): Promise try { const account = await horizonServer.getAccount(address); - const homeDomain = account.home_domain ?? (account as any).homeDomain; + const { home_domain: homeDomainSnake, homeDomain: homeDomainCamel } = account as AccountHomeDomain; + const homeDomain = homeDomainSnake ?? homeDomainCamel; if (!homeDomain) return address; const stellarTomlResponse = await fetch(`https://${homeDomain}/.well-known/stellar.toml`); 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 c9da60d..2704315 100644 --- a/src/utils/soroban.ts +++ b/src/utils/soroban.ts @@ -367,6 +367,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); +} + export async function getTopPayers(limit = 50): Promise { try { const params = [nativeToScVal(limit, { type: "u32" })];