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
8 changes: 6 additions & 2 deletions app/governance/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
};
Expand Down Expand Up @@ -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(() => {
Expand Down
19 changes: 14 additions & 5 deletions src/components/FundConfirmModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ 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";

type FundingStep = "approve" | "fund";

interface FundConfirmModalProps {
invoice: Invoice | null;
payerScore?: number | null;
onClose: () => void;
onSuccess: () => void;
payerScore?: PayerScoreResult | null;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<div className="fixed inset-0 z-[100] flex flex-col bg-surface-container-lowest overflow-y-auto animate-in fade-in duration-200">
Expand Down Expand Up @@ -305,12 +312,14 @@ export default function FundConfirmModal({ invoice, onClose, onSuccess, payerSco
</div>

<div className="flex justify-between text-sm border-t border-surface-dim pt-4">
<span className="text-on-surface-variant">Your yield (discount):</span>
<span className="text-on-surface-variant">Gross yield:</span>
<span className="font-bold text-green-600 text-base">
{selectedInvoiceToken ? (
<div className="flex items-center gap-2">
<span>{formatTokenAmount(calculateYield(invoice.amount, invoice.discount_rate), selectedInvoiceToken)} {selectedInvoiceToken.symbol}</span>
<span className="bg-green-100 text-green-700 px-2 py-0.5 rounded text-xs">{(invoice.discount_rate / 100).toFixed(2)}%</span>
<span>{formatTokenAmount(yieldAmount, selectedInvoiceToken)}</span>
<span className="bg-green-100 text-green-700 px-2 py-0.5 rounded text-xs">
{invoice.discount_rate} bps / {(invoice.discount_rate / 100).toFixed(2)}%
</span>
</div>
) : null}
</span>
Expand Down
1 change: 1 addition & 0 deletions src/components/LPDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,7 @@ export default function LPDashboard() {
{/* Confirmation Modal */}
<FundConfirmModal
invoice={selectedInvoice}
payerScore={selectedInvoice ? payerScores.get(selectedInvoice.payer)?.score ?? null : null}
onClose={() => setSelectedInvoice(null)}
onSuccess={() => {
setSelectedInvoice(null);
Expand Down
72 changes: 49 additions & 23 deletions src/components/__tests__/FundConfirmModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -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(),
Expand All @@ -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 ─────────────────────────────────────────────
Expand All @@ -149,9 +162,7 @@ describe("FundConfirmModal (via LPDashboard)", () => {
render(<LPDashboard />);
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();
});
Expand All @@ -162,9 +173,7 @@ describe("FundConfirmModal (via LPDashboard)", () => {
render(<LPDashboard />);
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();
Expand All @@ -184,20 +193,18 @@ describe("FundConfirmModal (via LPDashboard)", () => {
render(<LPDashboard />);
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(<LPDashboard />);
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(
Expand All @@ -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(<LPDashboard />);
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(<LPDashboard />);
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(<LPDashboard />);
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 ──────────────────────────
Expand All @@ -258,7 +278,9 @@ describe("FundConfirmModal (via LPDashboard)", () => {

render(<LPDashboard />);
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);
Expand All @@ -270,7 +292,9 @@ describe("FundConfirmModal (via LPDashboard)", () => {

render(<LPDashboard />);
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(
Expand All @@ -285,7 +309,9 @@ describe("FundConfirmModal (via LPDashboard)", () => {

render(<LPDashboard />);
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();
Expand Down