diff --git a/__tests__/contract/role-detection.test.ts b/__tests__/contract/role-detection.test.ts new file mode 100644 index 0000000..2469984 --- /dev/null +++ b/__tests__/contract/role-detection.test.ts @@ -0,0 +1,164 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockServer } = vi.hoisted(() => { + const mockServer: any = { + getHealth: vi.fn(() => Promise.resolve({ status: 'healthy' })), + simulateTransaction: vi.fn(), + }; + return { mockServer }; +}); + +vi.mock('@stellar/stellar-sdk', async (importOriginal) => { + const actual = await importOriginal(); + function MockRpcServer() { + return mockServer; + } + return { + ...actual, + rpc: { + Server: MockRpcServer, + Api: { + isSimulationSuccess: vi.fn((res: any) => Boolean(res?.result?.retval !== undefined)), + }, + }, + scValToNative: vi.fn((val: any) => val), + nativeToScVal: vi.fn((val: any) => val), + Address: { + fromString: vi.fn(() => ({ + toScVal: vi.fn(() => ({})), + })), + }, + TransactionBuilder: vi.fn(function (this: any) { + this.addOperation = vi.fn().mockReturnThis(); + this.setTimeout = vi.fn().mockReturnThis(); + this.build = vi.fn(() => ({})); + }), + Operation: { + invokeContractFunction: vi.fn(() => ({})), + }, + Account: vi.fn(function (this: any, address: string) { + this.accountId = () => address; + }), + BASE_FEE: '100', + }; +}); + +vi.mock('@/constants', () => ({ + CONTRACT_ID: 'CCONTRACTIDTEST000000000000000000000000000000000000000000', + NETWORK_PASSPHRASE: 'Test SDF Network ; September 2015', + RPC_URL: 'https://soroban-testnet.stellar.org', + TESTNET_USDC_TOKEN_ID: 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', + TESTNET_EURC_TOKEN_ID: 'CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', + TESTNET_XLM_TOKEN_ID: 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC', +})); + +import { + getWalletRoles, + listInvoicesBySubmitter, + listInvoicesByPayer, + listInvoicesByLp, +} from '@/utils/soroban'; + +describe('Wallet Role Detection Integration Tests', () => { + const TEST_ADDR = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('detects freelancer role when list_invoices_by_submitter returns invoices', async () => { + mockServer.simulateTransaction.mockImplementation(async () => { + return { + result: { + retval: [ + { + id: 1n, + freelancer: TEST_ADDR, + payer: 'GPAYER123', + amount: 1000n, + due_date: 1700000000n, + discount_rate: 200, + status: { Pending: {} }, + }, + ], + }, + }; + }); + + const submitterInvoices = await listInvoicesBySubmitter(TEST_ADDR); + const payerInvoices = await listInvoicesByPayer(TEST_ADDR); + const lpInvoices = await listInvoicesByLp(TEST_ADDR); + expect(submitterInvoices.length).toBe(1); + expect(payerInvoices.length).toBe(1); + expect(lpInvoices.length).toBe(1); + + const roles = await getWalletRoles(TEST_ADDR); + expect(roles).toContain('freelancer'); + }); + + it('detects multiple roles (freelancer, payer, lp) when all view functions return invoices', async () => { + mockServer.simulateTransaction.mockImplementation(async () => { + return { + result: { + retval: [ + { + id: 101n, + freelancer: TEST_ADDR, + payer: TEST_ADDR, + funder: TEST_ADDR, + amount: 5000n, + due_date: 1700000000n, + discount_rate: 150, + status: { Funded: {} }, + }, + ], + }, + }; + }); + + const roles = await getWalletRoles(TEST_ADDR); + expect(roles).toContain('freelancer'); + expect(roles).toContain('payer'); + expect(roles).toContain('lp'); + }); + + it('returns empty roles array when user has no invoices across views', async () => { + mockServer.simulateTransaction.mockImplementation(async () => { + return { + result: { + retval: [], + }, + }; + }); + + const roles = await getWalletRoles('GUSERWITHNOINVOICES'); + expect(roles).toEqual([]); + }); + + it('falls back gracefully to table scan if dedicated contract view functions fail', async () => { + let callCount = 0; + mockServer.simulateTransaction.mockImplementation(async () => { + callCount++; + if (callCount <= 4) { + return { result: null }; + } + return { + result: { + retval: { + id: 1n, + freelancer: TEST_ADDR, + payer: 'GPAYER', + amount: 100n, + due_date: 1700000000n, + discount_rate: 100, + status: 'Pending', + }, + }, + }; + }); + + const roles = await getWalletRoles(TEST_ADDR); + expect(roles).toContain('freelancer'); + }); +}); diff --git a/__tests__/contract/soroban-extended.test.ts b/__tests__/contract/soroban-extended.test.ts index 517a597..8c6cb27 100644 --- a/__tests__/contract/soroban-extended.test.ts +++ b/__tests__/contract/soroban-extended.test.ts @@ -180,11 +180,9 @@ describe('soroban – getWalletRoles', () => { due_date: 1n, discount_rate: 100, }; - (scValToNative as any).mockReturnValueOnce(inv); - (rpc.Api.isSimulationSuccess as any).mockReturnValueOnce(true).mockReturnValueOnce(false); - mockServer.simulateTransaction - .mockResolvedValueOnce({ result: { retval: {} } }) - .mockResolvedValueOnce({ error: 'fail' }); + (scValToNative as any).mockReturnValue([inv]); + (rpc.Api.isSimulationSuccess as any).mockReturnValue(true); + mockServer.simulateTransaction.mockResolvedValue({ result: { retval: [inv] } }); const roles = await getWalletRoles(ADDR); expect(roles).toContain('freelancer'); }); @@ -730,11 +728,9 @@ describe('soroban – getWalletRoles extended', () => { due_date: 1n, discount_rate: 100, }; - (scValToNative as any).mockReturnValueOnce(inv); - (rpc.Api.isSimulationSuccess as any).mockReturnValueOnce(true).mockReturnValueOnce(false); - mockServer.simulateTransaction - .mockResolvedValueOnce({ result: { retval: {} } }) - .mockResolvedValueOnce({ error: 'fail' }); + (scValToNative as any).mockReturnValue([inv]); + (rpc.Api.isSimulationSuccess as any).mockReturnValue(true); + mockServer.simulateTransaction.mockResolvedValue({ result: { retval: [inv] } }); const roles = await getWalletRoles(ADDR); expect(roles).toContain('payer'); }); @@ -751,11 +747,9 @@ describe('soroban – getWalletRoles extended', () => { due_date: 1n, discount_rate: 100, }; - (scValToNative as any).mockReturnValueOnce(inv); - (rpc.Api.isSimulationSuccess as any).mockReturnValueOnce(true).mockReturnValueOnce(false); - mockServer.simulateTransaction - .mockResolvedValueOnce({ result: { retval: {} } }) - .mockResolvedValueOnce({ error: 'fail' }); + (scValToNative as any).mockReturnValue([inv]); + (rpc.Api.isSimulationSuccess as any).mockReturnValue(true); + mockServer.simulateTransaction.mockResolvedValue({ result: { retval: [inv] } }); const roles = await getWalletRoles(ADDR); expect(roles).toContain('lp'); }); diff --git a/__tests__/feature-flags-no-render.test.tsx b/__tests__/feature-flags-no-render.test.tsx new file mode 100644 index 0000000..4f3b5ba --- /dev/null +++ b/__tests__/feature-flags-no-render.test.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import OracleBadge from '../src/components/OracleBadge'; +import InvoiceNftCard from '../src/components/InvoiceNftCard'; +import InsurancePoolPanel from '../src/components/InsurancePoolPanel'; + +vi.mock('../src/hooks/useInvoiceNft', () => ({ + useInvoiceNft: vi.fn(() => ({ state: null, loading: false, reload: vi.fn() })), +})); + +vi.mock('../src/hooks/useInsurance', () => ({ + useInsurance: vi.fn(() => ({ + poolInfo: null, + isEnrolled: false, + isLoading: false, + refresh: vi.fn(), + })), +})); + +vi.mock('../src/hooks/useTransaction', () => ({ + useTransaction: vi.fn(() => ({ execute: vi.fn() })), +})); + +vi.mock('../src/hooks/useApprovedTokens', () => ({ + useApprovedTokens: vi.fn(() => ({ defaultToken: { symbol: 'USDC', decimals: 7 } })), +})); + +vi.mock('../src/context/WalletContext', () => ({ + useWallet: () => ({ address: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC6' }), +})); + +describe('Feature Flags Graceful No-Render Verification', () => { + beforeEach(() => { + vi.stubEnv('NEXT_PUBLIC_ORACLE_ENABLED', 'false'); + vi.stubEnv('NEXT_PUBLIC_NFT_ENABLED', 'false'); + vi.stubEnv('NEXT_PUBLIC_INSURANCE_POOL_ENABLED', 'false'); + }); + + it('renders nothing when NEXT_PUBLIC_ORACLE_ENABLED is false', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + expect(screen.queryByText(/Oracle Verified/i)).not.toBeInTheDocument(); + }); + + it('renders nothing when NEXT_PUBLIC_NFT_ENABLED is false', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + expect(screen.queryByText(/Invoice NFT/i)).not.toBeInTheDocument(); + }); + + it('renders nothing when NEXT_PUBLIC_INSURANCE_POOL_ENABLED is false', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + expect(screen.queryByText(/Default Protection/i)).not.toBeInTheDocument(); + }); +}); diff --git a/app/dashboard/payer/page.tsx b/app/dashboard/payer/page.tsx index 64ff11b..473f5de 100644 --- a/app/dashboard/payer/page.tsx +++ b/app/dashboard/payer/page.tsx @@ -1,28 +1,24 @@ -"use client"; +'use client'; -import Link from "next/link"; -import { useCallback, useEffect, useMemo, useState, Suspense } from "react"; -import Footer from "@/components/Footer"; -import Navbar from "@/components/Navbar"; -import { TokenAmount, TokenIcon } from "@/components/TokenSelector"; -import { useToast } from "@/context/ToastContext"; -import { useWallet } from "@/context/WalletContext"; -import { useApprovedTokens } from "@/hooks/useApprovedTokens"; -import { - APPEAL_WINDOW_LEDGERS, - formatLedgerWindow, - hashEvidence, -} from "@/utils/evidence"; -import { formatAddress, formatDate, formatTokenAmount } from "@/utils/format"; +import Link from 'next/link'; +import { useCallback, useEffect, useMemo, useState, Suspense } from 'react'; +import Footer from '@/components/Footer'; +import Navbar from '@/components/Navbar'; +import { TokenAmount, TokenIcon } from '@/components/TokenSelector'; +import { useToast } from '@/context/ToastContext'; +import { useWallet } from '@/context/WalletContext'; +import { useApprovedTokens } from '@/hooks/useApprovedTokens'; +import { APPEAL_WINDOW_LEDGERS, formatLedgerWindow, hashEvidence } from '@/utils/evidence'; +import { formatAddress, formatDate, formatTokenAmount } from '@/utils/format'; import { Invoice, appealDefault, getAllInvoices, markPaid, submitSignedTransaction, -} from "@/utils/soroban"; +} from '@/utils/soroban'; -type PayerTab = "Outstanding" | "Settled" | "Pending" | "Disputed"; +type PayerTab = 'Outstanding' | 'Settled' | 'Pending' | 'Disputed'; interface AppealState { invoice: Invoice; @@ -31,57 +27,52 @@ interface AppealState { submitting: boolean; } -const TABS: PayerTab[] = ["Outstanding", "Settled", "Pending", "Disputed"]; +const TABS: PayerTab[] = ['Outstanding', 'Settled', 'Pending', 'Disputed']; function isOverdue(invoice: Invoice): boolean { - return ( - Number(invoice.due_date) * 1000 < Date.now() && invoice.status !== "Paid" - ); + return Number(invoice.due_date) * 1000 < Date.now() && invoice.status !== 'Paid'; } function invoiceTab(invoice: Invoice): PayerTab { - if (invoice.status === "Paid" || invoice.status === "Appealed") - return "Settled"; + if (invoice.status === 'Paid' || invoice.status === 'Appealed') return 'Settled'; if ( - invoice.status === "Disputed" || - invoice.status === "Expired" || - invoice.status === "Defaulted" + invoice.status === 'Disputed' || + invoice.status === 'Expired' || + invoice.status === 'Defaulted' ) { - return "Disputed"; + return 'Disputed'; } - if (invoice.status === "Funded") return "Outstanding"; - return "Pending"; + if (invoice.status === 'Funded') return 'Outstanding'; + return 'Pending'; } function disputeMeta(invoice: Invoice) { - const id = invoice.id.toString().padStart(4, "0"); - const expired = - invoice.status === "Expired" || invoice.status === "Defaulted"; + const id = invoice.id.toString().padStart(4, '0'); + const expired = invoice.status === 'Expired' || invoice.status === 'Defaulted'; return { - evidenceHash: `0x${id}evidence${id}`.padEnd(18, "0"), + evidenceHash: `0x${id}evidence${id}`.padEnd(18, '0'), voteLink: `/governance/${Number(invoice.id) || 1}`, disputeDate: formatDate(invoice.due_date), - timeout: expired ? "Expired" : "2d 8h remaining", + timeout: expired ? 'Expired' : '2d 8h remaining', ruling: expired - ? "Ruling: Dismissed" - : invoice.status === "Disputed" - ? "Resolution pending" - : "Ruling: Resolved", + ? 'Ruling: Dismissed' + : invoice.status === 'Disputed' + ? 'Resolution pending' + : 'Ruling: Resolved', }; } function StatusPill({ invoice }: { invoice: Invoice }) { const overdue = isOverdue(invoice); - const label = - overdue && invoice.status === "Funded" ? "Overdue" : invoice.status; + const label = overdue && invoice.status === 'Funded' ? 'Overdue' : invoice.status; const color = - label === "Paid" || label === "Appealed" - ? "bg-emerald-500/15 text-emerald-600 border-emerald-500/30" - : label === "Overdue" || label === "Expired" || label === "Defaulted" - ? "bg-red-500/15 text-red-600 border-red-500/30" - : label === "Disputed" - ? "bg-amber-500/15 text-amber-600 border-amber-500/30" - : "bg-primary/15 text-primary border-primary/30"; + label === 'Paid' || label === 'Appealed' + ? 'bg-emerald-500/15 text-emerald-600 border-emerald-500/30' + : label === 'Overdue' || label === 'Expired' || label === 'Defaulted' + ? 'bg-red-500/15 text-red-600 border-red-500/30' + : label === 'Disputed' + ? 'bg-amber-500/15 text-amber-600 border-amber-500/30' + : 'bg-primary/15 text-primary border-primary/30'; return ( - {connected ? "receipt_long" : "account_balance_wallet"} + {connected ? 'receipt_long' : 'account_balance_wallet'}

{connected ? `No ${tab.toLowerCase()} invoices found` - : "Connect your wallet to view payer invoices"} + : 'Connect your wallet to view payer invoices'}

); @@ -124,13 +115,13 @@ function AppealDefaultModal({

Appeal Default

- Invoice #{state.invoice.id.toString()} will be appealed with a - client-side evidence hash. + Invoice #{state.invoice.id.toString()} will be appealed with a client-side evidence + hash.

- Appeal window remaining:{" "} + Appeal window remaining:{' '} {formatLedgerWindow(APPEAL_WINDOW_LEDGERS)} @@ -163,7 +154,7 @@ function AppealDefaultModal({ disabled={!state.evidenceHash || state.submitting} className="flex-[2] rounded-xl bg-primary px-4 py-3 text-sm font-bold text-white disabled:opacity-50" > - {state.submitting ? "Submitting..." : "Submit Appeal"} + {state.submitting ? 'Submitting...' : 'Submit Appeal'}
@@ -183,7 +174,7 @@ function PayerDashboardContent() { const { address, isConnected, connect, signTx } = useWallet(); const { addToast, updateToast } = useToast(); const { tokenMap, defaultToken } = useApprovedTokens(); - const [activeTab, setActiveTab] = useState("Outstanding"); + const [activeTab, setActiveTab] = useState('Outstanding'); const [invoices, setInvoices] = useState([]); const [loading, setLoading] = useState(false); const [settlingId, setSettlingId] = useState(null); @@ -197,9 +188,9 @@ function PayerDashboardContent() { setInvoices(all.filter((invoice) => invoice.payer === address)); } catch (error) { addToast({ - type: "error", - title: "Could not load payer invoices", - message: error instanceof Error ? error.message : "Unknown error", + type: 'error', + title: 'Could not load payer invoices', + message: error instanceof Error ? error.message : 'Unknown error', }); } finally { setLoading(false); @@ -207,14 +198,15 @@ function PayerDashboardContent() { }, [addToast, address, isConnected]); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect void loadInvoices(); }, [loadInvoices]); const totalsByToken = useMemo(() => { return invoices - .filter((invoice) => invoiceTab(invoice) === "Outstanding") + .filter((invoice) => invoiceTab(invoice) === 'Outstanding') .reduce((totals, invoice) => { - const tokenId = invoice.token ?? defaultToken?.contractId ?? ""; + const tokenId = invoice.token ?? defaultToken?.contractId ?? ''; totals.set(tokenId, (totals.get(tokenId) ?? 0n) + invoice.amount); return totals; }, new Map()); @@ -222,35 +214,32 @@ function PayerDashboardContent() { const visibleInvoices = useMemo( () => invoices.filter((invoice) => invoiceTab(invoice) === activeTab), - [activeTab, invoices], + [activeTab, invoices] ); const handleSettle = async (invoice: Invoice) => { if (!address) return; setSettlingId(invoice.id.toString()); const toastId = addToast({ - type: "pending", + type: 'pending', title: `Settling invoice #${invoice.id}...`, }); try { - const tx = await markPaid(address, invoice.id); + const tx = await markPaid(address, invoice.id, invoice.amount); const { txHash } = await submitSignedTransaction({ tx, signTx }); updateToast(toastId, { - type: "success", - title: "Invoice settled", + type: 'success', + title: 'Invoice settled', txHash, }); setInvoices((current) => - current.map((item) => - item.id === invoice.id ? { ...item, status: "Paid" } : item, - ), + current.map((item) => (item.id === invoice.id ? { ...item, status: 'Paid' } : item)) ); } catch (error) { updateToast(toastId, { - type: "error", - title: "Settlement failed", - message: - error instanceof Error ? error.message : "Transaction rejected", + type: 'error', + title: 'Settlement failed', + message: error instanceof Error ? error.message : 'Transaction rejected', }); } finally { setSettlingId(null); @@ -270,35 +259,28 @@ function PayerDashboardContent() { if (!appealState || !address) return; setAppealState({ ...appealState, submitting: true }); const toastId = addToast({ - type: "pending", + type: 'pending', title: `Appealing invoice #${appealState.invoice.id}...`, }); try { - const tx = await appealDefault( - address, - appealState.invoice.id, - appealState.evidenceHash, - ); + const tx = await appealDefault(address, appealState.invoice.id, appealState.evidenceHash); const { txHash } = await submitSignedTransaction({ tx, signTx }); updateToast(toastId, { - type: "success", - title: "Default appealed", + type: 'success', + title: 'Default appealed', txHash, }); setInvoices((current) => current.map((item) => - item.id === appealState.invoice.id - ? { ...item, status: "Appealed" } - : item, - ), + item.id === appealState.invoice.id ? { ...item, status: 'Appealed' } : item + ) ); setAppealState(null); } catch (error) { updateToast(toastId, { - type: "error", - title: "Appeal failed", - message: - error instanceof Error ? error.message : "Transaction rejected", + type: 'error', + title: 'Appeal failed', + message: error instanceof Error ? error.message : 'Transaction rejected', }); setAppealState({ ...appealState, submitting: false }); } @@ -313,12 +295,10 @@ function PayerDashboardContent() {

Payer Dashboard

-

- Invoice Inbox -

+

Invoice Inbox

- Track invoices addressed to your wallet, settle funded invoices, - follow disputes, and appeal defaults. + Track invoices addressed to your wallet, settle funded invoices, follow disputes, and + appeal defaults.

{isConnected ? ( @@ -328,7 +308,7 @@ function PayerDashboardContent() { className="inline-flex items-center gap-2 rounded-xl border border-outline-variant/30 px-4 py-2.5 text-sm font-bold text-on-surface-variant hover:text-primary disabled:opacity-50" > refresh @@ -350,30 +330,21 @@ function PayerDashboardContent() {

{invoices.length}

-

- Invoices addressed to you -

+

Invoices addressed to you

- {Array.from(totalsByToken.entries()).map( - ([tokenId, amount]) => { - const token = tokenMap.get(tokenId) ?? defaultToken; - if (!token) return null; - return ( - - - - ); - }, - )} + {Array.from(totalsByToken.entries()).map(([tokenId, amount]) => { + const token = tokenMap.get(tokenId) ?? defaultToken; + if (!token) return null; + return ( + + + + ); + })}
-

- Outstanding total by token -

+

Outstanding total by token

@@ -388,16 +359,11 @@ function PayerDashboardContent() { onClick={() => setActiveTab(tab)} className={`rounded-xl px-4 py-2 text-sm font-bold ${ activeTab === tab - ? "bg-primary text-white" - : "bg-surface-container text-on-surface-variant" + ? 'bg-primary text-white' + : 'bg-surface-container text-on-surface-variant' }`} > - {tab} ( - { - invoices.filter((invoice) => invoiceTab(invoice) === tab) - .length - } - ) + {tab} ({invoices.filter((invoice) => invoiceTab(invoice) === tab).length}) ))} @@ -407,13 +373,13 @@ function PayerDashboardContent() { {[ - "Invoice ID", - "Freelancer", - "Amount", - "Token", - "Due Date", - "State", - "Action", + 'Invoice ID', + 'Freelancer', + 'Amount', + 'Token', + 'Due Date', + 'State', + 'Action', ].map((header) => ( ) : loading ? ( - + Loading invoices... ) : ( visibleInvoices.map((invoice) => { const token = - tokenMap.get( - invoice.token ?? defaultToken?.contractId ?? "", - ) ?? defaultToken; - const disputed = activeTab === "Disputed"; + tokenMap.get(invoice.token ?? defaultToken?.contractId ?? '') ?? defaultToken; + const disputed = activeTab === 'Disputed'; const meta = disputed ? disputeMeta(invoice) : null; return ( #{invoice.id.toString()} @@ -462,9 +421,7 @@ function PayerDashboardContent() {
{formatAddress(invoice.freelancer)}
-
- Reputation: 96% -
+
Reputation: 96%
{token @@ -472,20 +429,14 @@ function PayerDashboardContent() { : invoice.amount.toString()} - {token ? : "TOKEN"} + {token ? : 'TOKEN'}
{formatDate(invoice.due_date)}
{isOverdue(invoice) && ( -
- Overdue -
- )} - {meta && ( -
- {meta.timeout} -
+
Overdue
)} + {meta &&
{meta.timeout}
} @@ -494,11 +445,7 @@ function PayerDashboardContent() {
{meta.disputeDate}
{meta.ruling}
)} {meta && (
- + Governance vote - {(invoice.status === "Expired" || - invoice.status === "Defaulted") && ( + {(invoice.status === 'Expired' || invoice.status === 'Defaulted') && (