From 11f3dca429941df6d237d401599a5a88434efe56 Mon Sep 17 00:00:00 2001 From: Nimatstar Date: Tue, 23 Jun 2026 14:08:01 +0100 Subject: [PATCH] feat(gf-16): protocol management pages (/allocations, /harvest, /governance) - Add /allocations page with per-pool exposure table, colour-coded bar chart (green/amber/red vs 35% cap), and IL % highlighting for pools above the 2% threshold - Add /harvest page with live countdown timer (ledger-based cooldown), Harvest Now button enabled only when cooldown elapses, tx state feedback (pending/confirmed/failed), estimated bounty display, and last-20-harvests history table - Add /governance page with active proposal cards including timelock countdowns, a Veto button visible to all (enabled only for Guardian Multisig once wallet connect is wired), Execute button appearing after timelock elapses, and a full proposal history table with Executed/Vetoed status badges - Add useAllocations, useLastHarvest, useGovernance hooks (stub data, wired for GF-12 replacement) closes #48 --- app/src/app/allocations/page.tsx | 129 +++++++++++++++++++ app/src/app/governance/page.tsx | 192 ++++++++++++++++++++++++++++ app/src/app/harvest/page.tsx | 211 +++++++++++++++++++++++++++++++ app/src/hooks/useAllocations.ts | 44 +++++++ app/src/hooks/useGovernance.ts | 78 ++++++++++++ app/src/hooks/useLastHarvest.ts | 72 +++++++++++ 6 files changed, 726 insertions(+) create mode 100644 app/src/app/allocations/page.tsx create mode 100644 app/src/app/governance/page.tsx create mode 100644 app/src/app/harvest/page.tsx create mode 100644 app/src/hooks/useAllocations.ts create mode 100644 app/src/hooks/useGovernance.ts create mode 100644 app/src/hooks/useLastHarvest.ts diff --git a/app/src/app/allocations/page.tsx b/app/src/app/allocations/page.tsx new file mode 100644 index 0000000..43809aa --- /dev/null +++ b/app/src/app/allocations/page.tsx @@ -0,0 +1,129 @@ +'use client'; + +import { useAllocations } from '@/hooks/useAllocations'; + +function formatCurrency(n: number): string { + if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(2)}M`; + if (n >= 1_000) return `$${(n / 1_000).toFixed(0)}K`; + return `$${n.toFixed(0)}`; +} + +function ExposureBar({ pct }: { pct: number }) { + const color = pct >= 30 ? '#ef4444' : pct >= 25 ? '#f59e0b' : '#22c55e'; + return ( +
+
+
+
+ ); +} + +export default function AllocationsPage() { + const { pools, totalAllocated, isLoading } = useAllocations(); + + return ( +
+ + +
+
+

Pool Allocations

+

+ Live strategy allocation across AMM pools. No single pool may exceed 35% of strategy assets. +

+ {!isLoading && ( +

+ Total allocated: {formatCurrency(totalAllocated)} +

+ )} +
+ + {isLoading ? ( +
+ ) : ( + <> +
+ Normal (<25%) + Approaching cap (25–30%) + Near cap (≥30%) + Bar width scaled to 35% cap · dashed line = cap +
+ +
+ + + + + + + + + + + + + + {pools.map((pool) => { + const ilColor = pool.ilPct > 2 ? '#f59e0b' : '#64748b'; + return ( + + + + + + + + + + ); + })} + +
PairAllocated (USDC)% of Strategyvs 35% CapExposure30d Fee APYIL %
{pool.pair}{formatCurrency(pool.allocatedUSDC)}{pool.strategyPct.toFixed(1)}%+{pool.capHeadroom.toFixed(1)}% + + {pool.feeAPY30d.toFixed(1)}% + {pool.ilPct > 2 ? '⚠ ' : ''}{pool.ilPct.toFixed(1)}% +
+
+ + )} +
+
+ ); +} + +function dot(color: string): React.CSSProperties { + return { display: 'inline-block', width: 10, height: 10, borderRadius: '50%', background: color, marginRight: 4 }; +} + +const s: Record = { + page: { minHeight: '100vh', background: '#060810', color: '#f1f5f9', fontFamily: 'system-ui, sans-serif' }, + nav: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '1rem 2rem', background: 'rgba(6,8,16,0.85)', backdropFilter: 'blur(12px)', borderBottom: '1px solid rgba(255,255,255,0.06)', position: 'sticky', top: 0, zIndex: 100 }, + navLogo: { fontSize: '1.2rem', fontWeight: 700, color: '#f1f5f9', textDecoration: 'none' }, + navLinks: { display: 'flex', gap: '1.5rem', alignItems: 'center' }, + navLink: { fontSize: '0.875rem', color: '#64748b', textDecoration: 'none' }, + navLinkActive: { fontSize: '0.875rem', color: '#60a5fa', textDecoration: 'none', fontWeight: 600 }, + content: { maxWidth: 1200, margin: '0 auto', padding: '3rem 2rem' }, + header: { marginBottom: '2rem' }, + title: { fontSize: 'clamp(1.8rem, 4vw, 2.5rem)', fontWeight: 800, letterSpacing: '-0.03em', marginBottom: '0.5rem' }, + subtitle: { color: '#94a3b8', fontSize: '1rem', marginBottom: '0.75rem' }, + totalLine: { fontSize: '0.9rem', color: '#64748b', margin: 0 }, + totalValue: { color: '#f1f5f9' }, + skeleton: { height: 300, borderRadius: 12, background: 'rgba(255,255,255,0.04)' }, + legend: { display: 'flex', alignItems: 'center', gap: '1.25rem', marginBottom: '1rem', fontSize: '0.8rem', color: '#64748b', flexWrap: 'wrap' }, + legendNote: { color: '#475569' }, + tableWrap: { overflowX: 'auto', borderRadius: 12, border: '1px solid rgba(255,255,255,0.06)' }, + table: { width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }, + th: { padding: '0.9rem 1.25rem', textAlign: 'left', fontSize: '0.75rem', fontWeight: 600, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '1px solid rgba(255,255,255,0.06)', background: '#0d1120', whiteSpace: 'nowrap' }, + td: { padding: '0.9rem 1.25rem', color: '#94a3b8', borderBottom: '1px solid rgba(255,255,255,0.04)' }, + barTrack: { position: 'relative', height: 8, borderRadius: 999, background: 'rgba(255,255,255,0.06)', overflow: 'hidden' }, + bar: { height: '100%', borderRadius: 999, transition: 'width 0.5s ease' }, + capLine: { position: 'absolute', right: 0, top: 0, bottom: 0, width: 2, borderRight: '2px dashed rgba(255,255,255,0.3)' }, +}; diff --git a/app/src/app/governance/page.tsx b/app/src/app/governance/page.tsx new file mode 100644 index 0000000..57b381b --- /dev/null +++ b/app/src/app/governance/page.tsx @@ -0,0 +1,192 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useGovernance, type Proposal } from '@/hooks/useGovernance'; + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function TimelockCountdown({ expiry }: { expiry: string }) { + const [remaining, setRemaining] = useState(() => + Math.max(0, Math.floor((new Date(expiry).getTime() - Date.now()) / 1000)) + ); + + useEffect(() => { + if (remaining <= 0) return; + const id = setInterval(() => setRemaining((r) => Math.max(0, r - 1)), 1000); + return () => clearInterval(id); + }, [remaining]); + + if (remaining <= 0) { + return Timelock elapsed — executable; + } + + const d = Math.floor(remaining / 86400); + const h = Math.floor((remaining % 86400) / 3600); + const m = Math.floor((remaining % 3600) / 60); + return ( + + {d > 0 ? `${d}d ` : ''} + {h > 0 ? `${h}h ` : ''} + {m}m remaining + + ); +} + +function ProposalCard({ proposal }: { proposal: Proposal }) { + const isExpired = new Date(proposal.timelockExpiry).getTime() < Date.now(); + + return ( +
+
{proposal.action}
+
+ + Proposed by {proposal.proposedBy} + + · + {formatDate(proposal.proposedAt)} +
+
+ Timelock expiry: + + + +
+
+ + {isExpired && ( + + )} +
+
+ ); +} + +export default function GovernancePage() { + const { active, history, isLoading } = useGovernance(); + + return ( +
+ + +
+
+

Governance

+

+ All strategy changes require a 72-hour timelock. Guardian Multisig members can veto + within the window; anyone can execute after it elapses. +

+
+ + {isLoading ? ( +
+ ) : ( + <> +
+

Active Proposals

+ {active.length === 0 ? ( +

No active proposals.

+ ) : ( +
+ {active.map((p) => ( + + ))} +
+ )} +
+ +
+

Proposal History

+
+ + + + + + + + + + + {history.map((p) => ( + + + + + + + ))} + +
IDActionProposedStatus
{p.id}{p.action}{formatDate(p.proposedAt)} + + {p.status === 'executed' ? 'Executed' : 'Vetoed'} + +
+
+
+ + )} +
+
+ ); +} + +const s: Record = { + page: { minHeight: '100vh', background: '#060810', color: '#f1f5f9', fontFamily: 'system-ui, sans-serif' }, + nav: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '1rem 2rem', background: 'rgba(6,8,16,0.85)', backdropFilter: 'blur(12px)', borderBottom: '1px solid rgba(255,255,255,0.06)', position: 'sticky', top: 0, zIndex: 100 }, + navLogo: { fontSize: '1.2rem', fontWeight: 700, color: '#f1f5f9', textDecoration: 'none' }, + navLinks: { display: 'flex', gap: '1.5rem', alignItems: 'center' }, + navLink: { fontSize: '0.875rem', color: '#64748b', textDecoration: 'none' }, + navLinkActive: { fontSize: '0.875rem', color: '#60a5fa', textDecoration: 'none', fontWeight: 600 }, + content: { maxWidth: 900, margin: '0 auto', padding: '3rem 2rem' }, + header: { marginBottom: '2rem' }, + title: { fontSize: 'clamp(1.8rem, 4vw, 2.5rem)', fontWeight: 800, letterSpacing: '-0.03em', marginBottom: '0.5rem' }, + subtitle: { color: '#94a3b8', fontSize: '1rem', maxWidth: 680, lineHeight: 1.6 }, + skeleton: { height: 300, borderRadius: 12, background: 'rgba(255,255,255,0.04)' }, + section: { marginBottom: '3rem' }, + sectionTitle: { fontSize: '1.15rem', fontWeight: 700, color: '#f1f5f9', marginBottom: '1.25rem' }, + emptyState: { color: '#475569', fontSize: '0.9rem' }, + proposalList: { display: 'flex', flexDirection: 'column', gap: '1rem' }, + proposalCard: { background: '#0d1120', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 16, padding: '1.5rem' }, + proposalAction: { fontSize: '1rem', fontWeight: 600, color: '#f1f5f9', marginBottom: '0.75rem', lineHeight: 1.4 }, + proposalMeta: { display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap', fontSize: '0.8rem', color: '#64748b', marginBottom: '0.75rem' }, + address: { fontFamily: 'ui-monospace, monospace', fontSize: '0.78rem', color: '#94a3b8', background: 'rgba(255,255,255,0.05)', padding: '1px 6px', borderRadius: 4 }, + metaDot: { color: '#334155' }, + timelockRow: { display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.85rem', color: '#64748b', marginBottom: '1.25rem' }, + timelockLabel: { fontWeight: 600 }, + timelockValue: { color: '#94a3b8' }, + proposalActions: { display: 'flex', gap: '0.75rem' }, + vetoBtn: { padding: '0.5rem 1.25rem', borderRadius: 8, border: '1px solid rgba(239,68,68,0.25)', background: 'rgba(239,68,68,0.06)', color: '#64748b', fontSize: '0.875rem', fontWeight: 600, cursor: 'not-allowed' }, + executeBtn: { padding: '0.5rem 1.25rem', borderRadius: 8, border: 'none', background: '#3b82f6', color: '#fff', fontSize: '0.875rem', fontWeight: 600, cursor: 'pointer' }, + tableWrap: { overflowX: 'auto', borderRadius: 12, border: '1px solid rgba(255,255,255,0.06)' }, + table: { width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }, + th: { padding: '0.9rem 1.25rem', textAlign: 'left', fontSize: '0.75rem', fontWeight: 600, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '1px solid rgba(255,255,255,0.06)', background: '#0d1120' }, + td: { padding: '0.9rem 1.25rem', color: '#94a3b8', borderBottom: '1px solid rgba(255,255,255,0.04)' }, + mono: { fontFamily: 'ui-monospace, monospace', fontSize: '0.8rem' }, + badgeExecuted: { display: 'inline-block', padding: '2px 10px', borderRadius: 999, background: 'rgba(34,197,94,0.1)', color: '#86efac', fontSize: '0.75rem', fontWeight: 600 }, + badgeVetoed: { display: 'inline-block', padding: '2px 10px', borderRadius: 999, background: 'rgba(239,68,68,0.1)', color: '#fca5a5', fontSize: '0.75rem', fontWeight: 600 }, +}; diff --git a/app/src/app/harvest/page.tsx b/app/src/app/harvest/page.tsx new file mode 100644 index 0000000..9b469c8 --- /dev/null +++ b/app/src/app/harvest/page.tsx @@ -0,0 +1,211 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useLastHarvest } from '@/hooks/useLastHarvest'; + +type TxState = 'idle' | 'pending' | 'confirmed' | 'failed'; + +function formatDuration(seconds: number): string { + if (seconds <= 0) return 'Ready to harvest'; + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + const sec = seconds % 60; + const parts: string[] = []; + if (d > 0) parts.push(`${d}d`); + if (h > 0) parts.push(`${h}h`); + if (m > 0) parts.push(`${m}m`); + if (sec > 0 || parts.length === 0) parts.push(`${sec}s`); + return parts.join(' '); +} + +function formatCurrency(n: number): string { + if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(2)}M`; + if (n >= 1_000) return `$${(n / 1_000).toFixed(1)}K`; + return `$${n.toFixed(2)}`; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +export default function HarvestPage() { + const { timestamp, ledger, cooldownRemaining, estimatedYield, history, isLoading } = useLastHarvest(); + const [remaining, setRemaining] = useState(cooldownRemaining); + const [txState, setTxState] = useState('idle'); + + useEffect(() => { + setRemaining(cooldownRemaining); + }, [cooldownRemaining]); + + useEffect(() => { + if (remaining <= 0) return; + const id = setInterval(() => { + setRemaining((r) => Math.max(0, r - 1)); + }, 1000); + return () => clearInterval(id); + }, [remaining]); + + const canHarvest = remaining === 0; + const estimatedBounty = estimatedYield * 0.001; + + function handleHarvest() { + if (!canHarvest || txState !== 'idle') return; + setTxState('pending'); + // TODO(GF-08): Call sdk.harvest() once SDK is implemented + setTimeout(() => setTxState('confirmed'), 2000); + } + + return ( +
+ + +
+
+

Harvest Yield

+

+ Anyone can trigger a harvest once the cooldown elapses and earn 10 bps of harvested yield. +

+
+ + {isLoading ? ( +
+ ) : ( + <> +
+
+
+
Last Harvest
+
+ {timestamp + ? new Date(timestamp).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + : '—'} +
+ {ledger &&
Ledger #{ledger.toLocaleString()}
} +
+ +
+
Cooldown Remaining
+
+ {canHarvest ? 'Ready to harvest' : formatDuration(remaining)} +
+
+ +
+
Estimated Harvestable Yield
+
{formatCurrency(estimatedYield)}
+
+ +
+
Your Estimated Bounty
+
{formatCurrency(estimatedBounty)}
+
10 bps of yield
+
+
+ +
+ {txState === 'confirmed' ? ( +
+ Harvest confirmed! You earned approximately {formatCurrency(estimatedBounty)}. +
+ ) : ( + <> + + {txState === 'failed' && ( +

Transaction failed. Please try again.

+ )} + + )} +
+
+ +
+

Harvest History

+
+ + + + + + + + + + + + {history.map((record, i) => ( + + + + + + + + ))} + +
DateLedgerYield HarvestedBounty PaidCaller
{formatDate(record.date)}{record.ledger.toLocaleString()}{formatCurrency(record.yieldAmount)}{formatCurrency(record.bounty)}{record.caller}
+
+
+ + )} +
+
+ ); +} + +const s: Record = { + page: { minHeight: '100vh', background: '#060810', color: '#f1f5f9', fontFamily: 'system-ui, sans-serif' }, + nav: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '1rem 2rem', background: 'rgba(6,8,16,0.85)', backdropFilter: 'blur(12px)', borderBottom: '1px solid rgba(255,255,255,0.06)', position: 'sticky', top: 0, zIndex: 100 }, + navLogo: { fontSize: '1.2rem', fontWeight: 700, color: '#f1f5f9', textDecoration: 'none' }, + navLinks: { display: 'flex', gap: '1.5rem', alignItems: 'center' }, + navLink: { fontSize: '0.875rem', color: '#64748b', textDecoration: 'none' }, + navLinkActive: { fontSize: '0.875rem', color: '#60a5fa', textDecoration: 'none', fontWeight: 600 }, + content: { maxWidth: 900, margin: '0 auto', padding: '3rem 2rem' }, + header: { marginBottom: '2rem' }, + title: { fontSize: 'clamp(1.8rem, 4vw, 2.5rem)', fontWeight: 800, letterSpacing: '-0.03em', marginBottom: '0.5rem' }, + subtitle: { color: '#94a3b8', fontSize: '1rem', marginBottom: 0 }, + skeleton: { height: 300, borderRadius: 12, background: 'rgba(255,255,255,0.04)' }, + statusCard: { background: '#0d1120', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 20, padding: '2rem', marginBottom: '3rem' }, + statusGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '2rem', marginBottom: '2rem' }, + statusItem: {}, + statusLabel: { fontSize: '0.75rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: '0.4rem' }, + statusValue: { fontSize: '1.5rem', fontWeight: 800, letterSpacing: '-0.02em', color: '#f1f5f9' }, + statusSub: { fontSize: '0.75rem', color: '#475569', marginTop: '0.2rem' }, + harvestAction: { borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: '1.5rem' }, + harvestBtn: { padding: '0.8rem 2.5rem', borderRadius: 12, border: 'none', background: '#3b82f6', color: '#fff', fontSize: '1rem', fontWeight: 700, cursor: 'pointer', transition: 'background 0.15s' }, + harvestBtnDisabled: { padding: '0.8rem 2.5rem', borderRadius: 12, border: 'none', background: 'rgba(255,255,255,0.06)', color: '#475569', fontSize: '1rem', fontWeight: 700, cursor: 'not-allowed' }, + successBox: { padding: '1rem 1.5rem', borderRadius: 10, background: 'rgba(34,197,94,0.1)', border: '1px solid rgba(34,197,94,0.25)', color: '#86efac', fontSize: '0.95rem', fontWeight: 600 }, + errorText: { marginTop: '0.75rem', color: '#f87171', fontSize: '0.875rem' }, + section: { marginBottom: '3rem' }, + sectionTitle: { fontSize: '1.15rem', fontWeight: 700, color: '#f1f5f9', marginBottom: '1.25rem' }, + tableWrap: { overflowX: 'auto', borderRadius: 12, border: '1px solid rgba(255,255,255,0.06)' }, + table: { width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }, + th: { padding: '0.9rem 1.25rem', textAlign: 'left', fontSize: '0.75rem', fontWeight: 600, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '1px solid rgba(255,255,255,0.06)', background: '#0d1120', whiteSpace: 'nowrap' }, + td: { padding: '0.9rem 1.25rem', color: '#94a3b8', borderBottom: '1px solid rgba(255,255,255,0.04)' }, + mono: { fontFamily: 'ui-monospace, monospace', fontSize: '0.8rem' }, +}; diff --git a/app/src/hooks/useAllocations.ts b/app/src/hooks/useAllocations.ts new file mode 100644 index 0000000..040b770 --- /dev/null +++ b/app/src/hooks/useAllocations.ts @@ -0,0 +1,44 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +export interface PoolAllocation { + id: string; + pair: string; + allocatedUSDC: number; + strategyPct: number; + capHeadroom: number; + feeAPY30d: number; + ilPct: number; +} + +export interface AllocationsData { + pools: PoolAllocation[]; + totalAllocated: number; + isLoading: boolean; + error: string | null; +} + +export function useAllocations(): AllocationsData { + const [data, setData] = useState({ + pools: [], + totalAllocated: 0, + isLoading: true, + error: null, + }); + + useEffect(() => { + // TODO(GF-12): Replace with StrategyVault.allocations() Soroban RPC call + const pools: PoolAllocation[] = [ + { id: 'xlm-usdc', pair: 'XLM/USDC', allocatedUSDC: 215_000, strategyPct: 28.0, capHeadroom: 7.0, feeAPY30d: 4.2, ilPct: 1.3 }, + { id: 'eurc-usdc', pair: 'EURC/USDC', allocatedUSDC: 190_000, strategyPct: 24.7, capHeadroom: 10.3, feeAPY30d: 3.8, ilPct: 0.4 }, + { id: 'aqua-usdc', pair: 'AQUA/USDC', allocatedUSDC: 160_000, strategyPct: 20.8, capHeadroom: 14.2, feeAPY30d: 6.1, ilPct: 2.7 }, + { id: 'ybtc-usdc', pair: 'yBTC/USDC', allocatedUSDC: 120_000, strategyPct: 15.6, capHeadroom: 19.4, feeAPY30d: 5.3, ilPct: 0.8 }, + { id: 'yeth-usdc', pair: 'yETH/USDC', allocatedUSDC: 83_500, strategyPct: 10.9, capHeadroom: 24.1, feeAPY30d: 4.9, ilPct: 0.6 }, + ]; + const totalAllocated = pools.reduce((sum, p) => sum + p.allocatedUSDC, 0); + setData({ pools, totalAllocated, isLoading: false, error: null }); + }, []); + + return data; +} diff --git a/app/src/hooks/useGovernance.ts b/app/src/hooks/useGovernance.ts new file mode 100644 index 0000000..bd7321c --- /dev/null +++ b/app/src/hooks/useGovernance.ts @@ -0,0 +1,78 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +export type ProposalStatus = 'pending' | 'executed' | 'vetoed'; + +export interface Proposal { + id: string; + action: string; + proposedBy: string; + proposedAt: string; + timelockExpiry: string; + status: ProposalStatus; +} + +export interface GovernanceData { + active: Proposal[]; + history: Proposal[]; + isLoading: boolean; + error: string | null; +} + +export function useGovernance(): GovernanceData { + const [data, setData] = useState({ + active: [], + history: [], + isLoading: true, + error: null, + }); + + useEffect(() => { + // TODO(GF-12): Replace with Governance contract Soroban RPC call + const now = Date.now(); + const day = 24 * 60 * 60 * 1000; + + const active: Proposal[] = [ + { + id: 'prop-003', + action: 'Increase XLM/USDC pool allocation cap from 30% to 35%', + proposedBy: 'GSTRATEGIST001...XXXX', + proposedAt: new Date(now - 2 * day).toISOString(), + timelockExpiry: new Date(now + 2 * day).toISOString(), + status: 'pending', + }, + { + id: 'prop-004', + action: 'Add AQUA/XLM pool to approved strategy list', + proposedBy: 'GSTRATEGIST001...XXXX', + proposedAt: new Date(now - 12 * 60 * 60 * 1000).toISOString(), + timelockExpiry: new Date(now + day).toISOString(), + status: 'pending', + }, + ]; + + const history: Proposal[] = [ + { + id: 'prop-001', + action: 'Set harvest cooldown to 7 days', + proposedBy: 'GSTRATEGIST001...XXXX', + proposedAt: new Date(now - 30 * day).toISOString(), + timelockExpiry: new Date(now - 27 * day).toISOString(), + status: 'executed', + }, + { + id: 'prop-002', + action: 'Remove AQUA/USDC pool from strategy (low liquidity)', + proposedBy: 'GSTRATEGIST001...XXXX', + proposedAt: new Date(now - 60 * day).toISOString(), + timelockExpiry: new Date(now - 57 * day).toISOString(), + status: 'vetoed', + }, + ]; + + setData({ active, history, isLoading: false, error: null }); + }, []); + + return data; +} diff --git a/app/src/hooks/useLastHarvest.ts b/app/src/hooks/useLastHarvest.ts new file mode 100644 index 0000000..f0382ab --- /dev/null +++ b/app/src/hooks/useLastHarvest.ts @@ -0,0 +1,72 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +export interface HarvestRecord { + date: string; + ledger: number; + yieldAmount: number; + bounty: number; + caller: string; +} + +export interface LastHarvestData { + timestamp: number | null; + ledger: number | null; + cooldownRemaining: number; + estimatedYield: number; + history: HarvestRecord[]; + isLoading: boolean; + error: string | null; +} + +const COOLDOWN_SECONDS = 604_800; + +const CALLERS = [ + 'GABCDXXXX...YYYY', + 'GEFGHXXXX...YYYY', + 'GIJKLXXXX...YYYY', + 'GMNOPXXXX...YYYY', +]; + +export function useLastHarvest(): LastHarvestData { + const [data, setData] = useState({ + timestamp: null, + ledger: null, + cooldownRemaining: COOLDOWN_SECONDS, + estimatedYield: 0, + history: [], + isLoading: true, + error: null, + }); + + useEffect(() => { + // TODO(GF-12): Replace with Harvester contract Soroban RPC call + const lastTimestamp = Date.now() - 3 * 24 * 60 * 60 * 1000; + const elapsed = Math.floor((Date.now() - lastTimestamp) / 1000); + const cooldownRemaining = Math.max(0, COOLDOWN_SECONDS - elapsed); + + const history: HarvestRecord[] = Array.from({ length: 20 }, (_, i) => { + const yieldAmount = 1200 + Math.floor(Math.abs(Math.sin(i * 1.3)) * 800); + return { + date: new Date(lastTimestamp - i * COOLDOWN_SECONDS * 1000).toISOString(), + ledger: 4_982_720 - i * 17_280, + yieldAmount, + bounty: Math.round(yieldAmount * 0.001 * 100) / 100, + caller: CALLERS[i % CALLERS.length], + }; + }); + + setData({ + timestamp: lastTimestamp, + ledger: 4_982_720, + cooldownRemaining, + estimatedYield: 1_450, + history, + isLoading: false, + error: null, + }); + }, []); + + return data; +}