diff --git a/src/index.ts b/src/index.ts index b0d218a..b030cf6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -321,6 +321,8 @@ export { ResilientRpcClient } from "./resilientRpc.js"; export type { RetryConfig } from "./resilientRpc.js"; export { connectWallet, getPublicKey, signTransaction } from "./wallet.js"; +export { LobstrAdapter } from "./wallets/adapters/LobstrAdapter.js"; +export type { LobstrAdapterOptions } from "./wallets/adapters/LobstrAdapter.js"; export { checkRPCHealth } from "./health.js"; export { FallbackChain, FallbackExhaustedError } from "./fallbackChain.js"; diff --git a/src/wallets/adapters/LobstrAdapter.ts b/src/wallets/adapters/LobstrAdapter.ts index f7a1178..bed6c94 100644 --- a/src/wallets/adapters/LobstrAdapter.ts +++ b/src/wallets/adapters/LobstrAdapter.ts @@ -1,8 +1,9 @@ /** - * LobstrAdapter — Adapter for the LOBSTR wallet extension. + * LobstrAdapter — Adapter for the LOBSTR wallet extension and deep link provider. */ import type { WalletAdapter } from "../../types.js"; +import { WalletConnectionTimeoutError } from "../../errors.js"; type Unsubscribe = () => void; @@ -17,21 +18,58 @@ declare global { } } +/** + * Options for configuring {@link LobstrAdapter}. + */ +export interface LobstrAdapterOptions { + /** + * Maximum time (in milliseconds) to wait for a connection response before timing out. + * Default: 60000 (60 seconds). + */ + connectionTimeoutMs?: number; +} + export class LobstrAdapter implements WalletAdapter { readonly name = "LOBSTR"; + readonly connectionTimeoutMs: number; private accountChangeHandlers: Array<(address: string) => void> = []; + private accountChangedHandler: ((data: any) => void) | null = null; + + constructor(options?: LobstrAdapterOptions) { + this.connectionTimeoutMs = options?.connectionTimeoutMs ?? 60_000; + } async connect(): Promise { if (!window.lobstr) { throw new Error("LOBSTR wallet not installed"); } - const result = await window.lobstr.connect(); - - // Set up account change listener - this.setupAccountChangeListener(); - - return result.publicKey; + let timer: NodeJS.Timeout | undefined; + + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new WalletConnectionTimeoutError( + `LOBSTR connection timed out after ${this.connectionTimeoutMs}ms`, + { timeoutMs: this.connectionTimeoutMs }, + ), + ); + }, this.connectionTimeoutMs); + }); + + try { + const connectPromise = window.lobstr.connect(); + const result = await Promise.race([connectPromise, timeoutPromise]); + + // Set up account change listener + this.setupAccountChangeListener(); + + return result.publicKey; + } finally { + if (timer) { + clearTimeout(timer); + } + } } async sign(xdr: string): Promise { @@ -48,8 +86,28 @@ export class LobstrAdapter implements WalletAdapter { throw new Error("LOBSTR wallet not installed"); } - const result = await window.lobstr.connect(); - return result.publicKey; + let timer: NodeJS.Timeout | undefined; + + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new WalletConnectionTimeoutError( + `LOBSTR connection timed out after ${this.connectionTimeoutMs}ms`, + { timeoutMs: this.connectionTimeoutMs }, + ), + ); + }, this.connectionTimeoutMs); + }); + + try { + const connectPromise = window.lobstr.connect(); + const result = await Promise.race([connectPromise, timeoutPromise]); + return result.publicKey; + } finally { + if (timer) { + clearTimeout(timer); + } + } } async signTransaction(xdr: string, _network: string): Promise { @@ -57,13 +115,16 @@ export class LobstrAdapter implements WalletAdapter { } disconnect(): void { + if (this.accountChangedHandler && window.lobstr && typeof window.lobstr.off === "function") { + window.lobstr.off("accountChanged", this.accountChangedHandler); + this.accountChangedHandler = null; + } this.accountChangeHandlers = []; - // LOBSTR doesn't have explicit disconnect } onAccountChange(handler: (address: string) => void): Unsubscribe { this.accountChangeHandlers.push(handler); - + return () => { const index = this.accountChangeHandlers.indexOf(handler); if (index > -1) { @@ -75,7 +136,11 @@ export class LobstrAdapter implements WalletAdapter { private setupAccountChangeListener(): void { if (!window.lobstr) return; - const handler = (data: any) => { + if (this.accountChangedHandler && typeof window.lobstr.off === "function") { + window.lobstr.off("accountChanged", this.accountChangedHandler); + } + + this.accountChangedHandler = (data: any) => { const newAddress = data?.publicKey; if (newAddress) { for (const h of this.accountChangeHandlers) { @@ -88,6 +153,9 @@ export class LobstrAdapter implements WalletAdapter { } }; - window.lobstr.on("accountChanged", handler); + if (typeof window.lobstr.on === "function") { + window.lobstr.on("accountChanged", this.accountChangedHandler); + } } } + diff --git a/test/sdkExports.test.ts b/test/sdkExports.test.ts index 671ca60..942cd0b 100644 --- a/test/sdkExports.test.ts +++ b/test/sdkExports.test.ts @@ -18,4 +18,10 @@ describe("public API surface (issues #586, #588, #589)", () => { expect(typeof sdk.footprintDiff).toBe("function"); expect(typeof sdk.submitTransaction).toBe("function"); }); + + it("exports LobstrAdapter and WalletConnectionTimeoutError", () => { + expect(typeof sdk.LobstrAdapter).toBe("function"); + expect(typeof sdk.WalletConnectionTimeoutError).toBe("function"); + expect(typeof sdk.isWalletConnectionTimeoutError).toBe("function"); + }); }); diff --git a/test/walletSessionManager.test.ts b/test/walletSessionManager.test.ts index 6016935..08776c3 100644 --- a/test/walletSessionManager.test.ts +++ b/test/walletSessionManager.test.ts @@ -8,6 +8,11 @@ import { WalletSessionManager, WalletNotConnectedError } from "../src/wallets/Wa import { FreighterAdapter } from "../src/wallets/adapters/FreighterAdapter.js"; import { LobstrAdapter } from "../src/wallets/adapters/LobstrAdapter.js"; import { XBullAdapter } from "../src/wallets/adapters/XBullAdapter.js"; +import { + WalletConnectionTimeoutError, + isWalletConnectionTimeoutError, + StellarSplitError, +} from "../src/errors.js"; const MOCK_PUBLIC_KEY = "GBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; const MOCK_PUBLIC_KEY_2 = "GCBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBF"; @@ -218,17 +223,146 @@ describe("LobstrAdapter", () => { expect(adapter.name).toBe("LOBSTR"); }); - it("connect() calls window.lobstr.connect", async () => { + it("defaults connectionTimeoutMs to 60000", () => { + const adapter = new LobstrAdapter(); + expect(adapter.connectionTimeoutMs).toBe(60_000); + }); + + it("allows configurable connectionTimeoutMs", () => { + const adapter = new LobstrAdapter({ connectionTimeoutMs: 5000 }); + expect(adapter.connectionTimeoutMs).toBe(5000); + }); + + it("connect() calls window.lobstr.connect and returns publicKey", async () => { (global as any).window = { lobstr: { connect: vi.fn().mockResolvedValue({ publicKey: MOCK_PUBLIC_KEY }), on: vi.fn(), + off: vi.fn(), }, }; const adapter = new LobstrAdapter(); const address = await adapter.connect(); expect(address).toBe(MOCK_PUBLIC_KEY); + expect((global as any).window.lobstr.connect).toHaveBeenCalledTimes(1); + expect((global as any).window.lobstr.on).toHaveBeenCalledWith("accountChanged", expect.any(Function)); + }); + + it("connect() rejects with WalletConnectionTimeoutError when connection takes longer than connectionTimeoutMs", async () => { + vi.useFakeTimers(); + + (global as any).window = { + lobstr: { + connect: vi.fn().mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({ publicKey: MOCK_PUBLIC_KEY }), 5000)), + ), + on: vi.fn(), + off: vi.fn(), + }, + }; + + const adapter = new LobstrAdapter({ connectionTimeoutMs: 1000 }); + const connectPromise = adapter.connect(); + + // Advance timers beyond timeout + vi.advanceTimersByTime(1001); + + await expect(connectPromise).rejects.toThrow(WalletConnectionTimeoutError); + await expect(connectPromise).rejects.toThrow("LOBSTR connection timed out after 1000ms"); + + vi.useRealTimers(); + }); + + it("clears timeout timer on successful connect()", async () => { + vi.useFakeTimers(); + const clearTimeoutSpy = vi.spyOn(global, "clearTimeout"); + + (global as any).window = { + lobstr: { + connect: vi.fn().mockResolvedValue({ publicKey: MOCK_PUBLIC_KEY }), + on: vi.fn(), + off: vi.fn(), + }, + }; + + const adapter = new LobstrAdapter({ connectionTimeoutMs: 5000 }); + const address = await adapter.connect(); + expect(address).toBe(MOCK_PUBLIC_KEY); + expect(clearTimeoutSpy).toHaveBeenCalled(); + + clearTimeoutSpy.mockRestore(); + vi.useRealTimers(); + }); + + it("clears timeout timer when window.lobstr.connect() rejects immediately", async () => { + vi.useFakeTimers(); + const clearTimeoutSpy = vi.spyOn(global, "clearTimeout"); + + (global as any).window = { + lobstr: { + connect: vi.fn().mockRejectedValue(new Error("User rejected connection")), + on: vi.fn(), + off: vi.fn(), + }, + }; + + const adapter = new LobstrAdapter({ connectionTimeoutMs: 5000 }); + await expect(adapter.connect()).rejects.toThrow("User rejected connection"); + expect(clearTimeoutSpy).toHaveBeenCalled(); + + clearTimeoutSpy.mockRestore(); + vi.useRealTimers(); + }); + + it("getAddress() rejects with WalletConnectionTimeoutError on timeout", async () => { + vi.useFakeTimers(); + + (global as any).window = { + lobstr: { + connect: vi.fn().mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({ publicKey: MOCK_PUBLIC_KEY }), 10000)), + ), + }, + }; + + const adapter = new LobstrAdapter({ connectionTimeoutMs: 2000 }); + const addrPromise = adapter.getAddress(); + + vi.advanceTimersByTime(2001); + + await expect(addrPromise).rejects.toThrow(WalletConnectionTimeoutError); + + vi.useRealTimers(); + }); + + it("disconnect() cleans up accountChanged event listener from window.lobstr.off", async () => { + const offMock = vi.fn(); + (global as any).window = { + lobstr: { + connect: vi.fn().mockResolvedValue({ publicKey: MOCK_PUBLIC_KEY }), + on: vi.fn(), + off: offMock, + }, + }; + + const adapter = new LobstrAdapter(); + await adapter.connect(); + + adapter.disconnect(); + expect(offMock).toHaveBeenCalledWith("accountChanged", expect.any(Function)); + }); + + it("WalletConnectionTimeoutError has correct error code, context, and prototype chain", () => { + const error = new WalletConnectionTimeoutError("Connection timed out", { timeoutMs: 60000 }); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(StellarSplitError); + expect(error).toBeInstanceOf(WalletConnectionTimeoutError); + expect(error.name).toBe("WalletConnectionTimeoutError"); + expect(error.code).toBe("WALLET_CONNECTION_TIMEOUT"); + expect(error.timeoutMs).toBe(60000); + expect(isWalletConnectionTimeoutError(error)).toBe(true); + expect(isWalletConnectionTimeoutError(new Error())).toBe(false); }); });