diff --git a/app/governance/page.tsx b/app/governance/page.tsx index a810b13..efc13c9 100644 --- a/app/governance/page.tsx +++ b/app/governance/page.tsx @@ -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 ( @@ -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(() => { diff --git a/app/pay/[id]/__tests__/PayInvoice.test.tsx b/app/pay/[id]/__tests__/PayInvoice.test.tsx index 9742df0..ee16d4b 100644 --- a/app/pay/[id]/__tests__/PayInvoice.test.tsx +++ b/app/pay/[id]/__tests__/PayInvoice.test.tsx @@ -1,96 +1,104 @@ -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(), + disputeInvoice: vi.fn(), submitSignedTransaction: vi.fn(), })); -describe('PayInvoicePage', () => { - const mockInvoice = { +const PAYER = "GCSXPYZSTPKX2GDVW6XSJDBE3PVSNSXCCLTGSPJXNF57IJU5EDU6IUDV"; +const OTHER_WALLET = "GDIEC472DEK3S5UWVKYDBXG74R53KMHGXGFIURLJUF6P6JJ352HLLJED"; + +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: 300, + status: "Funded", }; 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); + 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); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; - render(); + render(); 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 not the payer", async () => { + vi.mocked(useWallet).mockReturnValue({ + address: OTHER_WALLET, connect: vi.fn(), - }); + } as unknown as ReturnType); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; - render(); + render(); 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); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; - render(); + render(); 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(); }); }); @@ -130,17 +138,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); - (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>, + ); + vi.mocked(soroban.submitSignedTransaction).mockResolvedValue({ txHash: "hash123" }); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; - render(); + render(); // Open modal await waitFor(() => { @@ -158,7 +166,7 @@ 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" })); }); }); diff --git a/app/pay/[id]/page.tsx b/app/pay/[id]/page.tsx index e93c036..f8d10ec 100644 --- a/app/pay/[id]/page.tsx +++ b/app/pay/[id]/page.tsx @@ -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 { + disputeInvoice, + getInvoice, + markPaid, + submitSignedTransaction, + 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"; @@ -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) => { @@ -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 handleDispute = async (reasonHash: string) => { + if (!address || !invoice) return; + + setDisputeError(null); + try { + await disputeTransaction.runTransaction(async () => { + const tx = await disputeInvoice(address, invoice.id, reasonHash); + return submitSignedTransaction({ tx, signTx }); + }); + setInvoice({ ...invoice, status: "Disputed" }); + setIsDisputeModalOpen(false); + } catch (err) { + setDisputeError(err instanceof Error ? err.message : "Failed to raise dispute."); + } + }; + if (loadState === "loading") { return (
diff --git a/src/components/TokenSelector.tsx b/src/components/TokenSelector.tsx index a157f15..e52bb44 100644 --- a/src/components/TokenSelector.tsx +++ b/src/components/TokenSelector.tsx @@ -43,11 +43,11 @@ function getTokenName(token: TokenLike): string { return token.name ?? token.symbol; } -function getTokenLogo(token: TokenLike): string { +function getTokenLogo(token: Pick): string { return token.logo ?? `/tokens/${token.symbol.toLowerCase()}.svg`; } -function getTokenIconLabel(token: TokenLike): string { +function getTokenIconLabel(token: Pick): string { return token.iconLabel ?? (token.symbol.replace(/[^A-Z0-9]/gi, "").slice(0, 2).toUpperCase() || "TK"); } diff --git a/src/components/__tests__/DisputeInvoiceModal.test.tsx b/src/components/__tests__/DisputeInvoiceModal.test.tsx new file mode 100644 index 0000000..1043e37 --- /dev/null +++ b/src/components/__tests__/DisputeInvoiceModal.test.tsx @@ -0,0 +1,72 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import DisputeInvoiceModal from "../DisputeInvoiceModal"; +import type { Invoice } from "@/utils/soroban"; + +const invoice: Invoice = { + id: 16n, + freelancer: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + payer: "GCSXPYZSTPKX2GDVW6XSJDBE3PVSNSXCCLTGSPJXNF57IJU5EDU6IUDV", + amount: 1_000_000_000n, + due_date: 1_900_000_000n, + discount_rate: 300, + status: "Funded", +}; + +describe("DisputeInvoiceModal", () => { + it("shows the required evidence warning", () => { + render( + , + ); + + expect( + screen.getByText( + "Your evidence description will be hashed and recorded on-chain. Save this text — you will need to share it with governance.", + ), + ).toBeInTheDocument(); + }); + + it("requires evidence text", async () => { + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Raise Dispute" })); + + expect(await screen.findByText("Evidence description is required.")).toBeInTheDocument(); + }); + + it("hashes evidence before submitting", async () => { + const onDispute = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + fireEvent.change(screen.getByLabelText("Evidence description"), { + target: { value: "hello" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Raise Dispute" })); + + await waitFor(() => + expect(onDispute).toHaveBeenCalledWith( + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + "hello", + ), + ); + }); +}); diff --git a/src/hooks/useTransaction.ts b/src/hooks/useTransaction.ts new file mode 100644 index 0000000..ad0da1e --- /dev/null +++ b/src/hooks/useTransaction.ts @@ -0,0 +1,61 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useToast } from "@/context/ToastContext"; + +interface UseTransactionOptions { + pendingTitle: string; + pendingMessage?: string; + successTitle: string; + successMessage?: string; + errorTitle: string; +} + +interface TransactionResult { + txHash?: string; +} + +export function useTransaction({ + pendingTitle, + pendingMessage, + successTitle, + successMessage, + errorTitle, +}: UseTransactionOptions) { + const { addToast, updateToast } = useToast(); + const [isPending, setIsPending] = useState(false); + + const runTransaction = useCallback( + async (transaction: () => Promise): Promise => { + setIsPending(true); + const toastId = addToast({ + type: "pending", + title: pendingTitle, + message: pendingMessage, + }); + + try { + const result = await transaction(); + updateToast(toastId, { + type: "success", + title: successTitle, + message: successMessage, + txHash: result.txHash, + }); + return result; + } catch (error) { + updateToast(toastId, { + type: "error", + title: errorTitle, + message: error instanceof Error ? error.message : "Transaction failed.", + }); + throw error; + } finally { + setIsPending(false); + } + }, + [addToast, errorTitle, pendingMessage, pendingTitle, successMessage, successTitle, updateToast], + ); + + return { isPending, runTransaction }; +} diff --git a/src/utils/__tests__/disputeEvidence.test.ts b/src/utils/__tests__/disputeEvidence.test.ts new file mode 100644 index 0000000..098d5e9 --- /dev/null +++ b/src/utils/__tests__/disputeEvidence.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { hashDisputeEvidence } from "../disputeEvidence"; + +describe("hashDisputeEvidence", () => { + it("hashes trimmed evidence text with SHA-256", async () => { + await expect(hashDisputeEvidence(" hello ")).resolves.toBe( + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + ); + }); +}); diff --git a/src/utils/disputeEvidence.ts b/src/utils/disputeEvidence.ts new file mode 100644 index 0000000..dae53da --- /dev/null +++ b/src/utils/disputeEvidence.ts @@ -0,0 +1,9 @@ +export async function hashDisputeEvidence(evidence: string): Promise { + const normalized = evidence.trim(); + const bytes = new TextEncoder().encode(normalized); + const digest = await crypto.subtle.digest("SHA-256", bytes.buffer as ArrayBuffer); + + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/src/utils/evidence.ts b/src/utils/evidence.ts index 55396ad..c661f8f 100644 --- a/src/utils/evidence.ts +++ b/src/utils/evidence.ts @@ -6,7 +6,7 @@ export async function hashEvidence(text: string): Promise { if (typeof crypto !== "undefined" && crypto.subtle) { const encoded = new TextEncoder().encode(normalized); - const digest = await crypto.subtle.digest("SHA-256", encoded); + const digest = await crypto.subtle.digest("SHA-256", encoded.buffer as ArrayBuffer); return Array.from(new Uint8Array(digest)) .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); diff --git a/src/utils/federation.ts b/src/utils/federation.ts index 11922eb..ee89597 100644 --- a/src/utils/federation.ts +++ b/src/utils/federation.ts @@ -4,14 +4,19 @@ import { RPC_URL } from "@/constants"; const horizonServer = new rpc.Server(RPC_URL); const federationCache = new Map(); +interface AccountHomeDomain { + home_domain?: string; + homeDomain?: string; +} + export async function resolveFederatedAddress(address: string): Promise { if (!address) return address; const cached = federationCache.get(address); if (cached) return cached; try { - const account = await horizonServer.getAccount(address); - const homeDomain = account.home_domain ?? (account as any).homeDomain; + const account = await horizonServer.getAccount(address) as AccountHomeDomain; + const homeDomain = account.home_domain ?? account.homeDomain; if (!homeDomain) return address; const stellarTomlResponse = await fetch(`https://${homeDomain}/.well-known/stellar.toml`); diff --git a/src/utils/soroban.ts b/src/utils/soroban.ts index 5d90f57..7ad535d 100644 --- a/src/utils/soroban.ts +++ b/src/utils/soroban.ts @@ -142,6 +142,14 @@ function extractInvoiceIdFromTransaction(result: unknown): bigint | null { return null; } +function getSimulationError(simulated: unknown, fallback = "Unable to simulate transaction."): string { + if (simulated && typeof simulated === "object" && "error" in simulated) { + const { error } = simulated as { error?: unknown }; + return typeof error === "string" ? error : fallback; + } + return fallback; +} + async function readTokenContractValue(tokenId: string, method: string): Promise { const callResult = await server.simulateTransaction(buildReadTransaction(tokenId, method, [])); if (!rpc.Api.isSimulationSuccess(callResult) || !callResult.result?.retval) { @@ -708,7 +716,7 @@ export async function submitInvoice( const sim = await server.simulateTransaction(tx); if (!rpc.Api.isSimulationSuccess(sim)) { - throw new Error(`Simulation failed: ${(sim as any).error}`); + throw new Error(`Simulation failed: ${getSimulationError(sim)}`); } // Extract the predicted invoice ID from simulation retval @@ -717,18 +725,18 @@ export async function submitInvoice( const raw = scValToNative(sim.result!.retval); // Contract returns Result — unwrap Ok variant if (raw && typeof raw === "object" && "ok" in raw) { - invoiceId = BigInt((raw as any).ok); + invoiceId = BigInt(String((raw as { ok: unknown }).ok)); } else if (raw && typeof raw === "object" && "Ok" in raw) { - invoiceId = BigInt((raw as any).Ok); + invoiceId = BigInt(String((raw as { Ok: unknown }).Ok)); } else { - invoiceId = BigInt(raw as any); + invoiceId = BigInt(String(raw)); } - } catch (_) { + } catch { // If we can't parse it, proceed without the ID — it'll be shown after poll } const finalTx = rpc.assembleTransaction(tx, sim).build(); - return { tx: finalTx as any, invoiceId }; + return { tx: finalTx, invoiceId }; } export interface UpdateInvoiceArgs { @@ -772,17 +780,17 @@ export async function updateInvoice( const sim = await server.simulateTransaction(tx); if (!rpc.Api.isSimulationSuccess(sim)) { - throw new Error(`Simulation failed: ${(sim as any).error}`); + throw new Error(`Simulation failed: ${getSimulationError(sim)}`); } const finalTx = rpc.assembleTransaction(tx, sim).build(); - return { tx: finalTx as any }; + return { tx: finalTx }; } export async function cancelInvoice( freelancer: string, invoiceId: bigint -): Promise<{ tx: any }> { +): Promise<{ tx: Transaction }> { // Use a default sequence number / account for preparing or real one if needed let account: Account; try { @@ -805,11 +813,11 @@ export async function cancelInvoice( const sim = await server.simulateTransaction(txUrl); if (!rpc.Api.isSimulationSuccess(sim)) { - throw new Error(`Simulation failed: ${(sim as any).error}`); + throw new Error(`Simulation failed: ${getSimulationError(sim)}`); } const finalTx = rpc.assembleTransaction(txUrl, sim).build(); - return { tx: finalTx as any }; + return { tx: finalTx }; } export async function submitInvoiceTransaction({