diff --git a/.env.example b/.env.example
index ee0eed5..c9f714a 100644
--- a/.env.example
+++ b/.env.example
@@ -3,3 +3,14 @@
# Base URL of the GuildWorkman API. Defaults to the hosted instance if unset.
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
+
+# Stellar network the wallet-connection layer expects Freighter to be on.
+# Valid values: PUBLIC (mainnet), TESTNET, FUTURENET. Defaults to TESTNET if
+# unset, matching the Soroban contracts' current deployment target.
+# This is a security-relevant setting, not just a label: it's what
+# NetworkGuard (src/components/wallet/NetworkGuard.tsx) and the escrow
+# funding wizard check the connected wallet against before allowing funds to
+# move. Setting it to PUBLIC before the backend/contract integration is
+# actually deployed to mainnet would let a wallet connected to real funds
+# pass the guard — only change it once that deployment exists.
+NEXT_PUBLIC_STELLAR_NETWORK=TESTNET
diff --git a/README.md b/README.md
index dc2477c..29aaefa 100644
--- a/README.md
+++ b/README.md
@@ -199,14 +199,43 @@ Java backend that doesn't exist today.
Rather than pretend that integration is further along than it is, the
frontend currently does two honest things:
-1. **A real wallet connection.** The navbar's "Connect Wallet" button
- (`src/components/WalletButton.tsx`, `src/lib/wallet.ts`) uses
+1. **A real, resilient wallet connection.** The navbar's "Connect Wallet"
+ button (`src/components/WalletButton.tsx`, `src/lib/wallet.ts`) uses
`@stellar/freighter-api` to connect an actual Freighter wallet, showing a
- truncated address, network (Testnet/Mainnet), and a disconnect option. If
- the Freighter extension isn't installed, it shows an "Install Freighter"
- hint instead of failing silently. This makes **no contract calls** —
- booking, payment, and review logic are all unchanged and still go through
- the backend API/Paystack.
+ truncated address, network (Testnet/Mainnet/Futurenet), and a disconnect
+ option. If the Freighter extension isn't installed, it shows an "Install
+ Freighter" hint instead of failing silently. This makes **no contract
+ calls** — booking, payment, and review logic are all unchanged and still
+ go through the backend API/Paystack.
+
+ On top of the base connection, `useWallet()` (`src/lib/wallet.ts`) adds:
+ - **Session restore across reloads** — a localStorage flag is only a fast
+ hint; the actual restore verifies Freighter's own `isAllowed()` grant
+ before trusting it, so a session revoked inside the extension (or a
+ browser profile that never had it) self-heals instead of showing a
+ stale "connected" UI.
+ - **A live network-switch guard** — Freighter has no API to switch its
+ own network on a dApp's behalf (a deliberate security boundary), so
+ `useWallet` runs Freighter's `WatchWalletChanges` poller for the life
+ of a session and exposes `isWrongNetwork` / `expectedNetwork`.
+ `NetworkGuard` (`src/components/wallet/NetworkGuard.tsx`, mounted
+ app-wide in `src/app/layout.tsx`) shows a banner guiding the user to
+ switch inside Freighter and clears itself automatically once the
+ watcher detects the change — no reload or manual recheck required,
+ though a "check again" button short-circuits the wait.
+ `EscrowFundingWizard`'s connect-wallet step additionally **blocks
+ progress** past that step while on the wrong network, since funding
+ escrow there isn't recoverable after the fact.
+ - The expected network is configurable via `NEXT_PUBLIC_STELLAR_NETWORK`
+ — one of `PUBLIC` (mainnet) / `TESTNET` / `FUTURENET`, defaults to
+ `TESTNET` (see `.env.example`). This is security-relevant, not just a
+ label: it's what the guard checks a connected wallet against before
+ letting funds move, so it should only be pointed at `PUBLIC` once the
+ backend/contract integration is actually deployed there.
+
+ No new dependencies were added — `WatchWalletChanges` and `isAllowed()`
+ are both part of the `@stellar/freighter-api` version already in
+ `package.json`.
2. **An informational trust layer.** The homepage sections (`Hero`,
`HowItWorks`, `StatsBand`) plus "Escrow protected" badges on worker cards
and the booking flow explain in plain language what the contracts *will* do
@@ -222,13 +251,15 @@ src/
components/
ui/ # design-system primitives (Button, Input, Select, Card, Badge)
brand/ # the marks as components (Logo, NorthStar, AdinkraPattern)
+ wallet/ # NetworkGuard — app-wide wrong-network banner
*.tsx # page-level and shared client components
lib/
api.ts # typed API client — every backend call goes through here
- config.ts # API_BASE_URL resolution
+ config.ts # API_BASE_URL / EXPECTED_STELLAR_NETWORK resolution
types.ts # shared request/response types
constants.ts # category list and skill-detail seed data
- wallet.ts # useWallet() hook wrapping @stellar/freighter-api
+ wallet.ts # useWallet() hook — connect/session-restore/network-guard
+ # on top of @stellar/freighter-api
public/
assets/ # images used across the app
brand/ # exported logo marks (SVG masters + 4x PNGs)
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index e3661a2..5b435da 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -7,6 +7,7 @@ import Footer from "@/components/Footer";
import { ThemeProvider } from "@/components/theme";
import { NotificationProvider } from "@/components/notifications/useNotifications";
import NotificationToast from "@/components/notifications/NotificationToast";
+import { NetworkGuard, WalletProvider } from "@/components/wallet";
import { themeScript } from "@/lib/theme";
const inter = Inter({
@@ -41,10 +42,13 @@ export default function RootLayout({
-
- {children}
-
-
+
+
+
+ {children}
+
+
+
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx
index ef5738f..ebe4992 100644
--- a/src/components/Navbar.tsx
+++ b/src/components/Navbar.tsx
@@ -6,6 +6,7 @@ import { HiMenu, HiX } from "react-icons/hi";
import Logo from "./brand/Logo";
import ThemeToggle from "./ThemeToggle";
import NotificationCenter from "./notifications/NotificationCenter";
+import WalletButton from "./WalletButton";
import { buttonClasses } from "./ui/Button";
const navLinks = [
@@ -35,6 +36,7 @@ export default function Navbar() {
+
Log in
@@ -72,6 +74,7 @@ export default function Navbar() {
))}
+ Switch to {NETWORK_LABELS[expectedNetwork] ?? expectedNetwork} in Freighter
+
+ )}
{address}
)}
@@ -72,7 +89,7 @@ export default function WalletButton({ variant = "navbar" }: WalletButtonProps)
disabled={connecting}
className={`${baseButton} ${variant === "mobile" ? "w-full justify-center" : ""} disabled:opacity-60`}
>
-
+
{connecting ? "Connecting..." : "Connect Wallet"}
{freighterMissing && (
diff --git a/src/components/escrow/EscrowFundingWizard.tsx b/src/components/escrow/EscrowFundingWizard.tsx
index 7738cd1..2d8972a 100644
--- a/src/components/escrow/EscrowFundingWizard.tsx
+++ b/src/components/escrow/EscrowFundingWizard.tsx
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from "react";
import Button from "@/components/ui/Button";
import Card from "@/components/ui/Card";
-import { useWallet } from "@/lib/wallet";
+import { useWallet } from "@/components/wallet";
import EscrowStepper from "./EscrowStepper";
import ReviewStep from "./steps/ReviewStep";
import ConnectWalletStep from "./steps/ConnectWalletStep";
@@ -110,11 +110,14 @@ export default function EscrowFundingWizard({ bookingRef, amount, workerName }:
await wallet.connect();
}
+ // Only advance past this step once the wallet is both connected *and* on
+ // the expected network — funding on the wrong network isn't recoverable
+ // after the fact, so the guard has to block progress here, not just warn.
useEffect(() => {
- if (wallet.address && state.name === "connectWallet") {
+ if (wallet.address && !wallet.isWrongNetwork && state.name === "connectWallet") {
dispatch({ type: "WALLET_CONNECTED", address: wallet.address });
}
- }, [wallet.address, state.name]);
+ }, [wallet.address, wallet.isWrongNetwork, state.name]);
async function handleFund() {
dispatch({ type: "FUND_START" });
@@ -184,6 +187,9 @@ export default function EscrowFundingWizard({ bookingRef, amount, workerName }:
connecting={wallet.connecting}
freighterMissing={wallet.freighterMissing}
error={wallet.error}
+ isWrongNetwork={wallet.isWrongNetwork}
+ network={wallet.network}
+ expectedNetwork={wallet.expectedNetwork}
onConnect={handleConnectWallet}
/>
)}
diff --git a/src/components/escrow/steps/ConnectWalletStep.tsx b/src/components/escrow/steps/ConnectWalletStep.tsx
index efd1898..e84d167 100644
--- a/src/components/escrow/steps/ConnectWalletStep.tsx
+++ b/src/components/escrow/steps/ConnectWalletStep.tsx
@@ -1,12 +1,15 @@
import { FaWallet } from "react-icons/fa6";
import Button from "@/components/ui/Button";
-import { truncateAddress } from "@/lib/wallet";
+import { NETWORK_LABELS, truncateAddress, type StellarNetwork } from "@/lib/wallet";
interface ConnectWalletStepProps {
address: string | null;
connecting: boolean;
freighterMissing: boolean;
error: string | null;
+ isWrongNetwork: boolean;
+ network: string | null;
+ expectedNetwork: StellarNetwork;
onConnect: () => void;
}
@@ -15,8 +18,13 @@ export default function ConnectWalletStep({
connecting,
freighterMissing,
error,
+ isWrongNetwork,
+ network,
+ expectedNetwork,
onConnect,
}: ConnectWalletStepProps) {
+ const expectedLabel = NETWORK_LABELS[expectedNetwork] ?? expectedNetwork;
+
return (
Connect your wallet
@@ -24,7 +32,16 @@ export default function ConnectWalletStep({
We use your Stellar wallet to fund the escrow contract directly — no card details needed.
- {address ? (
+ {address && isWrongNetwork ? (
+
+
{truncateAddress(address)}
+
+ Wrong network — connected to{" "}
+ {network ? NETWORK_LABELS[network] ?? network : "an unrecognized network"}, but escrow funding requires{" "}
+ {expectedLabel}. Switch to {expectedLabel} in the Freighter extension to continue.
+
+
+ ) : address ? (
{truncateAddress(address)}
diff --git a/src/components/wallet/NetworkGuard.tsx b/src/components/wallet/NetworkGuard.tsx
new file mode 100644
index 0000000..00c450d
--- /dev/null
+++ b/src/components/wallet/NetworkGuard.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { HiExclamationCircle } from "react-icons/hi";
+import Button from "@/components/ui/Button";
+import { NETWORK_LABELS, truncateAddress } from "@/lib/wallet";
+import { useWallet } from "./WalletProvider";
+
+/**
+ * App-wide banner that appears whenever a connected wallet is on the wrong
+ * Stellar network. Freighter has no API to switch its own network on a
+ * dApp's behalf, so this guides the user through doing it themselves rather
+ * than pretending to do it for them — the underlying `useWallet` watcher
+ * clears the banner automatically once it detects the switch (usually
+ * within a few seconds); "Check again" just short-circuits that wait.
+ */
+export default function NetworkGuard() {
+ const { address, network, isWrongNetwork, expectedNetwork, recheckNetwork } = useWallet();
+ const [checking, setChecking] = useState(false);
+ const bannerRef = useRef(null);
+
+ // Move focus to the banner the moment it appears — a sighted keyboard
+ // user or screen-reader user acting elsewhere on the page otherwise has
+ // no reason to notice a `role="alert"` region that materialized outside
+ // their current focus.
+ useEffect(() => {
+ if (address && isWrongNetwork) {
+ bannerRef.current?.focus();
+ }
+ }, [address, isWrongNetwork]);
+
+ if (!address || !isWrongNetwork) return null;
+
+ const currentLabel = network ? NETWORK_LABELS[network] ?? network : "an unrecognized network";
+ const expectedLabel = NETWORK_LABELS[expectedNetwork] ?? expectedNetwork;
+
+ async function handleRecheck() {
+ setChecking(true);
+ await recheckNetwork();
+ setChecking(false);
+ }
+
+ return (
+
+
+
+
+
+ Wrong network — your wallet (
+ {truncateAddress(address)}) is connected to {currentLabel}, but
+ GuildWorkman runs on {expectedLabel}. Open the Freighter
+ extension and switch its network to {expectedLabel}; this banner clears itself once it detects the
+ change.
+
+
+
+
+
+ );
+}
diff --git a/src/components/wallet/WalletProvider.tsx b/src/components/wallet/WalletProvider.tsx
new file mode 100644
index 0000000..438a2b7
--- /dev/null
+++ b/src/components/wallet/WalletProvider.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { createContext, useContext, type ReactNode } from "react";
+import { useWalletState, type WalletContextValue } from "@/lib/wallet";
+
+const WalletContext = createContext(null);
+
+/**
+ * Owns the single `useWalletState()` instance for the whole app — session
+ * restore and the live network-switch watcher both run real side effects
+ * (Freighter calls, a polling interval), so every consumer sharing one
+ * instance through context is what keeps that to one poller and one source
+ * of truth, rather than each `WalletButton`/`NetworkGuard` mount running
+ * its own independent copy. See the WHY comment above `useWalletState` in
+ * `src/lib/wallet.ts` for the failure mode this avoids.
+ */
+export function WalletProvider({ children }: { children: ReactNode }) {
+ const wallet = useWalletState();
+ return {children};
+}
+
+export function useWallet(): WalletContextValue {
+ const ctx = useContext(WalletContext);
+ if (!ctx) {
+ throw new Error("useWallet must be used within a ");
+ }
+ return ctx;
+}
diff --git a/src/components/wallet/index.ts b/src/components/wallet/index.ts
new file mode 100644
index 0000000..61c1f21
--- /dev/null
+++ b/src/components/wallet/index.ts
@@ -0,0 +1,6 @@
+// Barrel for the wallet system (same pattern as theme/index.ts,
+// notifications/index.ts). WalletProvider/NetworkGuard only import from
+// "@/lib/wallet" and "./WalletProvider" directly — nothing here imports
+// back through this barrel — so re-exporting creates no circular-import risk.
+export { default as NetworkGuard } from "./NetworkGuard";
+export { WalletProvider, useWallet } from "./WalletProvider";
diff --git a/src/lib/config.ts b/src/lib/config.ts
index f59af86..493ef31 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -1,2 +1,12 @@
export const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "https://guildworkman-api.onrender.com";
+
+/** The Stellar networks Freighter can be connected to. */
+export type StellarNetwork = "PUBLIC" | "TESTNET" | "FUTURENET";
+
+/** The network GuildWorkman's escrow flow expects the connected wallet to be
+ on. Defaults to Testnet, matching the Soroban contracts' current
+ deployment target (see the README's "Web3 / Stellar touches" section) —
+ override via NEXT_PUBLIC_STELLAR_NETWORK once a mainnet deployment exists. */
+export const EXPECTED_STELLAR_NETWORK: StellarNetwork =
+ (process.env.NEXT_PUBLIC_STELLAR_NETWORK as StellarNetwork | undefined) ?? "TESTNET";
diff --git a/src/lib/test/wallet.test.tsx b/src/lib/test/wallet.test.tsx
new file mode 100644
index 0000000..281f184
--- /dev/null
+++ b/src/lib/test/wallet.test.tsx
@@ -0,0 +1,335 @@
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+/** `@stellar/freighter-api` talks to a real browser extension over
+ postMessage — nothing jsdom can answer. Mocked here (via `vi.hoisted` so
+ the factory below can reference it) so `useWalletState` is driven purely
+ through controllable promises and a fake `WatchWalletChanges` whose
+ callback we invoke by hand — there's no `setTimeout`/polling in the fake
+ at all, so nothing here depends on real time or fake timers to be
+ deterministic. */
+const freighter = vi.hoisted(() => {
+ type WatchCallback = (params: {
+ address: string;
+ network: string;
+ networkPassphrase: string;
+ error?: unknown;
+ }) => void;
+
+ class FakeWatchWalletChanges {
+ static instances: FakeWatchWalletChanges[] = [];
+ cb: WatchCallback | null = null;
+ stopped = false;
+ constructor(public timeout: number) {
+ FakeWatchWalletChanges.instances.push(this);
+ }
+ watch(cb: WatchCallback) {
+ this.cb = cb;
+ return {};
+ }
+ stop() {
+ this.stopped = true;
+ }
+ }
+
+ return {
+ isConnected: vi.fn(),
+ isAllowed: vi.fn(),
+ getAddress: vi.fn(),
+ requestAccess: vi.fn(),
+ getNetwork: vi.fn(),
+ FakeWatchWalletChanges,
+ };
+});
+
+vi.mock("@stellar/freighter-api", () => ({
+ isConnected: freighter.isConnected,
+ isAllowed: freighter.isAllowed,
+ getAddress: freighter.getAddress,
+ requestAccess: freighter.requestAccess,
+ getNetwork: freighter.getNetwork,
+ WatchWalletChanges: freighter.FakeWatchWalletChanges,
+}));
+
+import { useWalletState } from "../wallet";
+
+const SESSION_KEY = "gw_wallet_connected";
+
+function latestWatcher() {
+ const instances = freighter.FakeWatchWalletChanges.instances;
+ return instances[instances.length - 1];
+}
+
+/** Minimal consumer exposing the hook's values through DOM attributes. */
+function WalletConsumer() {
+ const wallet = useWalletState();
+ return (
+
+
+
+
+
+ );
+}
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ window.localStorage.clear();
+ freighter.FakeWatchWalletChanges.instances.length = 0;
+ freighter.isConnected.mockReset();
+ freighter.isAllowed.mockReset();
+ freighter.getAddress.mockReset();
+ freighter.requestAccess.mockReset();
+ freighter.getNetwork.mockReset();
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.restoreAllMocks();
+});
+
+/** Flushes the microtask queue a few times inside `act`, enough for the
+ hook's chained `await`s (isConnected -> isAllowed -> Promise.all) to
+ settle and their resulting state updates to flush into the DOM. */
+async function flush(times = 6) {
+ for (let i = 0; i < times; i++) {
+ await act(async () => {
+ await Promise.resolve();
+ });
+ }
+}
+
+async function render() {
+ act(() => {
+ root.render();
+ });
+ await flush();
+}
+
+function attr(name: string) {
+ return container.firstElementChild!.getAttribute(name);
+}
+
+async function click(testid: string) {
+ act(() => {
+ container.querySelector(`[data-testid="${testid}"]`)!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await flush();
+}
+
+describe("useWalletState — connect", () => {
+ it("populates address/network and persists the session flag on success", async () => {
+ freighter.isConnected.mockResolvedValue({ isConnected: true });
+ freighter.requestAccess.mockResolvedValue({ address: "GABC1234...WXYZ" });
+ freighter.getNetwork.mockResolvedValue({ network: "TESTNET", networkPassphrase: "Test SDF Network" });
+
+ await render();
+ await click("connect");
+
+ expect(attr("data-address")).toBe("GABC1234...WXYZ");
+ expect(attr("data-network")).toBe("TESTNET");
+ expect(attr("data-wrong-network")).toBe("false");
+ expect(attr("data-connecting")).toBe("false");
+ expect(window.localStorage.getItem(SESSION_KEY)).toBe("true");
+ expect(latestWatcher()).toBeDefined();
+ expect(latestWatcher().stopped).toBe(false);
+ });
+
+ it("flags freighterMissing instead of prompting when the extension isn't installed", async () => {
+ freighter.isConnected.mockResolvedValue({ isConnected: false });
+
+ await render();
+ await click("connect");
+
+ expect(attr("data-freighter-missing")).toBe("true");
+ expect(attr("data-address")).toBe("");
+ expect(freighter.requestAccess).not.toHaveBeenCalled();
+ });
+
+ it("surfaces an error when the user declines the access request", async () => {
+ freighter.isConnected.mockResolvedValue({ isConnected: true });
+ freighter.requestAccess.mockResolvedValue({ error: "User declined access" });
+
+ await render();
+ await click("connect");
+
+ expect(attr("data-error")).toBe("User declined access");
+ expect(attr("data-address")).toBe("");
+ expect(window.localStorage.getItem(SESSION_KEY)).toBeNull();
+ });
+
+ it("flags isWrongNetwork when connected to a network other than expected (TESTNET)", async () => {
+ freighter.isConnected.mockResolvedValue({ isConnected: true });
+ freighter.requestAccess.mockResolvedValue({ address: "GPUBLIC..." });
+ freighter.getNetwork.mockResolvedValue({ network: "PUBLIC", networkPassphrase: "Public Global Stellar Network" });
+
+ await render();
+ await click("connect");
+
+ expect(attr("data-network")).toBe("PUBLIC");
+ expect(attr("data-wrong-network")).toBe("true");
+ });
+});
+
+describe("useWalletState — session restore", () => {
+ it("silently restores a session that Freighter still recognizes as allowed", async () => {
+ window.localStorage.setItem(SESSION_KEY, "true");
+ freighter.isConnected.mockResolvedValue({ isConnected: true });
+ freighter.isAllowed.mockResolvedValue({ isAllowed: true });
+ freighter.getAddress.mockResolvedValue({ address: "GRESTORED..." });
+ freighter.getNetwork.mockResolvedValue({ network: "TESTNET", networkPassphrase: "Test SDF Network" });
+
+ await render();
+
+ expect(attr("data-address")).toBe("GRESTORED...");
+ expect(attr("data-restoring")).toBe("false");
+ expect(latestWatcher()).toBeDefined();
+ });
+
+ it("clears a stale session flag when Freighter no longer allows this origin", async () => {
+ window.localStorage.setItem(SESSION_KEY, "true");
+ freighter.isConnected.mockResolvedValue({ isConnected: true });
+ freighter.isAllowed.mockResolvedValue({ isAllowed: false });
+
+ await render();
+
+ expect(attr("data-address")).toBe("");
+ expect(window.localStorage.getItem(SESSION_KEY)).toBeNull();
+ expect(freighter.getAddress).not.toHaveBeenCalled();
+ });
+
+ it("clears a stale session flag when the extension is no longer installed", async () => {
+ window.localStorage.setItem(SESSION_KEY, "true");
+ freighter.isConnected.mockResolvedValue({ isConnected: false });
+
+ await render();
+
+ expect(attr("data-address")).toBe("");
+ expect(window.localStorage.getItem(SESSION_KEY)).toBeNull();
+ });
+
+ it("does nothing on mount when no session was ever established", async () => {
+ freighter.isConnected.mockResolvedValue({ isConnected: true });
+
+ await render();
+
+ expect(attr("data-address")).toBe("");
+ expect(freighter.isAllowed).not.toHaveBeenCalled();
+ });
+});
+
+describe("useWalletState — live network-switch guard", () => {
+ async function connectOnTestnet() {
+ freighter.isConnected.mockResolvedValue({ isConnected: true });
+ freighter.requestAccess.mockResolvedValue({ address: "GLIVE..." });
+ freighter.getNetwork.mockResolvedValue({ network: "TESTNET", networkPassphrase: "Test SDF Network" });
+ await render();
+ await click("connect");
+ }
+
+ it("flips isWrongNetwork live when the watcher reports a network change", async () => {
+ await connectOnTestnet();
+ expect(attr("data-wrong-network")).toBe("false");
+
+ await act(async () => {
+ latestWatcher().cb?.({ address: "GLIVE...", network: "PUBLIC", networkPassphrase: "Public Global Stellar Network" });
+ });
+
+ expect(attr("data-network")).toBe("PUBLIC");
+ expect(attr("data-wrong-network")).toBe("true");
+
+ // Switching back clears the guard without a reload or reconnect.
+ await act(async () => {
+ latestWatcher().cb?.({ address: "GLIVE...", network: "TESTNET", networkPassphrase: "Test SDF Network" });
+ });
+ expect(attr("data-wrong-network")).toBe("false");
+ });
+
+ it("disconnects and clears the session when the watcher reports revoked access", async () => {
+ await connectOnTestnet();
+ const watcher = latestWatcher();
+
+ await act(async () => {
+ watcher.cb?.({ address: "", network: "", networkPassphrase: "", error: "access revoked" });
+ });
+
+ expect(attr("data-address")).toBe("");
+ expect(window.localStorage.getItem(SESSION_KEY)).toBeNull();
+ expect(watcher.stopped).toBe(true);
+ });
+
+ it("disconnects and clears the session when Freighter is uninstalled mid-session", async () => {
+ // Distinct from a revoked grant: the extension itself disappears (e.g.
+ // uninstalled, or disabled) between the watcher's polls. Freighter's own
+ // requestPublicKey/requestNetworkDetails calls fail the same way an
+ // access-revocation does — an error on the watch callback with no
+ // address/network — so this exercises the same handling with a scenario
+ // named for what it actually represents in production.
+ await connectOnTestnet();
+ const watcher = latestWatcher();
+
+ await act(async () => {
+ watcher.cb?.({
+ address: "",
+ network: "",
+ networkPassphrase: "",
+ error: "Freighter extension is not installed",
+ });
+ });
+
+ expect(attr("data-address")).toBe("");
+ expect(attr("data-network")).toBe("");
+ expect(window.localStorage.getItem(SESSION_KEY)).toBeNull();
+ expect(watcher.stopped).toBe(true);
+ });
+
+ it("recheckNetwork() re-verifies immediately, without waiting for the watcher", async () => {
+ await connectOnTestnet();
+ expect(attr("data-wrong-network")).toBe("false");
+
+ freighter.getNetwork.mockResolvedValue({ network: "PUBLIC", networkPassphrase: "Public Global Stellar Network" });
+ await click("recheck");
+
+ expect(attr("data-network")).toBe("PUBLIC");
+ expect(attr("data-wrong-network")).toBe("true");
+ });
+});
+
+describe("useWalletState — disconnect", () => {
+ it("stops the watcher and clears all state", async () => {
+ freighter.isConnected.mockResolvedValue({ isConnected: true });
+ freighter.requestAccess.mockResolvedValue({ address: "GBYE..." });
+ freighter.getNetwork.mockResolvedValue({ network: "TESTNET", networkPassphrase: "Test SDF Network" });
+
+ await render();
+ await click("connect");
+ const watcher = latestWatcher();
+
+ await click("disconnect");
+
+ expect(attr("data-address")).toBe("");
+ expect(window.localStorage.getItem(SESSION_KEY)).toBeNull();
+ expect(watcher.stopped).toBe(true);
+ });
+});
diff --git a/src/lib/test/walletProvider.test.tsx b/src/lib/test/walletProvider.test.tsx
new file mode 100644
index 0000000..b9045a4
--- /dev/null
+++ b/src/lib/test/walletProvider.test.tsx
@@ -0,0 +1,155 @@
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+/** Same fake as wallet.test.tsx — duplicated rather than shared because
+ `vi.mock` factories run per test file and this repo's other test files
+ (e.g. theme.test.ts / themeProvider.test.tsx) already each stub their own
+ externals rather than sharing a __mocks__ module. No real timers: the
+ fake's callback is only ever invoked by hand. */
+const freighter = vi.hoisted(() => {
+ type WatchCallback = (params: {
+ address: string;
+ network: string;
+ networkPassphrase: string;
+ error?: unknown;
+ }) => void;
+
+ class FakeWatchWalletChanges {
+ static instances: FakeWatchWalletChanges[] = [];
+ cb: WatchCallback | null = null;
+ stopped = false;
+ constructor(public timeout: number) {
+ FakeWatchWalletChanges.instances.push(this);
+ }
+ watch(cb: WatchCallback) {
+ this.cb = cb;
+ return {};
+ }
+ stop() {
+ this.stopped = true;
+ }
+ }
+
+ return {
+ isConnected: vi.fn(),
+ isAllowed: vi.fn(),
+ getAddress: vi.fn(),
+ requestAccess: vi.fn(),
+ getNetwork: vi.fn(),
+ FakeWatchWalletChanges,
+ };
+});
+
+vi.mock("@stellar/freighter-api", () => ({
+ isConnected: freighter.isConnected,
+ isAllowed: freighter.isAllowed,
+ getAddress: freighter.getAddress,
+ requestAccess: freighter.requestAccess,
+ getNetwork: freighter.getNetwork,
+ WatchWalletChanges: freighter.FakeWatchWalletChanges,
+}));
+
+import { useWallet, WalletProvider } from "../../components/wallet/WalletProvider";
+
+/** Two independent consumers, as WalletButton (desktop + mobile) and
+ NetworkGuard are in the real navbar/layout — this is the regression
+ test for "every consumer must share one wallet instance". */
+function ConsumerA() {
+ const wallet = useWallet();
+ return (
+
+ {wallet.address ?? ""}
+
+
+ );
+}
+
+function ConsumerB() {
+ const wallet = useWallet();
+ return {wallet.address ?? ""};
+}
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ window.localStorage.clear();
+ freighter.FakeWatchWalletChanges.instances.length = 0;
+ freighter.isConnected.mockReset();
+ freighter.isAllowed.mockReset();
+ freighter.getAddress.mockReset();
+ freighter.requestAccess.mockReset();
+ freighter.getNetwork.mockReset();
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.restoreAllMocks();
+});
+
+async function flush(times = 6) {
+ for (let i = 0; i < times; i++) {
+ await act(async () => {
+ await Promise.resolve();
+ });
+ }
+}
+
+function text(testid: string) {
+ return container.querySelector(`[data-testid="${testid}"]`)!.textContent;
+}
+
+describe("WalletProvider", () => {
+ it("throws when useWallet() is called outside a WalletProvider", () => {
+ function Bare() {
+ useWallet();
+ return null;
+ }
+ // React logs the thrown error to console during the failed render;
+ // that's expected here and not something this test needs to assert on.
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+ expect(() => {
+ act(() => {
+ root.render();
+ });
+ }).toThrow("useWallet must be used within a ");
+ consoleError.mockRestore();
+ });
+
+ it("shares one wallet instance — and one WatchWalletChanges poller — across every consumer", async () => {
+ freighter.isConnected.mockResolvedValue({ isConnected: true });
+ freighter.requestAccess.mockResolvedValue({ address: "GSHARED..." });
+ freighter.getNetwork.mockResolvedValue({ network: "TESTNET", networkPassphrase: "Test SDF Network" });
+
+ act(() => {
+ root.render(
+
+
+
+
+ );
+ });
+ await flush();
+
+ expect(text("a-address")).toBe("");
+ expect(text("b-address")).toBe("");
+
+ act(() => {
+ container.querySelector('[data-testid="a-connect"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await flush();
+
+ // Both consumers see the same connected address from one shared state...
+ expect(text("a-address")).toBe("GSHARED...");
+ expect(text("b-address")).toBe("GSHARED...");
+ // ...backed by exactly one poller, not one per consumer.
+ expect(freighter.FakeWatchWalletChanges.instances).toHaveLength(1);
+ });
+});
diff --git a/src/lib/wallet.ts b/src/lib/wallet.ts
index 7cdaf23..711c162 100644
--- a/src/lib/wallet.ts
+++ b/src/lib/wallet.ts
@@ -1,14 +1,63 @@
+/**
+ * Wallet connection layer — session restore, and a live network-switch
+ * guard, on top of `@stellar/freighter-api`.
+ *
+ * WHY isAllowed() INSTEAD OF TRUSTING THE SESSION FLAG ALONE
+ * The original implementation restored a session purely from a localStorage
+ * flag: if it was set, it assumed Freighter would hand back an address. That
+ * breaks the moment access is revoked from inside the extension (or the flag
+ * survives a browser profile that never granted it) — the UI would sit
+ * "connecting" against a wallet that will never answer. `isAllowed()` asks
+ * Freighter directly whether this origin currently holds a grant, so a
+ * revoked/missing grant is detected up front and the stale flag is cleared
+ * instead of leaving the app in limbo.
+ *
+ * WHY A LIVE WATCHER INSTEAD OF ONE-SHOT RESTORE
+ * Freighter has no API to switch the network on a dApp's behalf (that's a
+ * deliberate security boundary — only the user, inside the extension, can do
+ * it), so "guided switching" here means detecting the change the user makes
+ * themselves and reacting to it, not driving it. `WatchWalletChanges` polls
+ * Freighter for the active address/network and reports a callback whenever
+ * either changes. Running it for the lifetime of a connected session is what
+ * lets `NetworkGuard` (see `src/components/wallet/NetworkGuard.tsx`) clear
+ * itself the moment the user switches networks inside the extension, and
+ * what catches an account switch or an access revocation without requiring a
+ * reload.
+ *
+ * WHY THIS HOOK IS WRAPPED IN A CONTEXT PROVIDER
+ * `useWalletState` below owns real side effects — a session restore on
+ * mount and, once connected, a live polling watcher. It's exported for
+ * direct unit testing, but app code should never call it more than once:
+ * every call is a fully independent instance with its own poller, so two
+ * components each calling it (e.g. the navbar's desktop and mobile
+ * `WalletButton`, which are both mounted at once — only one is hidden via
+ * CSS) would double the background polling and could see each other's
+ * connect/disconnect drift out of sync. `WalletProvider` (in
+ * `src/components/wallet/WalletProvider.tsx`) runs the one instance the app
+ * uses; components consume it via `useWallet()` from `@/components/wallet`.
+ */
"use client";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import {
isConnected as freighterIsConnected,
+ isAllowed as freighterIsAllowed,
getAddress as freighterGetAddress,
requestAccess as freighterRequestAccess,
getNetwork as freighterGetNetwork,
+ WatchWalletChanges,
} from "@stellar/freighter-api";
+import { EXPECTED_STELLAR_NETWORK, type StellarNetwork } from "@/lib/config";
const SESSION_KEY = "gw_wallet_connected";
+/** How often the live watcher polls Freighter for address/network changes. */
+const WATCH_INTERVAL_MS = 3000;
+
+export const NETWORK_LABELS: Record = {
+ PUBLIC: "Mainnet",
+ TESTNET: "Testnet",
+ FUTURENET: "Futurenet",
+};
export function truncateAddress(address: string) {
return `${address.slice(0, 4)}...${address.slice(-4)}`;
@@ -18,32 +67,103 @@ interface WalletState {
address: string | null;
network: string | null;
connecting: boolean;
+ /** True only while a previously-authorized session is being re-verified
+ on mount — distinct from `connecting`, which is a user-initiated
+ request-access flow that can prompt the extension's UI. */
+ restoring: boolean;
error: string | null;
freighterMissing: boolean;
}
-export function useWallet() {
- const [state, setState] = useState({
- address: null,
- network: null,
- connecting: false,
- error: null,
- freighterMissing: false,
- });
+const initialState: WalletState = {
+ address: null,
+ network: null,
+ connecting: false,
+ restoring: false,
+ error: null,
+ freighterMissing: false,
+};
+
+/** The stateful engine behind wallet connection — see `WalletProvider` doc
+ comment above for why app code should go through that provider's
+ `useWallet()` instead of calling this directly (tests are the
+ exception: they exercise this hook in isolation). */
+export function useWalletState() {
+ const [state, setState] = useState(initialState);
+ const watcherRef = useRef(null);
+
+ const stopWatching = useCallback(() => {
+ watcherRef.current?.stop();
+ watcherRef.current = null;
+ }, []);
+
+ const startWatching = useCallback(() => {
+ stopWatching();
+ const watcher = new WatchWalletChanges(WATCH_INTERVAL_MS);
+ watcherRef.current = watcher;
+ watcher.watch(({ address, network, error }) => {
+ if (error || !address) {
+ // Access was revoked (or the extension locked) since we last
+ // checked — fall back to disconnected rather than show a session
+ // that no longer exists on Freighter's side.
+ localStorage.removeItem(SESSION_KEY);
+ stopWatching();
+ setState(initialState);
+ return;
+ }
+ setState((s) => ({ ...s, address, network: network || s.network }));
+ });
+ }, [stopWatching]);
+
+ useEffect(() => stopWatching, [stopWatching]);
+ // Restore a previously-authorized session on mount (page load / reload).
useEffect(() => {
if (localStorage.getItem(SESSION_KEY) !== "true") return;
- freighterIsConnected().then(({ isConnected, error }) => {
- if (error || !isConnected) return;
- freighterGetAddress().then(({ address, error: addrError }) => {
- if (addrError || !address) return;
- setState((s) => ({ ...s, address }));
- freighterGetNetwork().then(({ network }) => {
- setState((s) => ({ ...s, network: network ?? null }));
- });
- });
- });
+ let cancelled = false;
+ setState((s) => ({ ...s, restoring: true }));
+
+ (async () => {
+ const { isConnected } = await freighterIsConnected();
+ if (!isConnected) {
+ if (!cancelled) {
+ localStorage.removeItem(SESSION_KEY);
+ setState((s) => ({ ...s, restoring: false }));
+ }
+ return;
+ }
+
+ const { isAllowed } = await freighterIsAllowed();
+ if (!isAllowed) {
+ if (!cancelled) {
+ localStorage.removeItem(SESSION_KEY);
+ setState((s) => ({ ...s, restoring: false }));
+ }
+ return;
+ }
+
+ const [{ address, error: addrError }, { network }] = await Promise.all([
+ freighterGetAddress(),
+ freighterGetNetwork(),
+ ]);
+ if (cancelled) return;
+
+ if (addrError || !address) {
+ localStorage.removeItem(SESSION_KEY);
+ setState((s) => ({ ...s, restoring: false }));
+ return;
+ }
+
+ setState((s) => ({ ...s, address, network: network || null, restoring: false }));
+ startWatching();
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ // Restore runs once on mount; startWatching/stopWatching are stable refs.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const connect = useCallback(async () => {
@@ -71,15 +191,42 @@ export function useWallet() {
address,
network: network ?? null,
connecting: false,
+ restoring: false,
error: null,
freighterMissing: false,
});
- }, []);
+ startWatching();
+ }, [startWatching]);
const disconnect = useCallback(() => {
+ stopWatching();
localStorage.removeItem(SESSION_KEY);
- setState({ address: null, network: null, connecting: false, error: null, freighterMissing: false });
+ setState(initialState);
+ }, [stopWatching]);
+
+ /** Re-checks the active network immediately, rather than waiting for the
+ watcher's next poll — used by NetworkGuard's "check again" action so
+ switching inside Freighter feels instant. */
+ const recheckNetwork = useCallback(async () => {
+ const { network } = await freighterGetNetwork();
+ if (network) setState((s) => ({ ...s, network }));
}, []);
- return { ...state, connect, disconnect };
+ const isWrongNetwork =
+ state.address !== null && state.network !== null && state.network !== EXPECTED_STELLAR_NETWORK;
+
+ return {
+ ...state,
+ isWrongNetwork,
+ expectedNetwork: EXPECTED_STELLAR_NETWORK,
+ connect,
+ disconnect,
+ recheckNetwork,
+ };
}
+
+export type WalletContextValue = ReturnType;
+
+/** Re-exported so consumers can type `expectedNetwork` precisely without
+ reaching into `@/lib/config` themselves. */
+export type { StellarNetwork };