diff --git a/app/governance/page.tsx b/app/governance/page.tsx index a810b13..1387794 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-orange-500/15 text-orange-500 border-orange-500/30", icon: "block" }, }; const { color, icon } = config[status]; return ( @@ -202,10 +203,17 @@ export default function GovernancePage() { }, []); useEffect(() => { - load(); + const timeout = window.setTimeout(() => { + void load(); + }, 0); // Refresh every 30 s for real-time vote counts - const interval = setInterval(load, 30_000); - return () => clearInterval(interval); + const interval = window.setInterval(() => { + void load(); + }, 30_000); + return () => { + window.clearTimeout(timeout); + window.clearInterval(interval); + }; }, [load]); useEffect(() => { diff --git a/app/marketplace/__tests__/MarketplacePage.test.tsx b/app/marketplace/__tests__/MarketplacePage.test.tsx new file mode 100644 index 0000000..95d0e72 --- /dev/null +++ b/app/marketplace/__tests__/MarketplacePage.test.tsx @@ -0,0 +1,110 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import MarketplacePage from "../page"; +import { getAllInvoices, getPayerScoresBatch } from "@/utils/soroban"; + +const TOKEN_ID = "USDC"; + +vi.mock("@/components/Navbar", () => ({ + default: () => , +})); + +vi.mock("@/components/Footer", () => ({ + default: () => , +})); + +vi.mock("@/hooks/useDocumentTitle", () => ({ + useDocumentTitle: vi.fn(), +})); + +vi.mock("@/context/WalletContext", () => ({ + useWallet: () => ({ + isConnected: true, + }), +})); + +vi.mock("@/hooks/useApprovedTokens", () => ({ + useApprovedTokens: () => { + const token = { + contractId: TOKEN_ID, + name: "USD Coin", + symbol: "USDC", + decimals: 7, + iconLabel: "US", + logo: "/tokens/usdc.svg", + isAllowed: true, + }; + return { + tokens: [token], + tokenMap: new Map([[TOKEN_ID, token]]), + defaultToken: token, + }; + }, +})); + +vi.mock("@/utils/soroban", async () => { + const actual = await vi.importActual("@/utils/soroban"); + return { + ...actual, + getAllInvoices: vi.fn(), + getPayerScoresBatch: vi.fn(), + }; +}); + +function invoice(id: number, overrides: Partial>[number]> = {}) { + return { + id: BigInt(id), + status: "Pending", + freelancer: `GFREELANCER${id}`, + payer: `GPAYER${id}`, + amount: BigInt(id * 100_000_000), + due_date: BigInt(1_800_000_000 + id * 1_000), + discount_rate: id * 100, + token: TOKEN_ID, + ...overrides, + }; +} + +describe("MarketplacePage", () => { + beforeEach(() => { + vi.mocked(getAllInvoices).mockResolvedValue([ + invoice(1, { discount_rate: 300 }), + invoice(2, { status: "Funded", discount_rate: 800 }), + invoice(3, { discount_rate: 900 }), + ]); + vi.mocked(getPayerScoresBatch).mockResolvedValue( + new Map([ + ["GPAYER1", { score: 70, settled_on_time: 3, defaults: 0 }], + ["GPAYER3", { score: 95, settled_on_time: 8, defaults: 0 }], + ]), + ); + }); + + it("renders pending invoices with funding CTAs and payer reputation", async () => { + render(); + + expect(await screen.findByText("Invoice Marketplace")).toBeInTheDocument(); + await waitFor(() => expect(getAllInvoices).toHaveBeenCalledOnce()); + + expect(screen.getByText("Invoice #1")).toBeInTheDocument(); + expect(screen.getByText("Invoice #3")).toBeInTheDocument(); + expect(screen.queryByText("Invoice #2")).not.toBeInTheDocument(); + expect(screen.getAllByText("Fund Invoice")).toHaveLength(2); + expect(screen.getAllByLabelText(/risk level: low/i)).toHaveLength(2); + expect(screen.getByText("Reputation 70/100")).toBeInTheDocument(); + expect(screen.getByText("Reputation 95/100")).toBeInTheDocument(); + }); + + it("sorts by highest yield first by default and filters by minimum yield", async () => { + render(); + + await screen.findByText("Invoice #1"); + const cardsBefore = screen.getAllByText(/Invoice #/).map((node) => node.textContent); + expect(cardsBefore).toEqual(["Invoice #3", "Invoice #1"]); + + fireEvent.change(screen.getByLabelText(/min yield/i), { target: { value: "5" } }); + + expect(screen.queryByText("Invoice #1")).not.toBeInTheDocument(); + expect(screen.getByText("Invoice #3")).toBeInTheDocument(); + }); +}); diff --git a/app/marketplace/page.tsx b/app/marketplace/page.tsx new file mode 100644 index 0000000..99da438 --- /dev/null +++ b/app/marketplace/page.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import Footer from "@/components/Footer"; +import InvoiceMarketplaceCard from "@/components/InvoiceMarketplaceCard"; +import Navbar from "@/components/Navbar"; +import { useWallet } from "@/context/WalletContext"; +import { useApprovedTokens } from "@/hooks/useApprovedTokens"; +import { useDocumentTitle } from "@/hooks/useDocumentTitle"; +import { + filterMarketplaceInvoices, + paginateMarketplaceInvoices, + sortMarketplaceInvoices, + type MarketplaceFilters, + type MarketplaceSortKey, +} from "@/utils/marketplace"; +import { + getAllInvoices, + getPayerScoresBatch, + type Invoice, + type PayerScoreResult, +} from "@/utils/soroban"; + +const PAGE_SIZE = 20; + +const DEFAULT_FILTERS: MarketplaceFilters = { + token: "", + minYield: 0, + maxAmount: "", + minReputation: 0, +}; + +function MarketplaceSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+
+
+
+ {Array.from({ length: 4 }).map((__, itemIndex) => ( +
+ ))} +
+
+ ))} +
+ ); +} + +export default function MarketplacePage() { + useDocumentTitle({ pageTitle: "Invoice Marketplace" }); + + const { isConnected } = useWallet(); + const { tokens, tokenMap, defaultToken } = useApprovedTokens(); + const [invoices, setInvoices] = useState([]); + const [payerScores, setPayerScores] = useState>(new Map()); + const [filters, setFilters] = useState(DEFAULT_FILTERS); + const [sortKey, setSortKey] = useState("yield"); + const [page, setPage] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const loadMarketplace = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const allInvoices = await getAllInvoices(); + const pendingInvoices = allInvoices.filter((invoice) => invoice.status === "Pending"); + const uniquePayers = [...new Set(pendingInvoices.map((invoice) => invoice.payer))]; + const scores = await getPayerScoresBatch(uniquePayers); + setInvoices(pendingInvoices); + setPayerScores(scores); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : "Failed to load marketplace invoices."); + setInvoices([]); + setPayerScores(new Map()); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + const timeout = window.setTimeout(() => { + void loadMarketplace(); + }, 0); + + return () => window.clearTimeout(timeout); + }, [loadMarketplace]); + + const filteredInvoices = useMemo( + () => filterMarketplaceInvoices({ invoices, filters, payerScores }), + [filters, invoices, payerScores], + ); + const sortedInvoices = useMemo( + () => sortMarketplaceInvoices(filteredInvoices, sortKey), + [filteredInvoices, sortKey], + ); + const visibleInvoices = useMemo( + () => paginateMarketplaceInvoices(sortedInvoices, page, PAGE_SIZE), + [page, sortedInvoices], + ); + const maxPage = Math.max(0, Math.ceil(sortedInvoices.length / PAGE_SIZE) - 1); + + const updateFilter = (key: K, value: MarketplaceFilters[K]) => { + setPage(0); + setFilters((current) => ({ ...current, [key]: value })); + }; + + const updateSortKey = (value: MarketplaceSortKey) => { + setPage(0); + setSortKey(value); + }; + + return ( +
+ + +
+
+

LP Marketplace

+

Invoice Marketplace

+

+ Browse pending invoices available for funding and compare yield, due date, token, and payer reputation. +

+
+
+ +
+
+ + + + + +
+ +
+

+ {sortedInvoices.length.toLocaleString()} pending invoices available +

+
+ + Page {page + 1} + +
+
+ +
+ {isLoading ? ( + + ) : error ? ( +
+ {error} +
+ ) : visibleInvoices.length > 0 ? ( +
+ {visibleInvoices.map((invoice) => ( + + ))} +
+ ) : ( +
+

No pending invoices match these filters

+

Adjust the filters or check back later.

+
+ )} +
+
+ +
+
+ ); +} 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/utils/__tests__/marketplace.test.ts b/src/utils/__tests__/marketplace.test.ts new file mode 100644 index 0000000..f9b9840 --- /dev/null +++ b/src/utils/__tests__/marketplace.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + filterMarketplaceInvoices, + paginateMarketplaceInvoices, + sortMarketplaceInvoices, +} from "../marketplace"; +import type { Invoice } from "../soroban"; + +function invoice(id: number, overrides: Partial = {}): Invoice { + return { + id: BigInt(id), + status: "Pending", + freelancer: "GFREELANCER", + payer: `GPAYER${id}`, + amount: BigInt(id * 100_000_000), + due_date: BigInt(1_800_000_000 + id), + discount_rate: id * 100, + token: "USDC", + ...overrides, + }; +} + +describe("marketplace utilities", () => { + it("filters pending invoices by token, yield, amount, and reputation", () => { + const scores = new Map([ + ["GPAYER1", { score: 30, settled_on_time: 0, defaults: 1 }], + ["GPAYER2", { score: 90, settled_on_time: 4, defaults: 0 }], + ]); + + const result = filterMarketplaceInvoices({ + invoices: [ + invoice(1, { token: "USDC", discount_rate: 300 }), + invoice(2, { token: "USDC", discount_rate: 700 }), + invoice(3, { token: "EURC", discount_rate: 900 }), + invoice(4, { status: "Funded", discount_rate: 1_000 }), + ], + payerScores: scores, + filters: { + token: "USDC", + minYield: 5, + maxAmount: "25", + minReputation: 80, + }, + }); + + expect(result.map((item) => item.id)).toEqual([2n]); + }); + + it("sorts and paginates invoices", () => { + const invoices = [invoice(1), invoice(3), invoice(2)]; + expect(sortMarketplaceInvoices(invoices, "yield").map((item) => item.id)).toEqual([3n, 2n, 1n]); + expect(paginateMarketplaceInvoices(invoices, 1, 2).map((item) => item.id)).toEqual([2n]); + }); +}); 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..d1b78c2 100644 --- a/src/utils/federation.ts +++ b/src/utils/federation.ts @@ -4,6 +4,11 @@ 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); @@ -11,7 +16,8 @@ export async function resolveFederatedAddress(address: string): Promise try { const account = await horizonServer.getAccount(address); - const homeDomain = account.home_domain ?? (account as any).homeDomain; + const { home_domain: homeDomainSnake, homeDomain: homeDomainCamel } = account as AccountHomeDomain; + const homeDomain = homeDomainSnake ?? homeDomainCamel; if (!homeDomain) return address; const stellarTomlResponse = await fetch(`https://${homeDomain}/.well-known/stellar.toml`); diff --git a/src/utils/marketplace.ts b/src/utils/marketplace.ts new file mode 100644 index 0000000..a04bc8a --- /dev/null +++ b/src/utils/marketplace.ts @@ -0,0 +1,65 @@ +import type { Invoice, PayerScoreResult } from "./soroban"; + +export type MarketplaceSortKey = "yield" | "amount" | "due_date"; + +export interface MarketplaceFilters { + token: string; + minYield: number; + maxAmount: string; + minReputation: number; +} + +export function effectiveYieldPercent(invoice: Invoice): number { + return invoice.discount_rate / 100; +} + +function amountWithinLimit(invoice: Invoice, maxAmount: string, decimals = 7): boolean { + if (!maxAmount.trim()) return true; + const parsed = Number(maxAmount); + if (!Number.isFinite(parsed) || parsed <= 0) return true; + const limit = BigInt(Math.floor(parsed * 10 ** decimals)); + return invoice.amount <= limit; +} + +export function filterMarketplaceInvoices({ + invoices, + filters, + payerScores, +}: { + invoices: Invoice[]; + filters: MarketplaceFilters; + payerScores: Map; +}) { + return invoices.filter((invoice) => { + if (invoice.status !== "Pending") return false; + if (filters.token && invoice.token !== filters.token) return false; + if (effectiveYieldPercent(invoice) < filters.minYield) return false; + if (!amountWithinLimit(invoice, filters.maxAmount)) return false; + + const score = payerScores.get(invoice.payer)?.score ?? 0; + if (score < filters.minReputation) return false; + + return true; + }); +} + +export function sortMarketplaceInvoices(invoices: Invoice[], sortKey: MarketplaceSortKey) { + return [...invoices].sort((a, b) => { + if (sortKey === "yield") { + return b.discount_rate - a.discount_rate; + } + if (sortKey === "amount") { + if (a.amount === b.amount) return 0; + return a.amount > b.amount ? -1 : 1; + } + if (a.due_date === b.due_date) return 0; + return a.due_date < b.due_date ? -1 : 1; + }); +} + +export function paginateMarketplaceInvoices(invoices: Invoice[], page: number, pageSize: number) { + const safePage = Math.max(0, page); + const safePageSize = Math.max(1, pageSize); + const start = safePage * safePageSize; + return invoices.slice(start, start + safePageSize); +}