diff --git a/src/components/admin/TreasuryManagementDashboard.tsx b/src/components/admin/TreasuryManagementDashboard.tsx
new file mode 100644
index 00000000..a3d51e01
--- /dev/null
+++ b/src/components/admin/TreasuryManagementDashboard.tsx
@@ -0,0 +1,446 @@
+import { useState } from 'react';
+import {
+ Cell,
+ Pie,
+ PieChart,
+ ResponsiveContainer,
+ Tooltip as RechartsTooltip,
+} from 'recharts';
+import { useMutation, useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query';
+import { Address } from '@stellar/stellar-sdk';
+import { CircleDollarSign, Loader2, ChevronDown } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { adminService, type AllocateFundsInput } from '@/services/admin.service';
+import { formatStroops, parseXlmToStroops } from '@/utils/treasury.utils';
+import showToast from '@/utils/toast.util';
+
+const PANEL_CLASS =
+ 'rounded-[2rem] border border-white/10 bg-white/[0.02] p-6 shadow-2xl backdrop-blur-md md:p-8';
+
+const DONUT_COLORS = [
+ '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4',
+];
+
+function timestamp(value: string): string {
+ const date = new Date(value);
+ return Number.isNaN(date.getTime())
+ ? value
+ : date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
+}
+
+function shortenAddress(address: string): string {
+ if (address.length <= 12) return address;
+ return `${address.slice(0, 6)}…${address.slice(-4)}`;
+}
+
+function validAddress(address: string): boolean {
+ try {
+ Address.fromString(address.trim());
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function LoadingRows({ count = 3 }: { count?: number }) {
+ return (
+
+ {Array.from({ length: count }, (_, i) => (
+
+ ))}
+
Loading…
+
+ );
+}
+
+function DataError({ onRetry }: { onRetry: () => void }) {
+ return (
+
+
Failed to load data.
+
+ Retry
+
+
+ );
+}
+
+interface AllocateFormProps {
+ adminAddress: string;
+ onSuccess?: () => void;
+}
+
+function AllocateForm({ adminAddress, onSuccess }: AllocateFormProps) {
+ const queryClient = useQueryClient();
+ const [destination, setDestination] = useState('');
+ const [amount, setAmount] = useState('');
+ const [purpose, setPurpose] = useState('');
+
+ const mutation = useMutation({
+ mutationFn: (input: AllocateFundsInput) => adminService.allocateFunds(input),
+ onSuccess: () => {
+ showToast.success('Funds allocated successfully.');
+ setDestination('');
+ setAmount('');
+ setPurpose('');
+ void queryClient.invalidateQueries({ queryKey: ['treasury', 'allocations'] });
+ void queryClient.invalidateQueries({ queryKey: ['treasury', 'balance'] });
+ onSuccess?.();
+ },
+ onError: (err: Error) => {
+ showToast.error(err.message || 'Allocation failed.');
+ },
+ });
+
+ const amountStroops = parseXlmToStroops(amount);
+ const addressValid = destination === '' || validAddress(destination);
+ const canSubmit =
+ validAddress(destination) &&
+ amountStroops !== null &&
+ amountStroops > 0n &&
+ purpose.trim().length > 0 &&
+ !mutation.isPending;
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!canSubmit || amountStroops === null) return;
+ mutation.mutate({
+ admin: adminAddress,
+ destinationAddress: destination.trim(),
+ amountStroops: amountStroops.toString(),
+ purpose: purpose.trim(),
+ });
+ };
+
+ return (
+
+
+ Allocate Funds
+
+
+ Send treasury funds to an approved destination with a recorded purpose.
+
+
+
+
+ );
+}
+
+export default function TreasuryManagementDashboard({ adminAddress }: { adminAddress: string }) {
+ // Balance
+ const balance = useQuery({
+ queryKey: ['treasury', 'balance'],
+ queryFn: () => adminService.getTreasuryBalance(),
+ staleTime: 15_000,
+ });
+
+ // Fee breakdown for donut chart
+ const breakdown = useQuery({
+ queryKey: ['treasury', 'fee-breakdown'],
+ queryFn: () => adminService.getTreasuryFeeBreakdown(),
+ staleTime: 30_000,
+ });
+
+ // Recent inflows
+ const inflows = useQuery({
+ queryKey: ['treasury', 'fee-events'],
+ queryFn: () => adminService.getTreasuryFeeEvents(),
+ staleTime: 15_000,
+ });
+
+ // Allocation history (paginated)
+ const allocations = useInfiniteQuery({
+ queryKey: ['treasury', 'allocations'],
+ queryFn: ({ pageParam }) =>
+ adminService.getTreasuryAllocations(pageParam as string | undefined),
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: page => page.nextCursor ?? undefined,
+ });
+
+ const allAllocations = allocations.data?.pages.flatMap(p => p.items) ?? [];
+ const hasNextPage = allocations.hasNextPage;
+
+ return (
+
+ {/* Balance + Donut Chart */}
+
+
+
+ Treasury
+
+
+ Balance Overview
+
+
+
+ {/* Total balance */}
+
+
Total Balance
+ {balance.isLoading ? (
+
+ ) : balance.isError ? (
+
void balance.refetch()} />
+ ) : (
+ <>
+
+ {formatStroops(balance.data?.accumulatedFeesStroops ?? '0')}
+
+ {balance.data?.updatedAt && (
+
+ Updated {timestamp(balance.data.updatedAt)}
+
+ )}
+ >
+ )}
+
+
+ {/* Per-source donut */}
+
+
+ Per-Source Breakdown
+
+ {breakdown.isLoading ? (
+
+ ) : breakdown.isError || !breakdown.data?.length ? (
+
No breakdown data available.
+ ) : (
+
+
+
+
+ {breakdown.data.map((entry, index) => (
+ |
+ ))}
+
+ [
+ `${Number(value).toFixed(1)}%`,
+ name,
+ ]}
+ contentStyle={{
+ background: '#0a1929',
+ border: '1px solid rgba(255,255,255,0.12)',
+ borderRadius: 8,
+ fontSize: 12,
+ }}
+ />
+
+
+
+ {breakdown.data.map((entry, index) => (
+
+
+ {entry.source}
+
+ {entry.percentage.toFixed(1)}%
+
+
+ ))}
+
+
+ )}
+
+
+
+
+ {/* Recent Inflows */}
+
+
+ Recent Inflows
+
+ Last 20 fee receipts collected into the treasury.
+ {inflows.isLoading ? (
+
+ ) : inflows.isError ? (
+ void inflows.refetch()} />
+ ) : !inflows.data?.length ? (
+
+ No fee inflows yet.
+
+ ) : (
+
+
+
+
+ Date
+ Source
+ Trader
+ Amount
+
+
+
+ {inflows.data.slice(0, 20).map(item => (
+
+
+ {timestamp(item.collectedAt)}
+
+
+ {shortenAddress(item.creatorAddress)}
+
+
+ {shortenAddress(item.traderAddress)}
+
+
+ {formatStroops(item.amountStroops)}
+
+
+ ))}
+
+
+
+ )}
+
+
+ {/* Allocate Funds Form */}
+
+
+ {/* Allocation History */}
+
+
+ Allocation History
+
+ All recorded fund allocations with purpose and destination.
+
+ {allocations.isLoading ? (
+
+ ) : allocations.isError ? (
+ void allocations.refetch()} />
+ ) : !allAllocations.length ? (
+
+ No allocations recorded yet.
+
+ ) : (
+ <>
+
+
+
+
+ Date
+ Destination
+ Purpose
+ Amount
+
+
+
+ {allAllocations.map(item => (
+
+
+ {timestamp(item.allocatedAt)}
+
+
+ {shortenAddress(item.destinationAddress)}
+
+
+ {item.purpose}
+
+
+ {formatStroops(item.amountStroops)}
+
+
+ ))}
+
+
+
+
+ {hasNextPage && (
+
+ void allocations.fetchNextPage()}
+ disabled={allocations.isFetchingNextPage}
+ className="border-white/15 text-white"
+ >
+ {allocations.isFetchingNextPage ? (
+
+ ) : (
+
+ )}
+ Load more
+
+
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/src/components/common/KeyFeeHistoryChart.tsx b/src/components/common/KeyFeeHistoryChart.tsx
new file mode 100644
index 00000000..e6c5470b
--- /dev/null
+++ b/src/components/common/KeyFeeHistoryChart.tsx
@@ -0,0 +1,211 @@
+import { useState } from 'react';
+import {
+ CartesianGrid,
+ ReferenceLine,
+ ResponsiveContainer,
+ Tooltip as RechartsTooltip,
+ XAxis,
+ YAxis,
+ LineChart,
+ Line,
+} from 'recharts';
+import Skeleton from '@/components/ui/skeleton';
+import { cn } from '@/lib/utils';
+import { useKeyFeeHistory, type FeeHistoryRange } from '@/hooks/useKeyFeeHistory';
+
+interface KeyFeeHistoryChartProps {
+ keyId: string;
+ className?: string;
+}
+
+const RANGE_OPTIONS: Array<{ value: FeeHistoryRange; label: string }> = [
+ { value: '7d', label: '7D' },
+ { value: '30d', label: '30D' },
+ { value: 'all', label: 'All' },
+];
+
+function formatTimestamp(iso: string): string {
+ const date = new Date(iso);
+ if (Number.isNaN(date.getTime())) return iso;
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
+}
+
+function formatBps(bps: number): string {
+ return `${(bps / 100).toFixed(2)}%`;
+}
+
+interface TooltipPayload {
+ payload?: { timestamp: string; feeBps: number; bracketLabel?: string };
+}
+
+function CustomTooltip({ active, payload }: { active?: boolean; payload?: TooltipPayload[] }) {
+ if (!active || !payload?.length) return null;
+ const point = payload[0]?.payload;
+ if (!point) return null;
+ return (
+
+
{formatTimestamp(point.timestamp)}
+
+ Fee: {formatBps(point.feeBps)}
+
+ {point.bracketLabel && (
+
{point.bracketLabel}
+ )}
+
+ );
+}
+
+export default function KeyFeeHistoryChart({ keyId, className }: KeyFeeHistoryChartProps) {
+ const [range, setRange] = useState('30d');
+ const { data, isLoading, isError, refetch } = useKeyFeeHistory(keyId, range);
+
+ const hasData = (data?.points?.length ?? 0) > 0;
+
+ return (
+
+
+
+
+ Dynamic Fees
+
+
+ Fee History
+
+
+
+
+ {data && (
+
+ {formatBps(data.currentFeeBps)}
+ {data.currentBracketLabel && (
+
+ · {data.currentBracketLabel}
+
+ )}
+
+ )}
+
+
+ {RANGE_OPTIONS.map(opt => (
+ setRange(opt.value)}
+ aria-pressed={range === opt.value}
+ className={cn(
+ 'rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors',
+ range === opt.value
+ ? 'bg-amber-500/20 text-amber-300'
+ : 'text-white/45 hover:text-white/75'
+ )}
+ >
+ {opt.label}
+
+ ))}
+
+
+
+
+
+ {isLoading && (
+
+
+
+ )}
+
+ {isError && (
+
+
Failed to load fee history.
+
void refetch()}
+ className="rounded-lg border border-white/15 bg-white/[0.05] px-4 py-2 text-xs font-semibold text-white/70 transition-colors hover:bg-white/[0.09]"
+ >
+ Retry
+
+
+ )}
+
+ {!isLoading && !isError && !hasData && (
+
+
No fee history for this period.
+
+ )}
+
+ {!isLoading && !isError && hasData && (
+
+
+
+
+
+ }
+ cursor={{ stroke: 'rgba(255,255,255,0.12)', strokeWidth: 1 }}
+ />
+ {/* Bracket threshold markers */}
+ {data!.bracketThresholds?.map(threshold => (
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/common/MarketMakerChip.tsx b/src/components/common/MarketMakerChip.tsx
new file mode 100644
index 00000000..e1ccfd7b
--- /dev/null
+++ b/src/components/common/MarketMakerChip.tsx
@@ -0,0 +1,65 @@
+import { Tooltip } from '@/components/ui/tooltip';
+import { cn } from '@/lib/utils';
+import type { MarketMakerInfo } from '@/hooks/useKeyMarketMaker';
+
+interface MarketMakerChipProps {
+ maker: MarketMakerInfo;
+ className?: string;
+}
+
+function shortenWallet(address: string): string {
+ if (address.length <= 12) return address;
+ return `${address.slice(0, 6)}…${address.slice(-4)}`;
+}
+
+export function MarketMakerChip({ maker, className }: MarketMakerChipProps) {
+ const tooltipContent = (
+
+
Market Maker Active
+
+ Spread:{' '}
+
+ {(maker.spreadBps / 100).toFixed(2)}%
+
+
+
+ Wallet:{' '}
+
+ {shortenWallet(maker.walletAddress)}
+
+
+
+ );
+
+ return (
+
+
+
+ MM Active
+
+
+ );
+}
+
+export function MMBadge({ className }: { className?: string }) {
+ return (
+
+ MM
+
+ );
+}
diff --git a/src/components/common/ProposalExecutionCountdown.tsx b/src/components/common/ProposalExecutionCountdown.tsx
new file mode 100644
index 00000000..0808ce37
--- /dev/null
+++ b/src/components/common/ProposalExecutionCountdown.tsx
@@ -0,0 +1,183 @@
+import { useEffect, useRef, useState } from 'react';
+import { Bell, BellOff, CheckCircle2, Clock3, Loader2 } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import showToast from '@/utils/toast.util';
+
+export interface ProposalExecutionCountdownProps {
+ proposalId: string;
+ timelockExpiresAt: string;
+ onExecute?: () => Promise;
+ isExecuting?: boolean;
+}
+
+function parseMs(isoOrMs: string): number {
+ const asNumber = Number(isoOrMs);
+ if (!Number.isNaN(asNumber)) return asNumber;
+ const date = new Date(isoOrMs);
+ return Number.isNaN(date.getTime()) ? 0 : date.getTime();
+}
+
+interface TimeLeft {
+ days: number;
+ hours: number;
+ minutes: number;
+ seconds: number;
+ total: number;
+}
+
+function calcTimeLeft(expiresAtMs: number): TimeLeft {
+ const total = Math.max(0, expiresAtMs - Date.now());
+ const seconds = Math.floor((total / 1000) % 60);
+ const minutes = Math.floor((total / 1000 / 60) % 60);
+ const hours = Math.floor((total / 1000 / 60 / 60) % 24);
+ const days = Math.floor(total / 1000 / 60 / 60 / 24);
+ return { days, hours, minutes, seconds, total };
+}
+
+function pad(n: number): string {
+ return String(n).padStart(2, '0');
+}
+
+const NOTIFY_KEY_PREFIX = 'proposal_notify_';
+
+export default function ProposalExecutionCountdown({
+ proposalId,
+ timelockExpiresAt,
+ onExecute,
+ isExecuting = false,
+}: ProposalExecutionCountdownProps) {
+ const expiresAtMs = parseMs(timelockExpiresAt);
+ const [timeLeft, setTimeLeft] = useState(() => calcTimeLeft(expiresAtMs));
+
+ const notifyKey = `${NOTIFY_KEY_PREFIX}${proposalId}`;
+ const [notifyEnabled, setNotifyEnabled] = useState(() => {
+ try {
+ return localStorage.getItem(notifyKey) === 'true';
+ } catch {
+ return false;
+ }
+ });
+
+ const toastFiredRef = useRef(false);
+
+ useEffect(() => {
+ const tick = () => {
+ const tl = calcTimeLeft(expiresAtMs);
+ setTimeLeft(tl);
+
+ if (notifyEnabled && !toastFiredRef.current && tl.total > 0 && tl.total <= 3_600_000) {
+ toastFiredRef.current = true;
+ const mins = Math.ceil(tl.total / 60_000);
+ showToast.message(`⏰ Proposal timelock expires in ${mins} minute${mins === 1 ? '' : 's'}.`);
+ }
+ };
+
+ const id = setInterval(tick, 1000);
+ return () => clearInterval(id);
+ }, [expiresAtMs, notifyEnabled]);
+
+ const toggleNotify = () => {
+ const next = !notifyEnabled;
+ setNotifyEnabled(next);
+ try {
+ if (next) {
+ localStorage.setItem(notifyKey, 'true');
+ } else {
+ localStorage.removeItem(notifyKey);
+ }
+ } catch {
+ // localStorage may be blocked in private browsing
+ }
+ };
+
+ const expired = timeLeft.total === 0;
+
+ return (
+
+
+
+
+
+ Execution Pending
+
+
+
+
+ {notifyEnabled ? (
+ <>
+
+ Notify Me
+ >
+ ) : (
+ <>
+
+ Notify Me
+ >
+ )}
+
+
+
+
+ {expired
+ ? 'The timelock has expired. This proposal is ready to execute.'
+ : 'Time remaining before the parameter change can be executed on-chain.'}
+
+
+ {!expired && (
+
+ {[
+ { value: timeLeft.days, label: 'Days' },
+ { value: timeLeft.hours, label: 'Hours' },
+ { value: timeLeft.minutes, label: 'Min' },
+ { value: timeLeft.seconds, label: 'Sec' },
+ ].map(({ value, label }) => (
+
+
+ {pad(value)}
+
+
+ {label}
+
+
+ ))}
+
+ )}
+
+ {expired && (
+
+
+ Timelock expired
+
+ )}
+
+ {onExecute && (
+ void onExecute()}
+ disabled={!expired || isExecuting}
+ className="mt-6"
+ aria-disabled={!expired || isExecuting}
+ >
+ {isExecuting && }
+ {isExecuting ? 'Executing…' : 'Execute Proposal'}
+
+ )}
+
+ );
+}
diff --git a/src/hooks/useKeyFeeHistory.ts b/src/hooks/useKeyFeeHistory.ts
new file mode 100644
index 00000000..bb9d6337
--- /dev/null
+++ b/src/hooks/useKeyFeeHistory.ts
@@ -0,0 +1,49 @@
+import { useQuery } from '@tanstack/react-query';
+import { BaseApiService, type APIResponse } from '@/services/api.service';
+import { cacheManager } from '@/utils/cache.utils';
+
+export type FeeHistoryRange = '7d' | '30d' | 'all';
+
+export interface FeeHistoryPoint {
+ timestamp: string;
+ feeBps: number;
+ bracketLabel?: string;
+}
+
+export interface FeeHistoryResponse {
+ points: FeeHistoryPoint[];
+ currentFeeBps: number;
+ currentBracketLabel?: string;
+ bracketThresholds?: Array<{ feeBps: number; label: string; volumeXlm: number }>;
+}
+
+class KeyFeeHistoryService extends BaseApiService {
+ async getFeeHistory(keyId: string, range: FeeHistoryRange): Promise {
+ const cacheKey = `fee-history_${keyId}_${range}`;
+ const cached = cacheManager.get(cacheKey);
+ if (cached) return cached;
+
+ try {
+ const response = await this.api.get>(
+ `/keys/${keyId}/fee/history`,
+ { params: { range } }
+ );
+ const data = response.data.data;
+ cacheManager.set(cacheKey, data, 60_000);
+ return data;
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+}
+
+const keyFeeHistoryService = new KeyFeeHistoryService();
+
+export function useKeyFeeHistory(keyId: string, range: FeeHistoryRange = '30d') {
+ return useQuery({
+ queryKey: ['keys', keyId, 'fee-history', range],
+ queryFn: () => keyFeeHistoryService.getFeeHistory(keyId, range),
+ enabled: Boolean(keyId),
+ staleTime: 60_000,
+ });
+}
diff --git a/src/hooks/useKeyMarketMaker.ts b/src/hooks/useKeyMarketMaker.ts
new file mode 100644
index 00000000..393d8215
--- /dev/null
+++ b/src/hooks/useKeyMarketMaker.ts
@@ -0,0 +1,42 @@
+import { useQuery } from '@tanstack/react-query';
+import { BaseApiService, type APIResponse } from '@/services/api.service';
+import { cacheManager } from '@/utils/cache.utils';
+
+const CACHE_TTL = 30_000;
+
+export interface MarketMakerInfo {
+ walletAddress: string;
+ spreadBps: number;
+ active: boolean;
+}
+
+class KeyMarketMakerService extends BaseApiService {
+ async getMarketMaker(keyId: string): Promise {
+ const cacheKey = `market-maker_${keyId}`;
+ const cached = cacheManager.get(cacheKey);
+ if (cached !== undefined) return cached;
+
+ try {
+ const response = await this.api.get>(
+ `/keys/${keyId}/market-maker`
+ );
+ const data = response.data.data ?? null;
+ cacheManager.set(cacheKey, data, CACHE_TTL);
+ return data;
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+}
+
+const keyMarketMakerService = new KeyMarketMakerService();
+
+export function useKeyMarketMaker(keyId: string) {
+ return useQuery({
+ queryKey: ['keys', keyId, 'market-maker'],
+ queryFn: () => keyMarketMakerService.getMarketMaker(keyId),
+ enabled: Boolean(keyId),
+ staleTime: 20_000,
+ refetchInterval: 30_000,
+ });
+}
diff --git a/src/pages/AdminDashboardPage.tsx b/src/pages/AdminDashboardPage.tsx
index f16a9cfc..8b104d9e 100644
--- a/src/pages/AdminDashboardPage.tsx
+++ b/src/pages/AdminDashboardPage.tsx
@@ -4,6 +4,7 @@ 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 TreasuryManagementDashboard from '@/components/admin/TreasuryManagementDashboard';
import { useNavigationTiming } from '@/hooks/useNavigationTiming';
import { useStellarWallet } from '@/hooks/useStellarWallet';
import { isAdminWallet } from '@/utils/adminAccess';
@@ -57,6 +58,9 @@ export default function AdminDashboardPage() {
+
+
+
diff --git a/src/pages/CreatorDashboardPage.tsx b/src/pages/CreatorDashboardPage.tsx
index 4525be76..46763622 100644
--- a/src/pages/CreatorDashboardPage.tsx
+++ b/src/pages/CreatorDashboardPage.tsx
@@ -48,6 +48,7 @@ import CreatorRevenuePanel from '@/components/common/CreatorRevenuePanel';
import DistributeRevenuePanel from '@/components/common/DistributeRevenuePanel';
import CreatorAnnouncementsPanel from '@/components/common/CreatorAnnouncementsPanel';
import VestingSchedulesViewer from '@/components/creator/VestingSchedulesViewer';
+import KeyFeeHistoryChart from '@/components/common/KeyFeeHistoryChart';
const TABS = [
{ label: 'Overview', value: 'overview' },
@@ -311,6 +312,10 @@ export default function CreatorDashboardPage() {
)}
+ {activeTab === 'overview' && (
+
+ )}
+
{activeTab === 'overview' && (
,
+ },
+ ]
+ : []),
];
const chartData = (
@@ -1371,13 +1383,15 @@ function CreatorDetailPageContent() {
- {/* Secondary market sell panel with auction tab (#1174) */}
Secondary Market
+ {marketMaker?.active && (
+
+ )}
diff --git a/src/pages/ProposalDetailPage.tsx b/src/pages/ProposalDetailPage.tsx
index dd7f92db..61d69622 100644
--- a/src/pages/ProposalDetailPage.tsx
+++ b/src/pages/ProposalDetailPage.tsx
@@ -7,6 +7,7 @@ import {
Users,
} from 'lucide-react';
import ProposalOutcomeSummary from '@/components/common/ProposalOutcomeSummary';
+import ProposalExecutionCountdown from '@/components/common/ProposalExecutionCountdown';
import ProposalVoteHistory from '@/components/common/ProposalVoteHistory';
import { ProposalVotePanel } from '@/components/common/ProposalVotePanel';
import QuorumIndicator from '@/components/common/QuorumIndicator';
@@ -212,6 +213,15 @@ export default function ProposalDetailPage() {
+ {proposal.status === 'passed' && proposal.timelockExpiresAt && (
+
+ )}
+
Result and participation
diff --git a/src/services/admin.service.ts b/src/services/admin.service.ts
index 8c0d2c01..f9217aec 100644
--- a/src/services/admin.service.ts
+++ b/src/services/admin.service.ts
@@ -50,6 +50,37 @@ export interface TreasuryDistributionSubmission {
transactionHash: string;
}
+/** Per-source fee breakdown used for the treasury donut chart. */
+export interface TreasuryFeeSourceBreakdown {
+ source: string;
+ amountStroops: string;
+ /** Percentage share of total, 0–100. */
+ percentage: number;
+}
+
+/** A single allocation record (single-destination fund allocation). */
+export interface TreasuryAllocation {
+ id: string;
+ destinationAddress: string;
+ amountStroops: string;
+ purpose: string;
+ allocatedAt: string;
+ transactionHash: string;
+ admin: string;
+}
+
+export interface TreasuryAllocationsPage {
+ items: TreasuryAllocation[];
+ nextCursor: string | null;
+}
+
+export interface AllocateFundsInput {
+ admin: string;
+ destinationAddress: string;
+ amountStroops: string;
+ purpose: string;
+}
+
export interface AclContract {
address: string;
functions: string[];
@@ -619,6 +650,46 @@ class AdminService extends BaseApiService {
}
}
+ /** Read per-source fee breakdown for the treasury donut chart. */
+ async getTreasuryFeeBreakdown(): Promise {
+ try {
+ const response = await this.api.get>(
+ '/admin/treasury/fee-breakdown'
+ );
+ return response.data.data ?? [];
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+
+ /** Read paginated allocation history. */
+ async getTreasuryAllocations(cursor?: string): Promise {
+ try {
+ const params: Record = {};
+ if (cursor) params.cursor = cursor;
+ const response = await this.api.get>(
+ '/admin/treasury/allocations',
+ { params }
+ );
+ return response.data.data ?? { items: [], nextCursor: null };
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+
+ /** Allocate treasury funds to a single destination. */
+ async allocateFunds(input: AllocateFundsInput): Promise<{ transactionHash: string }> {
+ try {
+ const response = await this.api.post>(
+ '/admin/treasury/allocate',
+ input
+ );
+ 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.
diff --git a/src/types/governance.ts b/src/types/governance.ts
index 3d53643a..cbe8cb42 100644
--- a/src/types/governance.ts
+++ b/src/types/governance.ts
@@ -87,6 +87,8 @@ export interface Proposal {
oldValue?: string | number;
/** Proposed or executed new protocol parameter value (#1126). */
newValue?: string | number;
+ /** ISO timestamp (or ms epoch) when the timelock expires and the proposal can be executed (#1243). */
+ timelockExpiresAt?: string;
}
/** Props accepted by the QuorumIndicator component. */