diff --git a/app/governance/page.tsx b/app/governance/page.tsx
index a810b13..630389e 100644
--- a/app/governance/page.tsx
+++ b/app/governance/page.tsx
@@ -29,6 +29,7 @@ function StatusBadge({ status }: { status: ProposalStatus }) {
Active: { color: "bg-emerald-500/15 text-emerald-500 border-emerald-500/30", icon: "fiber_manual_record" },
Passed: { color: "bg-primary/15 text-primary border-primary/30", icon: "check_circle" },
Failed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "cancel" },
+ Vetoed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "block" },
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" },
};
@@ -202,10 +203,13 @@ export default function GovernancePage() {
}, []);
useEffect(() => {
- load();
+ const timeout = window.setTimeout(load, 0);
// Refresh every 30 s for real-time vote counts
const interval = setInterval(load, 30_000);
- return () => clearInterval(interval);
+ return () => {
+ window.clearTimeout(timeout);
+ clearInterval(interval);
+ };
}, [load]);
useEffect(() => {
diff --git a/src/components/FundConfirmModal.tsx b/src/components/FundConfirmModal.tsx
index ab382b8..919a53a 100644
--- a/src/components/FundConfirmModal.tsx
+++ b/src/components/FundConfirmModal.tsx
@@ -11,7 +11,7 @@ import {
Invoice,
submitSignedTransaction,
} from "@/utils/soroban";
-import { formatTokenAmount, formatDate, calculateYield } from "@/utils/format";
+import { formatTokenAmount, calculateYield } from "@/utils/format";
import { useFundInvoice } from "@/hooks/useInvoices";
import { getPayerScore, PayerScoreResult } from "@/utils/soroban";
@@ -19,6 +19,7 @@ type FundingStep = "approve" | "fund";
interface FundConfirmModalProps {
invoice: Invoice | null;
+ payerScore?: number | null;
onClose: () => void;
onSuccess: () => void;
payerScore?: PayerScoreResult | null;
@@ -73,7 +74,8 @@ export default function FundConfirmModal({ invoice, onClose, onSuccess, payerSco
useEffect(() => {
if (!invoice || !address) return;
- void refreshAllowance(invoice, address);
+ const timeout = window.setTimeout(() => void refreshAllowance(invoice, address), 0);
+ return () => window.clearTimeout(timeout);
}, [address, refreshAllowance, invoice]);
if (!invoice) return null;
@@ -136,6 +138,11 @@ export default function FundConfirmModal({ invoice, onClose, onSuccess, payerSco
};
const tokenSymbol = selectedInvoiceToken?.symbol ?? "USDC";
+ const yieldAmount = calculateYield(invoice.amount, invoice.discount_rate);
+ const daysToDue = Math.max(
+ 0,
+ Math.ceil((Number(invoice.due_date) * 1000 - referenceTimeMs) / (24 * 60 * 60 * 1000)),
+ );
return (
@@ -305,12 +312,14 @@ export default function FundConfirmModal({ invoice, onClose, onSuccess, payerSco
-
Your yield (discount):
+
Gross yield:
{selectedInvoiceToken ? (
- {formatTokenAmount(calculateYield(invoice.amount, invoice.discount_rate), selectedInvoiceToken)} {selectedInvoiceToken.symbol}
- {(invoice.discount_rate / 100).toFixed(2)}%
+ {formatTokenAmount(yieldAmount, selectedInvoiceToken)}
+
+ {invoice.discount_rate} bps / {(invoice.discount_rate / 100).toFixed(2)}%
+
) : null}
diff --git a/src/components/LPDashboard.tsx b/src/components/LPDashboard.tsx
index da5df7b..7f3ec86 100644
--- a/src/components/LPDashboard.tsx
+++ b/src/components/LPDashboard.tsx
@@ -791,6 +791,7 @@ export default function LPDashboard() {
{/* Confirmation Modal */}
setSelectedInvoice(null)}
onSuccess={() => {
setSelectedInvoice(null);
diff --git a/src/components/__tests__/FundConfirmModal.test.tsx b/src/components/__tests__/FundConfirmModal.test.tsx
index 59cbe53..5390b51 100644
--- a/src/components/__tests__/FundConfirmModal.test.tsx
+++ b/src/components/__tests__/FundConfirmModal.test.tsx
@@ -28,13 +28,14 @@ const getTokenAllowance = vi.fn();
const buildApproveTokenTransaction = vi.fn();
const fundInvoice = vi.fn();
const submitSignedTransaction = vi.fn();
+const getPayerScoresBatch = vi.fn();
// ─── Module mocks ─────────────────────────────────────────────────────────────
vi.mock("../../hooks/useInvoices", () => ({
useInvoices: vi.fn(),
useFundInvoice: vi.fn(() => ({
- mutate: vi.fn((id, { onSuccess, onError }) => {
+ mutate: vi.fn(() => {
// Manual trigger for testing
}),
isPending: false,
@@ -69,7 +70,7 @@ vi.mock("../../utils/soroban", () => ({
buildApproveTokenTransaction: (...args: unknown[]) => buildApproveTokenTransaction(...args),
fundInvoice: (...args: unknown[]) => fundInvoice(...args),
submitSignedTransaction: (...args: unknown[]) => submitSignedTransaction(...args),
- getPayerScoresBatch: vi.fn().mockResolvedValue(new Map()),
+ getPayerScoresBatch: (...args: unknown[]) => getPayerScoresBatch(...args),
}));
vi.mock("../../hooks/useApprovedTokens", () => ({
@@ -115,7 +116,7 @@ describe("FundConfirmModal (via LPDashboard)", () => {
beforeEach(() => {
addToast.mockClear();
updateToast.mockClear();
- (useInvoices as any).mockReturnValue({
+ vi.mocked(useInvoices).mockReturnValue({
data: [mockInvoice],
isLoading: false,
dataUpdatedAt: Date.now(),
@@ -124,6 +125,18 @@ describe("FundConfirmModal (via LPDashboard)", () => {
buildApproveTokenTransaction.mockReset();
fundInvoice.mockReset();
submitSignedTransaction.mockReset();
+ getPayerScoresBatch.mockResolvedValue(
+ new Map([
+ [
+ mockInvoice.payer,
+ {
+ score: 82,
+ settled_on_time: 12,
+ defaults: 1,
+ },
+ ],
+ ]),
+ );
});
// ── Yield calculation display ─────────────────────────────────────────────
@@ -149,9 +162,7 @@ describe("FundConfirmModal (via LPDashboard)", () => {
render();
fireEvent.click(await screen.findByRole("button", { name: "Fund" }));
- await waitFor(() =>
- expect(screen.getByText(/Fund Invoice #7/i)).toBeInTheDocument(),
- );
+ await waitFor(() => expect(screen.getByRole("button", { name: "Fund Now" })).toBeInTheDocument());
expect(screen.getByText(/970/i)).toBeInTheDocument();
});
@@ -162,9 +173,7 @@ describe("FundConfirmModal (via LPDashboard)", () => {
render();
fireEvent.click(await screen.findByRole("button", { name: "Fund" }));
- await waitFor(() =>
- expect(screen.getByText(/Fund Invoice #7/i)).toBeInTheDocument(),
- );
+ await waitFor(() => expect(screen.getByRole("button", { name: "Fund Now" })).toBeInTheDocument());
// 1000 × 3% = 30 USDC → 300_000_000 stroops → "30 USDC"
expect(screen.getAllByText(/30 USDC/i)[0]).toBeInTheDocument();
@@ -184,20 +193,18 @@ describe("FundConfirmModal (via LPDashboard)", () => {
render();
fireEvent.click(await screen.findByRole("button", { name: "Fund" }));
- await waitFor(() =>
- expect(screen.getByRole("button", { name: "Fund Invoice" })).toBeInTheDocument(),
- );
+ await waitFor(() => expect(screen.getByRole("button", { name: "Fund Now" })).toBeInTheDocument());
expect(screen.queryByRole("button", { name: "Approve USDC" })).not.toBeInTheDocument();
});
it("calls fundInvoice and submitSignedTransaction when 'Fund Invoice' is clicked", async () => {
const mutate = vi.fn();
- (useFundInvoice as any).mockReturnValue({ mutate, isPending: false });
+ vi.mocked(useFundInvoice).mockReturnValue({ mutate, isPending: false });
render();
fireEvent.click(await screen.findByRole("button", { name: "Fund" }));
- fireEvent.click(await screen.findByRole("button", { name: "Fund Invoice" }));
+ fireEvent.click(await screen.findByRole("button", { name: "Fund Now" }));
await waitFor(() => expect(mutate).toHaveBeenCalledTimes(1));
expect(mutate).toHaveBeenCalledWith(
@@ -207,33 +214,46 @@ describe("FundConfirmModal (via LPDashboard)", () => {
});
it("fires a success toast after a successful fund call", async () => {
- (useFundInvoice as any).mockReturnValue({
- mutate: vi.fn((id, { onSuccess }) => onSuccess()),
+ vi.mocked(useFundInvoice).mockReturnValue({
+ mutate: vi.fn((_id: bigint, { onSuccess }: { onSuccess: () => void }) => onSuccess()),
isPending: false
});
render();
fireEvent.click(await screen.findByRole("button", { name: "Fund" }));
- fireEvent.click(await screen.findByRole("button", { name: "Fund Invoice" }));
+ fireEvent.click(await screen.findByRole("button", { name: "Fund Now" }));
// Note: useFundInvoice internal logic handles showToast now
// but the component might still have its own onsuccess logic
});
it("shows an error message in the modal when fundInvoice rejects", async () => {
- (useFundInvoice as any).mockReturnValue({
- mutate: vi.fn((id, { onError }) => onError(new Error("Contract revert: insufficient balance"))),
+ vi.mocked(useFundInvoice).mockReturnValue({
+ mutate: vi.fn((_id: bigint, { onError }: { onError: (error: Error) => void }) =>
+ onError(new Error("Contract revert: insufficient balance")),
+ ),
isPending: false
});
render();
fireEvent.click(await screen.findByRole("button", { name: "Fund" }));
- fireEvent.click(await screen.findByRole("button", { name: "Fund Invoice" }));
+ fireEvent.click(await screen.findByRole("button", { name: "Fund Now" }));
await waitFor(() => {
expect(screen.getByText(/Contract revert: insufficient balance/)).toBeInTheDocument();
});
});
+
+ it("shows due-date timing, bps yield, and payer reputation before funding", async () => {
+ render();
+ fireEvent.click(await screen.findByRole("button", { name: "Fund" }));
+
+ await waitFor(() => expect(screen.getByRole("button", { name: "Fund Now" })).toBeInTheDocument());
+ expect(screen.getByText("Days to due date:")).toBeInTheDocument();
+ expect(screen.getByText("Payer reputation score:")).toBeInTheDocument();
+ expect(screen.getByText("82/100")).toBeInTheDocument();
+ expect(screen.getByText(/300 bps \/ 3\.00%/)).toBeInTheDocument();
+ });
});
// ── Approve button – insufficient allowance path ──────────────────────────
@@ -258,7 +278,9 @@ describe("FundConfirmModal (via LPDashboard)", () => {
render();
fireEvent.click(await screen.findByRole("button", { name: "Fund" }));
- fireEvent.click(await screen.findByRole("button", { name: "Approve USDC" }));
+ const approveButton = await screen.findByRole("button", { name: "Approve USDC" });
+ await waitFor(() => expect(approveButton).toBeEnabled());
+ fireEvent.click(approveButton);
await waitFor(() => expect(buildApproveTokenTransaction).toHaveBeenCalledTimes(1));
expect(submitSignedTransaction).toHaveBeenCalledTimes(1);
@@ -270,7 +292,9 @@ describe("FundConfirmModal (via LPDashboard)", () => {
render();
fireEvent.click(await screen.findByRole("button", { name: "Fund" }));
- fireEvent.click(await screen.findByRole("button", { name: "Approve USDC" }));
+ const approveButton = await screen.findByRole("button", { name: "Approve USDC" });
+ await waitFor(() => expect(approveButton).toBeEnabled());
+ fireEvent.click(approveButton);
await waitFor(() =>
expect(updateToast).toHaveBeenCalledWith(
@@ -285,7 +309,9 @@ describe("FundConfirmModal (via LPDashboard)", () => {
render();
fireEvent.click(await screen.findByRole("button", { name: "Fund" }));
- fireEvent.click(await screen.findByRole("button", { name: "Approve USDC" }));
+ const approveButton = await screen.findByRole("button", { name: "Approve USDC" });
+ await waitFor(() => expect(approveButton).toBeEnabled());
+ fireEvent.click(approveButton);
await waitFor(() => {
expect(screen.getByText(/User rejected tx/)).toBeInTheDocument();