From 03c43d7f11e28b4da40896488bf877b384d49763 Mon Sep 17 00:00:00 2001 From: neumattock <152253273+newmattock@users.noreply.github.com> Date: Tue, 26 May 2026 14:18:19 -0700 Subject: [PATCH] feat: add oracle verification badge --- app/governance/page.tsx | 16 +++++--- app/profile/[address]/page.tsx | 7 +++- src/components/LPDashboard.tsx | 40 ++++++++++--------- src/components/PayerIdentity.tsx | 23 +++++++++++ src/components/TokenSelector.tsx | 4 +- src/components/VerificationBadge.tsx | 38 ++++++++++++++++++ .../__tests__/VerificationBadge.test.tsx | 38 ++++++++++++++++++ .../__tests__/oracleVerification.test.ts | 22 ++++++++++ src/utils/federation.ts | 3 +- src/utils/oracleVerification.ts | 19 +++++++++ 10 files changed, 182 insertions(+), 28 deletions(-) create mode 100644 src/components/PayerIdentity.tsx create mode 100644 src/components/VerificationBadge.tsx create mode 100644 src/components/__tests__/VerificationBadge.test.tsx create mode 100644 src/utils/__tests__/oracleVerification.test.ts create mode 100644 src/utils/oracleVerification.ts diff --git a/app/governance/page.tsx b/app/governance/page.tsx index 6573981..a742347 100644 --- a/app/governance/page.tsx +++ b/app/governance/page.tsx @@ -29,6 +29,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 ( @@ -194,9 +195,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(() => { @@ -208,10 +210,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 41d6392..35fca7e 100644 --- a/app/profile/[address]/page.tsx +++ b/app/profile/[address]/page.tsx @@ -12,8 +12,10 @@ 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 VerificationBadge from "@/components/VerificationBadge"; interface ScoreHistoryPoint { period: string; @@ -151,7 +153,10 @@ export default function ProfilePage() {

Public reputation profile

-

{resolvedAddress}

+
+

{resolvedAddress}

+ +

{address}

{resolvedAddress !== address diff --git a/src/components/LPDashboard.tsx b/src/components/LPDashboard.tsx index 9e7497f..d857dbc 100644 --- a/src/components/LPDashboard.tsx +++ b/src/components/LPDashboard.tsx @@ -32,9 +32,11 @@ import LastUpdated from "./LastUpdated"; import InvoiceStatusBadge from "./InvoiceStatusBadge"; import FundConfirmModal from "./FundConfirmModal"; import type { DataTableColumn } from "./DataTable"; +import PayerIdentity from "./PayerIdentity"; type Tab = "discovery" | "my-funded" | "watchlist"; +type DisplayInvoice = Invoice & { watchAddedAt?: number }; @@ -76,8 +78,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 }); } }; @@ -114,7 +117,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) => { @@ -180,7 +183,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"]; @@ -195,6 +198,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; @@ -219,7 +225,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) { @@ -245,7 +251,7 @@ export default function LPDashboard() { } }; - const commonColumns: DataTableColumn[] = [ + const commonColumns: DataTableColumn[] = [ { id: "id", label: "ID", @@ -262,10 +268,8 @@ export default function LPDashboard() { {formatAddress(inv.freelancer)} - - Payer: - {formatAddress(inv.payer)} - + + Payer: ), @@ -311,7 +315,7 @@ export default function LPDashboard() { }, ]; - const discoveryColumns: DataTableColumn[] = [ + const discoveryColumns: DataTableColumn[] = [ ...commonColumns, { id: "risk", @@ -355,7 +359,7 @@ export default function LPDashboard() { }, ]; - const watchlistColumns: DataTableColumn[] = [ + const watchlistColumns: DataTableColumn[] = [ ...commonColumns, { id: "watchAddedAt", @@ -363,7 +367,7 @@ export default function LPDashboard() { sortable: true, renderCell: (inv) => ( - {new Date(inv.watchAddedAt).toLocaleDateString()} + {new Date(inv.watchAddedAt ?? 0).toLocaleDateString()} ), }, @@ -562,7 +566,7 @@ export default function LPDashboard() { ) : ( - (activeTab === "discovery" ? discoveryInvoices : watchlistInvoices).map((invoice: any, index: number) => ( + (activeTab === "discovery" ? discoveryInvoices : watchlistInvoices).map((invoice: DisplayInvoice, index: number) => ( {formatAddress(invoice.freelancer)} - + {t("lpDashboard.tableHeaders.payer")}:{" "} - - {formatAddress(invoice.payer)} - + @@ -600,7 +602,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()); +}