Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/governance/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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-red-500/15 text-red-500 border-red-500/30", icon: "block" },
};
const { color, icon } = config[status];
return (
Expand Down
144 changes: 144 additions & 0 deletions src/components/LPEarningsTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"use client";

import { useMemo, useState } from "react";
import type { ApprovedToken } from "@/hooks/useApprovedTokens";
import type { Invoice } from "@/utils/soroban";
import {
buildLPEarningsCsv,
buildLPEarningsRows,
formatEarningsPayer,
getLPEarningsExportFilename,
} from "@/utils/lpEarnings";
import { downloadFile } from "@/utils/exportData";

interface LPEarningsTableProps {
invoices: Invoice[];
tokenMap?: Map<string, ApprovedToken>;
defaultToken?: ApprovedToken | null;
}

const PAGE_SIZE = 20;

export default function LPEarningsTable({
invoices,
tokenMap = new Map(),
defaultToken = null,
}: LPEarningsTableProps) {
const [page, setPage] = useState(1);
const rows = useMemo(
() => buildLPEarningsRows(invoices, tokenMap, defaultToken),
[defaultToken, invoices, tokenMap],
);
const pageCount = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
const currentPage = Math.min(page, pageCount);
const visibleRows = rows.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);

const exportCsv = () => {
downloadFile(
buildLPEarningsCsv(rows),
getLPEarningsExportFilename(),
"text/csv;charset=utf-8;",
);
};

return (
<section className="rounded-2xl border border-outline-variant/15 bg-surface-container-lowest p-5 shadow-sm">
<div className="mb-4 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
<div>
<h3 className="font-headline text-xl font-bold text-on-surface">
Earnings History
</h3>
<p className="text-sm text-on-surface-variant">
Settled LP positions for accounting and tax export.
</p>
</div>
<button
type="button"
onClick={exportCsv}
disabled={rows.length === 0}
className="inline-flex items-center gap-2 self-start rounded-xl bg-primary px-4 py-2 text-sm font-bold text-surface-container-lowest shadow-sm transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="material-symbols-outlined text-[18px]" aria-hidden="true">
download
</span>
Export CSV
</button>
</div>

<div className="overflow-x-auto rounded-xl border border-outline-variant/10">
<table className="w-full text-left">
<thead className="bg-surface-container-low">
<tr>
{[
"Invoice ID",
"Payer",
"Settlement Date",
"Amount Funded",
"Payout Received",
"Earned",
"Token",
"Yield %",
].map((heading) => (
<th
key={heading}
className="px-4 py-3 text-[11px] font-bold uppercase tracking-wider text-on-surface-variant"
>
{heading}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-surface-dim">
{visibleRows.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-10 text-center text-sm text-on-surface-variant">
No settled LP earnings yet.
</td>
</tr>
) : (
visibleRows.map((row) => (
<tr key={row.invoiceId} className="hover:bg-surface-variant/10">
<td className="px-4 py-3 font-bold text-primary">#{row.invoiceId}</td>
<td className="px-4 py-3 font-mono text-xs">{formatEarningsPayer(row.payer)}</td>
<td className="px-4 py-3 text-sm">{row.settlementDate}</td>
<td className="px-4 py-3 text-sm">{row.amountFunded}</td>
<td className="px-4 py-3 text-sm font-bold">{row.payoutReceived}</td>
<td className="px-4 py-3 text-sm font-bold text-green-600">{row.earned}</td>
<td className="px-4 py-3 text-sm">{row.token}</td>
<td className="px-4 py-3 text-sm">{row.yieldPercent}</td>
</tr>
))
)}
</tbody>
</table>
</div>

<div className="mt-4 flex items-center justify-between gap-3 text-sm text-on-surface-variant">
<span>
Showing {visibleRows.length} of {rows.length} settled positions
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPage((value) => Math.max(1, value - 1))}
disabled={currentPage === 1}
className="rounded-lg border border-outline-variant/20 px-3 py-1 font-bold disabled:cursor-not-allowed disabled:opacity-40"
>
Previous
</button>
<span className="text-xs font-bold">
Page {currentPage} / {pageCount}
</span>
<button
type="button"
onClick={() => setPage((value) => Math.min(pageCount, value + 1))}
disabled={currentPage === pageCount}
className="rounded-lg border border-outline-variant/20 px-3 py-1 font-bold disabled:cursor-not-allowed disabled:opacity-40"
>
Next
</button>
</div>
</div>
</section>
);
}
16 changes: 14 additions & 2 deletions src/components/LPPortfolio.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,6 +10,7 @@ import LPTokenMetricsCards from "./LPTokenMetricsCards";
import LPPortfolioAllocationChart from "./LPPortfolioAllocationChart";
import WeeklyYieldChart from "./WeeklyYieldChart";
import { calculatePerTokenMetrics } from "@/utils/per-token-yield";
import LPEarningsTable from "./LPEarningsTable";

interface LPPortfolioProps {
invoices: Invoice[];
Expand All @@ -31,7 +32,12 @@ export default function LPPortfolio({
onTransfer,
}: LPPortfolioProps) {
const [showUSDEquivalent, setShowUSDEquivalent] = useState(false);
const now = Date.now();
const [now, setNow] = useState(() => Date.now());

useEffect(() => {
const interval = window.setInterval(() => setNow(Date.now()), 60 * 1000);
return () => window.clearInterval(interval);
}, []);

// Calculate per-token metrics
const perTokenMetrics = useMemo(
Expand Down Expand Up @@ -170,6 +176,12 @@ export default function LPPortfolio({
/>
)}

<LPEarningsTable
invoices={invoices}
tokenMap={tokenMap}
defaultToken={defaultToken}
/>

{/* Portfolio Table */}
<InvoiceTable
tableId="lp_portfolio_table"
Expand Down
68 changes: 68 additions & 0 deletions src/utils/__tests__/lpEarnings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import type { Invoice } from "@/utils/soroban";
import {
buildLPEarningsCsv,
buildLPEarningsRows,
getLPEarningsExportFilename,
} from "../lpEarnings";

function invoice(overrides: Partial<Invoice>): Invoice {
return {
id: 1n,
status: "Paid",
freelancer: "freelancer",
payer: "payer",
amount: 1000_0000000n,
due_date: 1_800_000_000n,
discount_rate: 300,
funder: "lp",
funded_at: 1_801_000_000n,
token: "usdc-token",
...overrides,
};
}

const tokenMap = new Map([
["usdc-token", { contractId: "usdc-token", symbol: "USDC", decimals: 7 }],
["eurc-token", { contractId: "eurc-token", symbol: "EURC", decimals: 7 }],
]);

describe("LP earnings history", () => {
it("builds newest-first rows for paid invoices only", () => {
const rows = buildLPEarningsRows(
[
invoice({ id: 1n, funded_at: 1_701_000_000n }),
invoice({ id: 2n, status: "Funded", funded_at: 1_901_000_000n }),
invoice({ id: 3n, funded_at: 1_801_000_000n, token: "eurc-token" }),
],
tokenMap,
null,
);

expect(rows.map((row) => row.invoiceId)).toEqual(["3", "1"]);
expect(rows[0]).toMatchObject({
token: "EURC",
amountFunded: "970 EURC",
payoutReceived: "1,000 EURC",
earned: "30 EURC",
yieldPercent: "3.00%",
});
});

it("exports all rows to CSV with the required headers", () => {
const rows = buildLPEarningsRows([invoice({ id: 7n })], tokenMap, null);
const csv = buildLPEarningsCsv(rows);

expect(csv.split("\n")[0]).toBe(
"Invoice ID,Payer,Settlement Date,Amount Funded,Payout Received,Earned,Token,Yield %",
);
expect(csv).toContain('"7","payer"');
expect(csv).toContain('"30 USDC"');
});

it("uses the requested ILN-LP-Earnings date filename", () => {
expect(getLPEarningsExportFilename(new Date("2026-05-26T09:00:00Z"))).toBe(
"ILN-LP-Earnings-2026-05-26.csv",
);
});
});
104 changes: 104 additions & 0 deletions src/utils/lpEarnings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { ApprovedToken } from "@/hooks/useApprovedTokens";
import type { Invoice } from "@/utils/soroban";
import {
calculateYield,
formatAddress,
formatTokenAmount,
type TokenDisplayMeta,
} from "@/utils/format";

export interface LPEarningsRow {
invoiceId: string;
payer: string;
settlementTimestamp: bigint;
settlementDate: string;
amountFunded: string;
payoutReceived: string;
earned: string;
token: string;
yieldPercent: string;
}

function getTokenForInvoice(
invoice: Invoice,
tokenMap: Map<string, ApprovedToken>,
defaultToken: ApprovedToken | null,
): TokenDisplayMeta {
return (
tokenMap.get(invoice.token ?? defaultToken?.contractId ?? "") ??
defaultToken ?? {
symbol: "USDC",
decimals: 7,
}
);
}

function formatIsoDate(timestamp: bigint): string {
return new Date(Number(timestamp) * 1000).toISOString().slice(0, 10);
}

export function buildLPEarningsRows(
invoices: Invoice[],
tokenMap = new Map<string, ApprovedToken>(),
defaultToken: ApprovedToken | null = null,
): LPEarningsRow[] {
return invoices
.filter((invoice) => invoice.status === "Paid")
.map((invoice) => {
const token = getTokenForInvoice(invoice, tokenMap, defaultToken);
const earned = calculateYield(invoice.amount, invoice.discount_rate);
const payoutReceived = invoice.amount;
const amountFunded = invoice.amount - earned;
const settlementTimestamp = invoice.funded_at ?? invoice.due_date;

return {
invoiceId: invoice.id.toString(),
payer: invoice.payer,
settlementTimestamp,
settlementDate: formatIsoDate(settlementTimestamp),
amountFunded: formatTokenAmount(amountFunded, token),
payoutReceived: formatTokenAmount(payoutReceived, token),
earned: formatTokenAmount(earned, token),
token: token.symbol,
yieldPercent: `${(invoice.discount_rate / 100).toFixed(2)}%`,
};
})
.sort((a, b) => Number(b.settlementTimestamp - a.settlementTimestamp));
}

export function buildLPEarningsCsv(rows: LPEarningsRow[]): string {
const headers = [
"Invoice ID",
"Payer",
"Settlement Date",
"Amount Funded",
"Payout Received",
"Earned",
"Token",
"Yield %",
];

const escapeCell = (value: string) => `"${value.replace(/"/g, '""')}"`;
const body = rows.map((row) =>
[
row.invoiceId,
row.payer,
row.settlementDate,
row.amountFunded,
row.payoutReceived,
row.earned,
row.token,
row.yieldPercent,
].map(escapeCell).join(","),
);

return [headers.join(","), ...body].join("\n");
}

export function getLPEarningsExportFilename(date = new Date()): string {
return `ILN-LP-Earnings-${date.toISOString().slice(0, 10)}.csv`;
}

export function formatEarningsPayer(address: string): string {
return formatAddress(address);
}