From 5a295895d2d6aca0dad30a5caf5d0aba3a237e08 Mon Sep 17 00:00:00 2001 From: neumattock <152253273+newmattock@users.noreply.github.com> Date: Tue, 26 May 2026 11:15:12 -0700 Subject: [PATCH] feat: build LP portfolio dashboard --- .../__tests__/LPPortfolioDashboard.test.tsx | 107 +++++++++++ app/dashboard/lp/page.tsx | 126 ++++++++++++ app/governance/page.tsx | 14 +- src/components/LPPositionTable.tsx | 117 ++++++++++++ src/components/LPStatsCards.tsx | 75 ++++++++ src/components/TokenSelector.tsx | 4 +- src/utils/evidence.ts | 2 +- src/utils/federation.ts | 8 +- src/utils/soroban.ts | 179 +++++++++++++++--- 9 files changed, 601 insertions(+), 31 deletions(-) create mode 100644 app/dashboard/lp/__tests__/LPPortfolioDashboard.test.tsx create mode 100644 app/dashboard/lp/page.tsx create mode 100644 src/components/LPPositionTable.tsx create mode 100644 src/components/LPStatsCards.tsx diff --git a/app/dashboard/lp/__tests__/LPPortfolioDashboard.test.tsx b/app/dashboard/lp/__tests__/LPPortfolioDashboard.test.tsx new file mode 100644 index 0000000..5cd3f12 --- /dev/null +++ b/app/dashboard/lp/__tests__/LPPortfolioDashboard.test.tsx @@ -0,0 +1,107 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import LPPortfolioDashboardPage from "../page"; +import { getLPPortfolioStats, listInvoicesByLP } from "@/utils/soroban"; + +const LP_ADDRESS = "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC6"; + +vi.mock("@/components/Navbar", () => ({ + default: () => , +})); + +vi.mock("@/components/Footer", () => ({ + default: () => , +})); + +vi.mock("@/hooks/useDocumentTitle", () => ({ + useDocumentTitle: vi.fn(), +})); + +vi.mock("@/context/WalletContext", () => ({ + useWallet: vi.fn(() => ({ + address: LP_ADDRESS, + isConnected: true, + connect: vi.fn(), + })), +})); + +vi.mock("@/hooks/useApprovedTokens", () => ({ + useApprovedTokens: () => { + const token = { + contractId: "USDC", + name: "USD Coin", + symbol: "USDC", + decimals: 7, + iconLabel: "US", + logo: "/tokens/usdc.svg", + isAllowed: true, + }; + return { + tokenMap: new Map([["USDC", token]]), + defaultToken: token, + }; + }, +})); + +vi.mock("@/utils/soroban", async () => { + const actual = await vi.importActual("@/utils/soroban"); + return { + ...actual, + getLPPortfolioStats: vi.fn(), + listInvoicesByLP: vi.fn(), + }; +}); + +const mockStats = { + total_deployed_by_token: [{ token: "USDC", amount: 250_000_000n }], + total_earned: 12_500_000n, + active_positions_count: 1, + average_yield_bps: 500, +}; + +const mockPosition = { + id: 42n, + status: "Funded", + freelancer: "GFREELANCER", + payer: "GPAYER", + amount: 250_000_000n, + due_date: 1_800_000_000n, + discount_rate: 500, + funder: LP_ADDRESS, + token: "USDC", +}; + +describe("LPPortfolioDashboardPage", () => { + beforeEach(() => { + vi.mocked(getLPPortfolioStats).mockResolvedValue(mockStats); + vi.mocked(listInvoicesByLP).mockResolvedValue([mockPosition]); + }); + + it("loads LP stats and positions for the connected wallet", async () => { + render(); + + expect(await screen.findByText("LP Portfolio Dashboard")).toBeInTheDocument(); + await waitFor(() => expect(getLPPortfolioStats).toHaveBeenCalledWith(LP_ADDRESS)); + expect(listInvoicesByLP).toHaveBeenCalledWith(LP_ADDRESS, 0, 10); + + expect(screen.getAllByText("25 USDC")).toHaveLength(2); + expect(screen.getAllByText("1.25 USDC")).toHaveLength(2); + expect(screen.getByText((_content, element) => element?.textContent === "5.00%")).toBeInTheDocument(); + expect(screen.getByText("#42")).toBeInTheDocument(); + expect(screen.getByText("Transfer Position")).toBeInTheDocument(); + }); + + it("paginates LP positions through list_invoices_by_lp", async () => { + vi.mocked(listInvoicesByLP).mockResolvedValue(Array.from({ length: 10 }, (_, index) => ({ + ...mockPosition, + id: BigInt(index + 1), + }))); + + render(); + const nextButton = await screen.findByRole("button", { name: /next/i }); + await waitFor(() => expect(nextButton).not.toBeDisabled()); + fireEvent.click(nextButton); + + await waitFor(() => expect(listInvoicesByLP).toHaveBeenLastCalledWith(LP_ADDRESS, 1, 10)); + }); +}); diff --git a/app/dashboard/lp/page.tsx b/app/dashboard/lp/page.tsx new file mode 100644 index 0000000..0d74336 --- /dev/null +++ b/app/dashboard/lp/page.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Footer from "@/components/Footer"; +import LPPositionTable from "@/components/LPPositionTable"; +import LPStatsCards from "@/components/LPStatsCards"; +import Navbar from "@/components/Navbar"; +import { useWallet } from "@/context/WalletContext"; +import { useApprovedTokens } from "@/hooks/useApprovedTokens"; +import { useDocumentTitle } from "@/hooks/useDocumentTitle"; +import { + getLPPortfolioStats, + listInvoicesByLP, + type Invoice, + type LPPortfolioStats, +} from "@/utils/soroban"; + +const PAGE_SIZE = 10; + +export default function LPPortfolioDashboardPage() { + useDocumentTitle({ pageTitle: "LP Portfolio" }); + + const { address, isConnected, connect } = useWallet(); + const { tokenMap, defaultToken } = useApprovedTokens(); + const [stats, setStats] = useState(null); + const [positions, setPositions] = useState([]); + const [page, setPage] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const loadPortfolio = useCallback(async () => { + if (!address) return; + + setIsLoading(true); + setError(null); + try { + const [nextStats, nextPositions] = await Promise.all([ + getLPPortfolioStats(address), + listInvoicesByLP(address, page, PAGE_SIZE), + ]); + setStats(nextStats); + setPositions(nextPositions.filter((position) => position.status === "Funded")); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : "Failed to load LP portfolio."); + setStats(null); + setPositions([]); + } finally { + setIsLoading(false); + } + }, [address, page]); + + useEffect(() => { + const timeout = window.setTimeout(() => { + void loadPortfolio(); + }, 0); + + return () => window.clearTimeout(timeout); + }, [loadPortfolio]); + + return ( +
+ + +
+
+
+
+

Liquidity Provider

+

LP Portfolio Dashboard

+

+ Track deployed capital, yield, and active funded invoices for the connected wallet. +

+
+ {!isConnected && ( + + )} +
+
+
+ +
+ {!address ? ( +
+ account_balance_wallet +

Connect an LP wallet

+

+ Portfolio stats and active positions are scoped to the wallet that funded invoices. +

+
+ ) : ( + <> + {error && ( +
+ {error} +
+ )} + + setPage(Math.max(0, nextPage))} + /> + + )} +
+ +
+
+ ); +} diff --git a/app/governance/page.tsx b/app/governance/page.tsx index 87a40dd..8b895a2 100644 --- a/app/governance/page.tsx +++ b/app/governance/page.tsx @@ -23,6 +23,7 @@ function StatusBadge({ status }: { status: ProposalStatus }) { Failed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "cancel" }, Executed: { color: "bg-purple-500/15 text-purple-500 border-purple-500/30", icon: "rocket_launch" }, Pending: { color: "bg-amber-500/15 text-amber-500 border-amber-500/30", icon: "schedule" }, + Vetoed: { color: "bg-orange-500/15 text-orange-500 border-orange-500/30", icon: "block" }, }; const { color, icon } = config[status]; return ( @@ -121,7 +122,7 @@ function ProposalCard({ proposal }: { proposal: Proposal }) { // ─── Filter tab ─────────────────────────────────────────────────────────────── -const FILTERS: Array = ["All", "Active", "Passed", "Failed", "Executed", "Pending"]; +const FILTERS: Array = ["All", "Active", "Passed", "Failed", "Executed", "Pending", "Vetoed"]; function FilterTabs({ active, @@ -172,10 +173,15 @@ export default function GovernancePage() { }, []); useEffect(() => { - load(); + const timeout = window.setTimeout(() => { + void load(); + }, 0); // Refresh every 30 s for real-time vote counts - const interval = setInterval(load, 30_000); - return () => clearInterval(interval); + const interval = window.setInterval(load, 30_000); + return () => { + window.clearTimeout(timeout); + window.clearInterval(interval); + }; }, [load]); const filtered = diff --git a/src/components/LPPositionTable.tsx b/src/components/LPPositionTable.tsx new file mode 100644 index 0000000..9e42b03 --- /dev/null +++ b/src/components/LPPositionTable.tsx @@ -0,0 +1,117 @@ +"use client"; + +import Link from "next/link"; +import type { ApprovedToken } from "@/hooks/useApprovedTokens"; +import type { Invoice } from "@/utils/soroban"; +import { calculateYield, formatDate, formatTokenAmount } from "@/utils/format"; +import InvoiceStatusBadge from "./InvoiceStatusBadge"; +import SkeletonRow from "./SkeletonRow"; + +interface LPPositionTableProps { + positions: Invoice[]; + isLoading: boolean; + page: number; + pageSize: number; + hasNextPage: boolean; + tokenMap: Map; + defaultToken: ApprovedToken | null; + onPageChange: (page: number) => void; +} + +const POSITION_COLUMNS = ["w-8", "w-24", "w-16", "w-20", "w-20", "w-20", "w-28"]; + +function resolveToken(invoice: Invoice, tokenMap: Map, defaultToken: ApprovedToken | null) { + return tokenMap.get(invoice.token ?? "") ?? defaultToken ?? { symbol: "USDC", decimals: 7 }; +} + +export default function LPPositionTable({ + positions, + isLoading, + page, + pageSize, + hasNextPage, + tokenMap, + defaultToken, + onPageChange, +}: LPPositionTableProps) { + return ( +
+
+
+

Active Funded Invoices

+

Positions funded by the connected LP wallet.

+
+
+ + Page {page + 1} + +
+
+ +
+ + + + {["Invoice ID", "Amount Funded", "Token", "Effective Yield", "Due Date", "Status", ""].map((heading) => ( + + ))} + + + + {isLoading ? ( + Array.from({ length: Math.min(pageSize, 5) }).map((_, index) => ( + + )) + ) : positions.length > 0 ? ( + positions.map((position) => { + const token = resolveToken(position, tokenMap, defaultToken); + return ( + + + + + + + + + + ); + }) + ) : ( + + + + )} + +
+ {heading} +
#{position.id.toString()}{formatTokenAmount(position.amount, token)}{token.symbol}{formatTokenAmount(calculateYield(position.amount, position.discount_rate), token)}{formatDate(position.due_date)} + + + + Transfer Position + +
+ No active funded invoices found for this LP wallet. +
+
+
+ ); +} diff --git a/src/components/LPStatsCards.tsx b/src/components/LPStatsCards.tsx new file mode 100644 index 0000000..826db07 --- /dev/null +++ b/src/components/LPStatsCards.tsx @@ -0,0 +1,75 @@ +"use client"; + +import type { ApprovedToken } from "@/hooks/useApprovedTokens"; +import type { LPPortfolioStats } from "@/utils/soroban"; +import { formatTokenAmount } from "@/utils/format"; + +interface LPStatsCardsProps { + stats: LPPortfolioStats | null; + tokenMap: Map; + defaultToken: ApprovedToken | null; + isLoading: boolean; +} + +function SkeletonValue() { + return ; +} + +function resolveToken( + tokenId: string, + tokenMap: Map, + defaultToken: ApprovedToken | null, +) { + return tokenMap.get(tokenId) ?? defaultToken ?? { symbol: "USDC", decimals: 7 }; +} + +export default function LPStatsCards({ stats, tokenMap, defaultToken, isLoading }: LPStatsCardsProps) { + const deployed = stats?.total_deployed_by_token ?? []; + const totalEarned = stats?.total_earned ?? 0n; + const activePositions = stats?.active_positions_count ?? 0; + const averageYield = ((stats?.average_yield_bps ?? 0) / 100).toFixed(2); + const earnedToken = resolveToken(deployed[0]?.token ?? "", tokenMap, defaultToken); + + return ( +
+
+

Total Deployed Capital

+ {isLoading ? ( + + ) : deployed.length > 0 ? ( +
+ {deployed.map((entry) => { + const token = resolveToken(entry.token, tokenMap, defaultToken); + return ( +

+ {formatTokenAmount(entry.amount, token)} +

+ ); + })} +
+ ) : ( +

0

+ )} +
+ +
+

Total Earned

+ {isLoading ? ( + + ) : ( +

{formatTokenAmount(totalEarned, earnedToken)}

+ )} +
+ +
+

Active Positions

+ {isLoading ? :

{activePositions}

} +
+ +
+

Average Yield %

+ {isLoading ? :

{averageYield}%

} +
+
+ ); +} diff --git a/src/components/TokenSelector.tsx b/src/components/TokenSelector.tsx index a157f15..e52bb44 100644 --- a/src/components/TokenSelector.tsx +++ b/src/components/TokenSelector.tsx @@ -43,11 +43,11 @@ function getTokenName(token: TokenLike): string { return token.name ?? token.symbol; } -function getTokenLogo(token: TokenLike): string { +function getTokenLogo(token: Pick): string { return token.logo ?? `/tokens/${token.symbol.toLowerCase()}.svg`; } -function getTokenIconLabel(token: TokenLike): string { +function getTokenIconLabel(token: Pick): string { return token.iconLabel ?? (token.symbol.replace(/[^A-Z0-9]/gi, "").slice(0, 2).toUpperCase() || "TK"); } diff --git a/src/utils/evidence.ts b/src/utils/evidence.ts index 55396ad..c661f8f 100644 --- a/src/utils/evidence.ts +++ b/src/utils/evidence.ts @@ -6,7 +6,7 @@ export async function hashEvidence(text: string): Promise { if (typeof crypto !== "undefined" && crypto.subtle) { const encoded = new TextEncoder().encode(normalized); - const digest = await crypto.subtle.digest("SHA-256", encoded); + const digest = await crypto.subtle.digest("SHA-256", encoded.buffer as ArrayBuffer); return Array.from(new Uint8Array(digest)) .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); diff --git a/src/utils/federation.ts b/src/utils/federation.ts index 11922eb..d1b78c2 100644 --- a/src/utils/federation.ts +++ b/src/utils/federation.ts @@ -4,6 +4,11 @@ import { RPC_URL } from "@/constants"; const horizonServer = new rpc.Server(RPC_URL); const federationCache = new Map(); +interface AccountHomeDomain { + home_domain?: string; + homeDomain?: string; +} + export async function resolveFederatedAddress(address: string): Promise { if (!address) return address; const cached = federationCache.get(address); @@ -11,7 +16,8 @@ export async function resolveFederatedAddress(address: string): Promise try { const account = await horizonServer.getAccount(address); - const homeDomain = account.home_domain ?? (account as any).homeDomain; + const { home_domain: homeDomainSnake, homeDomain: homeDomainCamel } = account as AccountHomeDomain; + const homeDomain = homeDomainSnake ?? homeDomainCamel; if (!homeDomain) return address; const stellarTomlResponse = await fetch(`https://${homeDomain}/.well-known/stellar.toml`); diff --git a/src/utils/soroban.ts b/src/utils/soroban.ts index 4b99e8c..bf639d6 100644 --- a/src/utils/soroban.ts +++ b/src/utils/soroban.ts @@ -73,6 +73,18 @@ export interface ReputationEvent { score?: number; } +export interface LPTokenDeployment { + token: string; + amount: bigint; +} + +export interface LPPortfolioStats { + total_deployed_by_token: LPTokenDeployment[]; + total_earned: bigint; + active_positions_count: number; + average_yield_bps: number; +} + // ─── Private helpers ────────────────────────────────────────────────────────── const KNOWN_TOKEN_METADATA: Record> = { @@ -133,6 +145,84 @@ function extractInvoiceIdFromTransaction(result: unknown): bigint | null { return null; } +function toBigInt(value: unknown): bigint { + if (typeof value === "bigint") return value; + if (typeof value === "number" && Number.isFinite(value)) return BigInt(Math.trunc(value)); + if (typeof value === "string" && value.trim()) return BigInt(value); + return 0n; +} + +function toNumber(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "bigint") return Number(value); + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} + +function parseTokenDeployments(value: unknown): LPTokenDeployment[] { + if (!value) return []; + + if (value instanceof Map) { + return Array.from(value.entries()).map(([token, amount]) => ({ + token: String(token), + amount: toBigInt(amount), + })); + } + + if (Array.isArray(value)) { + return value + .map((entry) => { + if (Array.isArray(entry) && entry.length >= 2) { + return { token: String(entry[0]), amount: toBigInt(entry[1]) }; + } + if (entry && typeof entry === "object") { + const record = entry as Record; + return { + token: String(record.token ?? record.contract_id ?? record.contractId ?? ""), + amount: toBigInt(record.amount ?? record.total ?? record.deployed), + }; + } + return null; + }) + .filter((entry): entry is LPTokenDeployment => Boolean(entry?.token)); + } + + if (typeof value === "object") { + return Object.entries(value as Record).map(([token, amount]) => ({ + token, + amount: toBigInt(amount), + })); + } + + return []; +} + +function getSimulationError(result: unknown): string { + if (result && typeof result === "object" && "error" in result) { + const error = (result as { error?: unknown }).error; + return typeof error === "string" ? error : JSON.stringify(error); + } + return "unknown simulation error"; +} + +function parseInvoice(native: Record): Invoice { + return { + id: toBigInt(native.id), + freelancer: String(native.freelancer ?? ""), + payer: String(native.payer ?? ""), + amount: toBigInt(native.amount), + due_date: toBigInt(native.due_date), + discount_rate: toNumber(native.discount_rate), + status: parseStatus(native.status), + funder: native.funder === undefined ? undefined : String(native.funder), + funded_at: native.funded_at === undefined ? undefined : toBigInt(native.funded_at), + token: native.token === undefined ? undefined : String(native.token), + }; +} + async function readTokenContractValue(tokenId: string, method: string): Promise { const callResult = await server.simulateTransaction(buildReadTransaction(tokenId, method, [])); if (!rpc.Api.isSimulationSuccess(callResult) || !callResult.result?.retval) { @@ -164,18 +254,7 @@ export async function getInvoice(id: bigint): Promise { ); if (rpc.Api.isSimulationSuccess(callResult)) { const native = scValToNative(callResult.result!.retval); - return { - id: native.id, - freelancer: native.freelancer, - payer: native.payer, - amount: native.amount, - due_date: native.due_date, - discount_rate: native.discount_rate, - status: parseStatus(native.status), - funder: native.funder, - funded_at: native.funded_at, - token: native.token, - }; + return parseInvoice(native as Record); } throw new Error(`Failed to get invoice ${id}`); } @@ -278,6 +357,60 @@ export async function getUsdcAllowance(args: { return getTokenAllowance(args); } +export async function getLPPortfolioStats(lpAddress: string): Promise { + const params: xdr.ScVal[] = [Address.fromString(lpAddress).toScVal()]; + const callResult = await server.simulateTransaction( + buildReadTransaction(CONTRACT_ID, "get_lp_portfolio_stats", params) + ); + + if (!rpc.Api.isSimulationSuccess(callResult) || !callResult.result?.retval) { + throw new Error("Failed to fetch LP portfolio stats."); + } + + const native = scValToNative(callResult.result.retval) as Record; + return { + total_deployed_by_token: parseTokenDeployments( + native.total_deployed_by_token ?? native.totalDeployedByToken ?? native.deployed_by_token, + ), + total_earned: toBigInt(native.total_earned ?? native.totalEarned), + active_positions_count: toNumber( + native.active_positions_count ?? native.activePositionsCount ?? native.active_positions, + ), + average_yield_bps: toNumber(native.average_yield_bps ?? native.averageYieldBps ?? native.average_yield), + }; +} + +export async function listInvoicesByLP( + lpAddress: string, + page = 0, + pageSize = 10 +): Promise { + const offset = Math.max(0, page) * Math.max(1, pageSize); + const params: xdr.ScVal[] = [ + Address.fromString(lpAddress).toScVal(), + nativeToScVal(offset, { type: "u32" }), + nativeToScVal(pageSize, { type: "u32" }), + ]; + const callResult = await server.simulateTransaction( + buildReadTransaction(CONTRACT_ID, "list_invoices_by_lp", params) + ); + + if (!rpc.Api.isSimulationSuccess(callResult) || !callResult.result?.retval) { + throw new Error("Failed to fetch LP positions."); + } + + const native = scValToNative(callResult.result.retval); + const rows = Array.isArray(native) + ? native + : native && typeof native === "object" && Array.isArray((native as { invoices?: unknown[] }).invoices) + ? (native as { invoices: unknown[] }).invoices + : []; + + return rows + .filter((row): row is Record => Boolean(row) && typeof row === "object") + .map(parseInvoice); +} + /** Returns the invoice amount — used to pass the correct funding amount to fund_invoice. */ async function getInvoiceRequiredFunding(invoiceId: bigint): Promise { const invoice = await getInvoice(invoiceId); @@ -580,7 +713,7 @@ export async function submitInvoice( const sim = await server.simulateTransaction(tx); if (!rpc.Api.isSimulationSuccess(sim)) { - throw new Error(`Simulation failed: ${(sim as any).error}`); + throw new Error(`Simulation failed: ${getSimulationError(sim)}`); } // Extract the predicted invoice ID from simulation retval @@ -589,18 +722,18 @@ export async function submitInvoice( const raw = scValToNative(sim.result!.retval); // Contract returns Result — unwrap Ok variant if (raw && typeof raw === "object" && "ok" in raw) { - invoiceId = BigInt((raw as any).ok); + invoiceId = BigInt((raw as { ok: unknown }).ok as string | number | bigint); } else if (raw && typeof raw === "object" && "Ok" in raw) { - invoiceId = BigInt((raw as any).Ok); + invoiceId = BigInt((raw as { Ok: unknown }).Ok as string | number | bigint); } else { - invoiceId = BigInt(raw as any); + invoiceId = BigInt(raw as string | number | bigint); } - } catch (_) { + } catch { // If we can't parse it, proceed without the ID — it'll be shown after poll } const finalTx = rpc.assembleTransaction(tx, sim).build(); - return { tx: finalTx as any, invoiceId }; + return { tx: finalTx, invoiceId }; } export interface UpdateInvoiceArgs { @@ -644,17 +777,17 @@ export async function updateInvoice( const sim = await server.simulateTransaction(tx); if (!rpc.Api.isSimulationSuccess(sim)) { - throw new Error(`Simulation failed: ${(sim as any).error}`); + throw new Error(`Simulation failed: ${getSimulationError(sim)}`); } const finalTx = rpc.assembleTransaction(tx, sim).build(); - return { tx: finalTx as any }; + return { tx: finalTx }; } export async function cancelInvoice( freelancer: string, invoiceId: bigint -): Promise<{ tx: any }> { +): Promise<{ tx: Transaction }> { // Use a default sequence number / account for preparing or real one if needed let account: Account; try { @@ -677,11 +810,11 @@ export async function cancelInvoice( const sim = await server.simulateTransaction(txUrl); if (!rpc.Api.isSimulationSuccess(sim)) { - throw new Error(`Simulation failed: ${(sim as any).error}`); + throw new Error(`Simulation failed: ${getSimulationError(sim)}`); } const finalTx = rpc.assembleTransaction(txUrl, sim).build(); - return { tx: finalTx as any }; + return { tx: finalTx }; } export async function submitInvoiceTransaction({