Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions app/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -545,6 +546,18 @@ export default function AnalyticsPage() {
<FundingChart />
</section>

{/* ── Per-token Volume Breakdown ───────────────────────────────── */}
<section
aria-labelledby="token-volume-heading"
className="mt-14"
>
<SectionHeading>
<span id="token-volume-heading">Token Volume</span>
</SectionHeading>

<PerTokenVolumeChart />
</section>

{/* ── Default Rate Trend ──────────────────────────────────────────────── */}
<section
aria-labelledby="default-rate-heading"
Expand Down
10 changes: 7 additions & 3 deletions app/governance/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function StatusBadge({ status }: { status: ProposalStatus }) {
Failed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "cancel" },
Executed: { color: "bg-purple-500/15 text-purple-500 border-purple-500/30", icon: "rocket_launch" },
Pending: { color: "bg-amber-500/15 text-amber-500 border-amber-500/30", icon: "schedule" },
Vetoed: { color: "bg-orange-500/15 text-orange-500 border-orange-500/30", icon: "block" },
};
const { color, icon } = config[status];
return (
Expand Down Expand Up @@ -200,10 +201,13 @@ export default function GovernancePage() {
}, []);

useEffect(() => {
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(() => {
Expand Down
4 changes: 2 additions & 2 deletions src/components/TokenSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ function getTokenName(token: TokenLike): string {
return token.name ?? token.symbol;
}

function getTokenLogo(token: TokenLike): string {
function getTokenLogo(token: Pick<TokenLike, "logo" | "symbol">): string {
return token.logo ?? `/tokens/${token.symbol.toLowerCase()}.svg`;
}

function getTokenIconLabel(token: TokenLike): string {
function getTokenIconLabel(token: Pick<TokenLike, "iconLabel" | "symbol">): string {
return token.iconLabel ?? (token.symbol.replace(/[^A-Z0-9]/gi, "").slice(0, 2).toUpperCase() || "TK");
}

Expand Down
219 changes: 219 additions & 0 deletions src/components/charts/PerTokenVolumeChart.tsx
Original file line number Diff line number Diff line change
@@ -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<number, string>) {
if (!active || !payload?.length) return null;
const row = payload[0].payload as PerTokenVolumeBucket;

return (
<div className="rounded-xl border border-outline-variant/20 bg-surface-container-lowest px-4 py-3 shadow-xl">
<p className="mb-2 text-[11px] font-bold uppercase tracking-widest text-on-surface-variant">
Week of {label}
</p>
<div className="flex flex-col gap-1.5">
{payload.map((entry) => (
<div key={entry.name} className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-xs font-medium text-on-surface">{entry.name}</span>
</div>
<span className="text-xs font-bold text-on-surface">
{formatUsd(Number(entry.value))}
</span>
</div>
))}
<div className="my-1 h-px bg-outline-variant/10" />
<div className="flex items-center justify-between gap-4">
<span className="text-xs font-bold text-on-surface">USD-equivalent</span>
<span className="text-xs font-extrabold text-primary">{formatUsd(row.totalUsd)}</span>
</div>
</div>
</div>
);
}

export default function PerTokenVolumeChart() {
const [range, setRange] = useState<VolumeTimeRange>("30D");
const [rawStats, setRawStats] = useState<unknown>(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 (
<div className="flex flex-col gap-6 rounded-[24px] border border-outline-variant/15 bg-surface-container-lowest p-6 shadow-sm">
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-start">
<div>
<h3 className="font-headline text-xl font-bold text-on-surface">
Per-token Volume
</h3>
<p className="text-sm text-on-surface-variant">
Weekly funded volume by supported token from contract stats
</p>
</div>

<div className="flex items-center gap-1 self-start rounded-xl bg-surface-container p-1">
{TIME_RANGES.map((option) => (
<button
key={option}
type="button"
onClick={() => setRange(option)}
className={`rounded-lg px-3 py-1.5 text-xs font-bold transition-all ${
range === option
? "bg-primary text-white shadow-sm"
: "text-on-surface-variant hover:text-on-surface"
}`}
aria-pressed={range === option}
>
{option.replace("D", " days")}
</button>
))}
</div>
</div>

<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<div className="rounded-2xl bg-surface-container-low p-4">
<p className="text-[11px] font-bold uppercase tracking-[0.16em] text-on-surface-variant">
Total USD-equiv
</p>
<p className="mt-1 font-headline text-2xl font-bold text-on-surface">
{formatUsd(summary.totalUsd)}
</p>
</div>
{(["USDC", "EURC", "XLM"] as const).map((token) => (
<div key={token} className="rounded-2xl bg-surface-container-low p-4">
<div className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: TOKEN_COLORS[token] }}
/>
<p className="text-[11px] font-bold uppercase tracking-[0.16em] text-on-surface-variant">
{token}
</p>
</div>
<p className="mt-1 font-headline text-2xl font-bold text-on-surface">
{formatUsd(summary[token])}
</p>
</div>
))}
</div>

{error && (
<div className="rounded-xl border border-amber-500/25 bg-amber-500/10 px-4 py-3 text-sm text-amber-500">
Contract stats unavailable: {error}
</div>
)}

<div className="relative h-[220px] w-full md:h-[280px]">
{loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-surface-container-lowest/50 backdrop-blur-[1px]">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-outline-variant/30 border-t-primary" />
</div>
)}

{isEmpty ? (
<div className="flex h-full flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-outline-variant/20">
<span className="material-symbols-outlined text-4xl text-outline-variant/40">
bar_chart
</span>
<p className="text-sm font-medium text-on-surface-variant">
No per-token volume data for this period
</p>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={buckets} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke={GRID_STROKE}
strokeOpacity={0.35}
/>
<XAxis dataKey="label" tick={CHART_TICK_STYLE} tickLine={false} axisLine={false} />
<YAxis
tick={CHART_TICK_STYLE}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => formatUsd(value).replace("$", "")}
/>
<Tooltip content={<TokenVolumeTooltip />} />
<Bar dataKey="USDC" stackId="volume" fill={TOKEN_COLORS.USDC} radius={[4, 4, 0, 0]} />
<Bar dataKey="EURC" stackId="volume" fill={TOKEN_COLORS.EURC} radius={[4, 4, 0, 0]} />
<Bar dataKey="XLM" stackId="volume" fill={TOKEN_COLORS.XLM} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
);
}
78 changes: 78 additions & 0 deletions src/utils/__tests__/perTokenVolume.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
2 changes: 1 addition & 1 deletion src/utils/evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export async function hashEvidence(text: string): Promise<string> {

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("");
Expand Down
Loading