Skip to content
Open
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
42 changes: 0 additions & 42 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 31 additions & 2 deletions src/adapters/ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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 {
Expand All @@ -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,
Expand All @@ -38,6 +46,27 @@ export class LedgerAdapter implements WalletAdapter {
}
}

private async checkFirmwareVersion(str: Str): Promise<void> {
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 a<b, 0 if equal, >0 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<Transport> {
try {
return await TransportWebHID.create();
Expand Down
18 changes: 18 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 4 additions & 3 deletions src/wallets/adapters/FreighterAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import type { WalletAdapter } from "../../types.js";
import { FreighterNotInstalledError } from "../../errors.js";

type Unsubscribe = () => void;

Expand All @@ -24,7 +25,7 @@ export class FreighterAdapter implements WalletAdapter {

async connect(): Promise<string> {
if (!window.freighter) {
throw new Error("Freighter wallet not installed");
throw new FreighterNotInstalledError();
}

const address = await window.freighter.getPublicKey();
Expand All @@ -38,15 +39,15 @@ export class FreighterAdapter implements WalletAdapter {

async sign(xdr: string, network: string): Promise<string> {
if (!window.freighter) {
throw new Error("Freighter wallet not installed");
throw new FreighterNotInstalledError();
}

return await window.freighter.signTransaction(xdr, network);
}

async getAddress(): Promise<string> {
if (!window.freighter) {
throw new Error("Freighter wallet not installed");
throw new FreighterNotInstalledError();
}

return await window.freighter.getPublicKey();
Expand Down
51 changes: 51 additions & 0 deletions test/freighterAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
95 changes: 95 additions & 0 deletions test/ledgerAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading