From b508e9f58e9b779456f02238ad57ae454c809b96 Mon Sep 17 00:00:00 2001 From: ushpraise Date: Fri, 25 Sep 2026 07:35:22 +0000 Subject: [PATCH] fix: resolve issues #771 #772 #773 #774 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #774 – Persist WalletConnect session across page reloads - WalletConnectAdapter now writes session (topic, relayUrl, chainId, address, expiry) to localStorage on construction. - New static WalletConnectAdapter.restore(client) factory reads the stored session, validates its expiry, and returns a ready adapter without requiring a new QR scan. - disconnect() clears the stored session. - Accepts optional relayUrl and expiry constructor options. #773 – Detect negative-weight edges in PaymentGraphChecker - New checkGraph(graph: PaymentGraph): GraphValidationResult method. - Returns { valid: false, reason } on the first negative-weight edge, naming the offending source → target and the weight value. - Zero-weight (pass-through) edges are allowed. - Exports PaymentGraph, PaymentGraphEdge, GraphValidationResult types. #772 – Handle Freighter not-installed error - New FreighterNotInstalledError class with Freighter install URL (https://www.freighter.app) in the message. - All public FreighterAdapter methods (connect, sign, getAddress, signTransaction) call assertInstalled() before any Freighter API use. #771 – Check XBull extension version before connection - Exports MIN_XBULL_VERSION = '2.0.0'. - New ExtensionVersionError with installedVersion/requiredVersion fields. - connect() reads window.xBullSDK.version and throws ExtensionVersionError when the version is below the minimum. - Static XBullAdapter.compareVersions() utility for semver comparison. - If window.xBullSDK is absent the check is skipped gracefully. Tests: 158 tests pass across 6 test files. --- src/adapters/walletconnect.ts | 157 ++++++++++++++++++ src/graph/PaymentGraphChecker.ts | 71 +++++++++ src/wallets/adapters/FreighterAdapter.ts | 57 +++++-- src/wallets/adapters/XBullAdapter.ts | 82 ++++++++++ test/freighterAdapter.test.ts | 138 ++++++++++++++++ test/paymentGraphChecker.test.ts | 86 ++++++++++ test/walletconnect.test.ts | 162 ++++++++++++++++++- test/xbullAdapter.test.ts | 194 +++++++++++++++++++++++ 8 files changed, 934 insertions(+), 13 deletions(-) create mode 100644 test/freighterAdapter.test.ts create mode 100644 test/xbullAdapter.test.ts diff --git a/src/adapters/walletconnect.ts b/src/adapters/walletconnect.ts index b776c7a..c5e51e9 100644 --- a/src/adapters/walletconnect.ts +++ b/src/adapters/walletconnect.ts @@ -17,19 +17,55 @@ export interface WalletConnectAdapterOptions { chainId: string; /** The connected wallet's Stellar public key. */ address: string; + /** + * Relay URL used by the WalletConnect session. + * Persisted alongside the topic so the session can be restored on reload. + */ + relayUrl?: string; + /** + * Unix timestamp (seconds) at which the WalletConnect session expires. + * When provided the adapter validates the expiry before restoring a stored + * session and persists it so future restores can make the same check. + */ + expiry?: number; } +/** Shape of the data written to / read from localStorage. */ +interface PersistedSession { + topic: string; + relayUrl: string; + chainId: string; + address: string; + /** Unix timestamp in seconds when this session expires. */ + expiry: number; +} + +/** localStorage key used to store the active WalletConnect session. */ +const STORAGE_KEY = "stellarsplit:wc:session"; + /** * WalletConnect adapter — routes signing through a WalletConnect session * instead of the Freighter browser extension. + * + * ### Session persistence + * On construction the adapter persists the session to `localStorage` so it + * survives page reloads. Call the static `restore()` factory to hydrate an + * adapter from a previously-persisted session without requiring the user to + * scan a QR code again. `disconnect()` clears the stored data. */ export class WalletConnectAdapter implements WalletAdapter { private readonly opts: WalletConnectAdapterOptions; constructor(opts: WalletConnectAdapterOptions) { this.opts = opts; + // Persist the session immediately on construction. + this.persistSession(); } + // --------------------------------------------------------------------------- + // WalletAdapter interface + // --------------------------------------------------------------------------- + async getAddress(): Promise { return this.opts.address; } @@ -44,4 +80,125 @@ export class WalletConnectAdapter implements WalletAdapter { }, }); } + + /** + * Clear the persisted session data from localStorage and reset internal + * state so the user must reconnect after the next page load. + */ + disconnect(): void { + WalletConnectAdapter.clearStoredSession(); + } + + // --------------------------------------------------------------------------- + // Static helpers for session persistence + // --------------------------------------------------------------------------- + + /** + * Restore a previously-persisted WalletConnect session. + * + * Returns `null` when: + * - no session has been stored, or + * - the stored session has expired. + * + * The caller is responsible for providing the live WalletConnect `client` + * instance; only the session metadata is read from localStorage. + * + * @example + * ```ts + * const adapter = WalletConnectAdapter.restore(signClient); + * if (adapter) { + * // Session is still valid — no QR scan needed. + * } else { + * // Show QR code and create a new adapter on successful pairing. + * } + * ``` + */ + static restore( + client: WalletConnectAdapterOptions["client"] + ): WalletConnectAdapter | null { + const raw = WalletConnectAdapter.readRawSession(); + if (!raw) return null; + + // Validate expiry before restoring. + const nowSeconds = Math.floor(Date.now() / 1000); + if (raw.expiry <= nowSeconds) { + // Stale session — clean up so the user is not stuck. + WalletConnectAdapter.clearStoredSession(); + return null; + } + + return new WalletConnectAdapter({ + client, + topic: raw.topic, + chainId: raw.chainId, + address: raw.address, + relayUrl: raw.relayUrl, + expiry: raw.expiry, + }); + } + + /** + * Remove the persisted session entry from localStorage. + * Called automatically by `disconnect()`. + */ + static clearStoredSession(): void { + try { + if (typeof localStorage !== "undefined") { + localStorage.removeItem(STORAGE_KEY); + } + } catch { + // localStorage may be unavailable in some environments (e.g. SSR). + } + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** Write current session data to localStorage. */ + private persistSession(): void { + try { + if (typeof localStorage === "undefined") return; + + const data: PersistedSession = { + topic: this.opts.topic, + relayUrl: this.opts.relayUrl ?? "", + chainId: this.opts.chainId, + address: this.opts.address, + // Default to 7 days from now if no expiry provided. + expiry: + this.opts.expiry ?? Math.floor(Date.now() / 1000) + 7 * 24 * 3600, + }; + + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } catch { + // Silently ignore write failures (storage quota, SSR, etc.). + } + } + + /** Parse raw stored session data without validation. Returns `null` on any error. */ + private static readRawSession(): PersistedSession | null { + try { + if (typeof localStorage === "undefined") return null; + + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + + const parsed = JSON.parse(raw) as Partial; + + // Basic shape validation. + if ( + typeof parsed.topic !== "string" || + typeof parsed.chainId !== "string" || + typeof parsed.address !== "string" || + typeof parsed.expiry !== "number" + ) { + return null; + } + + return parsed as PersistedSession; + } catch { + return null; + } + } } diff --git a/src/graph/PaymentGraphChecker.ts b/src/graph/PaymentGraphChecker.ts index 8bcac41..c9adaf6 100644 --- a/src/graph/PaymentGraphChecker.ts +++ b/src/graph/PaymentGraphChecker.ts @@ -39,6 +39,52 @@ export class UnreachableRecipientError extends Error { } } +/** Represents a single directed edge in a payment graph. */ +export interface PaymentGraphEdge { + /** Source node (account or asset identifier). */ + from: string; + /** Destination node. */ + to: string; + /** + * Numeric weight for this edge (e.g. an amount or fee multiplier). + * Zero-weight edges are allowed (pass-through hops); negative weights are not. + */ + weight: number; +} + +/** A payment graph expressed as a list of directed, weighted edges. */ +export interface PaymentGraph { + edges: PaymentGraphEdge[]; +} + +/** Result returned by `checkGraph()`. */ +export interface GraphValidationResult { + /** `true` when all edges pass validation. */ + valid: boolean; + /** + * Human-readable description of the first violation found, or `undefined` + * when the graph is valid. + */ + reason?: string; +} + +/** + * Validate the structural integrity of a payment graph. + * + * Currently enforced rules: + * - No edge may have a negative weight. Zero-weight edges (pass-through hops) + * are permitted. A negative-weight edge can cause unbounded fund extraction + * when the graph is traversed greedily. + * + * @example + * ```ts + * const result = checker.checkGraph(graph); + * if (!result.valid) { + * throw new Error(result.reason); + * } + * ``` + */ + interface CacheKey { sourceAsset: string; sourceAccount: string; @@ -127,6 +173,31 @@ export class PaymentGraphChecker { return result; } + /** + * Validate a payment graph for structural correctness. + * + * Returns a failure result when any edge has a negative weight, naming the + * offending edge (`source → target`) in the reason message. + * Zero-weight edges are permitted — they represent pass-through hops where + * no fee or amount is exchanged. + * + * Valid graphs (all weights ≥ 0) return `{ valid: true }`. + * + * @param graph - The directed payment graph to validate. + */ + checkGraph(graph: PaymentGraph): GraphValidationResult { + for (const edge of graph.edges) { + if (edge.weight < 0) { + return { + valid: false, + reason: `Negative-weight edge detected: ${edge.from} → ${edge.to} (weight: ${edge.weight})`, + }; + } + } + + return { valid: true }; + } + /** * Find a payment path from source to destination using Stellar path-finding. */ diff --git a/src/wallets/adapters/FreighterAdapter.ts b/src/wallets/adapters/FreighterAdapter.ts index 5b8b001..49c1fc4 100644 --- a/src/wallets/adapters/FreighterAdapter.ts +++ b/src/wallets/adapters/FreighterAdapter.ts @@ -1,5 +1,10 @@ /** * FreighterAdapter — Adapter for the Freighter wallet extension. + * + * Before any Freighter API call the adapter checks whether the extension is + * installed (`window.freighter`). If it is absent a `FreighterNotInstalledError` + * is thrown with the install URL so callers can surface an actionable message + * to the user instead of a cryptic TypeError. */ import type { WalletAdapter } from "../../types.js"; @@ -16,6 +21,26 @@ declare global { } } +/** Install URL shown to users when the Freighter extension is not found. */ +const FREIGHTER_INSTALL_URL = "https://www.freighter.app"; + +/** + * Thrown when a Freighter API call is attempted but the browser extension is + * not installed. The `message` includes the install URL so it can be shown + * directly to the user. + */ +export class FreighterNotInstalledError extends Error { + constructor() { + super( + `Freighter wallet extension is not installed. ` + + `Install it from ${FREIGHTER_INSTALL_URL}` + ); + this.name = "FreighterNotInstalledError"; + // Maintain correct instanceof checks in transpiled environments. + Object.setPrototypeOf(this, new.target.prototype); + } +} + export class FreighterAdapter implements WalletAdapter { readonly name = "Freighter"; private accountChangeHandlers: Array<(address: string) => void> = []; @@ -23,11 +48,9 @@ export class FreighterAdapter implements WalletAdapter { private lastKnownAddress: string | null = null; async connect(): Promise { - if (!window.freighter) { - throw new Error("Freighter wallet not installed"); - } + this.assertInstalled(); - const address = await window.freighter.getPublicKey(); + const address = await window.freighter!.getPublicKey(); this.lastKnownAddress = address; // Start polling for account changes (Freighter doesn't have a native event) @@ -37,19 +60,15 @@ export class FreighterAdapter implements WalletAdapter { } async sign(xdr: string, network: string): Promise { - if (!window.freighter) { - throw new Error("Freighter wallet not installed"); - } + this.assertInstalled(); - return await window.freighter.signTransaction(xdr, network); + return await window.freighter!.signTransaction(xdr, network); } async getAddress(): Promise { - if (!window.freighter) { - throw new Error("Freighter wallet not installed"); - } + this.assertInstalled(); - return await window.freighter.getPublicKey(); + return await window.freighter!.getPublicKey(); } async signTransaction(xdr: string, network: string): Promise { @@ -76,6 +95,20 @@ export class FreighterAdapter implements WalletAdapter { }; } + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Assert that the Freighter extension is available in the current window. + * Throws `FreighterNotInstalledError` when it is not. + */ + private assertInstalled(): void { + if (!window.freighter) { + throw new FreighterNotInstalledError(); + } + } + private startAccountChangePolling(): void { if (this.pollInterval) return; diff --git a/src/wallets/adapters/XBullAdapter.ts b/src/wallets/adapters/XBullAdapter.ts index 95b0eca..3701480 100644 --- a/src/wallets/adapters/XBullAdapter.ts +++ b/src/wallets/adapters/XBullAdapter.ts @@ -1,5 +1,10 @@ /** * XBullAdapter — Adapter for the xBull wallet extension. + * + * Before connecting, the adapter reads `window.xBullSDK.version` and rejects + * with an `ExtensionVersionError` when the installed version is older than + * `MIN_XBULL_VERSION`. This prevents cryptic runtime errors caused by API + * surface mismatches with outdated extension builds. */ import type { WalletAdapter } from "../../types.js"; @@ -13,6 +18,43 @@ declare global { sign(params: { xdr: string; publicKey: string }): Promise<{ xdr: string }>; onAccountChange(handler: (publicKey: string) => void): () => void; }; + /** xBull SDK namespace that exposes the installed extension version. */ + xBullSDK?: { + version: string; + }; + } +} + +/** + * Minimum xBull extension version that supports the current API surface used + * by this adapter. Versions below this value do not expose all required + * methods and will cause cryptic runtime errors. + * + * Export this constant so integrators can display the required version in + * their own UI if needed. + */ +export const MIN_XBULL_VERSION = "2.0.0"; + +/** + * Thrown when the installed xBull extension version is older than + * `MIN_XBULL_VERSION`. + */ +export class ExtensionVersionError extends Error { + /** The version string reported by the installed extension. */ + readonly installedVersion: string; + /** The minimum version required by this adapter. */ + readonly requiredVersion: string; + + constructor(installedVersion: string, requiredVersion: string) { + super( + `xBull extension version ${installedVersion} is below the required ` + + `minimum version ${requiredVersion}. Please update the xBull extension.` + ); + this.name = "ExtensionVersionError"; + this.installedVersion = installedVersion; + this.requiredVersion = requiredVersion; + // Maintain correct instanceof checks in transpiled environments. + Object.setPrototypeOf(this, new.target.prototype); } } @@ -27,6 +69,9 @@ export class XBullAdapter implements WalletAdapter { throw new Error("xBull wallet not installed"); } + // Verify the extension version before attempting to use its API. + this.assertVersionCompatible(); + const result = await window.xbull.connect(); this.currentPublicKey = result.public_key; @@ -87,6 +132,43 @@ export class XBullAdapter implements WalletAdapter { }; } + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Compare the installed xBull extension version against `MIN_XBULL_VERSION`. + * Throws `ExtensionVersionError` when the installed version is too old. + * + * If `window.xBullSDK.version` is not present the check is skipped to avoid + * a false-positive on versions that do not yet expose the property. + */ + private assertVersionCompatible(): void { + const installed = window.xBullSDK?.version; + if (!installed) return; // Cannot determine version; allow connection attempt. + + if (XBullAdapter.compareVersions(installed, MIN_XBULL_VERSION) < 0) { + throw new ExtensionVersionError(installed, MIN_XBULL_VERSION); + } + } + + /** + * Simple semver-style comparison. + * Returns a negative number when `a < b`, 0 when equal, positive when `a > b`. + */ + static compareVersions(a: string, b: string): number { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + const len = Math.max(pa.length, pb.length); + + for (let i = 0; i < len; i++) { + const diff = (pa[i] ?? 0) - (pb[i] ?? 0); + if (diff !== 0) return diff; + } + + return 0; + } + private setupAccountChangeListener(): void { if (!window.xbull) return; diff --git a/test/freighterAdapter.test.ts b/test/freighterAdapter.test.ts new file mode 100644 index 0000000..4248999 --- /dev/null +++ b/test/freighterAdapter.test.ts @@ -0,0 +1,138 @@ +/** + * Tests for FreighterAdapter — focuses on the #772 acceptance criteria: + * - FreighterNotInstalledError is thrown with the install URL when the + * extension is absent. + * - Normal connection flow is unchanged when the extension is present. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + FreighterAdapter, + FreighterNotInstalledError, +} from "../src/wallets/adapters/FreighterAdapter.js"; + +const MOCK_ADDRESS = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; +const INSTALL_URL = "https://www.freighter.app"; + +function makeFreighterMock() { + return { + isConnected: vi.fn().mockResolvedValue(true), + getPublicKey: vi.fn().mockResolvedValue(MOCK_ADDRESS), + signTransaction: vi.fn().mockResolvedValue("signed-xdr"), + }; +} + +describe("FreighterAdapter (#772)", () => { + let adapter: FreighterAdapter; + + afterEach(() => { + vi.unstubAllGlobals(); + adapter?.disconnect(); + }); + + // ------------------------------------------------------------------------- + // Extension not installed + // ------------------------------------------------------------------------- + describe("when Freighter is not installed", () => { + beforeEach(() => { + // Simulate missing extension + vi.stubGlobal("window", { freighter: undefined }); + adapter = new FreighterAdapter(); + }); + + it("throws FreighterNotInstalledError from connect()", async () => { + await expect(adapter.connect()).rejects.toThrow(FreighterNotInstalledError); + }); + + it("throws FreighterNotInstalledError from getAddress()", async () => { + await expect(adapter.getAddress()).rejects.toThrow(FreighterNotInstalledError); + }); + + it("throws FreighterNotInstalledError from sign()", async () => { + await expect(adapter.sign("xdr", "testnet")).rejects.toThrow( + FreighterNotInstalledError + ); + }); + + it("throws FreighterNotInstalledError from signTransaction()", async () => { + await expect(adapter.signTransaction("xdr", "testnet")).rejects.toThrow( + FreighterNotInstalledError + ); + }); + + it("error message includes the Freighter install URL", async () => { + try { + await adapter.connect(); + expect.fail("should have thrown"); + } catch (err) { + expect((err as Error).message).toContain(INSTALL_URL); + } + }); + + it("error is an instance of FreighterNotInstalledError", async () => { + try { + await adapter.connect(); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(FreighterNotInstalledError); + } + }); + }); + + // ------------------------------------------------------------------------- + // Extension present — existing flow unchanged + // ------------------------------------------------------------------------- + describe("when Freighter is installed", () => { + let freighterMock: ReturnType; + + beforeEach(() => { + freighterMock = makeFreighterMock(); + vi.stubGlobal("window", { freighter: freighterMock }); + adapter = new FreighterAdapter(); + }); + + it("connect() returns the public key", async () => { + const address = await adapter.connect(); + expect(address).toBe(MOCK_ADDRESS); + }); + + it("getAddress() returns the public key", async () => { + const address = await adapter.getAddress(); + expect(address).toBe(MOCK_ADDRESS); + expect(freighterMock.getPublicKey).toHaveBeenCalled(); + }); + + it("signTransaction() delegates to freighter.signTransaction", async () => { + const result = await adapter.signTransaction("test-xdr", "Test SDF Network"); + expect(result).toBe("signed-xdr"); + expect(freighterMock.signTransaction).toHaveBeenCalledWith( + "test-xdr", + "Test SDF Network" + ); + }); + + it("does not throw FreighterNotInstalledError when extension is present", async () => { + await expect(adapter.connect()).resolves.not.toThrow(); + }); + }); + + // ------------------------------------------------------------------------- + // FreighterNotInstalledError shape + // ------------------------------------------------------------------------- + describe("FreighterNotInstalledError", () => { + it("has the correct name property", () => { + const err = new FreighterNotInstalledError(); + expect(err.name).toBe("FreighterNotInstalledError"); + }); + + it("is an instance of Error", () => { + const err = new FreighterNotInstalledError(); + expect(err).toBeInstanceOf(Error); + }); + + it("message contains the install URL", () => { + const err = new FreighterNotInstalledError(); + expect(err.message).toContain(INSTALL_URL); + }); + }); +}); diff --git a/test/paymentGraphChecker.test.ts b/test/paymentGraphChecker.test.ts index dd8d64c..a6420ed 100644 --- a/test/paymentGraphChecker.test.ts +++ b/test/paymentGraphChecker.test.ts @@ -162,4 +162,90 @@ describe("PaymentGraphChecker", () => { expect(fetchMock).toHaveBeenCalled(); }); }); + + // ------------------------------------------------------------------------- + // #773 — checkGraph: negative-weight edge detection + // ------------------------------------------------------------------------- + describe("checkGraph() — negative-weight edge detection (#773)", () => { + it("returns valid:true for an empty graph", () => { + const result = checker.checkGraph({ edges: [] }); + expect(result.valid).toBe(true); + }); + + it("returns valid:true when all edge weights are zero", () => { + const result = checker.checkGraph({ + edges: [ + { from: sourceAccount, to: recipientA, weight: 0 }, + { from: recipientA, to: recipientB, weight: 0 }, + ], + }); + expect(result.valid).toBe(true); + }); + + it("returns valid:true when all edge weights are positive", () => { + const result = checker.checkGraph({ + edges: [ + { from: sourceAccount, to: recipientA, weight: 100 }, + { from: recipientA, to: recipientB, weight: 50 }, + ], + }); + expect(result.valid).toBe(true); + }); + + it("returns valid:false when an edge has a negative weight", () => { + const result = checker.checkGraph({ + edges: [ + { from: sourceAccount, to: recipientA, weight: 100 }, + { from: recipientA, to: recipientB, weight: -1 }, + ], + }); + expect(result.valid).toBe(false); + }); + + it("includes the offending source and target in the reason message", () => { + const result = checker.checkGraph({ + edges: [ + { from: sourceAccount, to: recipientA, weight: -5 }, + ], + }); + expect(result.valid).toBe(false); + expect(result.reason).toContain(sourceAccount); + expect(result.reason).toContain(recipientA); + }); + + it("includes the negative weight value in the reason message", () => { + const result = checker.checkGraph({ + edges: [ + { from: sourceAccount, to: recipientA, weight: -99 }, + ], + }); + expect(result.reason).toContain("-99"); + }); + + it("detects the first negative-weight edge when multiple exist", () => { + const result = checker.checkGraph({ + edges: [ + { from: sourceAccount, to: recipientA, weight: 10 }, + { from: recipientA, to: recipientB, weight: -3 }, + { from: recipientB, to: sourceAccount, weight: -7 }, + ], + }); + expect(result.valid).toBe(false); + // First offending edge is recipientA → recipientB + expect(result.reason).toContain(recipientA); + expect(result.reason).toContain(recipientB); + }); + + it("allows mixed zero and positive weights", () => { + const result = checker.checkGraph({ + edges: [ + { from: sourceAccount, to: recipientA, weight: 0 }, + { from: recipientA, to: recipientB, weight: 42 }, + { from: recipientB, to: sourceAccount, weight: 0 }, + ], + }); + expect(result.valid).toBe(true); + expect(result.reason).toBeUndefined(); + }); + }); }); diff --git a/test/walletconnect.test.ts b/test/walletconnect.test.ts index b8d5ae1..44de892 100644 --- a/test/walletconnect.test.ts +++ b/test/walletconnect.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { WalletConnectAdapter } from "../src/adapters/walletconnect.js"; import type { WalletAdapter } from "../src/adapters/types.js"; @@ -11,11 +11,30 @@ const mockTopic = "mock-topic-123"; const mockChainId = "stellar:testnet"; const mockAddress = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; +// --------------------------------------------------------------------------- +// localStorage stub (jsdom provides one, but we reset between tests) +// --------------------------------------------------------------------------- +function makeLocalStorageStub() { + const store: Record = {}; + return { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { store[key] = value; }), + removeItem: vi.fn((key: string) => { delete store[key]; }), + clear: vi.fn(() => { for (const k in store) delete store[k]; }), + store, + }; +} + describe("WalletConnectAdapter", () => { let adapter: WalletAdapter; + let lsMock: ReturnType; beforeEach(() => { mockWalletConnectClient.request.mockClear(); + + lsMock = makeLocalStorageStub(); + vi.stubGlobal("localStorage", lsMock); + adapter = new WalletConnectAdapter({ client: mockWalletConnectClient, topic: mockTopic, @@ -24,6 +43,13 @@ describe("WalletConnectAdapter", () => { }); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + // ------------------------------------------------------------------------- + // Existing behaviour + // ------------------------------------------------------------------------- describe("getAddress", () => { it("returns the configured address", async () => { const address = await adapter.getAddress(); @@ -57,4 +83,138 @@ describe("WalletConnectAdapter", () => { await expect(adapter.signTransaction(mockXdr, mockNetwork)).rejects.toThrow("WalletConnect error"); }); }); + + // ------------------------------------------------------------------------- + // #774 — Session persistence + // ------------------------------------------------------------------------- + describe("session persistence (#774)", () => { + it("writes session data to localStorage on construction", () => { + expect(lsMock.setItem).toHaveBeenCalledWith( + "stellarsplit:wc:session", + expect.stringContaining(mockTopic) + ); + }); + + it("persists topic, chainId, and address", () => { + const raw = lsMock.store["stellarsplit:wc:session"]; + expect(raw).toBeDefined(); + const parsed = JSON.parse(raw!); + expect(parsed.topic).toBe(mockTopic); + expect(parsed.chainId).toBe(mockChainId); + expect(parsed.address).toBe(mockAddress); + }); + + it("persists an expiry timestamp in the future", () => { + const raw = lsMock.store["stellarsplit:wc:session"]; + const parsed = JSON.parse(raw!); + expect(parsed.expiry).toBeGreaterThan(Math.floor(Date.now() / 1000)); + }); + + it("persists a custom expiry when provided", () => { + lsMock.clear(); + const futureExpiry = Math.floor(Date.now() / 1000) + 3600; + new WalletConnectAdapter({ + client: mockWalletConnectClient, + topic: mockTopic, + chainId: mockChainId, + address: mockAddress, + expiry: futureExpiry, + }); + const raw = lsMock.store["stellarsplit:wc:session"]; + const parsed = JSON.parse(raw!); + expect(parsed.expiry).toBe(futureExpiry); + }); + + it("persists relayUrl when provided", () => { + lsMock.clear(); + new WalletConnectAdapter({ + client: mockWalletConnectClient, + topic: mockTopic, + chainId: mockChainId, + address: mockAddress, + relayUrl: "wss://relay.example.com", + }); + const raw = lsMock.store["stellarsplit:wc:session"]; + const parsed = JSON.parse(raw!); + expect(parsed.relayUrl).toBe("wss://relay.example.com"); + }); + }); + + // ------------------------------------------------------------------------- + // #774 — Session restore + // ------------------------------------------------------------------------- + describe("WalletConnectAdapter.restore()", () => { + it("returns null when localStorage is empty", () => { + lsMock.clear(); + // getItem returns null for missing keys + const restored = WalletConnectAdapter.restore(mockWalletConnectClient); + expect(restored).toBeNull(); + }); + + it("restores a valid non-expired session", () => { + const session = { + topic: mockTopic, + chainId: mockChainId, + address: mockAddress, + relayUrl: "", + expiry: Math.floor(Date.now() / 1000) + 3600, + }; + lsMock.store["stellarsplit:wc:session"] = JSON.stringify(session); + + const restored = WalletConnectAdapter.restore(mockWalletConnectClient); + expect(restored).not.toBeNull(); + }); + + it("returns the correct address after restore", async () => { + const session = { + topic: mockTopic, + chainId: mockChainId, + address: mockAddress, + relayUrl: "", + expiry: Math.floor(Date.now() / 1000) + 3600, + }; + lsMock.store["stellarsplit:wc:session"] = JSON.stringify(session); + + const restored = WalletConnectAdapter.restore(mockWalletConnectClient)!; + expect(await restored.getAddress()).toBe(mockAddress); + }); + + it("returns null for an expired session", () => { + const session = { + topic: mockTopic, + chainId: mockChainId, + address: mockAddress, + relayUrl: "", + expiry: Math.floor(Date.now() / 1000) - 10, // already expired + }; + lsMock.store["stellarsplit:wc:session"] = JSON.stringify(session); + + const restored = WalletConnectAdapter.restore(mockWalletConnectClient); + expect(restored).toBeNull(); + }); + + it("clears localStorage after restoring an expired session", () => { + const session = { + topic: mockTopic, + chainId: mockChainId, + address: mockAddress, + relayUrl: "", + expiry: Math.floor(Date.now() / 1000) - 10, + }; + lsMock.store["stellarsplit:wc:session"] = JSON.stringify(session); + + WalletConnectAdapter.restore(mockWalletConnectClient); + expect(lsMock.removeItem).toHaveBeenCalledWith("stellarsplit:wc:session"); + }); + }); + + // ------------------------------------------------------------------------- + // #774 — disconnect clears storage + // ------------------------------------------------------------------------- + describe("disconnect()", () => { + it("removes persisted session from localStorage", () => { + (adapter as WalletConnectAdapter).disconnect(); + expect(lsMock.removeItem).toHaveBeenCalledWith("stellarsplit:wc:session"); + }); + }); }); diff --git a/test/xbullAdapter.test.ts b/test/xbullAdapter.test.ts new file mode 100644 index 0000000..ffc35ab --- /dev/null +++ b/test/xbullAdapter.test.ts @@ -0,0 +1,194 @@ +/** + * Tests for XBullAdapter — focuses on the #771 acceptance criteria: + * - connect() rejects with ExtensionVersionError when the installed version + * is below MIN_XBULL_VERSION. + * - Compatible versions proceed with the existing connection flow. + * - MIN_XBULL_VERSION is exported. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + XBullAdapter, + ExtensionVersionError, + MIN_XBULL_VERSION, +} from "../src/wallets/adapters/XBullAdapter.js"; + +const MOCK_PUBLIC_KEY = + "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; + +function makeXBullMock() { + return { + connect: vi.fn().mockResolvedValue({ public_key: MOCK_PUBLIC_KEY }), + sign: vi + .fn() + .mockResolvedValue({ xdr: "signed-xdr" }), + onAccountChange: vi.fn().mockReturnValue(() => {}), + }; +} + +describe("XBullAdapter (#771)", () => { + let adapter: XBullAdapter; + + afterEach(() => { + vi.unstubAllGlobals(); + adapter?.disconnect(); + }); + + // ------------------------------------------------------------------------- + // MIN_XBULL_VERSION is exported + // ------------------------------------------------------------------------- + it("exports MIN_XBULL_VERSION as a string", () => { + expect(typeof MIN_XBULL_VERSION).toBe("string"); + expect(MIN_XBULL_VERSION.length).toBeGreaterThan(0); + }); + + // ------------------------------------------------------------------------- + // Version too old → reject + // ------------------------------------------------------------------------- + describe("when the installed version is below MIN_XBULL_VERSION", () => { + beforeEach(() => { + vi.stubGlobal("window", { + xbull: makeXBullMock(), + xBullSDK: { version: "1.0.0" }, // older than MIN_XBULL_VERSION (2.0.0) + }); + adapter = new XBullAdapter(); + }); + + it("throws ExtensionVersionError from connect()", async () => { + await expect(adapter.connect()).rejects.toThrow(ExtensionVersionError); + }); + + it("error message contains the required version", async () => { + try { + await adapter.connect(); + expect.fail("should have thrown"); + } catch (err) { + expect((err as Error).message).toContain(MIN_XBULL_VERSION); + } + }); + + it("error message contains the installed version", async () => { + try { + await adapter.connect(); + expect.fail("should have thrown"); + } catch (err) { + expect((err as Error).message).toContain("1.0.0"); + } + }); + + it("does not call xbull.connect() when version check fails", async () => { + const xbullMock = (window as any).xbull; + try { + await adapter.connect(); + } catch { + // expected + } + expect(xbullMock.connect).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // Version equal to minimum → allow + // ------------------------------------------------------------------------- + describe("when the installed version equals MIN_XBULL_VERSION", () => { + beforeEach(() => { + vi.stubGlobal("window", { + xbull: makeXBullMock(), + xBullSDK: { version: MIN_XBULL_VERSION }, + }); + adapter = new XBullAdapter(); + }); + + it("connect() resolves successfully", async () => { + const key = await adapter.connect(); + expect(key).toBe(MOCK_PUBLIC_KEY); + }); + }); + + // ------------------------------------------------------------------------- + // Version above minimum → allow + // ------------------------------------------------------------------------- + describe("when the installed version is above MIN_XBULL_VERSION", () => { + beforeEach(() => { + vi.stubGlobal("window", { + xbull: makeXBullMock(), + xBullSDK: { version: "3.5.1" }, + }); + adapter = new XBullAdapter(); + }); + + it("connect() resolves with the public key", async () => { + const key = await adapter.connect(); + expect(key).toBe(MOCK_PUBLIC_KEY); + }); + + it("does not throw ExtensionVersionError", async () => { + await expect(adapter.connect()).resolves.not.toThrow(); + }); + }); + + // ------------------------------------------------------------------------- + // No xBullSDK namespace → skip check (graceful degradation) + // ------------------------------------------------------------------------- + describe("when window.xBullSDK is not present", () => { + beforeEach(() => { + vi.stubGlobal("window", { + xbull: makeXBullMock(), + // xBullSDK intentionally absent + }); + adapter = new XBullAdapter(); + }); + + it("connect() proceeds without throwing a version error", async () => { + await expect(adapter.connect()).resolves.toBe(MOCK_PUBLIC_KEY); + }); + }); + + // ------------------------------------------------------------------------- + // ExtensionVersionError shape + // ------------------------------------------------------------------------- + describe("ExtensionVersionError", () => { + it("has the correct name property", () => { + const err = new ExtensionVersionError("1.0.0", "2.0.0"); + expect(err.name).toBe("ExtensionVersionError"); + }); + + it("is an instance of Error", () => { + const err = new ExtensionVersionError("1.0.0", "2.0.0"); + expect(err).toBeInstanceOf(Error); + }); + + it("exposes installedVersion and requiredVersion", () => { + const err = new ExtensionVersionError("1.2.3", "2.0.0"); + expect(err.installedVersion).toBe("1.2.3"); + expect(err.requiredVersion).toBe("2.0.0"); + }); + }); + + // ------------------------------------------------------------------------- + // XBullAdapter.compareVersions utility + // ------------------------------------------------------------------------- + describe("compareVersions()", () => { + it("returns negative when a < b", () => { + expect(XBullAdapter.compareVersions("1.0.0", "2.0.0")).toBeLessThan(0); + }); + + it("returns 0 when a === b", () => { + expect(XBullAdapter.compareVersions("2.0.0", "2.0.0")).toBe(0); + }); + + it("returns positive when a > b", () => { + expect(XBullAdapter.compareVersions("3.1.0", "2.5.9")).toBeGreaterThan(0); + }); + + it("handles minor version differences", () => { + expect(XBullAdapter.compareVersions("2.1.0", "2.0.0")).toBeGreaterThan(0); + expect(XBullAdapter.compareVersions("2.0.0", "2.1.0")).toBeLessThan(0); + }); + + it("handles patch version differences", () => { + expect(XBullAdapter.compareVersions("2.0.1", "2.0.0")).toBeGreaterThan(0); + expect(XBullAdapter.compareVersions("2.0.0", "2.0.1")).toBeLessThan(0); + }); + }); +});