From 1fd68587423b0a3f63ab0fae65731637b565d531 Mon Sep 17 00:00:00 2001
From: tek-lizzy
Date: Wed, 30 Sep 2026 16:12:15 +0100
Subject: [PATCH 1/2] feat(admin): add treasury distribution history viewer
---
docs/admin-treasury-api.md | 28 ++
src/components/admin/TreasuryPanel.tsx | 276 ++++++++++++++++++
.../admin/__tests__/TreasuryPanel.test.tsx | 95 ++++++
src/hooks/useTreasuryAdmin.ts | 58 ++++
src/lib/queryKeys.ts | 3 +
src/pages/AdminDashboardPage.tsx | 2 +
src/services/__tests__/admin.service.test.ts | 56 ++++
src/services/admin.service.ts | 107 +++++++
src/utils/__tests__/treasury.utils.test.ts | 51 ++++
src/utils/treasury.utils.ts | 64 ++++
10 files changed, 740 insertions(+)
create mode 100644 docs/admin-treasury-api.md
create mode 100644 src/components/admin/TreasuryPanel.tsx
create mode 100644 src/components/admin/__tests__/TreasuryPanel.test.tsx
create mode 100644 src/hooks/useTreasuryAdmin.ts
create mode 100644 src/utils/__tests__/treasury.utils.test.ts
create mode 100644 src/utils/treasury.utils.ts
diff --git a/docs/admin-treasury-api.md b/docs/admin-treasury-api.md
new file mode 100644
index 00000000..9cafe204
--- /dev/null
+++ b/docs/admin-treasury-api.md
@@ -0,0 +1,28 @@
+# Admin treasury API contract
+
+The treasury panel calls the authenticated backend API; this client repository does not contain the Soroban contract source or its ABI/spec, so it deliberately does not invent contract entrypoint names or simulate successful transactions. The backend must read the deployed contract/event index and execute/confirm the real distribution transaction.
+
+All routes use the existing `BaseApiService` credentials and return the standard `{ success, data, message }` envelope.
+
+| Method | Route | `data` |
+| --- | --- | --- |
+| `GET` | `/admin/treasury` | `{ accumulatedFeesStroops: string, updatedAt?: string }` |
+| `GET` | `/admin/treasury/distributions` | `TreasuryDistribution[]` |
+| `GET` | `/admin/treasury/fees` | Recent `FeeCollected` records, newest first |
+| `POST` | `/admin/treasury/distributions` | Confirmed `{ epoch: number, transactionHash: string }` |
+
+Distribution request body:
+
+```json
+{
+ "admin": "G...",
+ "totalAmountStroops": "10000000",
+ "recipients": [
+ { "address": "G...", "amountStroops": "10000000" }
+ ]
+}
+```
+
+Every amount is a non-negative decimal integer in stroops (1 XLM = 10,000,000 stroops). The client validates the Stellar addresses, rejects duplicate recipients, and requires the allocation sum to equal the displayed treasury balance. The backend must independently authenticate/authorize the admin, re-read the contract balance, validate allocations, submit the contract call, wait for successful confirmation, and only then return the epoch and transaction hash. A response without those confirmation fields is treated as a failure.
+
+Distribution records use `{ id, epoch, totalDistributedStroops, recipients: [{ address, amountStroops }], distributedAt, transactionHash }`. Fee event records use `{ id, creatorAddress, traderAddress, amountStroops, collectedAt, transactionHash }`.
diff --git a/src/components/admin/TreasuryPanel.tsx b/src/components/admin/TreasuryPanel.tsx
new file mode 100644
index 00000000..ddd7bb55
--- /dev/null
+++ b/src/components/admin/TreasuryPanel.tsx
@@ -0,0 +1,276 @@
+import { useMemo, useState } from 'react';
+import { Address } from '@stellar/stellar-sdk';
+import { CircleDollarSign, Loader2, Plus, Trash2 } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import {
+ useDistributeTreasuryFees,
+ useTreasuryBalance,
+ useTreasuryDistributions,
+ useTreasuryFeeEvents,
+} from '@/hooks/useTreasuryAdmin';
+import type { TreasuryDistributionRecipient } from '@/services/admin.service';
+import {
+ formatStroops,
+ parseXlmToStroops,
+ validateTreasuryDistribution,
+} from '@/utils/treasury.utils';
+
+const PANEL_CLASS =
+ 'rounded-[2rem] border border-white/10 bg-white/[0.02] p-6 shadow-2xl backdrop-blur-md md:p-8';
+
+interface RecipientDraft {
+ id: number;
+ address: string;
+ amount: string;
+}
+
+function timestamp(value: string): string {
+ const date = new Date(value);
+ return Number.isNaN(date.getTime())
+ ? value
+ : date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
+}
+
+function validAddress(address: string): boolean {
+ try {
+ Address.fromString(address.trim());
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function hasDistributableBalance(value: string | undefined): boolean {
+ return Boolean(value && /^\d+$/.test(value) && BigInt(value) > 0n);
+}
+
+function LoadingRows({ count = 2 }: { count?: number }) {
+ return (
+
+ {Array.from({ length: count }, (_, index) => (
+
+ ))}
+
Loading treasury data
+
+ );
+}
+
+function DataError({ onRetry }: { onRetry: () => void }) {
+ return (
+
+
Treasury data could not be loaded.
+
+ Retry
+
+
+ );
+}
+
+export default function TreasuryPanel({ adminAddress }: { adminAddress: string }) {
+ const balance = useTreasuryBalance();
+ const distributions = useTreasuryDistributions();
+ const feeEvents = useTreasuryFeeEvents();
+ const distribute = useDistributeTreasuryFees();
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [recipients, setRecipients] = useState([]);
+ const [nextId, setNextId] = useState(1);
+
+ const distributionRecipients = useMemo(
+ () =>
+ recipients.map(recipient => ({
+ address: recipient.address.trim(),
+ amountStroops:
+ parseXlmToStroops(recipient.amount)?.toString() ?? '',
+ })),
+ [recipients]
+ );
+ const distributionError = balance.data
+ ? validateTreasuryDistribution(
+ balance.data.accumulatedFeesStroops,
+ distributionRecipients
+ )
+ : 'Treasury balance is unavailable.';
+ const dataLoading = balance.isLoading || distributions.isLoading || feeEvents.isLoading;
+
+ const openDistributionDialog = () => {
+ setRecipients([{ id: nextId, address: '', amount: '' }]);
+ setNextId(value => value + 1);
+ setDialogOpen(true);
+ };
+
+ const addRecipient = () => {
+ setRecipients(current => [...current, { id: nextId, address: '', amount: '' }]);
+ setNextId(value => value + 1);
+ };
+
+ const updateRecipient = (id: number, field: 'address' | 'amount', value: string) => {
+ setRecipients(current =>
+ current.map(recipient =>
+ recipient.id === id ? { ...recipient, [field]: value } : recipient
+ )
+ );
+ };
+
+ const submitDistribution = (event: React.FormEvent) => {
+ event.preventDefault();
+ if (!balance.data || distributionError) return;
+ distribute.mutate(
+ {
+ admin: adminAddress,
+ totalAmountStroops: balance.data.accumulatedFeesStroops,
+ recipients: distributionRecipients,
+ },
+ { onSuccess: () => setDialogOpen(false) }
+ );
+ };
+
+ return (
+
+
+
+
+
+
+ Protocol treasury
+
+
Accumulated fees
+
Unallocated protocol fees available for distribution.
+
+
+ {distribute.isPending ? : }
+ Distribute now
+
+
+
+ {balance.isLoading ? (
+
+ ) : balance.isError || !balance.data ? (
+
void balance.refetch()} />
+ ) : (
+
+ {formatStroops(balance.data.accumulatedFeesStroops)}
+
+ )}
+ {balance.data?.updatedAt && Updated {timestamp(balance.data.updatedAt)}
}
+
+
+
+
+ Distribution history
+ Completed distribution epochs, recipients, and confirmed transaction dates.
+ {distributions.isLoading ?
: distributions.isError ? (
+ void distributions.refetch()} />
+ ) : (distributions.data?.length ?? 0) === 0 ? (
+ No treasury distributions yet.
+ ) : (
+
+
+
+ Epoch Total distributed Recipients Date
+
+
+ {[...(distributions.data ?? [])].sort((a, b) => b.epoch - a.epoch).map(item => (
+
+ {item.epoch}
+ {formatStroops(item.totalDistributedStroops)}
+ {item.recipients.map(recipient => `${recipient.address} — ${formatStroops(recipient.amountStroops)}`).join('; ')}
+ {timestamp(item.distributedAt)}
+
+ ))}
+
+
+
+ )}
+
+
+
+ Recent fee contributions
+ Recent per-trade FeeCollected events added to the treasury.
+ {feeEvents.isLoading ?
: feeEvents.isError ? (
+ void feeEvents.refetch()} />
+ ) : (feeEvents.data?.length ?? 0) === 0 ? (
+ No fee collection events found.
+ ) : (
+
+
+
+ Date Creator Trader Fee collected
+
+
+ {feeEvents.data?.slice(0, 20).map(item => (
+
+ {timestamp(item.collectedAt)}
+ {item.creatorAddress}
+ {item.traderAddress}
+ {formatStroops(item.amountStroops)}
+
+ ))}
+
+
+
+ )}
+
+
+
!distribute.isPending && setDialogOpen(open)}>
+
+
+ Distribute treasury fees
+
+ Assign the full accumulated balance to one or more Stellar recipients. Amounts use XLM, up to 7 decimal places.
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/admin/__tests__/TreasuryPanel.test.tsx b/src/components/admin/__tests__/TreasuryPanel.test.tsx
new file mode 100644
index 00000000..d4e08d60
--- /dev/null
+++ b/src/components/admin/__tests__/TreasuryPanel.test.tsx
@@ -0,0 +1,95 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { Keypair } from '@stellar/stellar-sdk';
+import TreasuryPanel from '../TreasuryPanel';
+
+const mocks = vi.hoisted(() => ({
+ balance: vi.fn(),
+ distributions: vi.fn(),
+ fees: vi.fn(),
+ mutate: vi.fn(),
+}));
+
+vi.mock('@/hooks/useTreasuryAdmin', () => ({
+ useTreasuryBalance: mocks.balance,
+ useTreasuryDistributions: mocks.distributions,
+ useTreasuryFeeEvents: mocks.fees,
+ useDistributeTreasuryFees: () => ({ mutate: mocks.mutate, isPending: false }),
+}));
+
+const ADMIN = Keypair.random().publicKey();
+const RECIPIENT = Keypair.random().publicKey();
+
+function setSuccessfulQueries() {
+ mocks.balance.mockReturnValue({
+ data: { accumulatedFeesStroops: '10000000', updatedAt: '2026-09-30T10:00:00Z' },
+ isLoading: false,
+ isError: false,
+ refetch: vi.fn(),
+ });
+ mocks.distributions.mockReturnValue({
+ data: [{
+ id: 'dist-1', epoch: 2, totalDistributedStroops: '2500000',
+ recipients: [{ address: RECIPIENT, amountStroops: '2500000' }],
+ distributedAt: '2026-09-29T10:00:00Z', transactionHash: 'hash-1',
+ }],
+ isLoading: false,
+ isError: false,
+ refetch: vi.fn(),
+ });
+ mocks.fees.mockReturnValue({
+ data: [{
+ id: 'fee-1', creatorAddress: RECIPIENT, traderAddress: ADMIN,
+ amountStroops: '250000', collectedAt: '2026-09-30T09:00:00Z', transactionHash: 'fee-hash',
+ }],
+ isLoading: false,
+ isError: false,
+ refetch: vi.fn(),
+ });
+}
+
+describe('TreasuryPanel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setSuccessfulQueries();
+ });
+
+ it('renders the accumulated balance, epoch history, and recent fee events', () => {
+ render( );
+
+ expect(screen.getByTestId('treasury-balance')).toHaveTextContent('1 XLM');
+ expect(screen.getByTestId('treasury-distribution-row')).toHaveTextContent('2');
+ expect(screen.getByTestId('treasury-distribution-row')).toHaveTextContent('0.25 XLM');
+ expect(screen.getByTestId('treasury-fee-row')).toHaveTextContent('0.025 XLM');
+ });
+
+ it('requires exact full-balance allocations and submits the admin distribution payload', () => {
+ render( );
+ fireEvent.click(screen.getByTestId('treasury-distribute-now'));
+
+ fireEvent.change(screen.getByLabelText('Recipient 1 address'), {
+ target: { value: RECIPIENT },
+ });
+ fireEvent.change(screen.getByLabelText('Amount (XLM)'), {
+ target: { value: '0.9' },
+ });
+ const submit = screen.getByTestId('treasury-distribution-submit');
+ expect(submit).toBeDisabled();
+ expect(screen.getByTestId('treasury-distribution-validation')).toHaveTextContent('must equal');
+
+ fireEvent.change(screen.getByLabelText('Amount (XLM)'), {
+ target: { value: '1' },
+ });
+ expect(submit).toBeEnabled();
+ fireEvent.click(submit);
+
+ expect(mocks.mutate).toHaveBeenCalledWith(
+ {
+ admin: ADMIN,
+ totalAmountStroops: '10000000',
+ recipients: [{ address: RECIPIENT, amountStroops: '10000000' }],
+ },
+ expect.objectContaining({ onSuccess: expect.any(Function) })
+ );
+ });
+});
diff --git a/src/hooks/useTreasuryAdmin.ts b/src/hooks/useTreasuryAdmin.ts
new file mode 100644
index 00000000..9ed510d5
--- /dev/null
+++ b/src/hooks/useTreasuryAdmin.ts
@@ -0,0 +1,58 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { queryKeys } from '@/lib/queryKeys';
+import {
+ adminService,
+ type TreasuryDistributionInput,
+} from '@/services/admin.service';
+import showToast from '@/utils/toast.util';
+
+function messageFor(error: unknown): string {
+ return error instanceof Error
+ ? error.message
+ : 'Treasury request failed. Please try again.';
+}
+
+export function useTreasuryBalance() {
+ return useQuery({
+ queryKey: queryKeys.admin.treasury(),
+ queryFn: () => adminService.getTreasuryBalance(),
+ refetchInterval: 30_000,
+ });
+}
+
+export function useTreasuryDistributions() {
+ return useQuery({
+ queryKey: queryKeys.admin.treasuryDistributions(),
+ queryFn: () => adminService.getTreasuryDistributions(),
+ });
+}
+
+export function useTreasuryFeeEvents() {
+ return useQuery({
+ queryKey: queryKeys.admin.treasuryFees(),
+ queryFn: () => adminService.getTreasuryFeeEvents(),
+ });
+}
+
+export function useDistributeTreasuryFees() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationKey: ['admin', 'treasury', 'distribute'],
+ mutationFn: (input: TreasuryDistributionInput) =>
+ adminService.distributeTreasuryFees(input),
+ onSuccess: result => {
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.admin.treasury(),
+ });
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.admin.treasuryDistributions(),
+ });
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.admin.treasuryFees(),
+ });
+ showToast.success(`Treasury distribution epoch ${result.epoch} confirmed`);
+ },
+ onError: error => showToast.error(messageFor(error)),
+ });
+}
diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts
index b12237ee..34fdbc66 100644
--- a/src/lib/queryKeys.ts
+++ b/src/lib/queryKeys.ts
@@ -102,6 +102,9 @@ export const queryKeys = {
ratings: () => ['leaderboard', 'ratings'] as const,
},
admin: {
+ treasury: () => ['admin', 'treasury', 'balance'] as const,
+ treasuryDistributions: () => ['admin', 'treasury', 'distributions'] as const,
+ treasuryFees: () => ['admin', 'treasury', 'fees'] as const,
oracleCallers: () => ['admin', 'oracle', 'callers'] as const,
multiSigPending: () => ['admin', 'multisig', 'pending'] as const,
multiSigHistory: () => ['admin', 'multisig', 'history'] as const,
diff --git a/src/pages/AdminDashboardPage.tsx b/src/pages/AdminDashboardPage.tsx
index 324a96d6..2b0dee24 100644
--- a/src/pages/AdminDashboardPage.tsx
+++ b/src/pages/AdminDashboardPage.tsx
@@ -3,6 +3,7 @@ import TimelockQueuePanel from '@/components/admin/TimelockQueuePanel';
import OracleAccessPanel from '@/components/admin/OracleAccessPanel';
import AclWhitelistPanel from '@/components/admin/AclWhitelistPanel';
import UpgradeProxyPanel from '@/components/admin/UpgradeProxyPanel';
+import TreasuryPanel from '@/components/admin/TreasuryPanel';
import { useNavigationTiming } from '@/hooks/useNavigationTiming';
import { useStellarWallet } from '@/hooks/useStellarWallet';
import { isAdminWallet } from '@/utils/adminAccess';
@@ -31,6 +32,7 @@ export default function AdminDashboardPage() {
{isAdmin && (
<>
+
diff --git a/src/services/__tests__/admin.service.test.ts b/src/services/__tests__/admin.service.test.ts
index 941f453d..53d994ae 100644
--- a/src/services/__tests__/admin.service.test.ts
+++ b/src/services/__tests__/admin.service.test.ts
@@ -267,3 +267,59 @@ describe('adminService timelock actions (#1014)', () => {
);
});
});
+
+describe('adminService treasury', () => {
+ beforeEach(() => {
+ mockGet.mockReset();
+ mockPost.mockReset();
+ });
+
+ it('loads the exact treasury balance and fee/distribution records', async () => {
+ const treasury = { accumulatedFeesStroops: '123456789', updatedAt: '2026-09-30T10:00:00Z' };
+ const distributions = [{
+ id: 'dist-1', epoch: 4, totalDistributedStroops: '10000000',
+ recipients: [{ address: CALLER_A, amountStroops: '10000000' }],
+ distributedAt: '2026-09-30T10:00:00Z', transactionHash: 'tx-confirmed',
+ }];
+ const feeEvents = [{
+ id: 'fee-1', creatorAddress: CALLER_A, traderAddress: CALLER_B,
+ amountStroops: '250000', collectedAt: '2026-09-30T09:00:00Z', transactionHash: 'tx-fee',
+ }];
+ mockGet
+ .mockResolvedValueOnce(fakeApiResponse(treasury))
+ .mockResolvedValueOnce(fakeApiResponse(distributions))
+ .mockResolvedValueOnce(fakeApiResponse(feeEvents));
+
+ expect(await adminService.getTreasuryBalance()).toEqual(treasury);
+ expect(await adminService.getTreasuryDistributions()).toEqual(distributions);
+ expect(await adminService.getTreasuryFeeEvents()).toEqual(feeEvents);
+ expect(mockGet.mock.calls.map(([url]) => url)).toEqual([
+ '/admin/treasury',
+ '/admin/treasury/distributions',
+ '/admin/treasury/fees',
+ ]);
+ });
+
+ it('submits recipient allocations and requires a confirmed on-chain hash', async () => {
+ const input = {
+ admin: CALLER_A,
+ totalAmountStroops: '10000000',
+ recipients: [{ address: CALLER_B, amountStroops: '10000000' }],
+ };
+ const result = { epoch: 5, transactionHash: 'confirmed-hash' };
+ mockPost.mockResolvedValueOnce(fakeApiResponse(result));
+
+ expect(await adminService.distributeTreasuryFees(input)).toEqual(result);
+ expect(mockPost).toHaveBeenCalledWith('/admin/treasury/distributions', input);
+ });
+
+ it('rejects a distribution response without a transaction confirmation', async () => {
+ mockPost.mockResolvedValueOnce(fakeApiResponse({ epoch: 5 }));
+
+ await expect(adminService.distributeTreasuryFees({
+ admin: CALLER_A,
+ totalAmountStroops: '10000000',
+ recipients: [{ address: CALLER_B, amountStroops: '10000000' }],
+ })).rejects.toMatchObject({ status: 502 });
+ });
+});
diff --git a/src/services/admin.service.ts b/src/services/admin.service.ts
index 39b1b781..1f4a6b0c 100644
--- a/src/services/admin.service.ts
+++ b/src/services/admin.service.ts
@@ -10,6 +10,46 @@ export interface OracleCaller {
addedAt?: string;
}
+/** Current protocol treasury state. Monetary values are exact Stellar stroops. */
+export interface TreasuryBalance {
+ accumulatedFeesStroops: string;
+ updatedAt?: string;
+}
+
+export interface TreasuryDistributionRecipient {
+ address: string;
+ amountStroops: string;
+}
+
+export interface TreasuryDistribution {
+ id: string;
+ epoch: number;
+ totalDistributedStroops: string;
+ recipients: TreasuryDistributionRecipient[];
+ distributedAt: string;
+ transactionHash: string;
+}
+
+export interface TreasuryFeeCollectedEvent {
+ id: string;
+ creatorAddress: string;
+ traderAddress: string;
+ amountStroops: string;
+ collectedAt: string;
+ transactionHash: string;
+}
+
+export interface TreasuryDistributionInput {
+ admin: string;
+ totalAmountStroops: string;
+ recipients: TreasuryDistributionRecipient[];
+}
+
+export interface TreasuryDistributionSubmission {
+ epoch: number;
+ transactionHash: string;
+}
+
export interface AclContract {
address: string;
functions: string[];
@@ -533,6 +573,73 @@ class AdminService extends BaseApiService {
// and return the parsed history. Until then, return an empty array.
return [];
}
+
+ /** Read the on-chain protocol fee pool via the admin API. */
+ async getTreasuryBalance(): Promise {
+ try {
+ const response = await this.api.get>(
+ '/admin/treasury'
+ );
+ return response.data.data;
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+
+ /** Read completed treasury distributions with recipient breakdowns. */
+ async getTreasuryDistributions(): Promise {
+ try {
+ const response = await this.api.get<
+ APIResponse
+ >('/admin/treasury/distributions');
+ return response.data.data ?? [];
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+
+ /** Read recent on-chain FeeCollected event records. */
+ async getTreasuryFeeEvents(): Promise {
+ try {
+ const response = await this.api.get<
+ APIResponse
+ >('/admin/treasury/fees');
+ return response.data.data ?? [];
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+
+ /**
+ * Request a treasury distribution. The admin API must authorize the wallet,
+ * submit the contract transaction, and return its confirmed transaction hash.
+ */
+ async distributeTreasuryFees(
+ input: TreasuryDistributionInput
+ ): Promise {
+ try {
+ const response = await this.api.post<
+ APIResponse
+ >('/admin/treasury/distributions', input);
+ const result = response.data.data;
+ if (
+ !result ||
+ !Number.isSafeInteger(result.epoch) ||
+ result.epoch < 0 ||
+ typeof result.transactionHash !== 'string' ||
+ result.transactionHash.trim() === ''
+ ) {
+ throw new ApiError(
+ 'Treasury distribution was not confirmed by the server.',
+ 502
+ );
+ }
+ return result;
+ } catch (error) {
+ if (error instanceof ApiError) throw error;
+ throw this.handleError(error);
+ }
+ }
}
export const adminService = new AdminService();
diff --git a/src/utils/__tests__/treasury.utils.test.ts b/src/utils/__tests__/treasury.utils.test.ts
new file mode 100644
index 00000000..2d0d559c
--- /dev/null
+++ b/src/utils/__tests__/treasury.utils.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest';
+import { Keypair } from '@stellar/stellar-sdk';
+import {
+ formatStroops,
+ parseXlmToStroops,
+ validateTreasuryDistribution,
+} from '@/utils/treasury.utils';
+
+const RECIPIENT_A = Keypair.random().publicKey();
+const RECIPIENT_B = Keypair.random().publicKey();
+
+describe('treasury amount utilities', () => {
+ it('converts XLM decimal strings to exact stroops without floating point', () => {
+ expect(parseXlmToStroops('12.0000001')).toBe(120_000_001n);
+ expect(parseXlmToStroops('0.5')).toBe(5_000_000n);
+ expect(parseXlmToStroops('0')).toBe(0n);
+ expect(parseXlmToStroops('1.00000001')).toBeNull();
+ expect(parseXlmToStroops('01')).toBeNull();
+ });
+
+ it('formats stroops as XLM while trimming insignificant zeroes', () => {
+ expect(formatStroops('12345678')).toBe('1.2345678 XLM');
+ expect(formatStroops('10000000')).toBe('1 XLM');
+ expect(formatStroops('0')).toBe('0 XLM');
+ });
+});
+
+describe('treasury distribution validation', () => {
+ it('accepts valid unique recipients whose allocations equal the whole balance', () => {
+ expect(validateTreasuryDistribution('15000000', [
+ { address: RECIPIENT_A, amountStroops: '5000000' },
+ { address: RECIPIENT_B, amountStroops: '10000000' },
+ ])).toBeNull();
+ });
+
+ it('rejects invalid addresses, duplicate addresses, zero values, and mismatched totals', () => {
+ expect(validateTreasuryDistribution('10000000', [
+ { address: 'not-an-address', amountStroops: '10000000' },
+ ])).toContain('valid Stellar address');
+ expect(validateTreasuryDistribution('10000000', [
+ { address: RECIPIENT_A, amountStroops: '5000000' },
+ { address: RECIPIENT_A, amountStroops: '5000000' },
+ ])).toContain('unique');
+ expect(validateTreasuryDistribution('10000000', [
+ { address: RECIPIENT_A, amountStroops: '9999999' },
+ ])).toContain('equal');
+ expect(validateTreasuryDistribution('0', [
+ { address: RECIPIENT_A, amountStroops: '1' },
+ ])).toContain('no accumulated fees');
+ });
+});
diff --git a/src/utils/treasury.utils.ts b/src/utils/treasury.utils.ts
new file mode 100644
index 00000000..a58d9309
--- /dev/null
+++ b/src/utils/treasury.utils.ts
@@ -0,0 +1,64 @@
+import { Address } from '@stellar/stellar-sdk';
+import type { TreasuryDistributionRecipient } from '@/services/admin.service';
+
+const STROOPS_PER_XLM = 10_000_000n;
+
+export function parseXlmToStroops(value: string): bigint | null {
+ const normalized = value.trim();
+ if (!/^(?:0|[1-9]\d*)(?:\.\d{1,7})?$/.test(normalized)) return null;
+ const [whole, fraction = ''] = normalized.split('.');
+ return BigInt(whole) * STROOPS_PER_XLM + BigInt(fraction.padEnd(7, '0') || '0');
+}
+
+export function formatStroops(stroops: string | bigint): string {
+ let amount: bigint;
+ try {
+ amount = typeof stroops === 'bigint' ? stroops : BigInt(stroops);
+ } catch {
+ return '—';
+ }
+ const negative = amount < 0n;
+ const absolute = negative ? -amount : amount;
+ const whole = absolute / STROOPS_PER_XLM;
+ const fraction = (absolute % STROOPS_PER_XLM)
+ .toString()
+ .padStart(7, '0')
+ .replace(/0+$/, '');
+ return `${negative ? '-' : ''}${whole.toLocaleString()}${fraction ? `.${fraction}` : ''} XLM`;
+}
+
+export function validateTreasuryDistribution(
+ balanceStroops: string,
+ recipients: TreasuryDistributionRecipient[]
+): string | null {
+ if (!/^\d+$/.test(balanceStroops)) return 'Treasury balance is unavailable.';
+ const balance = BigInt(balanceStroops);
+ if (balance <= 0n) return 'There are no accumulated fees to distribute.';
+ if (recipients.length === 0) return 'Add at least one recipient.';
+
+ let total = 0n;
+ const addresses = new Set();
+ for (const [index, recipient] of recipients.entries()) {
+ const address = recipient.address.trim();
+ try {
+ Address.fromString(address);
+ } catch {
+ return `Recipient ${index + 1} must be a valid Stellar address.`;
+ }
+ const normalizedAddress = address.toLowerCase();
+ if (addresses.has(normalizedAddress)) {
+ return 'Recipient addresses must be unique.';
+ }
+ addresses.add(normalizedAddress);
+
+ if (!/^\d+$/.test(recipient.amountStroops)) {
+ return `Recipient ${index + 1} must have a valid amount.`;
+ }
+ const amount = BigInt(recipient.amountStroops);
+ if (amount <= 0n) return `Recipient ${index + 1} amount must be greater than zero.`;
+ total += amount;
+ }
+
+ if (total !== balance) return 'Recipient amounts must equal the accumulated treasury balance.';
+ return null;
+}
From 999055788d172be921dfa2741d9428e8ec09ab5f Mon Sep 17 00:00:00 2001
From: tek-lizzy
Date: Wed, 30 Sep 2026 18:16:22 +0100
Subject: [PATCH 2/2] feat: add platform-wide runtime error boundaries
---
docs/error-handling-conventions.md | 8 +
src/components/common/AppErrorBoundary.tsx | 42 ++-
.../common/CreatorPageErrorBoundary.tsx | 5 +-
.../common/KeyDetailPageErrorBoundary.tsx | 56 ++--
src/components/common/RouteErrorBoundary.tsx | 103 ++++++++
.../common/SectionErrorBoundary.tsx | 35 ++-
.../common/TradePanelErrorBoundary.tsx | 39 ++-
.../__tests__/AppErrorBoundary.test.tsx | 74 +++---
.../__tests__/RouteErrorBoundary.test.tsx | 67 +++++
.../SectionErrorBoundary.retry.test.tsx | 44 ++++
src/pages/AdminDashboardPage.tsx | 25 +-
src/pages/CreatorDetailPage.tsx | 239 +++++++++++-------
src/pages/HomePage.tsx | 33 ++-
src/pages/MarketplacePage.tsx | 224 +++++++++-------
src/pages/ProfilePage.tsx | 141 ++++++-----
src/routes.tsx | 42 ++-
16 files changed, 833 insertions(+), 344 deletions(-)
create mode 100644 src/components/common/RouteErrorBoundary.tsx
create mode 100644 src/components/common/__tests__/RouteErrorBoundary.test.tsx
create mode 100644 src/components/common/__tests__/SectionErrorBoundary.retry.test.tsx
diff --git a/docs/error-handling-conventions.md b/docs/error-handling-conventions.md
index e8859e9a..92997115 100644
--- a/docs/error-handling-conventions.md
+++ b/docs/error-handling-conventions.md
@@ -35,6 +35,14 @@ to be able to do.
| --- | ------------------ | ----------------------------- | ----------------- | -------------------------------------------------- |
| 6 | **Network banner** | Connected wallet, wrong chain | Persistent notice | `NetworkMismatchBanner` from `@/components/common` |
+For route-level runtime failures, wrap major routes with `RouteErrorBoundary`;
+for independent widgets within a page, use `SectionErrorBoundary`. Both provide
+a contextual fallback, retry by remounting the failed subtree, and a support
+link. Caught exception details and diagnostics are development-only and are
+not rendered or logged in production builds. These boundaries handle React
+render/lifecycle errors; query and mutation failures should continue to use
+the inline and toast patterns below.
+
---
## Decision Flowchart
diff --git a/src/components/common/AppErrorBoundary.tsx b/src/components/common/AppErrorBoundary.tsx
index fd5345b6..1a35259f 100644
--- a/src/components/common/AppErrorBoundary.tsx
+++ b/src/components/common/AppErrorBoundary.tsx
@@ -8,6 +8,7 @@ interface Props {
interface State {
hasError: boolean;
+ resetKey: number;
}
/**
@@ -16,25 +17,30 @@ interface State {
* CreatorPageErrorBoundary, etc). This is the last line of defense before
* React would otherwise unmount the whole tree to a blank screen.
*
- * A full reload is used for recovery rather than resetting local state:
- * an error this high up means the app-level state that produced it is
- * suspect, so a fresh mount is safer than trying to resume it.
+ * The retry action remounts the complete app subtree so the app can recover
+ * without requiring a browser reload.
*/
class AppErrorBoundary extends Component {
public state: State = {
hasError: false,
+ resetKey: 0,
};
- public static getDerivedStateFromError(): State {
+ public static getDerivedStateFromError(): Partial {
return { hasError: true };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
- console.error('Uncaught error at app root:', error, errorInfo);
+ if (import.meta.env.DEV) {
+ console.error('Uncaught error at app root:', error, errorInfo);
+ }
}
- private handleReload = () => {
- this.setState({ hasError: false });
+ private handleRetry = () => {
+ this.setState(state => ({
+ hasError: false,
+ resetKey: state.resetKey + 1,
+ }));
};
public render() {
@@ -54,23 +60,35 @@ class AppErrorBoundary extends Component {
Something went wrong
- The app hit an unexpected error and couldn't continue.
- Reloading the page usually fixes this.
+ The app hit an unexpected error and couldn't continue. Try
+ again, or contact support if the issue persists.
- Reload page
+ Retry
+
+ Contact support
+
);
}
- return this.props.children;
+ return (
+
+ {this.props.children}
+
+ );
}
}
diff --git a/src/components/common/CreatorPageErrorBoundary.tsx b/src/components/common/CreatorPageErrorBoundary.tsx
index 8b7edf45..a7d54cfd 100644
--- a/src/components/common/CreatorPageErrorBoundary.tsx
+++ b/src/components/common/CreatorPageErrorBoundary.tsx
@@ -47,7 +47,10 @@ class CreatorPageErrorBoundary extends Component {
aria-live="assertive"
>
-
+
{isNotFound
? 'Creator not found'
diff --git a/src/components/common/KeyDetailPageErrorBoundary.tsx b/src/components/common/KeyDetailPageErrorBoundary.tsx
index 833f817f..667428a9 100644
--- a/src/components/common/KeyDetailPageErrorBoundary.tsx
+++ b/src/components/common/KeyDetailPageErrorBoundary.tsx
@@ -12,25 +12,29 @@ interface ErrorBoundaryProps {
interface State {
hasError: boolean;
error: Error | null;
+ resetKey: number;
}
class KeyDetailPageErrorBoundary extends Component {
public state: State = {
hasError: false,
error: null,
+ resetKey: 0,
};
- public static getDerivedStateFromError(error: Error): State {
+ public static getDerivedStateFromError(error: Error): Partial {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
markErrorAsCaught(error);
- console.error(
- 'Error rendering key detail page:',
- error,
- errorInfo.componentStack
- );
+ if (import.meta.env.DEV) {
+ console.error(
+ 'Error rendering key detail page:',
+ error,
+ errorInfo.componentStack
+ );
+ }
}
public componentDidUpdate(prevProps: ErrorBoundaryProps) {
@@ -38,14 +42,20 @@ class KeyDetailPageErrorBoundary extends Component {
this.state.hasError &&
prevProps.locationKey !== this.props.locationKey
) {
- this.setState({ hasError: false, error: null });
+ this.setState(state => ({
+ hasError: false,
+ error: null,
+ resetKey: state.resetKey + 1,
+ }));
}
}
- private handleReload = () => {
- this.setState({ hasError: false, error: null }, () => {
- window.location.reload();
- });
+ private handleRetry = () => {
+ this.setState(state => ({
+ hasError: false,
+ error: null,
+ resetKey: state.resetKey + 1,
+ }));
};
public render() {
@@ -65,10 +75,10 @@ class KeyDetailPageErrorBoundary extends Component {
Something went wrong loading this key
- An unexpected error occurred. You can reload the page or return
- to the marketplace to continue browsing.
+ An unexpected error occurred. You can reload the page or
+ return to the marketplace to continue browsing.
- {this.state.error && (
+ {import.meta.env.DEV && this.state.error && (
{this.state.error.message}
@@ -77,11 +87,11 @@ class KeyDetailPageErrorBoundary extends Component
{
);
}
- return this.props.children;
+ return (
+
+ {this.props.children}
+
+ );
}
}
diff --git a/src/components/common/RouteErrorBoundary.tsx b/src/components/common/RouteErrorBoundary.tsx
new file mode 100644
index 00000000..e19e654a
--- /dev/null
+++ b/src/components/common/RouteErrorBoundary.tsx
@@ -0,0 +1,103 @@
+import { Component, type ErrorInfo, type ReactNode } from 'react';
+import { AlertCircle, RefreshCw } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { markErrorAsCaught } from '@/utils/globalErrorHandler.utils';
+
+interface RouteErrorBoundaryProps {
+ children: ReactNode;
+ routeName: string;
+}
+
+interface RouteErrorBoundaryState {
+ error: Error | null;
+ resetKey: number;
+}
+
+/** Isolates a route from the rest of the app and remounts it on retry. */
+export default class RouteErrorBoundary extends Component<
+ RouteErrorBoundaryProps,
+ RouteErrorBoundaryState
+> {
+ public state: RouteErrorBoundaryState = { error: null, resetKey: 0 };
+
+ public static getDerivedStateFromError(
+ error: Error
+ ): Partial {
+ return { error };
+ }
+
+ public componentDidCatch(error: Error, info: ErrorInfo) {
+ markErrorAsCaught(error);
+ if (import.meta.env.DEV) {
+ console.error(
+ `Uncaught error in ${this.props.routeName} route:`,
+ error,
+ info
+ );
+ }
+ }
+
+ private handleRetry = () => {
+ this.setState(state => ({ error: null, resetKey: state.resetKey + 1 }));
+ };
+
+ public render() {
+ if (this.state.error) {
+ return (
+
+
+
+
+ {this.props.routeName} couldn't load
+
+
+ An unexpected error interrupted this page. Retry, or
+ contact support if the problem continues.
+
+ {import.meta.env.DEV && (
+
+ {this.state.error.message}
+
+ )}
+
+
+
+ );
+ }
+
+ return (
+
+ {this.props.children}
+
+ );
+ }
+}
diff --git a/src/components/common/SectionErrorBoundary.tsx b/src/components/common/SectionErrorBoundary.tsx
index b49f7d34..4d8e6355 100644
--- a/src/components/common/SectionErrorBoundary.tsx
+++ b/src/components/common/SectionErrorBoundary.tsx
@@ -25,25 +25,37 @@ interface Props {
interface State {
hasError: boolean;
error: Error | null;
+ resetKey: number;
}
class SectionErrorBoundary extends Component {
public state: State = {
hasError: false,
error: null,
+ resetKey: 0,
};
- public static getDerivedStateFromError(error: Error): State {
+ public static getDerivedStateFromError(error: Error): Partial {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
markErrorAsCaught(error);
- console.error(`Uncaught error in section ${this.props.sectionName || 'Unknown'}:`, error, errorInfo);
+ if (import.meta.env.DEV) {
+ console.error(
+ `Uncaught error in section ${this.props.sectionName || 'Unknown'}:`,
+ error,
+ errorInfo
+ );
+ }
}
private handleRetry = () => {
- this.setState({ hasError: false, error: null });
+ this.setState(state => ({
+ hasError: false,
+ error: null,
+ resetKey: state.resetKey + 1,
+ }));
};
public render() {
@@ -61,7 +73,8 @@ class SectionErrorBoundary extends Component {
- {this.props.title ?? 'Something went wrong in this section'}
+ {this.props.title ??
+ 'Something went wrong in this section'}
{this.props.description !== '' && (
@@ -81,11 +94,23 @@ class SectionErrorBoundary extends Component {
Retry
+
+ Contact support
+
);
}
- return this.props.children;
+ return (
+
+ {this.props.children}
+
+ );
}
}
diff --git a/src/components/common/TradePanelErrorBoundary.tsx b/src/components/common/TradePanelErrorBoundary.tsx
index 31f985d1..7ae615de 100644
--- a/src/components/common/TradePanelErrorBoundary.tsx
+++ b/src/components/common/TradePanelErrorBoundary.tsx
@@ -12,6 +12,7 @@ export interface TradePanelErrorBoundaryProps {
export interface TradePanelErrorBoundaryState {
hasError: boolean;
error: Error | null;
+ resetKey: number;
}
export class TradePanelErrorBoundary extends Component<
@@ -21,21 +22,28 @@ export class TradePanelErrorBoundary extends Component<
public state: TradePanelErrorBoundaryState = {
hasError: false,
error: null,
+ resetKey: 0,
};
- public static getDerivedStateFromError(error: Error): TradePanelErrorBoundaryState {
+ public static getDerivedStateFromError(
+ error: Error
+ ): Partial {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
markErrorAsCaught(error);
- if (process.env.NODE_ENV !== 'test') {
+ if (import.meta.env.DEV) {
console.error('Uncaught error inside TradePanel:', error, errorInfo);
}
}
public handleRetry = () => {
- this.setState({ hasError: false, error: null });
+ this.setState(state => ({
+ hasError: false,
+ error: null,
+ resetKey: state.resetKey + 1,
+ }));
if (this.props.onReset) {
this.props.onReset();
}
@@ -51,12 +59,17 @@ export class TradePanelErrorBoundary extends Component<
className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-red-500/30 bg-slate-950/90 p-6 text-center text-white backdrop-blur-md"
>
-
+
- {this.props.fallbackMessage ?? 'Something went wrong inside the trade panel'}
+ {this.props.fallbackMessage ??
+ 'Something went wrong inside the trade panel'}
- The rest of the marketplace is still running. Click retry below to attempt remounting the trade panel.
+ The rest of the marketplace is still running. Click retry
+ below to attempt remounting the trade panel.
Retry
+
+ Contact support
+
);
}
- return this.props.children;
+ return (
+
+ {this.props.children}
+
+ );
}
}
diff --git a/src/components/common/__tests__/AppErrorBoundary.test.tsx b/src/components/common/__tests__/AppErrorBoundary.test.tsx
index d8d3c8f8..aa9fbe8f 100644
--- a/src/components/common/__tests__/AppErrorBoundary.test.tsx
+++ b/src/components/common/__tests__/AppErrorBoundary.test.tsx
@@ -1,37 +1,39 @@
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-
-import AppErrorBoundary from "@/components/common/AppErrorBoundary";
-
-describe("AppErrorBoundary", () => {
- afterEach(() => {
- vi.restoreAllMocks();
- });
-
- it("renders the fallback and recovers when the retry action resets the boundary", async () => {
- const user = userEvent.setup();
- const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
- let shouldThrow = true;
-
- const FlakyChild = () => {
- if (shouldThrow) throw new Error("Simulated app crash");
- return Recovered content
;
- };
-
- render(
-
-
- ,
- );
-
- expect(screen.getByRole("alert")).toBeInTheDocument();
- expect(screen.getByText("Something went wrong")).toBeInTheDocument();
-
- shouldThrow = false;
- await user.click(screen.getByRole("button", { name: /reload page/i }));
-
- expect(screen.getByText("Recovered content")).toBeInTheDocument();
- expect(consoleErrorSpy).toHaveBeenCalled();
- });
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import AppErrorBoundary from '@/components/common/AppErrorBoundary';
+
+describe('AppErrorBoundary', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renders the fallback and recovers when the retry action resets the boundary', async () => {
+ const user = userEvent.setup();
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+ let shouldThrow = true;
+
+ const FlakyChild = () => {
+ if (shouldThrow) throw new Error('Simulated app crash');
+ return Recovered content
;
+ };
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByRole('alert')).toBeInTheDocument();
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+
+ shouldThrow = false;
+ await user.click(screen.getByRole('button', { name: /retry/i }));
+
+ expect(screen.getByText('Recovered content')).toBeInTheDocument();
+ expect(consoleErrorSpy).toHaveBeenCalled();
+ });
});
diff --git a/src/components/common/__tests__/RouteErrorBoundary.test.tsx b/src/components/common/__tests__/RouteErrorBoundary.test.tsx
new file mode 100644
index 00000000..1a89c0e6
--- /dev/null
+++ b/src/components/common/__tests__/RouteErrorBoundary.test.tsx
@@ -0,0 +1,67 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import RouteErrorBoundary from '@/components/common/RouteErrorBoundary';
+
+let shouldThrow = true;
+
+function CrashableRoute() {
+ if (shouldThrow) throw new Error('private runtime detail');
+ return Recovered route content
;
+}
+
+describe('RouteErrorBoundary', () => {
+ beforeEach(() => {
+ shouldThrow = true;
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterEach(() => vi.restoreAllMocks());
+
+ it('shows a contextual fallback with retry and support actions, then remounts the route', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId('route-error-fallback')).toBeInTheDocument();
+ expect(
+ screen.getByRole('heading', { name: /portfolio couldn't load/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('link', { name: /contact support/i })
+ ).toHaveAttribute(
+ 'href',
+ 'https://github.com/accesslayerorg/accesslayer-client/issues'
+ );
+
+ shouldThrow = false;
+ fireEvent.click(screen.getByTestId('route-error-retry'));
+
+ expect(screen.getByText('Recovered route content')).toBeInTheDocument();
+ expect(
+ screen.queryByTestId('route-error-fallback')
+ ).not.toBeInTheDocument();
+ });
+
+ it('does not render error details in production builds', () => {
+ render(
+
+
+
+ );
+
+ if (import.meta.env.DEV) {
+ expect(screen.getByTestId('route-error-details')).toHaveTextContent(
+ 'private runtime detail'
+ );
+ } else {
+ expect(
+ screen.queryByTestId('route-error-details')
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText('private runtime detail')
+ ).not.toBeInTheDocument();
+ }
+ });
+});
diff --git a/src/components/common/__tests__/SectionErrorBoundary.retry.test.tsx b/src/components/common/__tests__/SectionErrorBoundary.retry.test.tsx
new file mode 100644
index 00000000..54cf5e5e
--- /dev/null
+++ b/src/components/common/__tests__/SectionErrorBoundary.retry.test.tsx
@@ -0,0 +1,44 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import SectionErrorBoundary from '@/components/common/SectionErrorBoundary';
+
+let shouldThrow = true;
+
+function CrashableSection() {
+ if (shouldThrow) throw new Error('section failed');
+ return Recovered section
;
+}
+
+describe('SectionErrorBoundary retry', () => {
+ beforeEach(() => {
+ shouldThrow = true;
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterEach(() => vi.restoreAllMocks());
+
+ it('keeps healthy siblings available and remounts only the failed section on retry', () => {
+ render(
+
+
Healthy sibling section
+
+
+
+
+ );
+
+ expect(screen.getByText('Healthy sibling section')).toBeInTheDocument();
+ expect(
+ screen.getByRole('link', { name: /contact support/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: /retry/i })
+ ).toBeInTheDocument();
+
+ shouldThrow = false;
+ fireEvent.click(screen.getByRole('button', { name: /retry/i }));
+
+ expect(screen.getByText('Healthy sibling section')).toBeInTheDocument();
+ expect(screen.getByText('Recovered section')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/AdminDashboardPage.tsx b/src/pages/AdminDashboardPage.tsx
index 2b0dee24..93d0d8ff 100644
--- a/src/pages/AdminDashboardPage.tsx
+++ b/src/pages/AdminDashboardPage.tsx
@@ -8,6 +8,7 @@ import { useNavigationTiming } from '@/hooks/useNavigationTiming';
import { useStellarWallet } from '@/hooks/useStellarWallet';
import { isAdminWallet } from '@/utils/adminAccess';
import { Navigate } from 'react-router';
+import SectionErrorBoundary from '@/components/common/SectionErrorBoundary';
export default function AdminDashboardPage() {
useNavigationTiming('admin-dashboard');
@@ -32,12 +33,24 @@ export default function AdminDashboardPage() {
{isAdmin && (
<>
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
>
)}
{!isConnected && (
diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx
index 76011490..a55ab67f 100644
--- a/src/pages/CreatorDetailPage.tsx
+++ b/src/pages/CreatorDetailPage.tsx
@@ -69,11 +69,19 @@ import PerformanceBondPanel from '@/components/common/PerformanceBondPanel';
import WhitelistStatusBadge from '@/components/common/WhitelistStatusBadge';
import ShareModal from '@/components/common/ShareModal';
-import { useTradeCooldownStatus, invalidateTradeCooldownStatus, resolveActiveTradeCooldown } from '@/hooks/useTradeCooldownStatus';
-import { isActiveCooldown, type ActiveTradeCooldown } from '@/utils/tradeCooldown.utils';
+import {
+ useTradeCooldownStatus,
+ invalidateTradeCooldownStatus,
+ resolveActiveTradeCooldown,
+} from '@/hooks/useTradeCooldownStatus';
+import {
+ isActiveCooldown,
+ type ActiveTradeCooldown,
+} from '@/utils/tradeCooldown.utils';
import TradeCooldownButton from '@/components/common/TradeCooldownButton';
import KeyHolderList from '@/components/common/KeyHolderList';
import HolderConcentrationChart from '@/components/common/HolderConcentrationChart';
+import SectionErrorBoundary from '@/components/common/SectionErrorBoundary';
function CreatorDetailPageContent() {
usePurchaseConfetti();
@@ -85,8 +93,9 @@ function CreatorDetailPageContent() {
const [recentSettlement, setRecentSettlement] =
useState(null);
const [shareModalOpen, setShareModalOpen] = useState(false);
- const [lastPurchasedAmount, setLastPurchasedAmount] =
- useState(null);
+ const [lastPurchasedAmount, setLastPurchasedAmount] = useState<
+ number | null
+ >(null);
const [deprecationDismissed, setDeprecationDismissed] = useState(false);
const {
data: creator,
@@ -172,7 +181,6 @@ function CreatorDetailPageContent() {
const performanceBond =
performanceBondData ?? creator?.performanceBond ?? null;
-
// Pre-launch auction phase (#924): a live tick that drives the countdown
// and flips the page back to the bonding curve view once the auction ends.
const { phase: auctionPhase } = useAuctionPhase({
@@ -193,11 +201,10 @@ function CreatorDetailPageContent() {
) ||
(creator?.instructorId &&
creator.instructorId.toUpperCase() === userAddress.toUpperCase())
- )
+ )
: false;
const isLockedOut = isWhitelistGateActive && !isUserWhitelisted;
-
// On-chain metadata fetch (#1033)
const {
data: onChainMetadata,
@@ -209,11 +216,22 @@ function CreatorDetailPageContent() {
const isFallbackActive = isOnChainError || !onChainMetadata;
const metadata = onChainMetadata ?? {};
- const displayName = metadata.name || creator?.title || creator?.name || 'Unnamed creator';
+ const displayName =
+ metadata.name || creator?.title || creator?.name || 'Unnamed creator';
const displaySymbol = metadata.symbol;
- const displayDescription = metadata.description || creator?.description || creator?.bio;
- const rawAvatar = metadata.image || metadata.imageCid || metadata.image_cid || metadata.ipfsCid || metadata.ipfs_cid || metadata.avatarUri || metadata.avatar_uri || metadata.cid;
- const displayAvatar = resolveIpfsUrl(rawAvatar) || creator?.avatarUri || creator?.thumbnail;
+ const displayDescription =
+ metadata.description || creator?.description || creator?.bio;
+ const rawAvatar =
+ metadata.image ||
+ metadata.imageCid ||
+ metadata.image_cid ||
+ metadata.ipfsCid ||
+ metadata.ipfs_cid ||
+ metadata.avatarUri ||
+ metadata.avatar_uri ||
+ metadata.cid;
+ const displayAvatar =
+ resolveIpfsUrl(rawAvatar) || creator?.avatarUri || creator?.thumbnail;
// Track stale data indicator (including fallback active state)
const { shouldShowBadge, handleRefetch } = useCreatorProfileStaleIndicator(
@@ -341,7 +359,8 @@ function CreatorDetailPageContent() {
const statItems = [
{
- label: auctionPhase === 'active' ? 'Current Highest Bid' : 'Current Price',
+ label:
+ auctionPhase === 'active' ? 'Current Highest Bid' : 'Current Price',
value:
auctionPhase === 'active'
? auctionStatValue
@@ -466,34 +485,37 @@ function CreatorDetailPageContent() {
className="mt-3 size-11 shrink-0 sm:size-9"
/>
-
{/* Historical Price Chart */}
-
-
+
+
+
{/* 4 Stat Cards */}
-
{/* Key Stats Panel (#952) */}
-
+
+
+
{/* Performance Bond Status Panel (#975) */}
-
+
+
+
{/* Deprecation Notice and Buy Action on Key Detail Page */}
{isKeyDeprecated(creator) && (
@@ -517,8 +539,8 @@ function CreatorDetailPageContent() {
{isKeyDeprecated(creator)
? 'Key is deprecated. New buys are disabled.'
: isLockedOut
- ? 'Early access is restricted to approved whitelisted wallets.'
- : 'Purchase keys for this creator.'}
+ ? 'Early access is restricted to approved whitelisted wallets.'
+ : 'Purchase keys for this creator.'}
{/* Configurable bid-ask spread between buy and sell price (#951) */}
)}
-
-
{/* Pre-Launch Auction Phase (#924) */}
{auctionPhase !== 'inactive' && (
)}
-
-
{/* Buy Cooldown Countdown (only meaningful for authenticated users) */}
{userAddress && !isTradeCooldownActive && (
@@ -598,7 +616,6 @@ function CreatorDetailPageContent() {
}}
/>
-
{isTwapLoading ? (
-
{/* Price Curve Chart */}
Price Curve
-
+
-
+
+
+
{/* Buy Simulation Tool */}
-
-
{/* TWAP for the live curve — not meaningful during the pre-launch auction */}
{auctionPhase !== 'active' &&
(isTwapLoading ? (
-
-
+
) : twapPrice != null ? (
-
+
- TWAP (24h)
+
+ TWAP (24h)
+
- ⓘ
+
+ ⓘ
+
-
{formatDisplayKeyPrice(twapPrice)}
+
+ {formatDisplayKeyPrice(twapPrice)}
+
- {twapDelta != null &&
{twapDelta < 0 ? '▼' : '▲'} {formatDisplayKeyPrice(Math.abs(twapDelta))} vs spot }
+ {twapDelta != null && (
+
+ {twapDelta < 0 ? '▼' : '▲'}{' '}
+ {formatDisplayKeyPrice(Math.abs(twapDelta))} vs
+ spot
+
+ )}
) : null)}
-
{/* Staking Rewards */}
-
{/* Price Chart — hidden during the pre-launch auction phase (#924) and
shown again once the auction closes and the curve activates */}
{auctionPhase !== 'active' && (
@@ -729,7 +782,6 @@ function CreatorDetailPageContent() {
/>
)}
-
{/* Buy Simulation Tool — likewise only once the curve is live */}
{auctionPhase !== 'active' && (
)}
-
-
{/* Holder Concentration */}
Holder Concentration
-
+
+
+
{/* Fee Structure */}
@@ -768,13 +820,15 @@ function CreatorDetailPageContent() {
{/* Co-Creator Section */}{' '}
-
+
+
+
{/* Key Holders */}
Key Holders
- {
- void fetchNextPage();
- }}
- />
+
+ {
+ void fetchNextPage();
+ }}
+ />
+
-
{/* Gated Content */}
@@ -805,17 +860,20 @@ function CreatorDetailPageContent() {
>
- 🎉 Welcome to the exclusive content section! Here you can access premium videos, articles, and perks from {creator.title || creator.name || 'this creator'}.
+ 🎉 Welcome to the exclusive content section! Here you
+ can access premium videos, articles, and perks from{' '}
+ {creator.title || creator.name || 'this creator'}.
-
Activity
-
+
+
+
{/* Key Buyback Modal (#923) */}
{isKeyDeprecated(creator) && (
@@ -825,14 +883,15 @@ function CreatorDetailPageContent() {
creatorId={creator.id}
creatorTitle={creator.title || creator.name || 'Creator Key'}
holdingsCount={holdingsCount}
- buybackPriceStroops={resolveCreatorKeyPriceStroops(creator) ?? 0}
+ buybackPriceStroops={
+ resolveCreatorKeyPriceStroops(creator) ?? 0
+ }
userAddress={userAddress}
onSettled={receipt => {
setRecentSettlement(receipt);
}}
/>
)}
-
{creator && (
);
-}
\ No newline at end of file
+}
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx
index 04641bb3..11b0b11b 100644
--- a/src/pages/HomePage.tsx
+++ b/src/pages/HomePage.tsx
@@ -11,6 +11,7 @@ import RecentlyViewedSection from '../components/home/RecentlyViewedSection';
import { useNavigationTiming } from '../hooks/useNavigationTiming';
import { useDocumentTitle } from '../hooks/useDocumentTitle';
import { useLocation } from 'react-router';
+import SectionErrorBoundary from '@/components/common/SectionErrorBoundary';
export default function HomePage() {
const location = useLocation();
@@ -25,14 +26,30 @@ export default function HomePage() {
<>
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
>
diff --git a/src/pages/MarketplacePage.tsx b/src/pages/MarketplacePage.tsx
index e2f23d3d..4bb6f319 100644
--- a/src/pages/MarketplacePage.tsx
+++ b/src/pages/MarketplacePage.tsx
@@ -16,6 +16,7 @@ import SearchBar from '@/components/common/SearchBar';
import SectionDivider from '@/components/common/SectionDivider';
import StickyFilterBar from '@/components/common/StickyFilterBar';
import { Button } from '@/components/ui/button';
+import SectionErrorBoundary from '@/components/common/SectionErrorBoundary';
import {
MARKETPLACE_SORT_OPTIONS,
sortCreatorsByOption,
@@ -39,7 +40,12 @@ const VALID_SORT_OPTIONS: CourseSortOption[] = [
];
function creatorMatchesSearch(creator: Course, query: string): boolean {
- return [creator.title, creator.name, creator.instructorId, creator.socialHandle]
+ return [
+ creator.title,
+ creator.name,
+ creator.instructorId,
+ creator.socialHandle,
+ ]
.filter(Boolean)
.some(field => field!.toLowerCase().includes(query));
}
@@ -62,7 +68,8 @@ export default function MarketplacePage() {
const initialSortParam = searchParams.get('sort');
const initialSort: CourseSortOption =
- initialSortParam && VALID_SORT_OPTIONS.includes(initialSortParam as CourseSortOption)
+ initialSortParam &&
+ VALID_SORT_OPTIONS.includes(initialSortParam as CourseSortOption)
? (initialSortParam as CourseSortOption)
: 'volume_desc';
@@ -107,14 +114,18 @@ export default function MarketplacePage() {
};
const hasActiveFilters =
- Boolean(trimmedSearch) || supplyTier !== 'all' || sortOption !== 'volume_desc';
+ Boolean(trimmedSearch) ||
+ supplyTier !== 'all' ||
+ sortOption !== 'volume_desc';
// #918 — sort and supply-tier filter are applied client-side over the
// pages already fetched from the server, so they work instantly and never
// require a fresh network fetch.
const visibleCreators = useMemo(() => {
const searched = trimmedSearch
- ? creators.filter(creator => creatorMatchesSearch(creator, trimmedSearch))
+ ? creators.filter(creator =>
+ creatorMatchesSearch(creator, trimmedSearch)
+ )
: creators;
const tierFiltered = searched.filter(creator =>
matchesSupplyTier(creator.creatorShareSupply, supplyTier)
@@ -150,7 +161,11 @@ export default function MarketplacePage() {
-
+
)}
- {isRefreshing ? 'Refreshing live prices…' : 'Prices refresh every 30s'}
+ {isRefreshing
+ ? 'Refreshing live prices…'
+ : 'Prices refresh every 30s'}
-
+
@@ -223,12 +245,17 @@ export default function MarketplacePage() {
data-testid="marketplace-sort-select"
value={sortOption}
onChange={e =>
- handleSortChange(e.target.value as CourseSortOption)
+ handleSortChange(
+ e.target.value as CourseSortOption
+ )
}
className="h-9 w-full rounded-lg border border-white/15 bg-slate-950/80 px-3 text-sm text-white outline-none focus:border-amber-400/60"
>
{MARKETPLACE_SORT_OPTIONS.map(option => (
-
+
{option.label}
))}
@@ -246,7 +273,9 @@ export default function MarketplacePage() {
data-testid="marketplace-supply-tier-select"
value={supplyTier}
onChange={e =>
- setSupplyTier(e.target.value as SupplyTierFilter)
+ setSupplyTier(
+ e.target.value as SupplyTierFilter
+ )
}
className="h-9 w-full rounded-lg border border-white/15 bg-slate-950/80 px-3 text-sm text-white outline-none focus:border-amber-400/60"
>
@@ -264,96 +293,107 @@ export default function MarketplacePage() {
-
- {isLoadingFirstPage ? (
-
-
-
- ) : error && creators.length === 0 ? (
-
- ) : visibleCreators.length === 0 ? (
-
- {creators.length === 0 ? (
-
- ) : (
-
- )}
-
- ) : (
-
-
- {visibleCreators.map(creator => (
-
+ {isLoadingFirstPage ? (
+
+
+
+ ) : error && creators.length === 0 ? (
+
+ ) : visibleCreators.length === 0 ? (
+
+ {creators.length === 0 ? (
+
- ))}
+ ) : (
+
+ )}
-
- {isFetchingNextPage && (
-
-
+ ) : (
+
+
+ {visibleCreators.map(creator => (
+
+ ))}
- )}
- {hasMore && (
-
- )}
-
-
- {hasMore && !isFetchingNextPage ? (
-
void fetchNextPage()}
- className="rounded-full border-white/10 bg-white/5 px-4 py-2 text-xs font-bold uppercase tracking-[0.18em] text-white"
+ {isFetchingNextPage && (
+
-
- Load more creators
-
- ) : (
- !isFetchingNextPage &&
- visibleCreators.length > 0 && (
-
- {`You've reached the end — ${formatNumber(visibleCreators.length)} creator key${visibleCreators.length === 1 ? '' : 's'} on the marketplace.`}
-
- )
+
+
)}
+
+ {hasMore && (
+
+ )}
+
+
+ {hasMore && !isFetchingNextPage ? (
+
void fetchNextPage()}
+ className="rounded-full border-white/10 bg-white/5 px-4 py-2 text-xs font-bold uppercase tracking-[0.18em] text-white"
+ >
+
+ Load more creators
+
+ ) : (
+ !isFetchingNextPage &&
+ visibleCreators.length > 0 && (
+
+ {`You've reached the end — ${formatNumber(visibleCreators.length)} creator key${visibleCreators.length === 1 ? '' : 's'} on the marketplace.`}
+
+ )
+ )}
+
-
- )}
-
+ )}
+
+
);
-}
\ No newline at end of file
+}
diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx
index 81fd4fbe..947b5eb8 100644
--- a/src/pages/ProfilePage.tsx
+++ b/src/pages/ProfilePage.tsx
@@ -1,7 +1,4 @@
-import {
- useMemo,
- useState,
-} from 'react';
+import { useMemo, useState } from 'react';
import { Link, useParams, useSearchParams } from 'react-router';
import { useAccount } from 'wagmi';
import {
@@ -23,6 +20,7 @@ import AtomicSwapHistory from '@/components/common/AtomicSwapHistory';
import ProtocolRevenueClaim from '@/components/common/ProtocolRevenueClaim';
import ProtocolRevenueDistributionTable from '@/components/common/ProtocolRevenueDistributionTable';
import WalletActivityFeed from '@/components/common/WalletActivityFeed';
+import SectionErrorBoundary from '@/components/common/SectionErrorBoundary';
import TruncatedAddress from '@/components/common/TruncatedAddress';
import { ProfileTabPillGroup } from '@/components/common/ProfileTabPill';
import { useProfileStore } from '@/hooks/useProfileStore';
@@ -116,7 +114,8 @@ export default function ProfilePage() {
);
const [isShareCopied, setIsShareCopied] = useState(false);
- const profileWallet = publicWallet?.trim() || connectedAddress || DEMO_WALLET;
+ const profileWallet =
+ publicWallet?.trim() || connectedAddress || DEMO_WALLET;
const isOwnProfile = !publicWallet
? true
: isOwnWallet(connectedAddress ?? null, publicWallet);
@@ -169,8 +168,9 @@ export default function ProfilePage() {
const heldKeyNames = useMemo(() => {
const map: Record
= {};
for (const holding of holdingsQuery.data ?? []) {
- map[holding.creatorId] =
- creators.find(c => c.id === holding.creatorId)?.title;
+ map[holding.creatorId] = creators.find(
+ c => c.id === holding.creatorId
+ )?.title;
}
return map;
}, [holdingsQuery.data, creators]);
@@ -314,15 +314,17 @@ export default function ProfilePage() {
-
+
+
+
{isOwnProfile && (
@@ -357,15 +359,18 @@ export default function ProfilePage() {
Vesting Schedules
- Reserved key allocations unlocking on a cliff-then-linear
- schedule. Claim vested amounts once the cliff passes.
+ Reserved key allocations unlocking on a
+ cliff-then-linear schedule. Claim vested amounts once
+ the cliff passes.
-
+
+
+
)}
@@ -383,20 +388,22 @@ export default function ProfilePage() {
Staking
- Keys currently locked in the staking contract with
- their unlock timers and accrued rewards.
+ Keys currently locked in the staking contract with their
+ unlock timers and accrued rewards.
- {!isOwnProfile ? (
-
- ) : (
+ {!isOwnProfile ? (
+
+
+
+ ) : (
<>
{/* Sub-tab navigation within Staking section */}
-
+
+
+
) : activeStakingSubTab === 'claim' ? (
) : (
)}
>
@@ -481,9 +494,11 @@ export default function ProfilePage() {
aria-labelledby="profile-tab-liquidity"
data-testid="portfolio-liquidity-panel"
>
-
+
+
+
)}
@@ -503,13 +518,16 @@ export default function ProfilePage() {
A full audit trail of past buys and sells
-
-
-
- )}
+ {' '}
+
+
+
+
+
+ )}
{/* Atomic swap history panel (#979) */}
{activeTab === 'atomic-swaps' && (
@@ -529,7 +547,9 @@ export default function ProfilePage() {
-
+
+
+
)}
@@ -548,11 +568,14 @@ export default function ProfilePage() {
Wallet Activity
- All trading, staking, and governance events for your wallet
+ All trading, staking, and governance events for your
+ wallet
-
+
+
+
)}
diff --git a/src/routes.tsx b/src/routes.tsx
index ad27784f..045f9adb 100644
--- a/src/routes.tsx
+++ b/src/routes.tsx
@@ -1,3 +1,4 @@
+import type { ReactNode } from 'react';
import RootLayout from './components/common/RootLayout';
import HomePage from './pages/HomePage';
import NotFoundPage from './pages/NotFoundPage';
@@ -26,6 +27,13 @@ import StatusPage from './pages/StatusPage';
import HolderLeaderboardPage from './pages/HolderLeaderboardPage';
import BundlesPage from './pages/BundlesPage';
import BundleDetailPage from './pages/BundleDetailPage';
+import RouteErrorBoundary from './components/common/RouteErrorBoundary';
+
+function withRouteErrorBoundary(element: ReactNode, routeName: string) {
+ return (
+ {element}
+ );
+}
export const routes = [
{
@@ -34,15 +42,15 @@ export const routes = [
children: [
{
path: '/',
- element: ,
+ element: withRouteErrorBoundary( , 'Marketplace'),
},
{
path: '/creators',
- element: ,
+ element: withRouteErrorBoundary( , 'Marketplace'),
},
{
path: '/marketplace',
- element: ,
+ element: withRouteErrorBoundary( , 'Marketplace'),
},
{
path: '/bundles',
@@ -66,17 +74,26 @@ export const routes = [
},
{
path: '/creator/:id',
- element: ,
+ element: withRouteErrorBoundary(
+ ,
+ 'Key detail'
+ ),
},
{
path: '/creators/:id',
- element: ,
+ element: withRouteErrorBoundary(
+ ,
+ 'Key detail'
+ ),
},
{
path: '/keys/:id',
- element: ,
- },
- {
+ element: withRouteErrorBoundary(
+ ,
+ 'Key detail'
+ ),
+ },
+ {
path: '/creator/:id/leaderboard',
element: ,
},
@@ -126,11 +143,11 @@ export const routes = [
},
{
path: '/profile',
- element: ,
+ element: withRouteErrorBoundary( , 'Portfolio'),
},
{
path: '/profile/:wallet',
- element: ,
+ element: withRouteErrorBoundary( , 'Portfolio'),
},
{
path: '/following',
@@ -170,7 +187,10 @@ export const routes = [
},
{
path: '/admin/dashboard',
- element: ,
+ element: withRouteErrorBoundary(
+ ,
+ 'Admin dashboard'
+ ),
},
{
path: '/revenue-distribution',