From f6a3b6e8ccdcb1aa8db0ad15c2e802e0062c6e28 Mon Sep 17 00:00:00 2001 From: OpadijoIdris Date: Mon, 29 Jun 2026 00:48:10 +0100 Subject: [PATCH] feat: add Stellar Freighter wallet connection (#386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement Freighter wallet integration so users can connect, view their address, and disconnect without leaving the app. All error paths are handled gracefully with user-friendly messages. Core additions: - src/context/WalletContext.tsx: React Context provider wrapping @stellar/freighter-api. Handles isConnected(), requestAccess(), and getAddress() calls. Persists connected address in sessionStorage and restores it on mount. Exposes connect(), disconnect(), and shortenAddress() via useWallet() hook. - src/components/wallet/WalletButton.tsx: Navbar button with two states: - Disconnected: "Connect Wallet" button with loading spinner. - Connected: shortened address (GABC...XYZ1) with dropdown showing full address, copy-to-clipboard, Stellar Explorer link, and Disconnect option. Error popup auto-dismisses after 6 s; links to freighter.app when extension is absent. Wiring: - src/main.tsx: WalletProvider added inside ThemeProvider/ToastProvider. - src/components/layout/Navbar.tsx: WalletButton placed between ThemeToggle and NotificationCentreDropdown. Error scenarios handled: - Freighter extension not installed - User rejects the connection request - Unexpected network/runtime errors Tests (18 tests, all passing): - src/context/__tests__/WalletContext.test.tsx — hook unit tests - src/components/wallet/__tests__/WalletButton.test.tsx — component tests Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 120 ++++++++++- package.json | 1 + src/components/layout/Navbar.tsx | 3 + src/components/wallet/WalletButton.tsx | 195 ++++++++++++++++++ .../wallet/__tests__/WalletButton.test.tsx | 181 ++++++++++++++++ src/context/WalletContext.tsx | 140 +++++++++++++ src/context/__tests__/WalletContext.test.tsx | 190 +++++++++++++++++ src/main.tsx | 9 +- 8 files changed, 835 insertions(+), 4 deletions(-) create mode 100644 src/components/wallet/WalletButton.tsx create mode 100644 src/components/wallet/__tests__/WalletButton.test.tsx create mode 100644 src/context/WalletContext.tsx create mode 100644 src/context/__tests__/WalletContext.test.tsx 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 */} + + +