From cd4f5dba522df9d6134549b514bf758b24b3ede3 Mon Sep 17 00:00:00 2001 From: ZacLou Date: Sun, 6 Sep 2026 03:48:30 +0800 Subject: [PATCH 1/2] 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/2] 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(); + }); +});