Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions app/governance/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions app/profile/[address]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
37 changes: 19 additions & 18 deletions src/components/LPDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
}
};

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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"];
Expand All @@ -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;
Expand Down Expand Up @@ -262,7 +267,7 @@ export default function LPDashboard() {
}
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLTableRowElement>, invoice: any, index: number) => {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTableRowElement>, invoice: DisplayInvoice, index: number) => {
const rowElements = Array.from(e.currentTarget.parentElement?.querySelectorAll('tr[role="row"]') || []);

switch (e.key) {
Expand All @@ -288,7 +293,7 @@ export default function LPDashboard() {
}
};

const commonColumns: DataTableColumn<any>[] = [
const commonColumns: DataTableColumn<DisplayInvoice>[] = [
{
id: "id",
label: "ID",
Expand All @@ -305,10 +310,8 @@ export default function LPDashboard() {
<Link href={`/profile/${inv.freelancer}`} className="text-sm font-medium text-primary hover:underline">
{formatAddress(inv.freelancer)}
</Link>
<span className="text-[10px] text-on-surface-variant">
Payer: <Link href={`/profile/${inv.payer}`} className="font-mono text-on-surface hover:underline">
{formatAddress(inv.payer)}
</Link>
<span className="inline-flex items-center gap-1 text-[10px] text-on-surface-variant">
Payer: <PayerIdentity address={inv.payer} />
</span>
</div>
),
Expand Down Expand Up @@ -354,7 +357,7 @@ export default function LPDashboard() {
},
];

const discoveryColumns: DataTableColumn<any>[] = [
const discoveryColumns: DataTableColumn<DisplayInvoice>[] = [
...commonColumns,
{
id: "risk",
Expand Down Expand Up @@ -398,15 +401,15 @@ export default function LPDashboard() {
},
];

const watchlistColumns: DataTableColumn<any>[] = [
const watchlistColumns: DataTableColumn<DisplayInvoice>[] = [
...commonColumns,
{
id: "watchAddedAt",
label: "Added",
sortable: true,
renderCell: (inv) => (
<span className="text-xs text-on-surface-variant">
{new Date(inv.watchAddedAt).toLocaleDateString()}
{new Date(inv.watchAddedAt ?? 0).toLocaleDateString()}
</span>
),
},
Expand Down Expand Up @@ -686,11 +689,9 @@ export default function LPDashboard() {
<Link href={`/profile/${invoice.freelancer}`} className="text-sm font-medium text-primary hover:underline">
{formatAddress(invoice.freelancer)}
</Link>
<span className="text-[10px] text-on-surface-variant">
<span className="inline-flex items-center gap-1 text-[10px] text-on-surface-variant">
{t("lpDashboard.tableHeaders.payer")}:{" "}
<Link href={`/profile/${invoice.payer}`} className="font-mono text-on-surface hover:underline">
{formatAddress(invoice.payer)}
</Link>
<PayerIdentity address={invoice.payer} />
</span>
</div>
</td>
Expand All @@ -708,7 +709,7 @@ export default function LPDashboard() {
</td>
{activeTab === "watchlist" && (
<td className="px-6 py-5 text-xs text-on-surface-variant">
{new Date(invoice.watchAddedAt).toLocaleDateString()}
{new Date(invoice.watchAddedAt ?? 0).toLocaleDateString()}
</td>
)}
{activeTab === "discovery" && (
Expand Down
23 changes: 23 additions & 0 deletions src/components/PayerIdentity.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className={`inline-flex flex-wrap items-center gap-1.5 ${className}`}>
<Link href={`/profile/${address}`} className="font-mono text-on-surface hover:underline">
{formatAddress(address)}
</Link>
<VerificationBadge verified={isOracleVerifiedAddress(address)} />
</span>
);
}
4 changes: 2 additions & 2 deletions src/components/TokenSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ function getTokenName(token: TokenLike): string {
return token.name ?? token.symbol;
}

function getTokenLogo(token: TokenLike): string {
function getTokenLogo(token: Pick<TokenLike, "symbol"> & Partial<Pick<TokenLike, "logo">>): string {
return token.logo ?? `/tokens/${token.symbol.toLowerCase()}.svg`;
}

function getTokenIconLabel(token: TokenLike): string {
function getTokenIconLabel(token: Pick<TokenLike, "symbol"> & Partial<Pick<TokenLike, "iconLabel">>): string {
return token.iconLabel ?? (token.symbol.replace(/[^A-Z0-9]/gi, "").slice(0, 2).toUpperCase() || "TK");
}

Expand Down
38 changes: 38 additions & 0 deletions src/components/VerificationBadge.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className="group relative inline-flex">
<span
className={`inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-bold ${classes}`}
aria-label={`${label}: ${tooltip}`}
>
<span className="material-symbols-outlined text-sm" aria-hidden="true">
{icon}
</span>
{label}
</span>
<span
role="tooltip"
className="pointer-events-none absolute bottom-full left-1/2 z-30 mb-2 hidden w-56 -translate-x-1/2 rounded-xl border border-outline-variant/20 bg-surface-container-high px-3 py-2 text-xs font-medium text-on-surface shadow-lg group-hover:block group-focus-within:block"
>
{tooltip}
</span>
</span>
);
}
38 changes: 38 additions & 0 deletions src/components/__tests__/VerificationBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<VerificationBadge verified />);

expect(screen.queryByText("Verified")).not.toBeInTheDocument();
});

it("renders verified state with oracle tooltip copy", () => {
process.env.NEXT_PUBLIC_ORACLE_ENABLED = "true";

render(<VerificationBadge verified />);

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(<VerificationBadge verified={false} />);

expect(screen.getByText("Unverified")).toBeInTheDocument();
expect(screen.getByRole("tooltip")).toHaveTextContent(
"This address has not been verified by the ILN off-chain oracle",
);
});
});
22 changes: 22 additions & 0 deletions src/utils/__tests__/oracleVerification.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
3 changes: 2 additions & 1 deletion src/utils/federation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export async function resolveFederatedAddress(address: string): Promise<string>

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`);
Expand Down
19 changes: 19 additions & 0 deletions src/utils/oracleVerification.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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());
}