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
10 changes: 7 additions & 3 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 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);
const interval = window.setInterval(load, 30_000);
return () => {
window.clearTimeout(timeout);
window.clearInterval(interval);
};
}, [load]);

useEffect(() => {
Expand Down
146 changes: 93 additions & 53 deletions app/pay/[id]/__tests__/PayInvoice.test.tsx
Original file line number Diff line number Diff line change
@@ -1,96 +1,106 @@
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import PayInvoicePage from '../page';
import * as soroban from '../../../../utils/soroban';
import { useWallet } from '../../../../context/WalletContext';
import { useToast } from '../../../../context/ToastContext';
import { render, screen, waitFor, fireEvent, within } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import PayInvoicePage from "../page";
import * as soroban from "@/utils/soroban";
import { useWallet } from "@/context/WalletContext";
import { useToast } from "@/context/ToastContext";
import type { Invoice } from "@/utils/soroban";

// Mock context and utils
vi.mock('../../../../context/WalletContext', () => ({
vi.mock("@/context/WalletContext", () => ({
useWallet: vi.fn(),
}));

vi.mock('../../../../context/ToastContext', () => ({
vi.mock("@/context/ToastContext", () => ({
useToast: vi.fn(),
}));

vi.mock('../../../../utils/soroban', () => ({
vi.mock("@/utils/soroban", () => ({
getInvoice: vi.fn(),
markPaid: vi.fn(),
submitSignedTransaction: vi.fn(),
transferLpPosition: vi.fn(),
}));

describe('PayInvoicePage', () => {
const mockInvoice = {
const PAYER = "GCSXPYZSTPKX2GDVW6XSJDBE3PVSNSXCCLTGSPJXNF57IJU5EDU6IUDV";
const FUNDER = "GDIEC472DEK3S5UWVKYDBXG74R53KMHGXGFIURLJUF6P6JJ352HLLJED";
const NEW_FUNDER = "GCBXRCREFHR6YEJ4VFRGRLRACPGWSZUKRIKG3ZNGV5DGYIUEE2GTWYNO";

type TestParams = Promise<{ id: string }> & { _resolvedValue?: { id: string } };

function createParams(): TestParams {
const params = Promise.resolve({ id: "1" }) as TestParams;
params._resolvedValue = { id: "1" };
return params;
}

describe("PayInvoicePage", () => {
const mockInvoice: Invoice = {
id: 1n,
freelancer: 'GFREELANCER',
payer: 'GPAYER',
freelancer: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
payer: PAYER,
amount: 1000000000n,
amount_paid: 0n,
due_date: 1713960000n,
status: 'Funded',
discount_rate: 500,
status: "Funded",
funder: FUNDER,
};

const mockToast = {
addToast: vi.fn().mockReturnValue('toast-id'),
addToast: vi.fn().mockReturnValue("toast-id"),
updateToast: vi.fn(),
};

beforeEach(() => {
vi.clearAllMocks();
(useToast as any).mockReturnValue(mockToast);
(soroban.getInvoice as any).mockResolvedValue(mockInvoice);
vi.mocked(useToast).mockReturnValue(mockToast as ReturnType<typeof useToast>);
vi.mocked(soroban.getInvoice).mockResolvedValue(mockInvoice);
});

it('should render invoice summary without wallet connection', async () => {
(useWallet as any).mockReturnValue({
it("should render invoice summary without wallet connection", async () => {
vi.mocked(useWallet).mockReturnValue({
address: null,
connect: vi.fn(),
});
} as unknown as ReturnType<typeof useWallet>);

const params = Promise.resolve({ id: '1' }) as any;
params._resolvedValue = { id: '1' };
render(<PayInvoicePage params={params} />);
render(<PayInvoicePage params={createParams()} />);

await waitFor(() => {
expect(screen.getByText(/100\s+USDC/)).toBeInTheDocument();
expect(screen.getByText('Connect Wallet and Pay')).toBeInTheDocument();
expect(screen.getByText("Connect Wallet and Pay")).toBeInTheDocument();
});
});

it('should show warning if connected wallet is not the payer', async () => {
(useWallet as any).mockReturnValue({
address: 'GWRONGWALLET',
it("should show warning if connected wallet is neither payer nor LP", async () => {
vi.mocked(useWallet).mockReturnValue({
address: NEW_FUNDER,
connect: vi.fn(),
});
} as unknown as ReturnType<typeof useWallet>);

const params = Promise.resolve({ id: '1' }) as any;
params._resolvedValue = { id: '1' };
render(<PayInvoicePage params={params} />);
render(<PayInvoicePage params={createParams()} />);

await waitFor(() => {
expect(screen.getByText('Address Mismatch')).toBeInTheDocument();
expect(screen.getByText('Restricted to Registered Payer')).toBeInTheDocument();
expect(screen.getByText("Address Mismatch")).toBeInTheDocument();
expect(screen.getByText("Restricted to Registered Payer")).toBeInTheDocument();
});
});

it('should show confirmation if invoice is already paid', async () => {
(soroban.getInvoice as any).mockResolvedValue({
it("should show confirmation if invoice is already paid", async () => {
vi.mocked(soroban.getInvoice).mockResolvedValue({
...mockInvoice,
status: 'Paid',
status: "Paid",
});

(useWallet as any).mockReturnValue({
address: 'GPAYER',
});
vi.mocked(useWallet).mockReturnValue({
address: PAYER,
} as unknown as ReturnType<typeof useWallet>);

const params = Promise.resolve({ id: '1' }) as any;
params._resolvedValue = { id: '1' };
render(<PayInvoicePage params={params} />);
render(<PayInvoicePage params={createParams()} />);

await waitFor(() => {
expect(screen.getByText('Invoice settled')).toBeInTheDocument();
expect(screen.getByText('Settlement Complete')).toBeInTheDocument();
expect(screen.getByText("Invoice settled")).toBeInTheDocument();
expect(screen.getByText("Settlement Complete")).toBeInTheDocument();
});
});

Expand Down Expand Up @@ -130,17 +140,17 @@ describe('PayInvoicePage', () => {

it('should call markPaid with correct amount when payment is confirmed', async () => {
const mockSignTx = vi.fn();
(useWallet as any).mockReturnValue({
address: 'GPAYER',
vi.mocked(useWallet).mockReturnValue({
address: PAYER,
signTx: mockSignTx,
});
} as unknown as ReturnType<typeof useWallet>);

(soroban.markPaid as any).mockResolvedValue('mock-tx');
(soroban.submitSignedTransaction as any).mockResolvedValue({ txHash: 'hash123' });
vi.mocked(soroban.markPaid).mockResolvedValue(
"mock-tx" as unknown as Awaited<ReturnType<typeof soroban.markPaid>>,
);
vi.mocked(soroban.submitSignedTransaction).mockResolvedValue({ txHash: "hash123" });

const params = Promise.resolve({ id: '1' }) as any;
params._resolvedValue = { id: '1' };
render(<PayInvoicePage params={params} />);
render(<PayInvoicePage params={createParams()} />);

// Open modal
await waitFor(() => {
Expand All @@ -158,7 +168,37 @@ describe('PayInvoicePage', () => {
await waitFor(() => {
expect(soroban.markPaid).toHaveBeenCalledWith('GPAYER', 1n, 500000000n); // 50 USDC in stroops
expect(soroban.submitSignedTransaction).toHaveBeenCalled();
expect(mockToast.updateToast).toHaveBeenCalledWith('toast-id', expect.objectContaining({ type: 'success' }));
expect(mockToast.updateToast).toHaveBeenCalledWith("toast-id", expect.objectContaining({ type: "success" }));
});
});

it("shows LP-only transfer action and updates the LP field after transfer", async () => {
const mockSignTx = vi.fn();
vi.mocked(useWallet).mockReturnValue({
address: FUNDER,
connect: vi.fn(),
signTx: mockSignTx,
} as unknown as ReturnType<typeof useWallet>);

vi.mocked(soroban.transferLpPosition).mockResolvedValue(
"transfer-tx" as unknown as Awaited<ReturnType<typeof soroban.transferLpPosition>>,
);
vi.mocked(soroban.submitSignedTransaction).mockResolvedValue({ txHash: "transfer-hash" });

render(<PayInvoicePage params={createParams()} />);

fireEvent.click(await screen.findByRole("button", { name: "Transfer Position" }));
fireEvent.change(screen.getByLabelText("New LP address"), {
target: { value: NEW_FUNDER },
});
fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Transfer Position" }));

await waitFor(() => {
expect(soroban.transferLpPosition).toHaveBeenCalledWith(FUNDER, 1n, NEW_FUNDER);
expect(soroban.submitSignedTransaction).toHaveBeenCalledWith({
tx: "transfer-tx",
signTx: mockSignTx,
});
});
});

Expand Down
34 changes: 29 additions & 5 deletions app/pay/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@

import { use, useEffect, useState, useCallback } from "react";
import Link from "next/link";
import { getInvoice, markPaid, submitSignedTransaction, type Invoice } from "@/utils/soroban";
import {
getInvoice,
markPaid,
submitSignedTransaction,
transferLpPosition,
type Invoice,
} from "@/utils/soroban";
import { formatAddress } from "@/utils/format";
import { formatUsdcFromStroops } from "@/utils/invoiceSubmission";
import { useWallet } from "@/context/WalletContext";
import { useToast } from "@/context/ToastContext";
import { TESTNET_USDC_TOKEN_ID, NETWORK_NAME } from "@/constants";
import { NETWORK_NAME } from "@/constants";
import ActivityFeed from "@/components/ActivityFeed";
import PartialPaymentModal from "@/components/PartialPaymentModal";

Expand Down Expand Up @@ -39,7 +45,8 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin
}, [invoiceId]);

useEffect(() => {
fetchInvoice();
const timeout = window.setTimeout(fetchInvoice, 0);
return () => window.clearTimeout(timeout);
}, [fetchInvoice]);

const handlePaymentConfirm = async (amount: bigint) => {
Expand Down Expand Up @@ -69,13 +76,29 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin
updateToast(toastId, {
type: "error",
title: "Payment Failed",
message: err.message || "An unexpected error occurred during payment."
message: err instanceof Error ? err.message : "An unexpected error occurred during payment."
});
} finally {
setIsPaying(false);
}
};

const handleTransferPosition = async (newLpAddress: string) => {
if (!address || !invoice) return;

setTransferError(null);
try {
await transferTransaction.runTransaction(async () => {
const tx = await transferLpPosition(address, invoice.id, newLpAddress);
return submitSignedTransaction({ tx, signTx });
});
setInvoice({ ...invoice, funder: newLpAddress });
setIsTransferModalOpen(false);
} catch (err) {
setTransferError(err instanceof Error ? err.message : "Failed to transfer LP position.");
}
};

if (loadState === "loading") {
return (
<main className="min-h-screen flex items-center justify-center">
Expand All @@ -97,6 +120,7 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin
}

const isPayer = address === invoice.payer;
const isCurrentLp = Boolean(address && invoice.funder && address === invoice.funder);
const isPaid = invoice.status === "Paid";
const isFunded = invoice.status === "Funded";

Expand All @@ -123,7 +147,7 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin
</div>
)}

{address && !isPayer && !isPaid && (
{address && !isPayer && !isCurrentLp && !isPaid && (
<div className="mb-6 flex items-center gap-3 rounded-2xl border border-amber-500/25 bg-amber-500/10 px-5 py-4 text-amber-400">
<span className="material-symbols-outlined text-2xl" style={{ fontVariationSettings: "'FILL' 1" }}>warning</span>
<div>
Expand Down
Loading