Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
94 changes: 81 additions & 13 deletions src/wallets/adapters/LobstrAdapter.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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<string> {
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<never>((_, 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<string> {
Expand All @@ -48,22 +86,45 @@ 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<never>((_, 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<string> {
return this.sign(xdr);
}

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) {
Expand All @@ -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) {
Expand All @@ -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);
}
}
}

6 changes: 6 additions & 0 deletions test/sdkExports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
136 changes: 135 additions & 1 deletion test/walletSessionManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
});
});

Expand Down
Loading