diff --git a/app/governance/page.tsx b/app/governance/page.tsx index a810b13..c1d66ef 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: "gavel" }, }; const { color, icon } = config[status]; return ( @@ -196,9 +197,10 @@ export default function GovernancePage() { const [votingPower, setVotingPower] = useState(0); const load = useCallback(async () => { - const data = await fetchProposals(); - setProposals(data); - setLoading(false); + fetchProposals().then((data) => { + setProposals(data); + setLoading(false); + }); }, []); useEffect(() => { @@ -210,10 +212,14 @@ export default function GovernancePage() { useEffect(() => { if (!isConnected || !address) { - setVotingPower(0); + Promise.resolve().then(() => { + setVotingPower(0); + }); return; } - getVotingPower(address).then(setVotingPower); + getVotingPower(address).then((power) => { + setVotingPower(power); + }); }, [address, isConnected]); const sorted = useMemo( diff --git a/app/profile/[address]/page.tsx b/app/profile/[address]/page.tsx index 5ea6a5e..e67ca48 100644 --- a/app/profile/[address]/page.tsx +++ b/app/profile/[address]/page.tsx @@ -12,6 +12,7 @@ import { } from "@/utils/soroban"; import { resolveFederatedAddress } from "@/utils/federation"; import { formatDate } from "@/utils/format"; +import { isOracleVerifiedAddress } from "@/utils/oracleVerification"; import ProfileActivityChart from "@/components/ProfileActivityChart"; import ProfileRecentInvoices from "@/components/ProfileRecentInvoices"; import ActivityHeatmap from "@/components/ActivityHeatmap"; diff --git a/src/components/LPDashboard.tsx b/src/components/LPDashboard.tsx index da5df7b..9bdc16b 100644 --- a/src/components/LPDashboard.tsx +++ b/src/components/LPDashboard.tsx @@ -40,6 +40,7 @@ import DynamicYieldAnalyticsChart from "./DynamicYieldAnalyticsChart"; import LPSettingsModal from "./LPSettingsModal"; import { useLPSettings } from "@/hooks/useLPSettings"; import type { DataTableColumn } from "./DataTable"; +import PayerIdentity from "./PayerIdentity"; type Tab = "discovery" | "my-funded" | "watchlist" | "earnings-history"; @@ -91,8 +92,9 @@ export default function LPDashboard() { } else { addToast({ type: "success", title: "Removed from Watchlist" }); } - } catch (error: any) { - addToast({ type: "error", title: "Watchlist Error", message: error.message }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unable to update watchlist."; + addToast({ type: "error", title: "Watchlist Error", message }); } }; @@ -129,7 +131,7 @@ export default function LPDashboard() { useEffect(() => { if (!selectedInvoice || !address) return; - void refreshAllowance(selectedInvoice, address); + void Promise.resolve().then(() => refreshAllowance(selectedInvoice, address)); }, [address, refreshAllowance, selectedInvoice]); const toggleInvoiceSelection = (id: string) => { @@ -199,7 +201,7 @@ export default function LPDashboard() { ); - const sortedInvoices = useMemo(() => [...filteredInvoices].sort((a: any, b: any) => { + const sortedInvoices = useMemo(() => [...filteredInvoices].sort((a, b) => { if (sortKey === "risk") { const ra = RISK_SORT_ORDER[payerRisks.get(a.payer) ?? "Unknown"]; const rb = RISK_SORT_ORDER[payerRisks.get(b.payer) ?? "Unknown"]; @@ -214,6 +216,9 @@ export default function LPDashboard() { } const aVal = a[sortKey]; const bVal = b[sortKey]; + if (aVal == null && bVal == null) return 0; + if (aVal == null) return sortOrder === "asc" ? -1 : 1; + if (bVal == null) return sortOrder === "asc" ? 1 : -1; if (aVal < bVal) return sortOrder === "asc" ? -1 : 1; if (aVal > bVal) return sortOrder === "asc" ? 1 : -1; return 0; @@ -262,7 +267,7 @@ export default function LPDashboard() { } }; - const handleKeyDown = (e: React.KeyboardEvent, invoice: any, index: number) => { + const handleKeyDown = (e: React.KeyboardEvent, invoice: DisplayInvoice, index: number) => { const rowElements = Array.from(e.currentTarget.parentElement?.querySelectorAll('tr[role="row"]') || []); switch (e.key) { @@ -288,7 +293,7 @@ export default function LPDashboard() { } }; - const commonColumns: DataTableColumn[] = [ + const commonColumns: DataTableColumn[] = [ { id: "id", label: "ID", @@ -305,10 +310,8 @@ export default function LPDashboard() { {formatAddress(inv.freelancer)} - - Payer: - {formatAddress(inv.payer)} - + + Payer: ), @@ -354,7 +357,7 @@ export default function LPDashboard() { }, ]; - const discoveryColumns: DataTableColumn[] = [ + const discoveryColumns: DataTableColumn[] = [ ...commonColumns, { id: "risk", @@ -398,7 +401,7 @@ export default function LPDashboard() { }, ]; - const watchlistColumns: DataTableColumn[] = [ + const watchlistColumns: DataTableColumn[] = [ ...commonColumns, { id: "watchAddedAt", @@ -406,7 +409,7 @@ export default function LPDashboard() { sortable: true, renderCell: (inv) => ( - {new Date(inv.watchAddedAt).toLocaleDateString()} + {new Date(inv.watchAddedAt ?? 0).toLocaleDateString()} ), }, @@ -686,11 +689,9 @@ export default function LPDashboard() { {formatAddress(invoice.freelancer)} - + {t("lpDashboard.tableHeaders.payer")}:{" "} - - {formatAddress(invoice.payer)} - + @@ -708,7 +709,7 @@ export default function LPDashboard() { {activeTab === "watchlist" && ( - {new Date(invoice.watchAddedAt).toLocaleDateString()} + {new Date(invoice.watchAddedAt ?? 0).toLocaleDateString()} )} {activeTab === "discovery" && ( diff --git a/src/components/PayerIdentity.tsx b/src/components/PayerIdentity.tsx new file mode 100644 index 0000000..e6ddf17 --- /dev/null +++ b/src/components/PayerIdentity.tsx @@ -0,0 +1,23 @@ +"use client"; + +import Link from "next/link"; +import { formatAddress } from "@/utils/format"; +import { isOracleVerifiedAddress } from "@/utils/oracleVerification"; +import VerificationBadge from "./VerificationBadge"; + +export default function PayerIdentity({ + address, + className = "", +}: { + address: string; + className?: string; +}) { + return ( + + + {formatAddress(address)} + + + + ); +} diff --git a/src/components/TokenSelector.tsx b/src/components/TokenSelector.tsx index a157f15..7b56e7f 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 & Partial>): string { return token.logo ?? `/tokens/${token.symbol.toLowerCase()}.svg`; } -function getTokenIconLabel(token: TokenLike): string { +function getTokenIconLabel(token: Pick & Partial>): string { return token.iconLabel ?? (token.symbol.replace(/[^A-Z0-9]/gi, "").slice(0, 2).toUpperCase() || "TK"); } diff --git a/src/components/VerificationBadge.tsx b/src/components/VerificationBadge.tsx new file mode 100644 index 0000000..4863caa --- /dev/null +++ b/src/components/VerificationBadge.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { isOracleVerificationEnabled } from "@/utils/oracleVerification"; + +export default function VerificationBadge({ verified }: { verified: boolean }) { + if (!isOracleVerificationEnabled()) { + return null; + } + + const label = verified ? "Verified" : "Unverified"; + const icon = verified ? "check_circle" : "radio_button_unchecked"; + const tooltip = verified + ? "This address has been verified by the ILN off-chain oracle" + : "This address has not been verified by the ILN off-chain oracle"; + const classes = verified + ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600" + : "border-outline-variant/30 bg-surface-container text-on-surface-variant"; + + return ( + + + + {label} + + + {tooltip} + + + ); +} diff --git a/src/components/__tests__/VerificationBadge.test.tsx b/src/components/__tests__/VerificationBadge.test.tsx new file mode 100644 index 0000000..3bcd970 --- /dev/null +++ b/src/components/__tests__/VerificationBadge.test.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import VerificationBadge from "../VerificationBadge"; + +describe("VerificationBadge", () => { + afterEach(() => { + delete process.env.NEXT_PUBLIC_ORACLE_ENABLED; + }); + + it("does not render when oracle verification is disabled", () => { + render(); + + expect(screen.queryByText("Verified")).not.toBeInTheDocument(); + }); + + it("renders verified state with oracle tooltip copy", () => { + process.env.NEXT_PUBLIC_ORACLE_ENABLED = "true"; + + render(); + + expect(screen.getByText("Verified")).toBeInTheDocument(); + expect(screen.getByRole("tooltip")).toHaveTextContent( + "This address has been verified by the ILN off-chain oracle", + ); + }); + + it("renders the neutral unverified state when enabled", () => { + process.env.NEXT_PUBLIC_ORACLE_ENABLED = "true"; + + render(); + + expect(screen.getByText("Unverified")).toBeInTheDocument(); + expect(screen.getByRole("tooltip")).toHaveTextContent( + "This address has not been verified by the ILN off-chain oracle", + ); + }); +}); diff --git a/src/utils/__tests__/oracleVerification.test.ts b/src/utils/__tests__/oracleVerification.test.ts new file mode 100644 index 0000000..7de8106 --- /dev/null +++ b/src/utils/__tests__/oracleVerification.test.ts @@ -0,0 +1,22 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { isOracleVerifiedAddress, isOracleVerificationEnabled } from "../oracleVerification"; + +describe("oracleVerification", () => { + afterEach(() => { + delete process.env.NEXT_PUBLIC_ORACLE_ENABLED; + delete process.env.NEXT_PUBLIC_ORACLE_VERIFIED_ADDRESSES; + }); + + it("keeps verification disabled by default", () => { + expect(isOracleVerificationEnabled()).toBe(false); + expect(isOracleVerifiedAddress("GABC")).toBe(false); + }); + + it("matches addresses from the public verified-address allowlist", () => { + process.env.NEXT_PUBLIC_ORACLE_ENABLED = "true"; + process.env.NEXT_PUBLIC_ORACLE_VERIFIED_ADDRESSES = "GAAA, GBBB"; + + expect(isOracleVerifiedAddress("GAAA")).toBe(true); + expect(isOracleVerifiedAddress("GCCC")).toBe(false); + }); +}); diff --git a/src/utils/federation.ts b/src/utils/federation.ts index 11922eb..b1d734b 100644 --- a/src/utils/federation.ts +++ b/src/utils/federation.ts @@ -11,7 +11,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 accountWithHomeDomain = account as { home_domain?: string; homeDomain?: string }; + const homeDomain = accountWithHomeDomain.home_domain ?? accountWithHomeDomain.homeDomain; if (!homeDomain) return address; const stellarTomlResponse = await fetch(`https://${homeDomain}/.well-known/stellar.toml`); diff --git a/src/utils/oracleVerification.ts b/src/utils/oracleVerification.ts new file mode 100644 index 0000000..b813635 --- /dev/null +++ b/src/utils/oracleVerification.ts @@ -0,0 +1,19 @@ +const TRUE_VALUES = new Set(["1", "true", "yes", "on"]); + +export function isOracleVerificationEnabled(): boolean { + return TRUE_VALUES.has((process.env.NEXT_PUBLIC_ORACLE_ENABLED ?? "").toLowerCase()); +} + +export function getOracleVerifiedAddresses(): Set { + return new Set( + (process.env.NEXT_PUBLIC_ORACLE_VERIFIED_ADDRESSES ?? "") + .split(",") + .map((address) => address.trim()) + .filter(Boolean), + ); +} + +export function isOracleVerifiedAddress(address: string): boolean { + if (!isOracleVerificationEnabled()) return false; + return getOracleVerifiedAddresses().has(address.trim()); +}