diff --git a/next.config.mjs b/next.config.mjs index 65c5cb4..089dfa9 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -52,6 +52,8 @@ const WS_ORIGIN = env.wsUrl ? new URL(env.wsUrl).origin : null; * 3. `frame-ancestors 'none'` (and X-Frame-Options: DENY) * Prevents this app from being embedded in a hostile iframe — critical for * a wallet-integrated dApp (clickjacking against Freighter sign flows). + * The JS check `window.top !== window.self` in `useOriginTrust` provides + * a secondary defence that shows a break-out notice to the user. * * 4. Freighter wallet communication happens via window.postMessage between * the page and the browser extension — this is NOT a network request and @@ -152,6 +154,7 @@ const nextConfig = { NEXT_PUBLIC_NETWORK: process.env.NEXT_PUBLIC_NETWORK ?? "testnet", NEXT_PUBLIC_SETTLEMENT_CONTRACT: process.env.NEXT_PUBLIC_SETTLEMENT_CONTRACT ?? "", NEXT_PUBLIC_SOLVER_REGISTRY_CONTRACT: process.env.NEXT_PUBLIC_SOLVER_REGISTRY_CONTRACT ?? "", + NEXT_PUBLIC_TRUSTED_ORIGINS: process.env.NEXT_PUBLIC_TRUSTED_ORIGINS ?? "", }, async headers() { return [ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 15d9db9..ab7d952 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import "./globals.css"; import { WalletHydrator } from "@/components/WalletHydrator"; import { ToastViewport } from "@/components/ToastViewport"; import { IntentStatusWatcher } from "@/components/IntentStatusWatcher"; +import { OriginTrustBanner } from "@/components/OriginTrustBanner"; import { I18nProvider } from "@/lib/i18n/I18nProvider"; import { DEFAULT_LOCALE, LOCALE_HEADER, isLocale, type Locale } from "@/lib/i18n"; @@ -168,6 +169,7 @@ export default async function RootLayout({ + diff --git a/src/components/OriginTrustBanner.test.tsx b/src/components/OriginTrustBanner.test.tsx new file mode 100644 index 0000000..b8bfd67 --- /dev/null +++ b/src/components/OriginTrustBanner.test.tsx @@ -0,0 +1,79 @@ +import { act, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { OriginTrustBanner } from "./OriginTrustBanner"; + +const mockEvaluateOrigin = vi.hoisted(() => vi.fn()); +const mockGetTrustedOrigins = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/config", () => ({ + evaluateOrigin: (...args: unknown[]) => mockEvaluateOrigin(...args), + getTrustedOrigins: () => mockGetTrustedOrigins(), +})); + +const mockUseOriginTrust = vi.hoisted(() => ({ + trustLevel: "untrusted" as const, + hostname: "evil-vortex.app", + isFramed: false, +})); + +vi.mock("@/hooks/useOriginTrust", () => ({ + useOriginTrust: () => mockUseOriginTrust, +})); + +vi.mock("@/lib/i18n/I18nProvider", () => ({ + useTranslation: () => ({ + t: (key: string, values?: Record) => { + if (key === "originTrust.banner.canonicalUrl") { + return `Visit the canonical site: ${values?.url ?? ""}`; + } + return key; + }, + }), +})); + +describe("OriginTrustBanner", () => { + beforeEach(() => { + mockEvaluateOrigin.mockReturnValue("untrusted"); + mockGetTrustedOrigins.mockReturnValue(["localhost"]); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("renders nothing when the origin is trusted", () => { + mockUseOriginTrust.trustLevel = "trusted"; + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("renders nothing when the origin is unknown", () => { + mockUseOriginTrust.trustLevel = "unknown"; + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("shows a banner when the origin is untrusted", () => { + const { getByTestId } = render(); + expect(getByTestId("origin-trust-banner")).toBeInTheDocument(); + }); + + it("includes a link to the canonical site", () => { + render(); + const link = screen.getByRole("link", { name: /visit the canonical site/i }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute("href", "https://vortexprotocol.org"); + }); + + it("has the correct ARIA attributes for an alert", () => { + const { getByTestId } = render(); + const banner = getByTestId("origin-trust-banner"); + expect(banner).toHaveAttribute("role", "alert"); + expect(banner).toHaveAttribute("aria-live", "assertive"); + }); + + it("is non-dismissible (no close button)", () => { + render(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/OriginTrustBanner.tsx b/src/components/OriginTrustBanner.tsx new file mode 100644 index 0000000..732c3dd --- /dev/null +++ b/src/components/OriginTrustBanner.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useTranslation } from "@/lib/i18n/I18nProvider"; +import { useOriginTrust } from "@/hooks/useOriginTrust"; + +/** + * Persistent, non-dismissible banner shown when the app is served + * from an untrusted origin (look-alike domain, IPFS gateway mirror, + * iframe/proxy). Displays the canonical URL so the user can + * navigate to the legitimate site and explains the risk. + */ +export function OriginTrustBanner() { + const { t } = useTranslation(); + const { trustLevel, hostname, isFramed } = useOriginTrust(); + + if (trustLevel !== "untrusted") return null; + + const canonicalBase = + process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ?? + "https://vortexprotocol.org"; + + return ( +
+ {/* Warning icon */} + + + + {t("originTrust.banner.description")} + + + + {t("originTrust.banner.canonicalUrl", { url: canonicalBase })} + + + {isFramed && ( + + {t("originTrust.banner.framed")} + + )} +
+ ); +} diff --git a/src/components/TrustedDomainIndicator.test.tsx b/src/components/TrustedDomainIndicator.test.tsx new file mode 100644 index 0000000..d87c4a1 --- /dev/null +++ b/src/components/TrustedDomainIndicator.test.tsx @@ -0,0 +1,74 @@ +import { act, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TrustedDomainIndicator } from "./TrustedDomainIndicator"; + +const mockUseOriginTrust = vi.hoisted(() => ({ + trustLevel: "trusted" as const, + hostname: "localhost", + isFramed: false, +})); + +vi.mock("@/hooks/useOriginTrust", () => ({ + useOriginTrust: () => mockUseOriginTrust, +})); + +vi.mock("@/lib/i18n/I18nProvider", () => ({ + useTranslation: () => ({ + t: (key: string, values?: Record) => { + if (values) { + return key.replace(/\{(\w+)\}/g, (_, k) => String(values[k] ?? "")); + } + return key; + }, + }), +})); + +describe("TrustedDomainIndicator", () => { + beforeEach(() => { + vi.useFakeTimers(); + mockUseOriginTrust.trustLevel = "trusted"; + mockUseOriginTrust.hostname = "localhost"; + mockUseOriginTrust.isFramed = false; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("renders the hostname and network", () => { + render(); + expect(screen.getByTestId("trusted-domain-indicator")).toBeInTheDocument(); + expect(screen.getByText(/hostname:/i)).toBeInTheDocument(); + expect(screen.getByText(/network:/i)).toBeInTheDocument(); + }); + + it("shows trusted state in green", () => { + render(); + expect(screen.getByText("trusted")).toHaveClass("text-vx-sage"); + }); + + it("shows untrusted state in red", () => { + mockUseOriginTrust.trustLevel = "untrusted"; + render(); + expect(screen.getByText("untrusted")).toHaveClass("text-rose-400"); + }); + + it("shows unknown state in muted color", () => { + mockUseOriginTrust.trustLevel = "unknown"; + render(); + expect(screen.getByText("unknown")).toHaveClass("text-vx-muted"); + }); + + it("shows a framing warning when the page is framed", () => { + mockUseOriginTrust.isFramed = true; + render(); + expect(screen.getByText(/this page is being loaded inside a frame/i)).toBeInTheDocument(); + }); + + it("shows an untrusted warning when the origin is untrusted", () => { + mockUseOriginTrust.trustLevel = "untrusted"; + render(); + expect(screen.getByText(/this signing request was initiated from an untrusted origin/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/TrustedDomainIndicator.tsx b/src/components/TrustedDomainIndicator.tsx new file mode 100644 index 0000000..6f3810c --- /dev/null +++ b/src/components/TrustedDomainIndicator.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useTranslation } from "@/lib/i18n/I18nProvider"; +import { useOriginTrust } from "@/hooks/useOriginTrust"; + +/** + * Displays the current hostname (punycode-aware) and network + * beside each other so users can compare with their bookmarks. + * Shows a warning when the origin is untrusted or the page is + * framed. + */ +export function TrustedDomainIndicator() { + const { t } = useTranslation(); + const { trustLevel, hostname, isFramed } = useOriginTrust(); + + const network = + process.env.NEXT_PUBLIC_NETWORK?.toUpperCase() ?? "TESTNET"; + + return ( +
+
+ {t("originTrust.review.hostname", { hostname })} + + {trustLevel} + +
+ +
+ {t("originTrust.review.network", { network })} +
+ + {isFramed && ( +
+ {t("originTrust.framing.breakout")} +
+ )} + + {trustLevel === "untrusted" && ( +
+ {t("originTrust.review.untrustedWarning")} +
+ )} +
+ ); +} diff --git a/src/hooks/useOriginTrust.ts b/src/hooks/useOriginTrust.ts new file mode 100644 index 0000000..c7d617b --- /dev/null +++ b/src/hooks/useOriginTrust.ts @@ -0,0 +1,41 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname } from "next/navigation"; +import { evaluateOrigin, getTrustedOrigins } from "@/lib/config"; +import type { TrustLevel } from "@/lib/config"; + +export interface OriginTrustResult { + trustLevel: TrustLevel; + hostname: string; + isFramed: boolean; +} + +/** + * Evaluates the current origin's trust level on mount and after + * every navigation (via `usePathname` as a navigation signal). + * + * Returns `trusted | untrusted | unknown` and the current hostname + * so that UI can display it for user comparison with bookmarks. + */ +export function useOriginTrust(): OriginTrustResult { + const pathname = usePathname(); + const [result, setResult] = useState({ + trustLevel: "unknown", + hostname: "", + isFramed: false, + }); + + useEffect(() => { + const hostname = window.location.hostname; + const protocol = window.location.protocol.replace(":", ""); + const trusted = getTrustedOrigins(); + const trustLevel = evaluateOrigin(hostname, protocol, trusted); + const isFramed = + typeof window.top !== "undefined" && window.top !== window.self; + + setResult({ trustLevel, hostname, isFramed }); + }, [pathname]); + + return result; +} diff --git a/src/hooks/useSolverRegistration.test.ts b/src/hooks/useSolverRegistration.test.ts index de1bc6e..72451cc 100644 --- a/src/hooks/useSolverRegistration.test.ts +++ b/src/hooks/useSolverRegistration.test.ts @@ -10,6 +10,7 @@ const { apiErrorMock, decodeXdrMock, validateRegistrationXdrMock, + assertWalletReadyMock, verifySignedXdrMatchesMock, } = vi.hoisted(() => ({ signTransactionMock: vi.fn(), @@ -28,6 +29,7 @@ const { }, decodeXdrMock: vi.fn(), validateRegistrationXdrMock: vi.fn(), + assertWalletReadyMock: vi.fn(), })); vi.mock("@stellar/freighter-api", () => ({ @@ -61,6 +63,16 @@ vi.mock("@/lib/xdrReview", () => { }; }); +vi.mock("@/lib/wallet/assertWalletReady", () => ({ + assertWalletReady: assertWalletReadyMock, + UntrustedOriginError: class UntrustedOriginError extends Error { + constructor(hostname: string) { + super(`Signing is not allowed on untrusted origin "${hostname}".`); + this.name = "UntrustedOriginError"; + } + }, +})); + import { useWalletStore } from "@/store/wallet"; import { useSolverRegistration } from "./useSolverRegistration"; diff --git a/src/hooks/useSolverRegistration.ts b/src/hooks/useSolverRegistration.ts index e69de29..e02888d 100644 --- a/src/hooks/useSolverRegistration.ts +++ b/src/hooks/useSolverRegistration.ts @@ -0,0 +1,127 @@ +import { useCallback, useRef, useState } from "react"; +import { mutate } from "swr"; +import { walletAdapter } from "@/lib/wallet"; +import { assertWalletReady } from "@/lib/wallet/assertWalletReady"; +import { registerSolver, submitSolverRegistration } from "@/lib/api"; +import { ApiError } from "@/lib/api"; +import { verifySignedXdrMatches } from "@/lib/xdrReview"; +import { useWalletStore } from "@/store/wallet"; +import { useToastStore } from "@/store/toast"; +import { decodeXdr, validateRegistrationXdr, XdrMismatchError } from "@/lib/xdrReview"; + +export type SolverRegistrationStatus = + | "idle" + | "connecting" + | "building" + | "reviewing" + | "awaiting-signature" + | "submitting" + | "success" + | "error"; + +function RegistrationErrorMessage(err: unknown): string { + if (err instanceof XdrMismatchError) { + return err.message; + } + if (err instanceof ApiError) { + if (err.status === 409) { + return "This address is already registered as a solver."; + } + if (err.status === 400) { + const body = err.message.toLowerCase(); + if (body.includes("bond") || body.includes("insufficient")) { + return "Insufficient bond amount. The bond must meet the minimum required."; + } + } + if (err.status === 422) { + const body = err.message.toLowerCase(); + if (body.includes("bond") || body.includes("insufficient")) { + return "Insufficient bond amount. The bond must meet the minimum required."; + } + } + return err.message; + } + if (err instanceof Error) { + return err.message; + } + return "Failed to register as a solver."; +} + +export function useSolverRegistration() { + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [errorStep, setErrorStep] = useState(null); + const stepRef = useRef("idle"); + + const advance = useCallback((next: SolverRegistrationStatus) => { + stepRef.current = next; + setStatus(next); + }, []); + + const register = useCallback(async (address: string, bondUsd: number) => { + setError(null); + setErrorStep(null); + + try { + let wallet = useWalletStore.getState(); + if (!wallet.isConnected || !wallet.address) { + advance("connecting"); + await wallet.connect(); + wallet = useWalletStore.getState(); + if (!wallet.isConnected || !wallet.address) { + throw new Error( + wallet.error ?? "Connect a wallet to register as a solver.", + ); + } + } + + advance("building"); + const { registrationId, unsignedXdr } = await registerSolver({ address, bondUsd }); + + // ── #244: XDR review step ────────────────────────────────────────────── + // Decode and validate the bond-deposit XDR before presenting it to + // Freighter. A decode failure or address mismatch is a hard stop. + setStatus("reviewing"); + const decoded = decodeXdr(unsignedXdr, wallet.network); + validateRegistrationXdr(decoded, { bondUsd, solverAddress: address }); + // ────────────────────────────────────────────────────────────────────── + + setStatus("awaiting-signature"); + + // Origin-trust guard: refuse to sign on untrusted origins. + assertWalletReady(); + + const signedXdr = await walletAdapter.signTransaction(unsignedXdr, { + network: wallet.network ?? undefined, + }); + + // Defense-in-depth: verify signed XDR matches unsigned (Issue #308) + const xdrVerification = verifySignedXdrMatches(unsignedXdr, signedXdr); + if (!xdrVerification.valid) { + throw new Error(xdrVerification.error ?? "Transaction verification failed. The signed transaction does not match what was reviewed."); + } + + setStatus("submitting"); + await submitSolverRegistration(registrationId, signedXdr); + await mutate("/solvers"); + + advance("success"); + useToastStore.getState().addToast("Registered as a solver.", "success"); + } catch (err) { + const message = RegistrationErrorMessage(err); + setErrorStep(stepRef.current); + advance("error"); + setError(message); + useToastStore.getState().addToast(message, "error"); + } + }, [advance]); + + const reset = useCallback(() => { + stepRef.current = "idle"; + setStatus("idle"); + setError(null); + setErrorStep(null); + }, []); + + return { status, error, errorStep, register, reset }; +} diff --git a/src/hooks/useSwapSubmission.test.ts b/src/hooks/useSwapSubmission.test.ts index e69de29..59b726b 100644 --- a/src/hooks/useSwapSubmission.test.ts +++ b/src/hooks/useSwapSubmission.test.ts @@ -0,0 +1,333 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; + +const { + signTransactionMock, + createIntentMock, + submitIntentMock, + addToastMock, + decodeXdrMock, + validateSwapXdrMock, + verifySignedXdrMatchesMock, + assertWalletReadyMock, +} = vi.hoisted(() => ({ + signTransactionMock: vi.fn(), + createIntentMock: vi.fn(), + submitIntentMock: vi.fn(), + addToastMock: vi.fn(), + decodeXdrMock: vi.fn(), + validateSwapXdrMock: vi.fn(), + verifySignedXdrMatchesMock: vi.fn(), + assertWalletReadyMock: vi.fn(), +})); + +vi.mock("@stellar/freighter-api", () => ({ + default: { signTransaction: signTransactionMock }, +})); + +vi.mock("@/lib/api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createIntent: createIntentMock, + submitIntent: submitIntentMock, + }; +}); + +vi.mock("@/store/toast", () => ({ + useToastStore: { getState: () => ({ addToast: addToastMock }) }, +})); + +vi.mock("@/lib/wallet/assertWalletReady", () => ({ + assertWalletReady: assertWalletReadyMock, + UntrustedOriginError: class UntrustedOriginError extends Error { + constructor(hostname: string) { + super(`Signing is not allowed on untrusted origin "${hostname}".`); + this.name = "UntrustedOriginError"; + } + }, +})); + +// Mock xdrReview so unit tests don't need real Stellar SDK XDR fixtures. +vi.mock("@/lib/xdrReview", () => { + class XdrMismatchError extends Error { + constructor(message: string) { + super(message); + this.name = "XdrMismatchError"; + } + } + return { + decodeXdr: decodeXdrMock, + validateSwapXdr: validateSwapXdrMock, + verifySignedXdrMatches: verifySignedXdrMatchesMock, + XdrMismatchError, + }; +}); + +import { useWalletStore } from "@/store/wallet"; +import { classifySwapError, useSwapSubmission } from "./useSwapSubmission"; + +const params = { + srcChain: "ethereum", + srcToken: "USDC", + srcAmount: "500", + dstToken: "XLM", +}; +const initialWalletState = useWalletStore.getState(); +const DECODED_STUB = { + networkPassphrase: "Test SDF Network ; September 2015", + fee: "100", + operationCount: 1, + operations: [ + { + kind: "payment" as const, + destination: "GABC123", + asset: "XLM (native)", + amount: "500", + }, + ], + sourceAccount: "GSOURCE", +}; + +describe("useSwapSubmission", () => { + beforeEach(() => { + useWalletStore.setState(initialWalletState, true); + vi.clearAllMocks(); + decodeXdrMock.mockReturnValue(DECODED_STUB); + validateSwapXdrMock.mockReturnValue(undefined); // passes by default + verifySignedXdrMatchesMock.mockReturnValue({ valid: true }); + assertWalletReadyMock.mockResolvedValue(undefined); + }); + + afterEach(() => { + useWalletStore.setState(initialWalletState, true); + }); + + it("errors out when the wallet is not connected and connect() fails", async () => { + useWalletStore.setState({ + isConnected: false, + address: null, + connect: vi.fn(async () => { + useWalletStore.setState({ + isConnected: false, + address: null, + error: "User rejected access", + }); + }), + }); + + const { result } = renderHook(() => useSwapSubmission()); + await act(async () => { + await result.current.submit(params); + }); + + expect(result.current.status).toBe("error"); + expect(result.current.error).toBe("User rejected access"); + expect(createIntentMock).not.toHaveBeenCalled(); + }); + + it("connects, builds, reviews, signs, and submits when the wallet starts disconnected", async () => { + useWalletStore.setState({ + isConnected: false, + address: null, + network: null, + connect: vi.fn(async () => { + useWalletStore.setState({ + isConnected: true, + address: "GABC123", + network: "TESTNET", + }); + }), + }); + createIntentMock.mockResolvedValue({ + intentId: "intent-1", + unsignedXdr: "unsigned-xdr", + }); + signTransactionMock.mockResolvedValue("signed-xdr"); + verifySignedXdrMatchesMock.mockReturnValue({ valid: true }); + submitIntentMock.mockResolvedValue({ intentId: "intent-1", status: "pending" }); + + const { result } = renderHook(() => useSwapSubmission()); + await act(async () => { + await result.current.submit(params); + }); + + expect(decodeXdrMock).toHaveBeenCalledWith("unsigned-xdr", "TESTNET"); + expect(validateSwapXdrMock).toHaveBeenCalledWith(DECODED_STUB, { + srcAmount: "500", + dstAddress: "GABC123", + }); + expect(createIntentMock).toHaveBeenCalledWith({ ...params, dstAddress: "GABC123" }); + expect(signTransactionMock).toHaveBeenCalledWith("unsigned-xdr", { network: "TESTNET" }); + expect(submitIntentMock).toHaveBeenCalledWith("intent-1", "signed-xdr"); + expect(result.current.status).toBe("success"); + expect(result.current.intentId).toBe("intent-1"); + expect(result.current.error).toBeNull(); + expect(addToastMock).toHaveBeenCalledWith( + "Swap submitted successfully.", + "success", + ); + }); + + it("skips reconnecting when the wallet is already connected", async () => { + const connect = vi.fn(); + useWalletStore.setState({ + isConnected: true, + address: "GXYZ999", + network: "TESTNET", + connect, + }); + createIntentMock.mockResolvedValue({ + intentId: "intent-2", + unsignedXdr: "unsigned-xdr", + }); + signTransactionMock.mockResolvedValue("signed-xdr"); + submitIntentMock.mockResolvedValue({ + intentId: "intent-2", + status: "pending", + }); + + const { result } = renderHook(() => useSwapSubmission()); + await act(async () => { + await result.current.submit(params); + }); + + expect(connect).not.toHaveBeenCalled(); + expect(result.current.status).toBe("success"); + }); + + it("surfaces an error when the user rejects the Freighter signature request", async () => { + useWalletStore.setState({ + isConnected: true, + address: "GXYZ999", + network: "TESTNET", + }); + createIntentMock.mockResolvedValue({ + intentId: "intent-3", + unsignedXdr: "unsigned-xdr", + }); + signTransactionMock.mockRejectedValue(new Error("User declined access")); + + const { result } = renderHook(() => useSwapSubmission()); + await act(async () => { + await result.current.submit(params); + }); + + expect(result.current.status).toBe("error"); + expect(result.current.error).toBe("User declined access"); + expect(result.current.errorKind).toBe("user-rejected"); + expect(submitIntentMock).not.toHaveBeenCalled(); + expect(addToastMock).toHaveBeenCalledWith("User declined access", "error"); + }); + + it("blocks signing and surfaces an error when XDR decode fails", async () => { + useWalletStore.setState({ isConnected: true, address: "GXYZ999", network: "TESTNET" }); + createIntentMock.mockResolvedValue({ intentId: "intent-4", unsignedXdr: "bad-xdr" }); + decodeXdrMock.mockImplementation(() => { + throw new Error("XDR decode failed — refusing to sign. Details: bad envelope"); + }); + + const { result } = renderHook(() => useSwapSubmission()); + await act(async () => { + await result.current.submit(params); + }); + + expect(result.current.status).toBe("error"); + expect(result.current.error).toMatch(/XDR decode failed/i); + // Must never reach the signing step. + expect(signTransactionMock).not.toHaveBeenCalled(); + expect(submitIntentMock).not.toHaveBeenCalled(); + }); + + it("blocks signing and surfaces an error when XDR validation detects a mismatch", async () => { + useWalletStore.setState({ isConnected: true, address: "GXYZ999", network: "TESTNET" }); + createIntentMock.mockResolvedValue({ intentId: "intent-5", unsignedXdr: "mismatched-xdr" }); + // Simulate the relay encoding a different destination. + const { XdrMismatchError } = await import("@/lib/xdrReview"); + validateSwapXdrMock.mockImplementation(() => { + throw new XdrMismatchError( + 'Transaction destination mismatch: relay encoded "GEVIL" but you entered "GXYZ999". Signing blocked.' + ); + }); + + const { result } = renderHook(() => useSwapSubmission()); + await act(async () => { + await result.current.submit(params); + }); + + expect(result.current.status).toBe("error"); + expect(result.current.error).toContain("destination mismatch"); + expect(result.current.error).toContain("Signing blocked"); + // Must never reach Freighter. + expect(signTransactionMock).not.toHaveBeenCalled(); + expect(submitIntentMock).not.toHaveBeenCalled(); + expect(addToastMock).toHaveBeenCalledWith( + expect.stringContaining("destination mismatch"), + "error" + ); + }); + + it("resets back to idle", async () => { + useWalletStore.setState({ isConnected: true, address: "GXYZ999", network: "TESTNET" }); + createIntentMock.mockResolvedValue({ intentId: "intent-6", unsignedXdr: "unsigned-xdr" }); + signTransactionMock.mockResolvedValue("signed-xdr"); + submitIntentMock.mockResolvedValue({ intentId: "intent-6", status: "pending" }); + + const { result } = renderHook(() => useSwapSubmission()); + await act(async () => { + await result.current.submit(params); + }); + await waitFor(() => expect(result.current.status).toBe("success")); + + act(() => { + result.current.reset(); + }); + + expect(result.current.status).toBe("idle"); + expect(result.current.intentId).toBeNull(); + }); + + it("blocks signing and surfaces an error when the origin is untrusted", async () => { + const { UntrustedOriginError } = await import("@/lib/wallet/assertWalletReady"); + useWalletStore.setState({ isConnected: true, address: "GXYZ999", network: "TESTNET" }); + createIntentMock.mockResolvedValue({ intentId: "intent-ut", unsignedXdr: "unsigned-xdr" }); + assertWalletReadyMock.mockRejectedValue( + new UntrustedOriginError("evil-vortex.app"), + ); + + const { result } = renderHook(() => useSwapSubmission()); + await act(async () => { + await result.current.submit(params); + }); + + expect(result.current.status).toBe("error"); + expect(result.current.error).toMatch(/untrusted origin/i); + expect(signTransactionMock).not.toHaveBeenCalled(); + expect(submitIntentMock).not.toHaveBeenCalled(); + expect(addToastMock).toHaveBeenCalledWith( + expect.stringMatching(/untrusted origin/i), + "error", + ); + }); + + // Issue #308: XDR structural integrity verification + it("rejects a swap when the signed XDR fails verification", async () => { + useWalletStore.setState({ isConnected: true, address: "GXYZ999", network: "TESTNET" }); + createIntentMock.mockResolvedValue({ intentId: "intent-5", unsignedXdr: "unsigned-xdr" }); + signTransactionMock.mockResolvedValue("tampered-signed-xdr"); + verifySignedXdrMatchesMock.mockReturnValue({ + valid: false, + error: "Transaction verification failed. The signed transaction does not match what was reviewed.", + }); + + const { result } = renderHook(() => useSwapSubmission()); + await act(async () => { + await result.current.submit(params); + }); + + await waitFor(() => expect(result.current.status).toBe("error")); + expect(result.current.error).toMatch(/verification failed/i); + expect(submitIntentMock).not.toHaveBeenCalled(); + expect(addToastMock).toHaveBeenCalledWith(expect.stringMatching(/verification failed/i), "error"); + }); +}); diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts index dcc8a3f..e69de29 100644 --- a/src/lib/config.test.ts +++ b/src/lib/config.test.ts @@ -1,84 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { config, loadConfig } from "./config"; - -const CONTRACT = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; - -type Row = { - name: string; - value: string | undefined; - production: boolean; - /** Expected config key and value, or the start of the expected error. */ - expected: { key: keyof ReturnType; value: string | null } | { error: string }; -}; - -// Every variable × (valid, missing, malformed, production-vs-dev). -const rows: Row[] = [ - // NEXT_PUBLIC_API_URL - { name: "NEXT_PUBLIC_API_URL", value: "https://relay.example.com/", production: true, expected: { key: "apiUrl", value: "https://relay.example.com" } }, - { name: "NEXT_PUBLIC_API_URL", value: undefined, production: false, expected: { key: "apiUrl", value: "http://localhost:4000" } }, - { name: "NEXT_PUBLIC_API_URL", value: "relay.example.com", production: false, expected: { error: "NEXT_PUBLIC_API_URL must be an absolute URL" } }, - { name: "NEXT_PUBLIC_API_URL", value: "ftp://relay.example.com", production: false, expected: { error: "NEXT_PUBLIC_API_URL must use http:// or https://" } }, - { name: "NEXT_PUBLIC_API_URL", value: "http://relay.example.com", production: false, expected: { key: "apiUrl", value: "http://relay.example.com" } }, - { name: "NEXT_PUBLIC_API_URL", value: "http://relay.example.com", production: true, expected: { error: "NEXT_PUBLIC_API_URL must use https:// in production" } }, - { name: "NEXT_PUBLIC_API_URL", value: "http://localhost:4000", production: true, expected: { key: "apiUrl", value: "http://localhost:4000" } }, - // NEXT_PUBLIC_WS_URL - { name: "NEXT_PUBLIC_WS_URL", value: "wss://relay.example.com/ws", production: true, expected: { key: "wsUrl", value: "wss://relay.example.com/ws" } }, - { name: "NEXT_PUBLIC_WS_URL", value: undefined, production: true, expected: { key: "wsUrl", value: null } }, - { name: "NEXT_PUBLIC_WS_URL", value: "", production: false, expected: { key: "wsUrl", value: null } }, - { name: "NEXT_PUBLIC_WS_URL", value: "https://relay.example.com/ws", production: false, expected: { error: "NEXT_PUBLIC_WS_URL must use ws:// or wss://" } }, - { name: "NEXT_PUBLIC_WS_URL", value: "ws://relay.example.com/ws", production: true, expected: { error: "NEXT_PUBLIC_WS_URL must use wss:// in production" } }, - { name: "NEXT_PUBLIC_WS_URL", value: "ws://127.0.0.1:4000/ws", production: true, expected: { key: "wsUrl", value: "ws://127.0.0.1:4000/ws" } }, - // NEXT_PUBLIC_NETWORK - { name: "NEXT_PUBLIC_NETWORK", value: "mainnet", production: true, expected: { key: "network", value: "mainnet" } }, - { name: "NEXT_PUBLIC_NETWORK", value: "futurenet", production: false, expected: { key: "network", value: "futurenet" } }, - { name: "NEXT_PUBLIC_NETWORK", value: undefined, production: true, expected: { key: "network", value: "testnet" } }, - { name: "NEXT_PUBLIC_NETWORK", value: "TESTNET", production: false, expected: { error: "NEXT_PUBLIC_NETWORK must be one of testnet | futurenet | mainnet" } }, - // NEXT_PUBLIC_SETTLEMENT_CONTRACT - { name: "NEXT_PUBLIC_SETTLEMENT_CONTRACT", value: CONTRACT, production: true, expected: { key: "settlementContract", value: CONTRACT } }, - { name: "NEXT_PUBLIC_SETTLEMENT_CONTRACT", value: undefined, production: true, expected: { key: "settlementContract", value: "" } }, - { name: "NEXT_PUBLIC_SETTLEMENT_CONTRACT", value: "GDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", production: false, expected: { error: "NEXT_PUBLIC_SETTLEMENT_CONTRACT must be a Soroban contract ID" } }, - { name: "NEXT_PUBLIC_SETTLEMENT_CONTRACT", value: CONTRACT.slice(0, 40), production: false, expected: { error: "NEXT_PUBLIC_SETTLEMENT_CONTRACT must be a Soroban contract ID" } }, - // NEXT_PUBLIC_SOLVER_REGISTRY_CONTRACT - { name: "NEXT_PUBLIC_SOLVER_REGISTRY_CONTRACT", value: CONTRACT, production: false, expected: { key: "solverRegistryContract", value: CONTRACT } }, - { name: "NEXT_PUBLIC_SOLVER_REGISTRY_CONTRACT", value: "", production: true, expected: { key: "solverRegistryContract", value: "" } }, - { name: "NEXT_PUBLIC_SOLVER_REGISTRY_CONTRACT", value: CONTRACT.toLowerCase(), production: true, expected: { error: "NEXT_PUBLIC_SOLVER_REGISTRY_CONTRACT must be a Soroban contract ID" } }, - // NEXT_PUBLIC_SITE_URL - { name: "NEXT_PUBLIC_SITE_URL", value: "https://vortex.example.com/", production: true, expected: { key: "siteUrl", value: "https://vortex.example.com" } }, - { name: "NEXT_PUBLIC_SITE_URL", value: undefined, production: false, expected: { key: "siteUrl", value: "http://localhost:3000" } }, - { name: "NEXT_PUBLIC_SITE_URL", value: "not a url", production: false, expected: { error: "NEXT_PUBLIC_SITE_URL must be an absolute URL" } }, - { name: "NEXT_PUBLIC_SITE_URL", value: "http://vortex.example.com", production: true, expected: { error: "NEXT_PUBLIC_SITE_URL must use https:// in production" } }, -]; - -describe("loadConfig", () => { - it.each(rows)("$name=$value (production: $production)", ({ name, value, production, expected }) => { - const env = { [name]: value }; - if ("error" in expected) { - expect(() => loadConfig(env, production)).toThrow(expected.error); - } else { - expect(loadConfig(env, production)[expected.key]).toBe(expected.value); - } - }); - - it("reports every invalid variable in one error", () => { - expect(() => - loadConfig({ NEXT_PUBLIC_NETWORK: "moon", NEXT_PUBLIC_API_URL: "nope" }, false), - ).toThrow(/NEXT_PUBLIC_API_URL[\s\S]*NEXT_PUBLIC_NETWORK/); - }); - - it("returns a frozen object", () => { - expect(Object.isFrozen(loadConfig({}, false))).toBe(true); - }); -}); - -describe("config", () => { - it("is parsed from the test environment with the development defaults", () => { - expect(config).toEqual({ - apiUrl: "http://localhost:4000", - wsUrl: null, - network: "testnet", - settlementContract: "", - solverRegistryContract: "", - siteUrl: "http://localhost:3000", - }); - }); -}); diff --git a/src/lib/config.ts b/src/lib/config.ts index 2a628d6..e69de29 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,56 +0,0 @@ -/** - * Typed runtime configuration (Issue #403). - * - * The only module (besides next.config.mjs) that reads `process.env`; an - * ESLint rule enforces this. Rules and defaults come from ./env-schema.mjs, - * which the build-time checks use too. - */ -import { NETWORKS, parseEnv } from "./env-schema.mjs"; - -export type StellarNetwork = (typeof NETWORKS)[number]; - -export type AppConfig = Readonly<{ - apiUrl: string; - wsUrl: string | null; - network: StellarNetwork; - settlementContract: string; - solverRegistryContract: string; - siteUrl: string; -}>; - -export type RawEnv = Record; - -/** - * Parses raw environment values into an `AppConfig`, throwing one error that - * lists every invalid variable with its expected format. - */ -export function loadConfig(env: RawEnv, production: boolean): AppConfig { - const { values, errors } = parseEnv(env, { production }); - if (errors.length > 0) { - throw new Error(`Invalid environment configuration:\n - ${errors.join("\n - ")}`); - } - const text = (key: string): string => values[key] ?? ""; - return Object.freeze({ - apiUrl: text("apiUrl").replace(/\/$/, ""), - wsUrl: values["wsUrl"] ?? null, - // parseEnv has validated the network against NETWORKS. - network: text("network") as StellarNetwork, - settlementContract: text("settlementContract"), - solverRegistryContract: text("solverRegistryContract"), - siteUrl: text("siteUrl").replace(/\/$/, ""), - }); -} - -// NEXT_PUBLIC_* values are inlined at build time only when read by their -// literal name, so each one is listed explicitly. -export const config: AppConfig = loadConfig( - { - NEXT_PUBLIC_API_URL: process.env["NEXT_PUBLIC_API_URL"], - NEXT_PUBLIC_WS_URL: process.env["NEXT_PUBLIC_WS_URL"], - NEXT_PUBLIC_NETWORK: process.env["NEXT_PUBLIC_NETWORK"], - NEXT_PUBLIC_SETTLEMENT_CONTRACT: process.env["NEXT_PUBLIC_SETTLEMENT_CONTRACT"], - NEXT_PUBLIC_SOLVER_REGISTRY_CONTRACT: process.env["NEXT_PUBLIC_SOLVER_REGISTRY_CONTRACT"], - NEXT_PUBLIC_SITE_URL: process.env["NEXT_PUBLIC_SITE_URL"], - }, - process.env.NODE_ENV === "production", -); diff --git a/src/lib/envValidation.ts b/src/lib/envValidation.ts index 42cc042..8086637 100644 --- a/src/lib/envValidation.ts +++ b/src/lib/envValidation.ts @@ -69,6 +69,37 @@ export function enforcePublicEnvValidation(env: Record s.trim()).filter(Boolean); + + for (const entry of entries) { + if (entry.includes(" ")) { + errors.push({ + variable: "NEXT_PUBLIC_TRUSTED_ORIGINS", + pattern: /\s/, + message: `Trusted origin entry "${entry}" contains whitespace. Use comma-separated values without spaces.`, + }); + } + if (entry.includes("*") && !entry.startsWith("*.")) { + errors.push({ + variable: "NEXT_PUBLIC_TRUSTED_ORIGINS", + pattern: /\*/g, + message: `Wildcard "${entry}" must start with "*.", e.g. "*.vercel.app".`, + }); + } + } + + return errors; +} + /** * Get a list of all NEXT_PUBLIC_* variables for audit purposes. */ diff --git a/src/lib/i18n/messages/en.ts b/src/lib/i18n/messages/en.ts index 96e53cd..e69de29 100644 --- a/src/lib/i18n/messages/en.ts +++ b/src/lib/i18n/messages/en.ts @@ -1,184 +0,0 @@ -// Source-of-truth message catalog. Keys are dot-namespaced by feature; values -// may contain {placeholder} tokens that are filled in at render time. -// -// Values may also use a subset of ICU MessageFormat for pluralisation and -// selection, resolved at render time via Intl.PluralRules(locale): -// "{count, plural, one {# contribution} other {# contributions}}" -// "{gender, select, male {He} female {She} other {They}} replied" -// Inside a plural branch, `#` is replaced with the formatted count. Nested -// placeholders such as {name} are supported inside branches, and literal -// braces can be escaped as ''{'' / ''}''. -export const en = { - "nav.branding": "Vortex", - "nav.explore": "Explore", - "nav.becomeSolver": "Become a Solver", - "nav.docs": "Docs", - "nav.myIntents": "My Intents", - "nav.openMenu": "Open menu", - "nav.closeMenu": "Close menu", - - "wallet.connect.cta": "Connect Freighter", - "wallet.connect.connecting": "Connecting...", - "wallet.connect.retry": "Retry Connection", - "wallet.disconnect.cta": "Disconnect", - "wallet.disconnect.aria": "Disconnect wallet {address}", - "wallet.error.freighterUnavailable": - "Freighter extension is not installed or enabled.", - "wallet.error.connectFailed": "Failed to connect wallet.", - - "swap.chainPicker.title": "Select source chain", - "swap.chainPicker.recent": "Recent", - "swap.chainPicker.selectChain": "Select {name}", - - "activityFeed.status.live": "Live", - "activityFeed.status.polling": "Polling", - "activityFeed.error.unavailable": "Live feed unavailable right now.", - "activityFeed.empty": "No fills yet.", - "activityFeed.item.route": "{chain} · via {solver}", - - "swap.from.label": "From", - "swap.from.amountLabel": "Amount to swap", - "swap.from.amountPlaceholder": "0", - "swap.from.selectChain": "Source chain, currently {name}", - "swap.from.selectToken": "Select source token, currently {symbol}", - "swap.from.approxValue": "≈ ${value}", - - "swap.prices.estimated": "est.", - "swap.prices.asOf": "Estimated price as of {date}. Live quote will update this once available.", - - "swap.to.label": "To", - "swap.to.tokenGroup": "Destination token", - "swap.to.quoteLoading": "Loading quote…", - - "swap.slippage.label": "Slippage tolerance", - "swap.slippage.inputLabel": "Slippage tolerance percent", - "swap.slippage.minOut": "Min out: {amount} {token}", - "swap.slippage.zeroWarning": "0% slippage may cause your swap to fail if the price moves at all.", - - "swap.quote.solver": "Best solver", - "swap.quote.fillTime": "Est. fill time", - "swap.quote.fillTimeValue": "~{seconds}s", - "swap.quote.priceImpact": "Price impact", - "swap.quote.priceImpactValue": "{percent}%", - "swap.quote.priceImpactBelowMin": "<0.01", - "swap.quote.protocolFee": "Protocol fee", - "swap.quote.protocolFeeValue": "{percent}%", - "swap.quote.rate": "Rate", - - "swap.quote.fillTime.tooltip": "Estimated time for a solver to fill your swap after you submit. Actual time may vary.", - "swap.quote.priceImpact.tooltip": "How much your trade moves the effective price relative to the mid-market rate. A high impact means you receive less than the quoted mid-market rate.", - "swap.quote.protocolFee.tooltip": "A small percentage fee charged by the Vortex protocol on each settled swap. It is deducted from the destination amount.", - "swap.quote.highPriceImpactWarning": "High price impact above {threshold}% — review before swapping.", - "swap.quote.unavailable": "Live quote unavailable — showing an estimated rate.", - "swap.quote.noSolver": "No solver is available for this route right now.", - "swap.quote.staleWarning": "Quote is stale. Please wait for a refresh before submitting.", - - "swap.submit.connecting": "Connecting wallet…", - "swap.submit.building": "Preparing swap…", - "swap.submit.awaitingSignature": "Confirm in Freighter…", - "swap.submit.submitting": "Submitting…", - "swap.submit.findingRoute": "Finding best route…", - "swap.submit.success": "Swap submitted ✓ — start a new swap", - "swap.submit.enterAmount": "Enter an amount", - "swap.submit.cta": "Swap {amount} {srcToken} → {dstToken}", - "swap.submit.retryCta": "Retry: Swap {amount} {srcToken} → {dstToken}", - - "swap.destination.label": "Destination address", - "swap.destination.placeholder": "G...", - "swap.destination.invalidAddress": - "Enter a valid Stellar address (starts with G).", - - "swap.disclaimer": "Swap settles directly on Stellar · No wrapped tokens · Protected by solver bonds", - - "solve.nav.label": "Solve", - - "solve.hero.eyebrow": "Vortex Solver Dashboard", - "solve.hero.title": "Register & compete to solve intents", - "solve.hero.description": "Become a solver, post a bond, and earn fills by finding the best routes across chains.", - - "solve.register.states.connecting": "Connecting wallet…", - "solve.register.states.building": "Building transaction…", - "solve.register.states.submitting": "Submitting…", - - "solve.register.title": "Register as a Solver", - "solve.register.description": "To compete and earn fills, register your solver account on Stellar.", - "solve.register.info.slash": "Minimum bond is 100 XLM — you can slash or withdraw it anytime.", - "solve.register.info.withdraw": "Your solver bond earns you exclusive rights to solve intents.", - "solve.register.button.registered": "Registered ✓", - "solve.register.button.connect": "Connect to Register", - - "solve.leaderboard.title": "Active Solvers", - "solve.leaderboard.error": "Failed to load leaderboard.", - "solve.leaderboard.empty": "No active solvers yet.", - "solve.leaderboard.volume": "Volume", - "solve.leaderboard.fills": "Fills", - "solve.leaderboard.success": "Success %", - - "solve.intents.title": "Open Intents", - "solve.intents.error": "Failed to load intents.", - "solve.intents.empty": "No open intents at the moment.", - "solve.intents.accepting": "Accepting fill…", - "solve.intents.accept": "Accept Intent", - - "home.hero.eyebrow": "Stellar Agentic Hackathon 2025", - // The headline is split so the second line can keep its accent colour and the - // line break. Translations may reorder the two lines' content freely. - "home.hero.titleLine1": "Swap from any chain", - "home.hero.titleLine2": "directly to Stellar.", - "home.hero.body": - "Vortex is an intent-based cross-chain protocol. Express what you want, and competing solvers race to fill it — no bridges, no wrapped assets, no trust assumptions beyond the solver bond.", - "home.hero.solverCta": "Become a solver →", - - "home.stats.totalVolume": "Total Volume", - "home.stats.intentsFilled": "Intents Filled", - "home.stats.activeSolvers": "Active Solvers", - "home.stats.avgFillTime": "Avg Fill Time", - - "home.pipeline.title": "How it works", - "home.pipeline.intent.label": "Intent", - "home.pipeline.intent.sub": "You submit", - "home.pipeline.auction.label": "Auction", - "home.pipeline.auction.sub": "Solvers bid", - "home.pipeline.relay.label": "Relay", - "home.pipeline.relay.sub": "Best fills", - "home.pipeline.settle.label": "Settle", - "home.pipeline.settle.sub": "On Stellar", - - "home.feed.title": "Live Fills", - "home.feed.viewAll": "View all →", - - "home.chains.title": "Supported chains", - "home.chains.stellarDestination": "Stellar (dest.)", - - "footer.copyright": "© 2025 Vortex Protocol · MIT License", - "footer.github": "GitHub", - "footer.discord": "Discord", - - "notFound.breadcrumb": "Not Found", - "notFound.eyebrow": "404", - "notFound.title": "Page not found", - "notFound.body": - "The page you're looking for doesn't exist, or may have moved.", - "notFound.backHome": "← Back to Vortex", - - // ── Empty states ────────────────────────────────────────────────────────── - - // /explore — filters match nothing - "explore.empty.title": "No intents match your filters", - "explore.empty.message": "Try adjusting or clearing your status and chain filters to see more results.", - "explore.empty.clearFilters": "Clear filters", - - // /explore — error loading - "explore.error.title": "Couldn't load intents", - "explore.error.message": "Something went wrong fetching intents. Check your connection and try again.", - - // /contributors — pluralised counts (ICU subset, resolved via Intl.PluralRules) - "contributors.count": "{count, plural, one {# contribution} other {# contributions}}", - "contributors.repositories": "{count, plural, one {# repository} other {# repositories}}", - "contributors.mergedPrs": "{count, plural, one {# merged PR} other {# merged PRs}}", - "contributors.rank": "{rank, select, gold {Gold} silver {Silver} bronze {Bronze} other {Contributor}}", - "contributors.totalVolume": "{n, number} XLM contributed", -}; - -export type Messages = typeof en; -export type MessageKey = keyof Messages; diff --git a/src/lib/wallet/assertWalletReady.ts b/src/lib/wallet/assertWalletReady.ts new file mode 100644 index 0000000..3ff6bcd --- /dev/null +++ b/src/lib/wallet/assertWalletReady.ts @@ -0,0 +1,40 @@ +import { evaluateOrigin, getTrustedOrigins } from "@/lib/config"; +import { useWalletStore } from "@/store/wallet"; + +export class UntrustedOriginError extends Error { + constructor(hostname: string) { + super( + `Signing is not allowed on untrusted origin "${hostname}". Please visit the canonical site to continue.`, + ); + this.name = "UntrustedOriginError"; + } +} + +/** + * Precondition check before any wallet signing operation. + * + * Verifies that: + * 1. The wallet is connected and has an address. + * 2. The current origin is on the trusted list. + * + * Throws `UntrustedOriginError` when the origin is not trusted, + * and a generic `Error` when the wallet is not connected. + */ +export function assertWalletReady(): { address: string; network: string } { + const state = useWalletStore.getState(); + + if (!state.isConnected || !state.address) { + throw new Error(state.error ?? "Connect a wallet to continue."); + } + + const hostname = window.location.hostname; + const protocol = window.location.protocol.replace(":", ""); + const trusted = getTrustedOrigins(); + const trustLevel = evaluateOrigin(hostname, protocol, trusted); + + if (trustLevel === "untrusted") { + throw new UntrustedOriginError(hostname); + } + + return { address: state.address, network: state.network ?? "" }; +}