From cd4f5dba522df9d6134549b514bf758b24b3ede3 Mon Sep 17 00:00:00 2001 From: ZacLou Date: Sun, 6 Sep 2026 03:48:30 +0800 Subject: [PATCH 1/3] feat(wallets): handle Freighter not-installed with friendly error (#772) - Add FreighterNotInstalledError with install URL in message - Check window.freighter presence before connect, sign, and getAddress - Throw FreighterNotInstalledError instead of raw TypeError - Existing logic unchanged when extension is present - Add unit tests for missing extension, error message, and normal flow Closes #772 --- src/wallets/adapters/FreighterAdapter.ts | 7 ++-- test/freighterAdapter.test.ts | 51 ++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 test/freighterAdapter.test.ts diff --git a/src/wallets/adapters/FreighterAdapter.ts b/src/wallets/adapters/FreighterAdapter.ts index 5b8b001..67b0f66 100644 --- a/src/wallets/adapters/FreighterAdapter.ts +++ b/src/wallets/adapters/FreighterAdapter.ts @@ -3,6 +3,7 @@ */ import type { WalletAdapter } from "../../types.js"; +import { FreighterNotInstalledError } from "../../errors.js"; type Unsubscribe = () => void; @@ -24,7 +25,7 @@ export class FreighterAdapter implements WalletAdapter { async connect(): Promise { if (!window.freighter) { - throw new Error("Freighter wallet not installed"); + throw new FreighterNotInstalledError(); } const address = await window.freighter.getPublicKey(); @@ -38,7 +39,7 @@ export class FreighterAdapter implements WalletAdapter { async sign(xdr: string, network: string): Promise { if (!window.freighter) { - throw new Error("Freighter wallet not installed"); + throw new FreighterNotInstalledError(); } return await window.freighter.signTransaction(xdr, network); @@ -46,7 +47,7 @@ export class FreighterAdapter implements WalletAdapter { async getAddress(): Promise { if (!window.freighter) { - throw new Error("Freighter wallet not installed"); + throw new FreighterNotInstalledError(); } return await window.freighter.getPublicKey(); diff --git a/test/freighterAdapter.test.ts b/test/freighterAdapter.test.ts new file mode 100644 index 0000000..3db25fd --- /dev/null +++ b/test/freighterAdapter.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from "vitest"; +import { FreighterAdapter } from "../src/wallets/adapters/FreighterAdapter.js"; +import { FreighterNotInstalledError } from "../src/errors.js"; + +describe("FreighterAdapter not-installed handling", () => { + it("throws FreighterNotInstalledError on connect when window.freighter is absent", async () => { + const adapter = new FreighterAdapter(); + // Ensure window.freighter is undefined + (globalThis as any).window = { freighter: undefined }; + await expect(adapter.connect()).rejects.toThrow(FreighterNotInstalledError); + }); + + it("throws FreighterNotInstalledError on sign when window.freighter is absent", async () => { + const adapter = new FreighterAdapter(); + (globalThis as any).window = { freighter: undefined }; + await expect(adapter.sign("xdr", "testnet")).rejects.toThrow(FreighterNotInstalledError); + }); + + it("throws FreighterNotInstalledError on getAddress when window.freighter is absent", async () => { + const adapter = new FreighterAdapter(); + (globalThis as any).window = { freighter: undefined }; + await expect(adapter.getAddress()).rejects.toThrow(FreighterNotInstalledError); + }); + + it("error message includes the Freighter install URL", async () => { + const adapter = new FreighterAdapter(); + (globalThis as any).window = { freighter: undefined }; + try { + await adapter.connect(); + expect.fail("Should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(FreighterNotInstalledError); + expect((err as Error).message).toContain("https://www.freighter.app"); + } + }); + + it("connects normally when window.freighter is present", async () => { + const adapter = new FreighterAdapter(); + (globalThis as any).window = { + freighter: { + isConnected: vi.fn().mockResolvedValue(true), + getPublicKey: vi.fn().mockResolvedValue("GABC..."), + signTransaction: vi.fn().mockResolvedValue("signed-xdr"), + }, + }; + + const address = await adapter.connect(); + expect(address).toBe("GABC..."); + adapter.disconnect(); + }); +}); From 0406364cf7c7f7ddcbd65a12643499697061c719 Mon Sep 17 00:00:00 2001 From: ZacLou Date: Sun, 6 Sep 2026 04:36:17 +0800 Subject: [PATCH 2/3] feat(ledger): check app version before signing, reject outdated firmware (#775) --- package-lock.json | 42 ----------------- src/adapters/ledger.ts | 33 ++++++++++++- src/errors.ts | 18 ++++++++ test/ledgerAdapter.test.ts | 95 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 44 deletions(-) create mode 100644 test/ledgerAdapter.test.ts diff --git a/package-lock.json b/package-lock.json index c4b0600..f206cc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3843,20 +3843,6 @@ } } }, - "node_modules/html-encoding-sniffer/node_modules/@noble/hashes": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", - "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/https-proxy-agent": { "version": "5.0.1", "license": "MIT", @@ -4055,20 +4041,6 @@ } } }, - "node_modules/jsdom/node_modules/@noble/hashes": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", - "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -6718,20 +6690,6 @@ } } }, - "node_modules/whatwg-url/node_modules/@noble/hashes": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", - "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/which": { "version": "2.0.2", "dev": true, diff --git a/src/adapters/ledger.ts b/src/adapters/ledger.ts index 80ca014..3a8ffcc 100644 --- a/src/adapters/ledger.ts +++ b/src/adapters/ledger.ts @@ -2,19 +2,26 @@ import TransportWebHID from "@ledgerhq/hw-transport-webhid"; import type Transport from "@ledgerhq/hw-transport"; import Str from "@ledgerhq/hw-app-str"; import type { WalletAdapter } from "../types.js"; +import { LedgerFirmwareTooOldError } from "../errors.js"; + +/** Minimum Ledger Stellar app version required for signing. */ +export const MIN_LEDGER_FIRMWARE = "2.0.0"; /** Ledger hardware wallet adapter implementing WalletAdapter. */ export class LedgerAdapter implements WalletAdapter { private readonly path: string; + private readonly skipFirmwareCheck: boolean; - constructor(path = "44'/148'/0'") { - this.path = path; + constructor(options?: { path?: string; skipFirmwareCheck?: boolean }) { + this.path = options?.path ?? "44'/148'/0'"; + this.skipFirmwareCheck = options?.skipFirmwareCheck ?? false; } async getAddress(): Promise { const transport = await this.openTransport(); try { const str = new Str(transport); + await this.checkFirmwareVersion(str); const { publicKey } = await str.getPublicKey(this.path); return publicKey; } finally { @@ -26,6 +33,7 @@ export class LedgerAdapter implements WalletAdapter { const transport = await this.openTransport(); try { const str = new Str(transport); + await this.checkFirmwareVersion(str); const txBytes = Uint8Array.from(atob(xdr), (c) => c.charCodeAt(0)); const { signature } = await str.signTransaction( this.path, @@ -38,6 +46,27 @@ export class LedgerAdapter implements WalletAdapter { } } + private async checkFirmwareVersion(str: Str): Promise { + if (this.skipFirmwareCheck) return; + const { version } = await str.getAppConfiguration(); + if (this.versionCompare(version, MIN_LEDGER_FIRMWARE) < 0) { + throw new LedgerFirmwareTooOldError(MIN_LEDGER_FIRMWARE, version); + } + } + + /** Semantic version comparison: returns <0 if a0 if a>b. */ + private versionCompare(a: string, b: string): number { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const na = pa[i] || 0; + const nb = pb[i] || 0; + if (na < nb) return -1; + if (na > nb) return 1; + } + return 0; + } + private async openTransport(): Promise { try { return await TransportWebHID.create(); diff --git a/src/errors.ts b/src/errors.ts index 02e444b..6697620 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -595,6 +595,24 @@ export class NoSignerProvidedError extends StellarSplitError { } } +/** Thrown when the Ledger device firmware (or Stellar app) is too old for the requested operation. */ +export class LedgerFirmwareTooOldError extends StellarSplitError { + readonly requiredVersion: string; + readonly actualVersion: string; + + constructor(requiredVersion: string, actualVersion: string) { + super( + `Ledger firmware/app version ${actualVersion} is too old. Minimum required: ${requiredVersion}`, + "LEDGER_FIRMWARE_TOO_OLD", + { requiredVersion, actualVersion } + ); + this.name = "LedgerFirmwareTooOldError"; + this.requiredVersion = requiredVersion; + this.actualVersion = actualVersion; + Object.setPrototypeOf(this, new.target.prototype); + } +} + /** Thrown when connection pool is improperly configured. */ export class ConnectionPoolConfigError extends StellarSplitError { readonly issue: string; diff --git a/test/ledgerAdapter.test.ts b/test/ledgerAdapter.test.ts new file mode 100644 index 0000000..22dd883 --- /dev/null +++ b/test/ledgerAdapter.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi } from "vitest"; +import { LedgerAdapter, MIN_LEDGER_FIRMWARE } from "../src/adapters/ledger.js"; +import { LedgerFirmwareTooOldError } from "../src/errors.js"; + +const mockTransport = { + close: vi.fn().mockResolvedValue(undefined), +}; + +const mockStr = { + getAppConfiguration: vi.fn(), + getPublicKey: vi.fn(), + signTransaction: vi.fn(), +}; + +vi.mock("@ledgerhq/hw-transport-webhid", () => ({ + default: { + create: vi.fn().mockResolvedValue(mockTransport), + }, +})); + +vi.mock("@ledgerhq/hw-app-str", () => ({ + default: vi.fn().mockImplementation(() => mockStr), +})); + +describe("LedgerAdapter firmware version check", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("throws LedgerFirmwareTooOldError when app version is below minimum", async () => { + mockStr.getAppConfiguration.mockResolvedValue({ version: "1.0.0" }); + + const adapter = new LedgerAdapter(); + await expect(adapter.getAddress()).rejects.toThrow(LedgerFirmwareTooOldError); + await expect(adapter.getAddress()).rejects.toThrow( + /Ledger firmware\/app version 1\.0\.0 is too old/ + ); + }); + + it("includes the required version in the error message", async () => { + mockStr.getAppConfiguration.mockResolvedValue({ version: "1.5.0" }); + + const adapter = new LedgerAdapter(); + try { + await adapter.getAddress(); + expect.fail("Should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(LedgerFirmwareTooOldError); + expect((err as LedgerFirmwareTooOldError).requiredVersion).toBe( + MIN_LEDGER_FIRMWARE + ); + expect((err as LedgerFirmwareTooOldError).actualVersion).toBe("1.5.0"); + } + }); + + it("proceeds normally when app version meets the minimum", async () => { + mockStr.getAppConfiguration.mockResolvedValue({ version: "2.0.0" }); + mockStr.getPublicKey.mockResolvedValue({ publicKey: "GABC..." }); + + const adapter = new LedgerAdapter(); + const address = await adapter.getAddress(); + expect(address).toBe("GABC..."); + expect(mockStr.getAppConfiguration).toHaveBeenCalled(); + }); + + it("proceeds normally when app version exceeds the minimum", async () => { + mockStr.getAppConfiguration.mockResolvedValue({ version: "3.1.0" }); + mockStr.getPublicKey.mockResolvedValue({ publicKey: "GDEF..." }); + + const adapter = new LedgerAdapter(); + const address = await adapter.getAddress(); + expect(address).toBe("GDEF..."); + }); + + it("skips firmware check when skipFirmwareCheck is true", async () => { + mockStr.getPublicKey.mockResolvedValue({ publicKey: "GHIJ..." }); + + const adapter = new LedgerAdapter({ skipFirmwareCheck: true }); + const address = await adapter.getAddress(); + expect(address).toBe("GHIJ..."); + expect(mockStr.getAppConfiguration).not.toHaveBeenCalled(); + }); + + it("checks firmware before signing transactions", async () => { + mockStr.getAppConfiguration.mockResolvedValue({ version: "2.1.0" }); + mockStr.signTransaction.mockResolvedValue({ + signature: Buffer.from("sig"), + }); + + const adapter = new LedgerAdapter(); + const signed = await adapter.signTransaction("mock-xdr", "testnet"); + expect(signed).toBe("c2ln"); // base64 of "sig" + expect(mockStr.getAppConfiguration).toHaveBeenCalled(); + }); +}); From e33a4de69c8e7c6fe64b6aeed659c40d36a8ec37 Mon Sep 17 00:00:00 2001 From: ZacLou Date: Sun, 6 Sep 2026 04:45:52 +0800 Subject: [PATCH 3/3] feat(walletconnect): persist session to localStorage, auto-restore on init, disconnect clears storage (#774) --- src/adapters/walletconnect.ts | 131 +++++++++++++++++++++++++++++--- test/walletconnect.test.ts | 137 ++++++++++++++++++++++++++++++++-- 2 files changed, 253 insertions(+), 15 deletions(-) diff --git a/src/adapters/walletconnect.ts b/src/adapters/walletconnect.ts index b776c7a..c4af75e 100644 --- a/src/adapters/walletconnect.ts +++ b/src/adapters/walletconnect.ts @@ -1,5 +1,15 @@ import type { WalletAdapter } from "./types.js"; +/** Session data persisted to localStorage for recovery across reloads. */ +export interface PersistedWalletConnectSession { + topic: string; + relayUrl: string; + chainId: string; + address: string; + /** Unix timestamp (ms) when the session expires. */ + expiry: number; +} + /** Options for constructing a WalletConnectAdapter. */ export interface WalletConnectAdapterOptions { /** WalletConnect Sign Client instance (from @walletconnect/sign-client). */ @@ -12,36 +22,139 @@ export interface WalletConnectAdapterOptions { }): Promise; }; /** Active WalletConnect session topic. */ - topic: string; + topic?: string; /** Stellar chain ID (e.g. "stellar:testnet"). */ - chainId: string; + chainId?: string; /** The connected wallet's Stellar public key. */ - address: string; + address?: string; + /** WalletConnect relay URL. */ + relayUrl?: string; + /** Session expiry timestamp (ms). */ + expiry?: number; + /** localStorage key override (default: stellar_split_walletconnect_session). */ + storageKey?: string; } +const DEFAULT_STORAGE_KEY = "stellar_split_walletconnect_session"; + /** * WalletConnect adapter — routes signing through a WalletConnect session * instead of the Freighter browser extension. + * + * Automatically persists the active session to localStorage on connect + * and restores it on construction when it has not expired. */ export class WalletConnectAdapter implements WalletAdapter { - private readonly opts: WalletConnectAdapterOptions; + private readonly client: WalletConnectAdapterOptions["client"]; + private topic: string | undefined; + private chainId: string | undefined; + private addressValue: string | undefined; + private relayUrl: string | undefined; + private readonly storageKey: string; constructor(opts: WalletConnectAdapterOptions) { - this.opts = opts; + this.client = opts.client; + this.storageKey = opts.storageKey ?? DEFAULT_STORAGE_KEY; + + if (opts.topic && opts.chainId && opts.address) { + // Fresh connection supplied directly. + this.topic = opts.topic; + this.chainId = opts.chainId; + this.addressValue = opts.address; + this.relayUrl = opts.relayUrl; + if (opts.expiry) { + this.persist({ + topic: opts.topic, + relayUrl: opts.relayUrl ?? "", + chainId: opts.chainId, + address: opts.address, + expiry: opts.expiry, + }); + } + } else { + // Attempt to restore a previously persisted session. + this.restore(); + } + } + + /** Returns true when a session (restored or explicitly set) is present. */ + get isConnected(): boolean { + return this.topic !== undefined && this.addressValue !== undefined; } async getAddress(): Promise { - return this.opts.address; + if (!this.addressValue) { + throw new Error( + "WalletConnect session not available. Connect or restore a session first." + ); + } + return this.addressValue; } async signTransaction(xdr: string, network: string): Promise { - return this.opts.client.request({ - topic: this.opts.topic, - chainId: this.opts.chainId, + if (!this.topic || !this.chainId) { + throw new Error( + "WalletConnect session not available. Connect or restore a session first." + ); + } + return this.client.request({ + topic: this.topic, + chainId: this.chainId, request: { method: "stellar_signXDR", params: { xdr, network }, }, }); } + + /** + * Persists a successfully established session so it survives page reloads. + * Call this once the WalletConnect pairing / session creation has completed. + */ + persist(session: PersistedWalletConnectSession): void { + this.topic = session.topic; + this.chainId = session.chainId; + this.addressValue = session.address; + this.relayUrl = session.relayUrl; + if (typeof localStorage !== "undefined") { + localStorage.setItem(this.storageKey, JSON.stringify(session)); + } + } + + /** + * Clears the active session and removes persisted data from localStorage. + * The underlying WalletConnect client should also be disconnected by the + * caller (this adapter does not own the client lifecycle). + */ + disconnect(): void { + this.topic = undefined; + this.chainId = undefined; + this.addressValue = undefined; + this.relayUrl = undefined; + if (typeof localStorage !== "undefined") { + localStorage.removeItem(this.storageKey); + } + } + + /** Attempts to restore a session from localStorage. */ + private restore(): void { + if (typeof localStorage === "undefined") return; + const raw = localStorage.getItem(this.storageKey); + if (!raw) return; + try { + const session: PersistedWalletConnectSession = JSON.parse(raw); + if (Date.now() >= session.expiry) { + // Session has expired — clean it up. + localStorage.removeItem(this.storageKey); + return; + } + this.topic = session.topic; + this.chainId = session.chainId; + this.addressValue = session.address; + this.relayUrl = session.relayUrl; + } catch { + // Malformed storage entry — discard. + localStorage.removeItem(this.storageKey); + } + } } diff --git a/test/walletconnect.test.ts b/test/walletconnect.test.ts index b8d5ae1..423febd 100644 --- a/test/walletconnect.test.ts +++ b/test/walletconnect.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { WalletConnectAdapter } from "../src/adapters/walletconnect.js"; +import { + WalletConnectAdapter, + type PersistedWalletConnectSession, +} from "../src/adapters/walletconnect.js"; import type { WalletAdapter } from "../src/adapters/types.js"; // Mock the WalletConnect client @@ -10,12 +13,19 @@ const mockWalletConnectClient = { const mockTopic = "mock-topic-123"; const mockChainId = "stellar:testnet"; const mockAddress = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; +const mockRelayUrl = "wss://relay.walletconnect.com"; + +const STORAGE_KEY = "stellar_split_walletconnect_session"; describe("WalletConnectAdapter", () => { let adapter: WalletAdapter; beforeEach(() => { mockWalletConnectClient.request.mockClear(); + // Clear localStorage before each test + if (typeof localStorage !== "undefined") { + localStorage.removeItem(STORAGE_KEY); + } adapter = new WalletConnectAdapter({ client: mockWalletConnectClient, topic: mockTopic, @@ -37,9 +47,9 @@ describe("WalletConnectAdapter", () => { it("calls WalletConnect client with correct parameters", async () => { mockWalletConnectClient.request.mockResolvedValue("signed-xdr"); - + const result = await adapter.signTransaction(mockXdr, mockNetwork); - + expect(mockWalletConnectClient.request).toHaveBeenCalledWith({ topic: mockTopic, chainId: mockChainId, @@ -52,9 +62,124 @@ describe("WalletConnectAdapter", () => { }); it("throws error when WalletConnect request fails", async () => { - mockWalletConnectClient.request.mockRejectedValue(new Error("WalletConnect error")); - - await expect(adapter.signTransaction(mockXdr, mockNetwork)).rejects.toThrow("WalletConnect error"); + mockWalletConnectClient.request.mockRejectedValue( + new Error("WalletConnect error") + ); + + await expect( + adapter.signTransaction(mockXdr, mockNetwork) + ).rejects.toThrow("WalletConnect error"); + }); + }); + + describe("persistence", () => { + it("persists session to localStorage when expiry is provided", () => { + const expiry = Date.now() + 86_400_000; + const a = new WalletConnectAdapter({ + client: mockWalletConnectClient, + topic: mockTopic, + chainId: mockChainId, + address: mockAddress, + relayUrl: mockRelayUrl, + expiry, + }); + + const raw = localStorage.getItem(STORAGE_KEY); + expect(raw).not.toBeNull(); + const stored: PersistedWalletConnectSession = JSON.parse(raw!); + expect(stored.topic).toBe(mockTopic); + expect(stored.chainId).toBe(mockChainId); + expect(stored.address).toBe(mockAddress); + expect(stored.relayUrl).toBe(mockRelayUrl); + expect(stored.expiry).toBe(expiry); + expect(a.isConnected).toBe(true); + }); + + it("restores session from localStorage on construction when not expired", () => { + const session: PersistedWalletConnectSession = { + topic: "restored-topic", + relayUrl: "wss://relay.example.com", + chainId: "stellar:mainnet", + address: "GRESTORED...", + expiry: Date.now() + 86_400_000, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); + + const a = new WalletConnectAdapter({ + client: mockWalletConnectClient, + }); + + expect(a.isConnected).toBe(true); + expect(a.getAddress()).resolves.toBe("GRESTORED..."); + }); + + it("discards expired session on construction", () => { + const session: PersistedWalletConnectSession = { + topic: "expired-topic", + relayUrl: "wss://relay.example.com", + chainId: "stellar:mainnet", + address: "GEXPIRED...", + expiry: Date.now() - 1, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); + + const a = new WalletConnectAdapter({ + client: mockWalletConnectClient, + }); + + expect(a.isConnected).toBe(false); + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it("throws when signing without a session", async () => { + const a = new WalletConnectAdapter({ + client: mockWalletConnectClient, + }); + await expect(a.signTransaction("xdr", "testnet")).rejects.toThrow( + /session not available/ + ); + }); + }); + + describe("disconnect", () => { + it("clears active session and localStorage", () => { + const expiry = Date.now() + 86_400_000; + const a = new WalletConnectAdapter({ + client: mockWalletConnectClient, + topic: mockTopic, + chainId: mockChainId, + address: mockAddress, + expiry, + }); + expect(a.isConnected).toBe(true); + + a.disconnect(); + + expect(a.isConnected).toBe(false); + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + }); + + describe("persist", () => { + it("allows explicit persistence after external pairing", () => { + const a = new WalletConnectAdapter({ + client: mockWalletConnectClient, + }); + expect(a.isConnected).toBe(false); + + const session: PersistedWalletConnectSession = { + topic: "paired-topic", + relayUrl: "wss://relay.paired.com", + chainId: "stellar:testnet", + address: "GPAIRED...", + expiry: Date.now() + 86_400_000, + }; + a.persist(session); + + expect(a.isConnected).toBe(true); + const raw = localStorage.getItem(STORAGE_KEY); + expect(raw).not.toBeNull(); + expect(JSON.parse(raw!).topic).toBe("paired-topic"); }); }); });