diff --git a/package-lock.json b/package-lock.json
index f195a41..8954a83 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"license": "MIT-0",
"dependencies": {
"@mswjs/interceptors": "^0.41.9",
+ "@stellar/freighter-api": "^6.0.1",
"@tailwindcss/vite": "^4.2.0",
"@tanstack/react-query": "^5.100.14",
"axios": "^1.16.1",
@@ -142,6 +143,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -504,6 +506,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -552,6 +555,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -1832,6 +1836,28 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@stellar/freighter-api": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/@stellar/freighter-api/-/freighter-api-6.0.1.tgz",
+ "integrity": "sha512-eqwakEqSg+zoLuPpSbKyrX0pG8DQFzL/J5GtbfuMCmJI+h+oiC9pQ5C6QLc80xopZQKdGt8dUAFCmDMNdAG95w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "buffer": "6.0.3",
+ "semver": "7.7.1"
+ }
+ },
+ "node_modules/@stellar/freighter-api/node_modules/semver": {
+ "version": "7.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
+ "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/@tailwindcss/node": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
@@ -2121,6 +2147,7 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -2376,6 +2403,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2396,6 +2424,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -2486,6 +2515,7 @@
"integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.59.1",
"@typescript-eslint/types": "8.59.1",
@@ -2876,6 +2906,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3000,6 +3031,26 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.10.24",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz",
@@ -3054,6 +3105,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -3068,6 +3120,30 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -3288,7 +3364,8 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/data-urls": {
"version": "7.0.0",
@@ -3380,6 +3457,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.344",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz",
@@ -3542,6 +3633,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4155,6 +4247,26 @@
"node": ">= 6"
}
},
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -5011,6 +5123,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@inquirer/confirm": "^6.0.11",
"@mswjs/interceptors": "^0.41.3",
@@ -5384,6 +5497,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5406,6 +5520,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -5984,6 +6099,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6088,6 +6204,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -6452,6 +6569,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/package.json b/package.json
index ee7a525..f17a330 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
},
"dependencies": {
"@mswjs/interceptors": "^0.41.9",
+ "@stellar/freighter-api": "^6.0.1",
"@tailwindcss/vite": "^4.2.0",
"@tanstack/react-query": "^5.100.14",
"axios": "^1.16.1",
diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx
index 1122d56..9f2764e 100644
--- a/src/components/layout/Navbar.tsx
+++ b/src/components/layout/Navbar.tsx
@@ -6,6 +6,7 @@ import { PendingApprovalBadge } from "../badges/PendingApprovalBadge";
import { useRoleGuard } from "../../hooks/useRoleGuard";
import { NotificationCentreDropdown } from "../notifications";
import { ThemeToggle } from "../theme-toggle";
+import { WalletButton } from "../wallet/WalletButton";
const baseNavLinks = [
{ label: "Home", path: "/home", icon: House },
@@ -87,6 +88,8 @@ export function Navbar() {
+
+
diff --git a/src/components/wallet/WalletButton.tsx b/src/components/wallet/WalletButton.tsx
new file mode 100644
index 0000000..b09d6b9
--- /dev/null
+++ b/src/components/wallet/WalletButton.tsx
@@ -0,0 +1,195 @@
+import { useEffect, useRef, useState } from "react";
+import {
+ AlertCircle,
+ ChevronDown,
+ Copy,
+ ExternalLink,
+ Loader2,
+ Wallet,
+ X,
+} from "lucide-react";
+import { useWallet } from "../../context/WalletContext";
+
+const FREIGHTER_INSTALL_URL = "https://www.freighter.app";
+
+export function WalletButton() {
+ const {
+ address,
+ isLoading,
+ error,
+ isFreighterInstalled,
+ connect,
+ disconnect,
+ shortenAddress,
+ } = useWallet();
+
+ const [dropdownOpen, setDropdownOpen] = useState(false);
+ const [errorVisible, setErrorVisible] = useState(false);
+ const [copied, setCopied] = useState(false);
+ const dropdownRef = useRef
(null);
+ const errorTimerRef = useRef | null>(null);
+
+ // Close dropdown on outside click.
+ useEffect(() => {
+ function onOutsideClick(e: MouseEvent) {
+ if (
+ dropdownRef.current &&
+ !dropdownRef.current.contains(e.target as Node)
+ ) {
+ setDropdownOpen(false);
+ }
+ }
+ document.addEventListener("mousedown", onOutsideClick);
+ return () => document.removeEventListener("mousedown", onOutsideClick);
+ }, []);
+
+ // Show error popup for 6 seconds then auto-dismiss.
+ useEffect(() => {
+ if (error) {
+ setErrorVisible(true);
+ if (errorTimerRef.current) clearTimeout(errorTimerRef.current);
+ errorTimerRef.current = setTimeout(() => setErrorVisible(false), 6000);
+ }
+ return () => {
+ if (errorTimerRef.current) clearTimeout(errorTimerRef.current);
+ };
+ }, [error]);
+
+ function copyAddress() {
+ if (!address) return;
+ navigator.clipboard.writeText(address).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ });
+ }
+
+ // ── Connected state ──────────────────────────────────────────────────────────
+ if (address) {
+ return (
+
+
+
+ {dropdownOpen && (
+
+ {/* Address block */}
+
+
+ Connected wallet
+
+
+ {address}
+
+
+
+ {/* Actions */}
+
+
+
+
+ View on explorer
+
+
+
+
+
+
+ )}
+
+ );
+ }
+
+ // ── Disconnected state ───────────────────────────────────────────────────────
+ return (
+
+
+
+ {/* Error popup */}
+ {errorVisible && error && (
+
+
+
+
+
+ {isFreighterInstalled === false
+ ? "Freighter not installed"
+ : "Connection failed"}
+
+
{error}
+ {isFreighterInstalled === false && (
+
+ Install Freighter
+
+
+ )}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/wallet/__tests__/WalletButton.test.tsx b/src/components/wallet/__tests__/WalletButton.test.tsx
new file mode 100644
index 0000000..8a90104
--- /dev/null
+++ b/src/components/wallet/__tests__/WalletButton.test.tsx
@@ -0,0 +1,181 @@
+import { act, render, screen } from "@testing-library/react";
+import { fireEvent } from "@testing-library/dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { WalletButton } from "../WalletButton";
+import { WalletProvider } from "../../../context/WalletContext";
+
+// ── Mock @stellar/freighter-api ──────────────────────────────────────────────
+vi.mock("@stellar/freighter-api", () => ({
+ isConnected: vi.fn(),
+ requestAccess: vi.fn(),
+ getAddress: vi.fn(),
+}));
+
+import { isConnected, requestAccess, getAddress } from "@stellar/freighter-api";
+
+const mockIsConnected = vi.mocked(isConnected);
+const mockRequestAccess = vi.mocked(requestAccess);
+const mockGetAddress = vi.mocked(getAddress);
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+function renderButton() {
+ return render(
+
+
+ ,
+ );
+}
+
+// ── Tests ────────────────────────────────────────────────────────────────────
+describe("WalletButton", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sessionStorage.clear();
+ mockGetAddress.mockResolvedValue({ address: "" });
+ });
+
+ it("renders Connect Wallet button when not connected", () => {
+ renderButton();
+ expect(
+ screen.getByRole("button", { name: /connect.*wallet/i }),
+ ).toBeInTheDocument();
+ });
+
+ it("shows loading state while connecting", async () => {
+ // requestAccess never resolves so we stay in loading state.
+ mockIsConnected.mockResolvedValue({ isConnected: true });
+ mockRequestAccess.mockImplementation(() => new Promise(() => {}));
+
+ renderButton();
+
+ act(() => {
+ fireEvent.click(screen.getByRole("button", { name: /connect.*wallet/i }));
+ });
+
+ expect(await screen.findByText(/connecting/i)).toBeInTheDocument();
+ });
+
+ it("displays shortened address and disables Connect button after connection", async () => {
+ const ADDR = "GABCDEF1234XYZ1";
+ mockIsConnected.mockResolvedValue({ isConnected: true });
+ mockRequestAccess.mockResolvedValue({ address: ADDR });
+
+ renderButton();
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: /connect.*wallet/i }));
+ });
+
+ // Should now show a wallet menu button with the shortened address.
+ expect(screen.getByRole("button", { name: /wallet menu/i })).toBeInTheDocument();
+ expect(screen.getByText(/GABC\.\.\.XYZ1/)).toBeInTheDocument();
+ // Connect button should be gone.
+ expect(
+ screen.queryByRole("button", { name: /connect.*wallet/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("opens dropdown and shows full address and disconnect option when connected", async () => {
+ const ADDR = "GABCDEF1234XYZ1";
+ mockIsConnected.mockResolvedValue({ isConnected: true });
+ mockRequestAccess.mockResolvedValue({ address: ADDR });
+
+ renderButton();
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: /connect.*wallet/i }));
+ });
+
+ // Open dropdown.
+ fireEvent.click(screen.getByRole("button", { name: /wallet menu/i }));
+
+ expect(screen.getByText(ADDR)).toBeInTheDocument();
+ expect(screen.getByRole("menuitem", { name: /disconnect/i })).toBeInTheDocument();
+ });
+
+ it("disconnects wallet when Disconnect button is clicked", async () => {
+ const ADDR = "GABCDEF1234XYZ1";
+ mockIsConnected.mockResolvedValue({ isConnected: true });
+ mockRequestAccess.mockResolvedValue({ address: ADDR });
+
+ renderButton();
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: /connect.*wallet/i }));
+ });
+
+ // Open dropdown.
+ fireEvent.click(screen.getByRole("button", { name: /wallet menu/i }));
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole("menuitem", { name: /disconnect/i }));
+ });
+
+ expect(
+ screen.getByRole("button", { name: /connect.*wallet/i }),
+ ).toBeInTheDocument();
+ });
+
+ it("shows error alert when Freighter is not installed", async () => {
+ mockIsConnected.mockResolvedValue({ isConnected: false });
+
+ renderButton();
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: /connect.*wallet/i }));
+ });
+
+ expect(screen.getByRole("alert")).toBeInTheDocument();
+ expect(screen.getByText(/freighter not installed/i)).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: /install freighter/i })).toHaveAttribute(
+ "href",
+ "https://www.freighter.app",
+ );
+ });
+
+ it("shows error alert when user rejects the connection", async () => {
+ mockIsConnected.mockResolvedValue({ isConnected: true });
+ mockRequestAccess.mockResolvedValue({
+ address: "",
+ error: { message: "User rejected" } as never,
+ });
+
+ renderButton();
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: /connect.*wallet/i }));
+ });
+
+ expect(screen.getByRole("alert")).toBeInTheDocument();
+ expect(screen.getByText(/connection failed/i)).toBeInTheDocument();
+ });
+
+ it("dismisses error popup when X button is clicked", async () => {
+ mockIsConnected.mockResolvedValue({ isConnected: false });
+
+ renderButton();
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: /connect.*wallet/i }));
+ });
+
+ expect(screen.getByRole("alert")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: /dismiss error/i }));
+
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+
+ it("shows connection failed message for unexpected errors", async () => {
+ mockIsConnected.mockRejectedValue(new Error("Network timeout"));
+
+ renderButton();
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: /connect.*wallet/i }));
+ });
+
+ expect(screen.getByRole("alert")).toBeInTheDocument();
+ expect(screen.getByText(/network timeout/i)).toBeInTheDocument();
+ });
+});
diff --git a/src/context/WalletContext.tsx b/src/context/WalletContext.tsx
new file mode 100644
index 0000000..9ed6088
--- /dev/null
+++ b/src/context/WalletContext.tsx
@@ -0,0 +1,140 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+ type ReactNode,
+} from "react";
+import {
+ getAddress,
+ isConnected,
+ requestAccess,
+} from "@stellar/freighter-api";
+
+const SESSION_KEY = "petad:wallet:address";
+
+interface WalletState {
+ address: string | null;
+ isLoading: boolean;
+ error: string | null;
+ isFreighterInstalled: boolean | null;
+}
+
+interface WalletContextValue extends WalletState {
+ connect: () => Promise;
+ disconnect: () => void;
+ shortenAddress: (addr: string) => string;
+}
+
+const WalletContext = createContext(null);
+
+export function WalletProvider({ children }: { children: ReactNode }) {
+ const [state, setState] = useState({
+ address: null,
+ isLoading: false,
+ error: null,
+ isFreighterInstalled: null,
+ });
+
+ // Restore wallet address from session on mount.
+ useEffect(() => {
+ const saved = sessionStorage.getItem(SESSION_KEY);
+ if (!saved) return;
+
+ getAddress()
+ .then((res) => {
+ if (!res.error && res.address === saved) {
+ setState((prev) => ({
+ ...prev,
+ address: res.address,
+ isFreighterInstalled: true,
+ }));
+ } else {
+ sessionStorage.removeItem(SESSION_KEY);
+ }
+ })
+ .catch(() => {
+ sessionStorage.removeItem(SESSION_KEY);
+ });
+ }, []);
+
+ const connect = useCallback(async () => {
+ setState((prev) => ({ ...prev, isLoading: true, error: null }));
+
+ try {
+ const connectionStatus = await isConnected();
+
+ if (!connectionStatus.isConnected) {
+ setState((prev) => ({
+ ...prev,
+ isLoading: false,
+ isFreighterInstalled: false,
+ error:
+ "Freighter wallet extension is not installed. Please install it to connect.",
+ }));
+ return;
+ }
+
+ setState((prev) => ({ ...prev, isFreighterInstalled: true }));
+
+ const access = await requestAccess();
+
+ if (access.error) {
+ const message =
+ typeof access.error === "string"
+ ? access.error
+ : (access.error as { message?: string }).message ??
+ "Connection request was rejected.";
+ setState((prev) => ({
+ ...prev,
+ isLoading: false,
+ error: message,
+ }));
+ return;
+ }
+
+ sessionStorage.setItem(SESSION_KEY, access.address);
+ setState((prev) => ({
+ ...prev,
+ address: access.address,
+ isLoading: false,
+ error: null,
+ }));
+ } catch (err) {
+ const message =
+ err instanceof Error ? err.message : "Wallet connection failed unexpectedly.";
+ setState((prev) => ({
+ ...prev,
+ isLoading: false,
+ error: message,
+ }));
+ }
+ }, []);
+
+ const disconnect = useCallback(() => {
+ sessionStorage.removeItem(SESSION_KEY);
+ setState((prev) => ({ ...prev, address: null, error: null }));
+ }, []);
+
+ const shortenAddress = useCallback((addr: string) => {
+ if (!addr || addr.length <= 10) return addr;
+ return `${addr.slice(0, 4)}...${addr.slice(-4)}`;
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useWallet(): WalletContextValue {
+ const ctx = useContext(WalletContext);
+ if (!ctx) {
+ throw new Error("useWallet must be used within a WalletProvider");
+ }
+ return ctx;
+}
diff --git a/src/context/__tests__/WalletContext.test.tsx b/src/context/__tests__/WalletContext.test.tsx
new file mode 100644
index 0000000..9141c4d
--- /dev/null
+++ b/src/context/__tests__/WalletContext.test.tsx
@@ -0,0 +1,190 @@
+import { act, render, screen } from "@testing-library/react";
+import { fireEvent } from "@testing-library/dom";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { WalletProvider, useWallet } from "../WalletContext";
+
+// ── Mock @stellar/freighter-api ──────────────────────────────────────────────
+vi.mock("@stellar/freighter-api", () => ({
+ isConnected: vi.fn(),
+ requestAccess: vi.fn(),
+ getAddress: vi.fn(),
+}));
+
+import { isConnected, requestAccess, getAddress } from "@stellar/freighter-api";
+
+const mockIsConnected = vi.mocked(isConnected);
+const mockRequestAccess = vi.mocked(requestAccess);
+const mockGetAddress = vi.mocked(getAddress);
+
+// ── Helper: component that exposes wallet state via the DOM ──────────────────
+function WalletConsumer() {
+ const { address, isLoading, error, isFreighterInstalled, connect, disconnect } =
+ useWallet();
+
+ return (
+
+
{address ?? "none"}
+
{isLoading ? "loading" : "idle"}
+
{error ?? "no-error"}
+
+ {isFreighterInstalled === null
+ ? "unknown"
+ : isFreighterInstalled
+ ? "yes"
+ : "no"}
+
+
+
+
+ );
+}
+
+function renderWithProvider() {
+ return render(
+
+
+ ,
+ );
+}
+
+// ── Tests ────────────────────────────────────────────────────────────────────
+describe("WalletContext", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sessionStorage.clear();
+ // Default: getAddress returns empty (no session to restore).
+ mockGetAddress.mockResolvedValue({ address: "" });
+ });
+
+ afterEach(() => {
+ sessionStorage.clear();
+ });
+
+ it("starts with no address and no error", () => {
+ renderWithProvider();
+ expect(screen.getByTestId("address")).toHaveTextContent("none");
+ expect(screen.getByTestId("error")).toHaveTextContent("no-error");
+ expect(screen.getByTestId("loading")).toHaveTextContent("idle");
+ });
+
+ it("reports Freighter not installed when isConnected returns false", async () => {
+ mockIsConnected.mockResolvedValue({ isConnected: false });
+
+ renderWithProvider();
+
+ await act(async () => {
+ fireEvent.click(screen.getByText("connect"));
+ });
+
+ expect(screen.getByTestId("installed")).toHaveTextContent("no");
+ expect(screen.getByTestId("error").textContent).toContain("not installed");
+ expect(screen.getByTestId("address")).toHaveTextContent("none");
+ });
+
+ it("surfaces error when requestAccess returns an error", async () => {
+ mockIsConnected.mockResolvedValue({ isConnected: true });
+ mockRequestAccess.mockResolvedValue({
+ address: "",
+ error: { message: "User declined the request" } as never,
+ });
+
+ renderWithProvider();
+
+ await act(async () => {
+ fireEvent.click(screen.getByText("connect"));
+ });
+
+ expect(screen.getByTestId("address")).toHaveTextContent("none");
+ expect(screen.getByTestId("error").textContent).toBeTruthy();
+ });
+
+ it("stores address in state and sessionStorage on successful connection", async () => {
+ const ADDR = "GABC1234XYZ";
+ mockIsConnected.mockResolvedValue({ isConnected: true });
+ mockRequestAccess.mockResolvedValue({ address: ADDR });
+
+ renderWithProvider();
+
+ await act(async () => {
+ fireEvent.click(screen.getByText("connect"));
+ });
+
+ expect(screen.getByTestId("address")).toHaveTextContent(ADDR);
+ expect(screen.getByTestId("error")).toHaveTextContent("no-error");
+ expect(sessionStorage.getItem("petad:wallet:address")).toBe(ADDR);
+ });
+
+ it("clears address and sessionStorage on disconnect", async () => {
+ const ADDR = "GABC1234XYZ";
+ mockIsConnected.mockResolvedValue({ isConnected: true });
+ mockRequestAccess.mockResolvedValue({ address: ADDR });
+
+ renderWithProvider();
+
+ await act(async () => {
+ fireEvent.click(screen.getByText("connect"));
+ });
+
+ expect(screen.getByTestId("address")).toHaveTextContent(ADDR);
+
+ act(() => {
+ fireEvent.click(screen.getByText("disconnect"));
+ });
+
+ expect(screen.getByTestId("address")).toHaveTextContent("none");
+ expect(sessionStorage.getItem("petad:wallet:address")).toBeNull();
+ });
+
+ it("restores wallet address from sessionStorage on mount", async () => {
+ const ADDR = "GRESTOREDADDR";
+ sessionStorage.setItem("petad:wallet:address", ADDR);
+ mockGetAddress.mockResolvedValue({ address: ADDR });
+
+ await act(async () => {
+ renderWithProvider();
+ });
+
+ expect(screen.getByTestId("address")).toHaveTextContent(ADDR);
+ });
+
+ it("clears stale sessionStorage when saved address no longer matches", async () => {
+ sessionStorage.setItem("petad:wallet:address", "GSTALE");
+ mockGetAddress.mockResolvedValue({ address: "GDIFFERENT" });
+
+ await act(async () => {
+ renderWithProvider();
+ });
+
+ expect(screen.getByTestId("address")).toHaveTextContent("none");
+ expect(sessionStorage.getItem("petad:wallet:address")).toBeNull();
+ });
+
+ it("shortenAddress truncates long addresses correctly", () => {
+ let shorten: ((addr: string) => string) | undefined;
+
+ function ShortenConsumer() {
+ const { shortenAddress } = useWallet();
+ shorten = shortenAddress;
+ return null;
+ }
+
+ render(
+
+
+ ,
+ );
+
+ expect(shorten!("GABCDEFG1234")).toBe("GABC...1234");
+ expect(shorten!("SHORT")).toBe("SHORT");
+ });
+
+ it("throws when useWallet is used outside WalletProvider", () => {
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+ expect(() => render()).toThrow(
+ "useWallet must be used within a WalletProvider",
+ );
+ consoleError.mockRestore();
+ });
+});
diff --git a/src/main.tsx b/src/main.tsx
index 609302d..fd1d749 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -5,6 +5,7 @@ import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from './lib/query-client'
import { ToastProvider } from './components/toast/ToastProvider'
import { ThemeProvider } from './components/theme-provider'
+import { WalletProvider } from './context/WalletContext'
import './index.css'
import App from './App.tsx'
@@ -68,9 +69,11 @@ async function bootstrap() {
- {/* 2. Wrap your App */}
-
-
+
+ {/* 2. Wrap your App */}
+
+
+