diff --git a/frontend/app/claim/ClaimVesting.tsx b/frontend/app/claim/ClaimVesting.tsx index 8cff81ba..5a7fbf0f 100644 --- a/frontend/app/claim/ClaimVesting.tsx +++ b/frontend/app/claim/ClaimVesting.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/Button"; import { useToast } from "@/app/providers/ToastProvider"; import { useWallet } from "@/app/hooks/useWallet"; import { + fetchScheduleCount, fetchVestingInfo, buildReleaseTx, submitTx, @@ -21,12 +22,14 @@ export function ClaimVesting() { const toast = useToast(); const [contractId, setContractId] = useState(""); - const [info, setInfo] = useState(null); + // One VestingInfo entry per schedule index + const [schedules, setSchedules] = useState([]); const [loading, setLoading] = useState(false); - const [releasing, setReleasing] = useState(false); + // Track which schedule index is currently releasing + const [releasingIndex, setReleasingIndex] = useState(null); const [error, setError] = useState(null); - /* ── Fetch vesting schedule ────────────────────────────────────────── */ + /* ── Fetch all vesting schedules ───────────────────────────────────── */ const handleLookup = useCallback(async () => { if (!connected || !publicKey) { toast.show({ @@ -44,12 +47,24 @@ export function ClaimVesting() { } setError(null); - setInfo(null); + setSchedules([]); setLoading(true); try { - const vestingInfo = await fetchVestingInfo(trimmed, publicKey); - setInfo(vestingInfo); + const count = await fetchScheduleCount(trimmed, publicKey); + + if (count === 0) { + setError("No vesting schedule found for your wallet on this contract."); + return; + } + + // Fetch all schedules in parallel + const infos = await Promise.all( + Array.from({ length: count }, (_, i) => + fetchVestingInfo(trimmed, publicKey, i), + ), + ); + setSchedules(infos); } catch (err: unknown) { const msg = err instanceof Error ? err.message : "Failed to fetch vesting info"; @@ -61,54 +76,65 @@ export function ClaimVesting() { } finally { setLoading(false); } - }, [connected, publicKey, contractId]); + }, [connected, publicKey, contractId, toast]); - /* ── Release unlocked tokens ───────────────────────────────────────── */ - const handleRelease = useCallback(async () => { - if (!connected || !publicKey || !info) return; + /* ── Release unlocked tokens for a specific schedule index ─────────── */ + const handleRelease = useCallback( + async (scheduleIndex: number) => { + if (!connected || !publicKey) return; + const info = schedules[scheduleIndex]; + if (!info) return; - if (info.releasableAmount <= 0n) { - toast.show({ - title: "No Tokens Available", - message: "No tokens available to release right now.", - variant: "error", - }); - return; - } - - setReleasing(true); - try { - const xdr = await buildReleaseTx(contractId.trim(), publicKey, publicKey); - const signedXdr = await signTransaction(xdr); - await submitTx(signedXdr); - toast.show({ - title: "Success", - message: "Tokens released successfully!", - variant: "success", - }); + if (info.releasableAmount <= 0n) { + toast.show({ + title: "No Tokens Available", + message: "No tokens available to release right now.", + variant: "error", + }); + return; + } - // Refresh data - const updated = await fetchVestingInfo(contractId.trim(), publicKey); - setInfo(updated); - } catch (err: unknown) { - const msg = - err instanceof Error ? err.message : "Release transaction failed"; - toast.show({ - title: "Release Failed", - message: msg, - variant: "error", - }); - } finally { - setReleasing(false); - } - }, [connected, publicKey, info, contractId, signTransaction]); + setReleasingIndex(scheduleIndex); + try { + const xdr = await buildReleaseTx( + contractId.trim(), + publicKey, + publicKey, + scheduleIndex, + ); + const signedXdr = await signTransaction(xdr); + await submitTx(signedXdr); + toast.show({ + title: "Success", + message: `Schedule ${scheduleIndex + 1}: tokens released successfully!`, + variant: "success", + }); - /* ── Computed display values ───────────────────────────────────────── */ - const schedule = info?.schedule; - const progressPct = - schedule && schedule.totalAmount > 0n - ? Number((info.vestedAmount * 100n) / schedule.totalAmount) - : 0; + // Refresh this schedule + const updated = await fetchVestingInfo( + contractId.trim(), + publicKey, + scheduleIndex, + ); + setSchedules((prev) => { + const next = [...prev]; + next[scheduleIndex] = updated; + return next; + }); + } catch (err: unknown) { + const msg = + err instanceof Error ? err.message : "Release transaction failed"; + toast.show({ + title: "Release Failed", + message: msg, + variant: "error", + }); + } finally { + setReleasingIndex(null); + } + }, + [connected, publicKey, schedules, contractId, signTransaction, toast], + ); /* ── Render ────────────────────────────────────────────────────────── */ return ( @@ -117,7 +143,7 @@ export function ClaimVesting() { {!connected && (

- Connect your Freighter wallet to view your vesting schedule. + Connect your Freighter wallet to view your vesting schedules.

@@ -166,106 +192,147 @@ export function ClaimVesting() { )} - {/* ── Vesting schedule details ──────────────────────────────── */} - {info && schedule && ( -
- {/* Header */} -
-

- Your Vesting Schedule -

- {schedule.revoked && ( - - Revoked - - )} -
+ {/* ── One card per schedule ─────────────────────────────────── */} + {schedules.map((info, idx) => ( + handleRelease(idx)} + /> + ))} +
+ )} + + ); +} - {/* Progress bar */} -
-
- Vesting Progress - - {progressPct}% - -
-
-
-
-
+/* ── Per-schedule card ─────────────────────────────────────────────── */ - {/* Stats grid */} -
- - - - -
+function ScheduleCard({ + info, + scheduleIndex, + scheduleCount, + releasing, + onRelease, +}: { + info: VestingInfo; + scheduleIndex: number; + scheduleCount: number; + releasing: boolean; + onRelease: () => void; +}) { + const { schedule } = info; - {/* Schedule metadata */} -
- - - - -
+ const progressPct = + schedule.totalAmount > 0n + ? Number((info.vestedAmount * 100n) / schedule.totalAmount) + : 0; - {/* Release button */} - -
+ return ( +
+ {/* Header */} +
+
+

+ {scheduleCount > 1 + ? `Vesting Schedule ${scheduleIndex + 1} of ${scheduleCount}` + : "Your Vesting Schedule"} +

+ {scheduleCount > 1 && ( + + #{scheduleIndex + 1} + )}
- )} - + {schedule.revoked && ( + + Revoked + + )} +
+ + {/* Progress bar */} +
+
+ Vesting Progress + + {progressPct.toFixed(1)}% + +
+
+
+
+
+ + {/* Stats grid */} +
+ + + + +
+ + {/* Schedule metadata */} +
+ + + + +
+ + {/* Release button */} + +
); } diff --git a/frontend/app/my-account/PersonalDashboard.tsx b/frontend/app/my-account/PersonalDashboard.tsx index 7c54b16e..4087e1f8 100644 --- a/frontend/app/my-account/PersonalDashboard.tsx +++ b/frontend/app/my-account/PersonalDashboard.tsx @@ -42,6 +42,7 @@ export default function PersonalDashboard() { const { connected, publicKey, connect, signTransaction } = useWallet(); const { fetchVestingSchedule, + fetchVestingScheduleCount, fetchCurrentLedger, fetchAccountBalances, fetchAccountOperations, @@ -58,8 +59,7 @@ export default function PersonalDashboard() { // Vesting const [vestingContractId, setVestingContractId] = useState(""); - const [vestingSchedule, setVestingSchedule] = - useState(null); + const [vestingSchedules, setVestingSchedules] = useState([]); const [vestingLoading, setVestingLoading] = useState(false); const [vestingError, setVestingError] = useState(null); const [currentLedger, setCurrentLedger] = useState(0); @@ -197,30 +197,42 @@ export default function PersonalDashboard() { [publicKey, fetchAccountOperations], ); - // Load vesting schedule by contract ID + // Load all vesting schedules for the connected wallet from a given contract const doVestingLookup = useCallback(async (contractId: string) => { if (!publicKey || !contractId.trim()) return; setVestingContractId(contractId); setVestingLoading(true); setVestingError(null); - setVestingSchedule(null); + setVestingSchedules([]); try { - const [schedule, ledger] = await Promise.all([ - fetchVestingSchedule(contractId.trim(), publicKey), + const [count, ledger] = await Promise.all([ + fetchVestingScheduleCount(contractId.trim(), publicKey), fetchCurrentLedger(), ]); - setVestingSchedule(schedule); + + if (count === 0) { + setVestingError("No vesting schedule found for your wallet on this contract."); + setVestingLoading(false); + return; + } + + // Fetch all schedules in parallel + const schedulePromises = Array.from({ length: count }, (_, i) => + fetchVestingSchedule(contractId.trim(), publicKey, i), + ); + const schedules = await Promise.all(schedulePromises); + setVestingSchedules(schedules); setCurrentLedger(ledger); } catch (err) { setVestingError( err instanceof Error ? err.message - : "Failed to fetch vesting schedule. Check the contract ID.", + : "Failed to fetch vesting schedules. Check the contract ID.", ); } finally { setVestingLoading(false); } - }, [publicKey, fetchVestingSchedule, fetchCurrentLedger]); + }, [publicKey, fetchVestingScheduleCount, fetchVestingSchedule, fetchCurrentLedger]); const lookupVesting = useCallback(async () => { await doVestingLookup(vestingContractId); @@ -268,7 +280,7 @@ export default function PersonalDashboard() { onLookup={lookupVesting} loading={vestingLoading} error={vestingError} - schedule={vestingSchedule} + schedules={vestingSchedules} currentLedger={currentLedger} /> diff --git a/frontend/app/my-account/components/VestingCard.tsx b/frontend/app/my-account/components/VestingCard.tsx index 45c5e797..85805c46 100644 --- a/frontend/app/my-account/components/VestingCard.tsx +++ b/frontend/app/my-account/components/VestingCard.tsx @@ -54,6 +54,13 @@ export function VestingCard({ Contract: {truncateAddress(contractId, 6)} + {schedule.scheduleCount !== undefined && + schedule.scheduleCount > 1 && ( + + Schedule {(schedule.scheduleIndex ?? 0) + 1} of{" "} + {schedule.scheduleCount} + + )}
void; loading: boolean; error: string | null; - schedule: VestingScheduleInfo | null; + schedules: VestingScheduleInfo[]; currentLedger: number; }) { return ( -
+

- Vesting Schedule + Vesting Schedules

- {schedule && ( -
- + {schedules.length > 0 && ( +
+ {schedules.map((schedule) => ( + + ))}
)}
diff --git a/frontend/components/forms/VestingForm.tsx b/frontend/components/forms/VestingForm.tsx index 89434233..578dac90 100644 --- a/frontend/components/forms/VestingForm.tsx +++ b/frontend/components/forms/VestingForm.tsx @@ -13,6 +13,11 @@ import { AlertCircle, Clock, Unlock } from "lucide-react"; const vestingReleaseSchema = z.object({ vestingContractId: z.string().regex(/^C[A-Z0-9]{55}$/, "Invalid vesting contract ID"), recipientAddress: z.string().regex(/^G[A-Z2-7]{55}$/, "Invalid recipient address"), + scheduleIndex: z.coerce + .number() + .int("Must be a whole number") + .min(0, "Must be 0 or greater") + .optional(), }); type VestingReleaseFormData = z.infer; @@ -58,6 +63,7 @@ export function VestingReleaseForm({ onSuccess }: VestingReleaseFormProps) { const result = await simulator.checkVestingRelease( formData.vestingContractId, formData.recipientAddress, + formData.scheduleIndex, ); setPreflightResult({ @@ -131,6 +137,22 @@ export function VestingReleaseForm({ onSuccess }: VestingReleaseFormProps) { )} +
+ + + {errors.scheduleIndex && ( +

{errors.scheduleIndex.message}

+ )} +
+ {/* Pre-flight check status */} {preflightResult && (
@@ -173,6 +195,11 @@ export function VestingReleaseForm({ onSuccess }: VestingReleaseFormProps) { const vestingRevokeSchema = z.object({ vestingContractId: z.string().regex(/^C[A-Z0-9]{55}$/, "Invalid vesting contract ID"), recipientAddress: z.string().regex(/^G[A-Z2-7]{55}$/, "Invalid recipient address"), + scheduleIndex: z.coerce + .number() + .int("Must be a whole number") + .min(0, "Must be 0 or greater") + .optional(), }); type VestingRevokeFormData = z.infer; @@ -220,6 +247,7 @@ export function VestingRevokeForm({ adminAddress, onSuccess }: VestingRevokeForm formData.vestingContractId, formData.recipientAddress, adminAddress, + formData.scheduleIndex, ); setPreflightResult({ @@ -293,6 +321,22 @@ export function VestingRevokeForm({ adminAddress, onSuccess }: VestingRevokeForm )}
+
+ + + {errors.scheduleIndex && ( +

{errors.scheduleIndex.message}

+ )} +
+ {/* Pre-flight check status */} {preflightResult && (
diff --git a/frontend/hooks/useSoroban.ts b/frontend/hooks/useSoroban.ts index 2c54bd93..52130b12 100644 --- a/frontend/hooks/useSoroban.ts +++ b/frontend/hooks/useSoroban.ts @@ -40,8 +40,14 @@ export function useSoroban() { ); const fetchVestingSchedule = useCallback( + (vestingContractId: string, recipient: string, scheduleIndex?: number) => + stellar.fetchVestingSchedule(vestingContractId, recipient, networkConfig, scheduleIndex), + [networkConfig], + ); + + const fetchVestingScheduleCount = useCallback( (vestingContractId: string, recipient: string) => - stellar.fetchVestingSchedule(vestingContractId, recipient, networkConfig), + stellar.fetchVestingScheduleCount(vestingContractId, recipient, networkConfig), [networkConfig], ); @@ -91,6 +97,7 @@ export function useSoroban() { fetchTopHolders, fetchCurrentLedger, fetchVestingSchedule, + fetchVestingScheduleCount, fetchSupplyBreakdown, fetchAccountBalances, fetchTransactionHistory, @@ -109,6 +116,7 @@ export function useSoroban() { fetchTopHolders, fetchCurrentLedger, fetchVestingSchedule, + fetchVestingScheduleCount, fetchSupplyBreakdown, fetchAccountBalances, fetchTransactionHistory, diff --git a/frontend/hooks/useTransactionSimulator.ts b/frontend/hooks/useTransactionSimulator.ts index 38b4e2b1..c1c28939 100644 --- a/frontend/hooks/useTransactionSimulator.ts +++ b/frontend/hooks/useTransactionSimulator.ts @@ -130,9 +130,10 @@ export function useTransactionSimulator() { async checkVestingRelease( vestingContractId: string, recipientAddress: string, + scheduleIndex?: number, ): Promise { return runSimulation(() => - simulateVestingRelease(vestingContractId, recipientAddress, networkConfig), + simulateVestingRelease(vestingContractId, recipientAddress, networkConfig, scheduleIndex), ); }, @@ -143,6 +144,7 @@ export function useTransactionSimulator() { vestingContractId: string, recipientAddress: string, adminAddress: string, + scheduleIndex?: number, ): Promise { return runSimulation(() => simulateVestingRevoke( @@ -150,6 +152,7 @@ export function useTransactionSimulator() { recipientAddress, adminAddress, networkConfig, + scheduleIndex, ), ); },