From 12525a312833dfb4e519d3b56921890a2ec0cfa1 Mon Sep 17 00:00:00 2001 From: neumattock <152253273+newmattock@users.noreply.github.com> Date: Tue, 26 May 2026 09:42:36 -0700 Subject: [PATCH] feat: add LP risk summary panel --- src/components/LPPortfolio.tsx | 28 +++++- src/components/LPRiskPanel.tsx | 144 +++++++++++++++++++++++++++++ src/utils/__tests__/lpRisk.test.ts | 81 ++++++++++++++++ src/utils/lpRisk.ts | 69 ++++++++++++++ 4 files changed, 318 insertions(+), 4 deletions(-) create mode 100644 src/components/LPRiskPanel.tsx create mode 100644 src/utils/__tests__/lpRisk.test.ts create mode 100644 src/utils/lpRisk.ts diff --git a/src/components/LPPortfolio.tsx b/src/components/LPPortfolio.tsx index dd38602..158564c 100644 --- a/src/components/LPPortfolio.tsx +++ b/src/components/LPPortfolio.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useMemo } from "react"; +import { useEffect, useState, useMemo } from "react"; import { formatAddress, formatDate, formatUSDC, calculateYield } from "@/utils/format"; import type { Invoice } from "@/utils/soroban"; import type { ApprovedToken } from "@/hooks/useApprovedTokens"; @@ -9,6 +9,8 @@ import InvoiceTable, { ColumnDefinition } from "./InvoiceTable"; import LPTokenMetricsCards from "./LPTokenMetricsCards"; import WeeklyYieldChart from "./WeeklyYieldChart"; import { calculatePerTokenMetrics } from "@/utils/per-token-yield"; +import LPRiskPanel from "./LPRiskPanel"; +import { applyLPRiskFilter, type LPRiskFilter } from "@/utils/lpRisk"; interface LPPortfolioProps { invoices: Invoice[]; @@ -28,7 +30,13 @@ export default function LPPortfolio({ defaultToken = null, }: LPPortfolioProps) { const [showUSDEquivalent, setShowUSDEquivalent] = useState(false); - const now = Date.now(); + const [riskFilter, setRiskFilter] = useState("all"); + const [riskNow, setRiskNow] = useState(() => Date.now()); + + useEffect(() => { + const interval = window.setInterval(() => setRiskNow(Date.now()), 5 * 60 * 1000); + return () => window.clearInterval(interval); + }, []); // Calculate per-token metrics const perTokenMetrics = useMemo( @@ -40,6 +48,11 @@ export default function LPPortfolio({ .filter((invoice) => invoice.status === "Paid") .reduce((total, invoice) => total + calculateYield(invoice.amount, invoice.discount_rate), 0n); + const visibleInvoices = useMemo( + () => applyLPRiskFilter(invoices, riskFilter, riskNow), + [invoices, riskFilter, riskNow], + ); + const columns: ColumnDefinition[] = [ { id: "id", @@ -105,7 +118,7 @@ export default function LPPortfolio({ label: "", sortable: false, renderCell: (inv) => { - const isPastDue = Number(inv.due_date) * 1000 < now; + const isPastDue = Number(inv.due_date) * 1000 < riskNow; const isClaimEligible = inv.status === "Funded" && isPastDue; const isClaiming = claimingInvoiceId === inv.id.toString(); @@ -152,10 +165,17 @@ export default function LPPortfolio({ /> )} + + {/* Portfolio Table */} void; + now: number; +} + +const SEVERITY_CLASSES = { + green: "border-green-500/20 bg-green-500/10 text-green-700", + amber: "border-amber-500/25 bg-amber-500/10 text-amber-700", + red: "border-red-500/25 bg-red-500/10 text-red-700", +} as const; + +function MetricButton({ + icon, + label, + value, + sub, + severity, + active, + onClick, +}: { + icon: string; + label: string; + value: string; + sub: string; + severity: keyof typeof SEVERITY_CLASSES; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +export default function LPRiskPanel({ + invoices, + activeFilter, + onFilterChange, + now, +}: LPRiskPanelProps) { + const metrics = calculateLPRiskMetrics(invoices, now); + + const toggleFilter = (filter: LPRiskFilter) => { + onFilterChange(activeFilter === filter ? "all" : filter); + }; + + return ( +
+
+
+

+ Position Risk +

+

+ Flags disputed positions and funded invoices due within 24 hours. +

+
+
+ + Refreshes with portfolio data +
+
+ +
+ toggleFilter("at-risk")} + /> + toggleFilter("at-risk")} + /> + toggleFilter("disputed")} + /> +
+ + {activeFilter !== "all" && ( +
+ + Filtering table to {activeFilter === "disputed" ? "disputed positions" : "at-risk positions"}. + + +
+ )} +
+ ); +} diff --git a/src/utils/__tests__/lpRisk.test.ts b/src/utils/__tests__/lpRisk.test.ts new file mode 100644 index 0000000..c6a6485 --- /dev/null +++ b/src/utils/__tests__/lpRisk.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import type { Invoice } from "@/utils/soroban"; +import { + applyLPRiskFilter, + calculateLPRiskMetrics, + getRiskSeverity, + isAtRiskPosition, +} from "../lpRisk"; + +const NOW = Date.UTC(2026, 4, 26, 12, 0, 0); + +function invoice(overrides: Partial): Invoice { + return { + id: 1n, + status: "Funded", + freelancer: "freelancer", + payer: "payer", + amount: 1000_0000000n, + due_date: BigInt(Math.floor((NOW + 7 * 24 * 60 * 60 * 1000) / 1000)), + discount_rate: 300, + funder: "lp", + ...overrides, + }; +} + +describe("LP risk metrics", () => { + it("marks disputed and funded positions due within 24 hours as at risk", () => { + const disputed = invoice({ id: 1n, status: "Disputed" }); + const nearExpiry = invoice({ + id: 2n, + due_date: BigInt(Math.floor((NOW + 23 * 60 * 60 * 1000) / 1000)), + }); + const healthy = invoice({ + id: 3n, + due_date: BigInt(Math.floor((NOW + 3 * 24 * 60 * 60 * 1000) / 1000)), + }); + + expect(isAtRiskPosition(disputed, NOW)).toBe(true); + expect(isAtRiskPosition(nearExpiry, NOW)).toBe(true); + expect(isAtRiskPosition(healthy, NOW)).toBe(false); + }); + + it("calculates counts and capital at risk from LP positions", () => { + const invoices = [ + invoice({ id: 1n, amount: 500_0000000n, status: "Disputed" }), + invoice({ + id: 2n, + amount: 1200_0000000n, + due_date: BigInt(Math.floor((NOW + 60 * 60 * 1000) / 1000)), + }), + invoice({ id: 3n, amount: 900_0000000n, status: "Paid" }), + ]; + + expect(calculateLPRiskMetrics(invoices, NOW)).toEqual({ + positionsAtRisk: 2, + capitalAtRisk: 1700_0000000n, + disputedPositions: 1, + }); + }); + + it("filters table positions for at-risk and disputed metrics", () => { + const invoices = [ + invoice({ id: 1n, status: "Disputed" }), + invoice({ + id: 2n, + due_date: BigInt(Math.floor((NOW + 60 * 60 * 1000) / 1000)), + }), + invoice({ id: 3n, status: "Paid" }), + ]; + + expect(applyLPRiskFilter(invoices, "at-risk", NOW).map((item) => item.id)).toEqual([1n, 2n]); + expect(applyLPRiskFilter(invoices, "disputed", NOW).map((item) => item.id)).toEqual([1n]); + expect(applyLPRiskFilter(invoices, "all", NOW)).toHaveLength(3); + }); + + it("maps zero, low, and high metric counts to severity colours", () => { + expect(getRiskSeverity(0)).toBe("green"); + expect(getRiskSeverity(2)).toBe("amber"); + expect(getRiskSeverity(3)).toBe("red"); + }); +}); diff --git a/src/utils/lpRisk.ts b/src/utils/lpRisk.ts new file mode 100644 index 0000000..3d5bb60 --- /dev/null +++ b/src/utils/lpRisk.ts @@ -0,0 +1,69 @@ +import type { Invoice } from "@/utils/soroban"; + +export type LPRiskFilter = "all" | "at-risk" | "disputed"; + +export interface LPRiskMetrics { + positionsAtRisk: number; + capitalAtRisk: bigint; + disputedPositions: number; +} + +const DAY_MS = 24 * 60 * 60 * 1000; +const RISK_WINDOW_MS = DAY_MS; + +export function isDisputedPosition(invoice: Invoice): boolean { + return invoice.status.toLowerCase() === "disputed"; +} + +export function isNearingExpiry(invoice: Invoice, now = Date.now()): boolean { + if (invoice.status !== "Funded") return false; + const dueAt = Number(invoice.due_date) * 1000; + return dueAt <= now + RISK_WINDOW_MS; +} + +export function isAtRiskPosition(invoice: Invoice, now = Date.now()): boolean { + return isDisputedPosition(invoice) || isNearingExpiry(invoice, now); +} + +export function calculateLPRiskMetrics( + invoices: Invoice[], + now = Date.now(), +): LPRiskMetrics { + return invoices.reduce( + (metrics, invoice) => { + const disputed = isDisputedPosition(invoice); + const atRisk = disputed || isNearingExpiry(invoice, now); + + if (atRisk) { + metrics.positionsAtRisk += 1; + metrics.capitalAtRisk += invoice.amount; + } + if (disputed) { + metrics.disputedPositions += 1; + } + + return metrics; + }, + { + positionsAtRisk: 0, + capitalAtRisk: 0n, + disputedPositions: 0, + }, + ); +} + +export function applyLPRiskFilter( + invoices: Invoice[], + filter: LPRiskFilter, + now = Date.now(), +): Invoice[] { + if (filter === "all") return invoices; + if (filter === "disputed") return invoices.filter(isDisputedPosition); + return invoices.filter((invoice) => isAtRiskPosition(invoice, now)); +} + +export function getRiskSeverity(value: number): "green" | "amber" | "red" { + if (value === 0) return "green"; + if (value <= 2) return "amber"; + return "red"; +}