From cf777087a161369b40a5c24c5e1ed41a3e7b6cdd Mon Sep 17 00:00:00 2001 From: damiedee96 Date: Thu, 30 Jul 2026 13:22:26 +0100 Subject: [PATCH] feat: vesting admin dashboard (#369) - Add useVestingDashboard hook that pages through get_recipients_paginated, fetches all schedules per recipient, computes vested/released/remaining client-side, and builds a 12-month unlock projection - Add VestingDashboard component with: - Summary strip: total committed, vested, released, remaining + solvency badge - Stacked-area chart of projected unlocks over next 12 months (Recharts) - Sortable, paginated table: recipient, tranches, total, vested, released, remaining, next unlock date, status chip - Address filter search - CSV export reusing same pattern as HoldersTable - Paused-contract warning banner - Wire VestingDashboard into AdminPanel as a wide card below existing vesting cards; admin enters the vesting contract address to load --- .../[contractId]/components/AdminPanel.tsx | 8 + .../components/admin/VestingDashboard.tsx | 701 ++++++++++++++++++ .../[contractId]/hooks/useVestingDashboard.ts | 522 +++++++++++++ 3 files changed, 1231 insertions(+) create mode 100644 frontend/app/dashboard/[contractId]/components/admin/VestingDashboard.tsx create mode 100644 frontend/app/dashboard/[contractId]/hooks/useVestingDashboard.ts diff --git a/frontend/app/dashboard/[contractId]/components/AdminPanel.tsx b/frontend/app/dashboard/[contractId]/components/AdminPanel.tsx index 8970a464..751621a2 100644 --- a/frontend/app/dashboard/[contractId]/components/AdminPanel.tsx +++ b/frontend/app/dashboard/[contractId]/components/AdminPanel.tsx @@ -16,6 +16,7 @@ import { MintCard } from "./admin/MintCard"; import { SupplyCard } from "./admin/SupplyCard"; import { VestingCard } from "./admin/VestingCard"; import { ManageVestingCard } from "./admin/ManageVestingCard"; +import { VestingDashboard } from "./admin/VestingDashboard"; import { TransferAdminCard, RevokeAdminCard, @@ -182,6 +183,13 @@ export function AdminPanel({ + {/* ── Vesting Dashboard ── */} + + = { + cliff_pending: { + label: "Cliff pending", + className: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20", + icon: , + }, + vesting: { + label: "Vesting", + className: "bg-stellar-500/10 text-stellar-400 border-stellar-500/20", + icon: , + }, + fully_vested: { + label: "Fully vested", + className: "bg-green-500/10 text-green-400 border-green-500/20", + icon: , + }, + revoked: { + label: "Revoked", + className: "bg-red-500/10 text-red-400 border-red-500/20", + icon: , + }, +}; + +function StatusChip({ status }: { status: ScheduleStatus }) { + const meta = STATUS_META[status]; + return ( + + {meta.icon} + {meta.label} + + ); +} + +// --------------------------------------------------------------------------- +// Solvency badge +// --------------------------------------------------------------------------- + +function SolvencyBadge({ solvent }: { solvent: boolean | null }) { + if (solvent === null) return null; + return solvent ? ( + + + Solvent + + ) : ( + + + Underfunded + + ); +} + +// --------------------------------------------------------------------------- +// Summary strip +// --------------------------------------------------------------------------- + +function SummaryStrip({ + summary, + decimals, + symbol, +}: { + summary: VestingDashboardSummary; + decimals: number; + symbol?: string; +}) { + const fmt = (v: bigint) => formatTokenAmount(v, decimals); + const tick = symbol ? ` ${symbol}` : ""; + + return ( +
+
+

+ Total committed +

+

+ {fmt(summary.totalCommitted)} + {tick} +

+
+
+

+ Total vested +

+

+ {fmt(summary.totalVested)} + {tick} +

+
+
+

+ Total released +

+

+ {fmt(summary.totalReleased)} + {tick} +

+
+
+

+ Remaining +

+

+ {fmt(summary.totalRemaining)} + {tick} +

+ {summary.solvent !== null && ( +
+ +
+ )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Unlock projection chart +// --------------------------------------------------------------------------- + +function ProjectionChart({ + projection, + decimals, +}: { + projection: UnlockProjectionPoint[]; + decimals: number; +}) { + const data = projection.map((p) => ({ + label: p.label, + amount: Number(p.amount) / 10 ** decimals, + })); + + const hasData = data.some((d) => d.amount > 0); + + if (!hasData) { + return ( +
+ No projected unlocks in the next 12 months. +
+ ); + } + + return ( + + + + + + + + + + + + v >= 1_000_000 + ? `${(v / 1_000_000).toFixed(1)}M` + : v >= 1_000 + ? `${(v / 1_000).toFixed(1)}K` + : String(v) + } + /> + [v.toLocaleString(), "Unlocks"]} + /> + + + + ); +} + +// --------------------------------------------------------------------------- +// Sortable table +// --------------------------------------------------------------------------- + +type SortField = + | "address" + | "trancheCount" + | "totalAmount" + | "vested" + | "released" + | "remaining" + | "nextUnlockDate" + | "status"; +type SortDir = "asc" | "desc"; + +const ITEMS_PER_PAGE = 10; + +function SortIcon({ + field, + active, + dir, +}: { + field: SortField; + active: SortField; + dir: SortDir; +}) { + if (field !== active) + return ; + return dir === "asc" ? ( + + ) : ( + + ); +} + +function VestingTable({ + rows, + decimals, + symbol, +}: { + rows: RecipientRow[]; + decimals: number; + symbol?: string; +}) { + const [sortField, setSortField] = useState("totalAmount"); + const [sortDir, setSortDir] = useState("desc"); + const [search, setSearch] = useState(""); + const [page, setPage] = useState(1); + const fmt = (v: bigint) => formatTokenAmount(v, decimals); + const tick = symbol ? ` ${symbol}` : ""; + + const toggleSort = (f: SortField) => { + if (f === sortField) setSortDir((d) => (d === "asc" ? "desc" : "asc")); + else { setSortField(f); setSortDir("desc"); } + setPage(1); + }; + + const filtered = useMemo(() => { + if (!search.trim()) return rows; + const q = search.toLowerCase(); + return rows.filter((r) => r.address.toLowerCase().includes(q)); + }, [rows, search]); + + const sorted = useMemo(() => { + return [...filtered].sort((a, b) => { + let cmp = 0; + switch (sortField) { + case "address": + cmp = a.address.localeCompare(b.address); + break; + case "trancheCount": + cmp = a.trancheCount - b.trancheCount; + break; + case "totalAmount": + cmp = a.totalAmount < b.totalAmount ? -1 : a.totalAmount > b.totalAmount ? 1 : 0; + break; + case "vested": + cmp = a.vested < b.vested ? -1 : a.vested > b.vested ? 1 : 0; + break; + case "released": + cmp = a.released < b.released ? -1 : a.released > b.released ? 1 : 0; + break; + case "remaining": + cmp = a.remaining < b.remaining ? -1 : a.remaining > b.remaining ? 1 : 0; + break; + case "nextUnlockDate": + cmp = + (a.nextUnlockDate?.getTime() ?? Infinity) - + (b.nextUnlockDate?.getTime() ?? Infinity); + break; + case "status": + cmp = a.status.localeCompare(b.status); + break; + } + return sortDir === "asc" ? cmp : -cmp; + }); + }, [filtered, sortField, sortDir]); + + const totalPages = Math.max(1, Math.ceil(sorted.length / ITEMS_PER_PAGE)); + const paginated = sorted.slice( + (page - 1) * ITEMS_PER_PAGE, + page * ITEMS_PER_PAGE, + ); + + const Th = ({ + field, + children, + className = "", + }: { + field: SortField; + children: React.ReactNode; + className?: string; + }) => ( + toggleSort(field)} + > + + {children} + + + + ); + + return ( +
+ {/* Search */} + { setSearch(e.target.value); setPage(1); }} + placeholder="Filter by address…" + className="w-full rounded-lg border border-white/10 bg-void-800 px-3 py-2 text-sm text-white placeholder-gray-600 outline-none focus:border-stellar-500 focus:ring-1 focus:ring-stellar-500" + /> + + {/* Table */} +
+ + + + + + + + + + + + + + + {paginated.length === 0 ? ( + + + + ) : ( + paginated.map((row) => ( + + + + + + + + + + + )) + )} + +
RecipientTranchesTotalVestedReleasedRemainingNext unlockStatus
+ {search ? "No recipients match your filter." : "No vesting schedules found."} +
+ + + {row.trancheCount} + + {fmt(row.totalAmount)} + {tick} + + {fmt(row.vested)} + {tick} + + {fmt(row.released)} + {tick} + + {fmt(row.remaining)} + {tick} + + {row.nextUnlockDate + ? row.nextUnlockDate.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) + : "—"} + + +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + {filtered.length} recipient{filtered.length !== 1 ? "s" : ""} + +
+ + + {page} / {totalPages} + + +
+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// CSV export +// --------------------------------------------------------------------------- + +function exportVestingCsv( + rows: RecipientRow[], + decimals: number, + label: string, +) { + const fmt = (v: bigint) => formatTokenAmount(v, decimals); + const header = + "Recipient,Tranches,Total,Vested,Released,Remaining,Next Unlock,Status"; + const csvRows = rows.flatMap((r) => + r.schedules.map( + (s, i) => + `${r.address},${i === 0 ? r.trancheCount : ""},` + + `${i === 0 ? fmt(r.totalAmount) : ""},` + + `${fmt(s.vested)},` + + `${fmt(s.released)},` + + `${fmt(s.remaining)},` + + `${s.nextUnlockDate ? s.nextUnlockDate.toISOString().split("T")[0] : ""},` + + `${s.status}`, + ), + ); + const csv = [header, ...csvRows].join("\n"); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${label}-vesting.csv`; + link.click(); + URL.revokeObjectURL(url); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export function VestingDashboard({ + vestingContractId: initialVestingContractId, + tokenContractId, + decimals, + read, +}: VestingDashboardProps) { + const { data, loading, error, load } = useVestingDashboard(read); + + // Allow the admin to override the vesting contract address directly in the + // dashboard (the prop is a sensible default from the token contract context, + // but an admin may manage multiple vesting contracts). + const [vestingInput, setVestingInput] = useState( + initialVestingContractId ?? "", + ); + + // Propagate external prop changes into the input. + useEffect(() => { + if (initialVestingContractId) { + setVestingInput(initialVestingContractId); + } + }, [initialVestingContractId]); + + const handleLoad = () => { + if (vestingInput.trim()) { + load(vestingInput.trim(), tokenContractId); + } + }; + + const symbol = undefined; // callers can pass tokenSymbol via props if needed. + + return ( + + {data && ( + + )} + + + } + > +
+ {/* Vesting contract address input */} +
+ setVestingInput(e.target.value)} + placeholder="Vesting contract address (C…)" + className="flex-1 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder-gray-600 outline-none focus:border-stellar-500 focus:ring-1 focus:ring-stellar-500" + onKeyDown={(e) => e.key === "Enter" && handleLoad()} + /> + +
+ + {/* Loading */} + {loading && ( +
+ + Fetching all recipients and schedules… +
+ )} + + {/* Error */} + {!loading && error && ( +
+ + {error} +
+ )} + + {/* Content */} + {!loading && data && ( + <> + {/* Summary strip */} + + + {/* Paused warning */} + {data.summary.isPaused && ( +
+ + Vesting contract is currently paused — releases are blocked. +
+ )} + + {/* 12-month projection chart */} + {data.rows.length > 0 && ( +
+

+ Projected unlocks — next 12 months +

+ +
+ )} + + {/* Recipient table */} +
+

+ Recipients ({data.rows.length}) +

+ {data.rows.length === 0 ? ( +

+ No vesting schedules found on this contract. +

+ ) : ( + + )} +
+ + )} +
+
+ ); +} diff --git a/frontend/app/dashboard/[contractId]/hooks/useVestingDashboard.ts b/frontend/app/dashboard/[contractId]/hooks/useVestingDashboard.ts new file mode 100644 index 00000000..9b97d1fb --- /dev/null +++ b/frontend/app/dashboard/[contractId]/hooks/useVestingDashboard.ts @@ -0,0 +1,522 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { Address, nativeToScVal } from "@stellar/stellar-sdk"; +import type { ContractReadFn } from "./useContractRead"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ScheduleStatus = + | "cliff_pending" + | "vesting" + | "fully_vested" + | "revoked"; + +export interface ScheduleRow { + recipient: string; + scheduleIndex: number; + totalAmount: bigint; + vested: bigint; + released: bigint; + remaining: bigint; + cliffLedger: number; + endLedger: number; + revoked: boolean; + status: ScheduleStatus; + /** Approximate next unlock date (null if revoked or fully vested). */ + nextUnlockDate: Date | null; +} + +export interface RecipientRow { + address: string; + trancheCount: number; + totalAmount: bigint; + vested: bigint; + released: bigint; + remaining: bigint; + /** Soonest cliff / unlock among active schedules. */ + nextUnlockDate: Date | null; + /** Worst-case status across all schedules. */ + status: ScheduleStatus; + schedules: ScheduleRow[]; +} + +export interface VestingDashboardSummary { + totalCommitted: bigint; + totalVested: bigint; + totalReleased: bigint; + totalRemaining: bigint; + /** Tokens held by the vesting contract address (if readable). */ + contractBalance: bigint | null; + /** contractBalance >= totalRemaining — null when balance is unavailable. */ + solvent: boolean | null; + isPaused: boolean; +} + +/** One bar in the 12-month projected unlock chart. */ +export interface UnlockProjectionPoint { + label: string; // e.g. "Aug 2025" + amount: bigint; +} + +export interface VestingDashboardData { + rows: RecipientRow[]; + summary: VestingDashboardSummary; + projection: UnlockProjectionPoint[]; + currentLedger: number; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Soroban: ~5 s per ledger. */ +const LEDGERS_PER_DAY = 17_280; +const PAGE_SIZE = 20; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function ledgerToDate(ledger: number, currentLedger: number): Date { + const now = new Date(); + const deltaMs = ((ledger - currentLedger) / LEDGERS_PER_DAY) * 86_400_000; + return new Date(now.getTime() + deltaMs); +} + +function computeVested( + total: bigint, + cliffLedger: number, + endLedger: number, + currentLedger: number, +): bigint { + if (currentLedger < cliffLedger) return 0n; + if (currentLedger >= endLedger) return total; + const elapsed = BigInt(currentLedger - cliffLedger); + const duration = BigInt(endLedger - cliffLedger); + return (total * elapsed) / duration; +} + +function scheduleStatus( + schedule: Pick< + ScheduleRow, + "revoked" | "cliffLedger" | "endLedger" | "vested" | "totalAmount" + >, + currentLedger: number, +): ScheduleStatus { + if (schedule.revoked) return "revoked"; + if (currentLedger < schedule.cliffLedger) return "cliff_pending"; + if (schedule.vested >= schedule.totalAmount) return "fully_vested"; + return "vesting"; +} + +/** Worst-case status order for a recipient row. */ +const STATUS_RANK: Record = { + cliff_pending: 0, + vesting: 1, + fully_vested: 2, + revoked: 3, +}; + +function worstStatus(statuses: ScheduleStatus[]): ScheduleStatus { + if (!statuses.length) return "vesting"; + return statuses.reduce((a, b) => + STATUS_RANK[a] <= STATUS_RANK[b] ? a : b, + ); +} + +/** Label for a month offset from now. */ +function monthLabel(offset: number): string { + const d = new Date(); + d.setMonth(d.getMonth() + offset); + return d.toLocaleDateString("en-US", { month: "short", year: "numeric" }); +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +export interface UseVestingDashboardResult { + data: VestingDashboardData | null; + loading: boolean; + error: string | null; + load: (vestingContractId: string, tokenContractId?: string) => Promise; +} + +export function useVestingDashboard( + read: ContractReadFn, +): UseVestingDashboardResult { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback( + async (vestingContractId: string, tokenContractId?: string) => { + setLoading(true); + setError(null); + setData(null); + + // We need a separate reader pointed at the vesting contract. + // `read` from the hook is bound to the token contractId, so we build + // an inline read wrapper that swaps the contractId via a closure. + // To avoid the re-entrancy issue we accept the vestingContractId param + // and pass it to the shared `read` via its closure over contractId. + // Since `read` is already bound to a specific contract, we can't + // reuse it for a different contract. Instead we re-import and build + // an ad-hoc caller at the module level — see `readVesting` below. + + try { + // 1. Fetch current ledger via a known-good call on the vesting contract. + // We use `get_recipient_count` as a lightweight probe that also + // surfaces the total count without a second round-trip. + const countRaw = await readVestingContract( + vestingContractId, + "get_recipient_count", + [], + ); + const recipientCount = + typeof countRaw === "number" + ? countRaw + : typeof countRaw === "bigint" + ? Number(countRaw) + : 0; + + // 2. Get current ledger for vested amount calculation. + const currentLedger = await fetchCurrentLedger(vestingContractId); + + // 3. Fetch is_paused. + const pausedRaw = await readVestingContract( + vestingContractId, + "is_paused", + [], + ); + const isPaused = pausedRaw === true; + + // 4. Page through all recipients. + const allRecipients: string[] = []; + let start = 0; + while (start < recipientCount) { + const page = await readVestingContract( + vestingContractId, + "get_recipients_paginated", + [ + nativeToScVal(start, { type: "u32" }), + nativeToScVal(Math.min(PAGE_SIZE, recipientCount - start), { + type: "u32", + }), + ], + ); + const addrs = Array.isArray(page) ? (page as string[]) : []; + allRecipients.push(...addrs); + start += PAGE_SIZE; + if (addrs.length === 0) break; // safety guard against pruned-only pages + } + + // 5. For each recipient: fetch schedule count, then all schedules. + const recipientRows: RecipientRow[] = []; + + for (const address of allRecipients) { + const addrScVal = new Address(address).toScVal(); + + const countResult = await readVestingContract( + vestingContractId, + "get_schedule_count", + [addrScVal], + ); + const schedCount = + typeof countResult === "number" + ? countResult + : typeof countResult === "bigint" + ? Number(countResult) + : 0; + + if (schedCount === 0) continue; + + const scheduleRows: ScheduleRow[] = []; + + for (let i = 0; i < schedCount; i++) { + const schedRaw = await readVestingContract( + vestingContractId, + "get_schedule", + [addrScVal, nativeToScVal(i, { type: "u32" })], + ); + + if (!schedRaw || typeof schedRaw !== "object") continue; + + const s = schedRaw as Record; + + const totalAmount = bigintFrom(s["total_amount"] ?? s["totalAmount"]); + const released = bigintFrom(s["released"]); + const cliffLedger = numberFrom(s["cliff_ledger"] ?? s["cliffLedger"]); + const endLedger = numberFrom(s["end_ledger"] ?? s["endLedger"]); + const revoked = Boolean(s["revoked"]); + + const vested = revoked + ? totalAmount + : computeVested(totalAmount, cliffLedger, endLedger, currentLedger); + const remaining = revoked ? 0n : totalAmount - released; + + const row: ScheduleRow = { + recipient: address, + scheduleIndex: i, + totalAmount, + vested, + released, + remaining, + cliffLedger, + endLedger, + revoked, + status: "vesting", + nextUnlockDate: null, + }; + + row.status = scheduleStatus(row, currentLedger); + + // Next unlock = cliff date if still in cliff, otherwise null (already + // unlocking linearly — no single discrete "next" unlock). + if (row.status === "cliff_pending") { + row.nextUnlockDate = ledgerToDate(cliffLedger, currentLedger); + } else if (row.status === "vesting") { + // Show end date (full vest) as a reference point. + row.nextUnlockDate = ledgerToDate(endLedger, currentLedger); + } + + scheduleRows.push(row); + } + + if (scheduleRows.length === 0) continue; + + const totalAmount = scheduleRows.reduce( + (s, r) => s + r.totalAmount, + 0n, + ); + const vested = scheduleRows.reduce((s, r) => s + r.vested, 0n); + const released = scheduleRows.reduce((s, r) => s + r.released, 0n); + const remaining = scheduleRows.reduce((s, r) => s + r.remaining, 0n); + + const activeUnlockDates = scheduleRows + .filter((r) => r.nextUnlockDate !== null) + .map((r) => r.nextUnlockDate!.getTime()); + const nextUnlockDate = + activeUnlockDates.length > 0 + ? new Date(Math.min(...activeUnlockDates)) + : null; + + recipientRows.push({ + address, + trancheCount: scheduleRows.length, + totalAmount, + vested, + released, + remaining, + nextUnlockDate, + status: worstStatus(scheduleRows.map((r) => r.status)), + schedules: scheduleRows, + }); + } + + // 6. Compute summary. + const totalCommitted = recipientRows.reduce( + (s, r) => s + r.totalAmount, + 0n, + ); + const totalVested = recipientRows.reduce((s, r) => s + r.vested, 0n); + const totalReleased = recipientRows.reduce( + (s, r) => s + r.released, + 0n, + ); + const totalRemaining = recipientRows.reduce( + (s, r) => s + r.remaining, + 0n, + ); + + // 7. Solvency: try to read the vesting contract's token balance. + let contractBalance: bigint | null = null; + let solvent: boolean | null = null; + + if (tokenContractId) { + try { + const balRaw = await readAnyContract( + tokenContractId, + "balance", + [new Address(vestingContractId).toScVal()], + ); + if (balRaw !== null && balRaw !== undefined) { + contractBalance = bigintFrom(balRaw); + solvent = contractBalance >= totalRemaining; + } + } catch { + // Non-fatal; solvency badge simply won't appear. + } + } + + // 8. Build 12-month projection. + // For each active schedule, compute how many tokens unlock in each + // of the next 12 monthly buckets. + const now = new Date(); + const projection: UnlockProjectionPoint[] = Array.from( + { length: 12 }, + (_, i) => ({ + label: monthLabel(i + 1), + amount: 0n, + }), + ); + + for (const recip of recipientRows) { + for (const sched of recip.schedules) { + if (sched.revoked || sched.vested >= sched.totalAmount) continue; + + for (let mi = 0; mi < 12; mi++) { + const monthStartMs = (() => { + const d = new Date(now); + d.setMonth(d.getMonth() + mi); + return d.getTime(); + })(); + const monthEndMs = (() => { + const d = new Date(now); + d.setMonth(d.getMonth() + mi + 1); + return d.getTime(); + })(); + + const deltaStart = monthStartMs - now.getTime(); + const deltaEnd = monthEndMs - now.getTime(); + + const ledgerStart = + currentLedger + + Math.round((deltaStart / 86_400_000) * LEDGERS_PER_DAY); + const ledgerEnd = + currentLedger + + Math.round((deltaEnd / 86_400_000) * LEDGERS_PER_DAY); + + const vestedAtStart = computeVested( + sched.totalAmount, + sched.cliffLedger, + sched.endLedger, + Math.max(currentLedger, ledgerStart), + ); + const vestedAtEnd = computeVested( + sched.totalAmount, + sched.cliffLedger, + sched.endLedger, + ledgerEnd, + ); + + const unlockInBucket = vestedAtEnd - vestedAtStart; + if (unlockInBucket > 0n) { + projection[mi].amount += unlockInBucket; + } + } + } + } + + setData({ + rows: recipientRows, + summary: { + totalCommitted, + totalVested, + totalReleased, + totalRemaining, + contractBalance, + solvent, + isPaused, + }, + projection, + currentLedger, + }); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to load vesting data.", + ); + } finally { + setLoading(false); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + return { data, loading, error, load }; +} + +// --------------------------------------------------------------------------- +// Module-level helpers that bypass the hook's bound contractId +// --------------------------------------------------------------------------- + +async function readVestingContract( + contractId: string, + method: string, + args: import("@stellar/stellar-sdk").xdr.ScVal[], +): Promise { + return readAnyContract(contractId, method, args); +} + +async function readAnyContract( + contractId: string, + method: string, + args: import("@stellar/stellar-sdk").xdr.ScVal[], +): Promise { + const { + TransactionBuilder, + rpc, + Contract, + Account, + Networks, + } = await import("@stellar/stellar-sdk"); + const { scValToNative } = await import("@/lib/soroban"); + + const RPC_URL = + typeof window !== "undefined" && (window as Record).__SOROPAD_RPC + ? String((window as Record).__SOROPAD_RPC) + : process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ?? + "https://soroban-testnet.stellar.org"; + + const PASSPHRASE = + process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE ?? Networks.TESTNET; + + const READ_ONLY_SOURCE = + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + + const server = new rpc.Server(RPC_URL); + const tx = new TransactionBuilder(new Account(READ_ONLY_SOURCE, "0"), { + fee: "100", + networkPassphrase: PASSPHRASE, + }) + .addOperation(new Contract(contractId).call(method, ...args)) + .setTimeout(30) + .build(); + + const sim = await server.simulateTransaction(tx); + if (rpc.Api.isSimulationError(sim)) return null; + if (!rpc.Api.isSimulationSuccess(sim) || !sim.result) return null; + return scValToNative(sim.result.retval); +} + +async function fetchCurrentLedger(_vestingContractId: string): Promise { + const { rpc } = await import("@stellar/stellar-sdk"); + const RPC_URL = + process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ?? + "https://soroban-testnet.stellar.org"; + const server = new rpc.Server(RPC_URL); + const latest = await server.getLatestLedger(); + return latest.sequence; +} + +// --------------------------------------------------------------------------- +// Safe coercions from scValToNative's output +// --------------------------------------------------------------------------- + +function bigintFrom(v: unknown): bigint { + if (typeof v === "bigint") return v; + if (typeof v === "number") return BigInt(v); + if (typeof v === "string") return BigInt(v); + return 0n; +} + +function numberFrom(v: unknown): number { + if (typeof v === "number") return v; + if (typeof v === "bigint") return Number(v); + if (typeof v === "string") return parseInt(v, 10); + return 0; +}