diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1410bc4f..60fd2783 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import Settings from './pages/Settings'; import CustomReportBuilder from './pages/CustomReportBuilder'; import CrossAssetPayment from './pages/CrossAssetPayment'; import TransactionHistory from './pages/TransactionHistory'; +import VestingManagement from './pages/VestingManagement'; import EmployeePortal from './pages/EmployeePortal'; import Login from './pages/Login'; @@ -153,6 +154,14 @@ function App() { } /> + {}} />}> + + + } + /> } /> } /> diff --git a/frontend/src/components/AppNav.tsx b/frontend/src/components/AppNav.tsx index c7f12aa3..86704b41 100644 --- a/frontend/src/components/AppNav.tsx +++ b/frontend/src/components/AppNav.tsx @@ -11,6 +11,7 @@ import { ShieldAlert, Menu, X, + Lock, } from 'lucide-react'; import { Avatar } from './Avatar'; @@ -110,6 +111,23 @@ const AppNav: React.FC = () => { Cross-Asset + + `flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[13px] font-semibold transition ${ + isActive + ? 'text-(--accent) bg-white/5' + : 'text-(--muted) hover:bg-white/10 hover:text-white' + }` + } + onClick={() => setMobileOpen(false)} + > + + + + Vesting + + diff --git a/frontend/src/pages/VestingManagement.tsx b/frontend/src/pages/VestingManagement.tsx new file mode 100644 index 00000000..ef82b34c --- /dev/null +++ b/frontend/src/pages/VestingManagement.tsx @@ -0,0 +1,370 @@ +import React, { useEffect, useState } from 'react'; +import { Heading, Text, Button, Input } from '@stellar/design-system'; +import { toast } from 'sonner'; +import { + getVestingConfig, + getClaimableAmount, + buildClaimVestingXdr, + buildInitializeVestingXdr, + type VestingConfig, +} from '../services/vestingService'; +import { simulateTransaction } from '../services/transactionSimulation'; +import { TransactionSimulationPanel } from '../components/TransactionSimulationPanel'; +import type { SimulationResult } from '../services/transactionSimulation'; + +// Mock source key for read-only simulation calls (sequence doesn't matter for reads) +const MOCK_READ_SOURCE = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN'; + +function formatUnixToDate(ts: number): string { + return new Date(ts * 1000).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +function secondsToDays(seconds: number): number { + return Math.round(seconds / 86400); +} + +interface InitGrantForm { + funder: string; + beneficiary: string; + token: string; + startTime: string; + cliffDays: string; + durationDays: string; + amount: string; + clawbackAdmin: string; +} + +const InitGrantFormDefault: InitGrantForm = { + funder: '', + beneficiary: '', + token: '', + startTime: '', + cliffDays: '', + durationDays: '', + amount: '', + clawbackAdmin: '', +}; + +export default function VestingManagement() { + const [config, setConfig] = useState(null); + const [claimable, setClaimable] = useState(0); + const [loading, setLoading] = useState(true); + const [simResult, setSimResult] = useState(null); + const [isSimulating, setIsSimulating] = useState(false); + const [form, setForm] = useState(InitGrantFormDefault); + + const loadData = async () => { + setLoading(true); + try { + const [cfg, claimableAmt] = await Promise.all([ + getVestingConfig(MOCK_READ_SOURCE), + getClaimableAmount(MOCK_READ_SOURCE), + ]); + setConfig(cfg); + setClaimable(claimableAmt); + } catch (err) { + console.error(err); + toast.error('Failed to load vesting data'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { loadData(); }, []); + + const handleClaim = async () => { + setIsSimulating(true); + setSimResult(null); + try { + const xdr = buildClaimVestingXdr(MOCK_READ_SOURCE, '0'); + const result = await simulateTransaction({ envelopeXdr: xdr }); + setSimResult(result); + if (!result.success) { + toast.error('Claim simulation failed'); + } else { + toast.success('Claim simulation passed — ready to sign and broadcast'); + } + } catch (err: any) { + toast.error(err.message || 'An error occurred during claim simulation'); + } finally { + setIsSimulating(false); + } + }; + + const handleInitGrant = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSimulating(true); + setSimResult(null); + try { + const xdr = buildInitializeVestingXdr( + MOCK_READ_SOURCE, + form.funder, + form.beneficiary, + form.token, + Math.floor(new Date(form.startTime).getTime() / 1000), + Number(form.cliffDays) * 86400, + Number(form.durationDays) * 86400, + Number(form.amount), + form.clawbackAdmin, + '0' + ); + const result = await simulateTransaction({ envelopeXdr: xdr }); + setSimResult(result); + if (result.success) { + toast.success('Grant initialization simulation passed'); + } else { + toast.error('Grant simulation failed — review errors below'); + } + } catch (err: any) { + toast.error(err.message || 'An error occurred during grant initialization'); + } finally { + setIsSimulating(false); + } + }; + + const handleFormChange = (e: React.ChangeEvent) => { + setForm((prev) => ({ ...prev, [e.target.name]: e.target.value })); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + const vestedPercent = config + ? Math.min(100, ((config.totalAmount - (config.totalAmount - config.claimedAmount)) / config.totalAmount) * 100) + : 0; + const cliffDate = config ? formatUnixToDate(config.startTime + config.cliffSeconds) : ''; + const endDate = config ? formatUnixToDate(config.startTime + config.durationSeconds) : ''; + + return ( +
+
+ + Vesting Escrow Management + + + Manage on-chain token vesting grants via the Soroban{' '} + vesting_escrow contract. + +
+ + {config ? ( + <> + {/* Grant Dashboard */} +
+
+ + Active Vesting Grant + + + {config.isActive ? 'Active' : 'Revoked'} + +
+ +
+ {[ + { label: 'Beneficiary', value: `${config.beneficiary.slice(0, 8)}...` }, + { label: 'Total Amount', value: config.totalAmount.toLocaleString() }, + { label: 'Cliff Date', value: cliffDate }, + { label: 'End Date', value: endDate }, + ].map(({ label, value }) => ( +
+ + {label} + + + {value} + +
+ ))} +
+ + {/* Progress Bar */} +
+
+ Claimed: {config.claimedAmount.toLocaleString()} + Total: {config.totalAmount.toLocaleString()} +
+
+
+
+ + {vestedPercent.toFixed(1)}% vested + +
+ + {/* Claimable amount */} + {claimable > 0 && ( +
+
+ + Claimable:{' '} + {claimable.toLocaleString()} + + + Tokens available to withdraw now + +
+ +
+ )} + + {claimable === 0 && ( + + No tokens available to claim at this time. + + )} +
+ + {/* Simulation Result */} + {simResult && ( + setSimResult(null)} + /> + )} + + ) : ( + <> + {/* No active grant — show Admin form */} +
+ + No Active Grant + + + The vesting contract has not been initialized for this network. Use the form below to + create a new vesting grant. + + +
+
+ +
+
+ +
+
+ +
+ + + + +
+ +
+
+ +
+
+
+ + {simResult && ( + setSimResult(null)} + /> + )} + + )} +
+ ); +} diff --git a/frontend/src/services/vestingService.ts b/frontend/src/services/vestingService.ts new file mode 100644 index 00000000..46f7cb7e --- /dev/null +++ b/frontend/src/services/vestingService.ts @@ -0,0 +1,176 @@ +import { Contract, xdr, Address, scValToNative, nativeToScVal, TransactionBuilder, Networks, Keypair, Account } from '@stellar/stellar-sdk'; +import { contractService } from './contracts'; + +export interface VestingConfig { + beneficiary: string; + token: string; + startTime: number; + cliffSeconds: number; + durationSeconds: number; + totalAmount: number; + claimedAmount: number; + clawbackAdmin: string; + isActive: boolean; +} + +const NETWORK = import.meta.env.VITE_NETWORK || 'testnet'; +const NETWORK_PASSPHRASE = NETWORK === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET; +const RPC_URL = import.meta.env.PUBLIC_STELLAR_RPC_URL?.replace(/\/+$/, '') || 'https://soroban-testnet.stellar.org'; + +function getContractId(): string { + const id = contractService.getContractId('vesting_escrow', NETWORK as any); + if (!id) throw new Error('Vesting contract ID not found in registry'); + return id; +} + +export async function simulateSorobanCall(op: xdr.Operation, sourcePublicKey: string): Promise { + const account = new Account(sourcePublicKey, '0'); // Dummy sequence + const tx = new TransactionBuilder(account, { + fee: '1000', + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation(op) + .setTimeout(30) + .build(); + + const rpcResponse = await fetch(RPC_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'simulateTransaction', + params: { + transaction: tx.toEnvelopexdr().toString('base64'), + }, + }), + }); + + const rpcResult = await rpcResponse.json(); + if (rpcResult.error) { + throw new Error(rpcResult.error.message); + } + + if (rpcResult.result?.error) { + throw new Error(rpcResult.result.error); + } + + const resultData = rpcResult.result?.results?.[0]?.xdr; + if (!resultData) { + return null; + } + + const scVal = xdr.ScVal.fromXDR(resultData, 'base64'); + return scValToNative(scVal); +} + +export async function getVestingConfig(sourcePublicKey: string): Promise { + try { + const contract = new Contract(getContractId()); + const op = contract.call('get_config'); + const result = await simulateSorobanCall(op, sourcePublicKey); + + // result should be a map representing VestingConfig + return { + beneficiary: result.beneficiary.toString(), + token: result.token.toString(), + startTime: Number(result.start_time), + cliffSeconds: Number(result.cliff_seconds), + durationSeconds: Number(result.duration_seconds), + totalAmount: Number(result.total_amount), + claimedAmount: Number(result.claimed_amount), + clawbackAdmin: result.clawback_admin.toString(), + isActive: Boolean(result.is_active), + }; + } catch (err: any) { + if (err.message && err.message.includes('Not initialized')) { + return null; // Contract not initialized yet + } + throw err; + } +} + +export async function getClaimableAmount(sourcePublicKey: string): Promise { + try { + const contract = new Contract(getContractId()); + const op = contract.call('get_claimable_amount'); + const result = await simulateSorobanCall(op, sourcePublicKey); + return Number(result); + } catch (err: any) { + if (err.message && err.message.includes('Not initialized')) { + return 0; // Contract not initialized yet + } + throw err; + } +} + +export function buildInitializeVestingXdr( + sourcePublicKey: string, + funder: string, + beneficiary: string, + token: string, + startTime: number, + cliffSeconds: number, + durationSeconds: number, + amount: number, + clawbackAdmin: string, + sequenceNumber: string +): string { + const contract = new Contract(getContractId()); + + const args = [ + new Address(funder).toScVal(), + new Address(beneficiary).toScVal(), + new Address(token).toScVal(), + nativeToScVal(startTime, { type: 'u64' }), + nativeToScVal(cliffSeconds, { type: 'u64' }), + nativeToScVal(durationSeconds, { type: 'u64' }), + nativeToScVal(amount, { type: 'i128' }), + new Address(clawbackAdmin).toScVal(), + ]; + + const op = contract.call('initialize', ...args); + const account = new Account(sourcePublicKey, sequenceNumber); + + const tx = new TransactionBuilder(account, { + fee: '10000', + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation(op) + .setTimeout(180) + .build(); + + return tx.toEnvelopexdr().toString('base64'); +} + +export function buildClaimVestingXdr(sourcePublicKey: string, sequenceNumber: string): string { + const contract = new Contract(getContractId()); + const op = contract.call('claim'); + const account = new Account(sourcePublicKey, sequenceNumber); + + const tx = new TransactionBuilder(account, { + fee: '10000', + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation(op) + .setTimeout(180) + .build(); + + return tx.toEnvelopexdr().toString('base64'); +} + +export function buildClawbackVestingXdr(sourcePublicKey: string, sequenceNumber: string): string { + const contract = new Contract(getContractId()); + const op = contract.call('clawback'); + const account = new Account(sourcePublicKey, sequenceNumber); + + const tx = new TransactionBuilder(account, { + fee: '10000', + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation(op) + .setTimeout(180) + .build(); + + return tx.toEnvelopexdr().toString('base64'); +}