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
5 changes: 5 additions & 0 deletions .changeset/gold-chefs-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nomicfoundation/hardhat-ledger": patch
---

Workaround `@ledgerhq/errors` issue #15967
81 changes: 81 additions & 0 deletions packages/hardhat-ledger/src/internal/cjs-imports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* @file This file exists to workaround an issue in `@ledgerhq/errors`, because
* its ESM build is broken.
*
* See: https://github.com/LedgerHQ/ledger-live/issues/15967
*
* While we can pin the version of `@ledgerhq/errors` that we use directly,
* other ledger packages have different version ranges, and we can end up with
* multiple installations and still hit the error.
*
* The workaround consists on importing the CJS version of the package, by using
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor grammar: “consists on importing” should be “consists of importing”.

Suggested change
* The workaround consists on importing the CJS version of the package, by using
* The workaround consists of importing the CJS version of the package, by using

Copilot uses AI. Check for mistakes.
* `createRequire` to `require` it, instead of `import`ing it.
*
* Given that the rest of the packages can also `import` `@ledgerhq/errors`, we
* need to `require` every ledger package to be safe, otherwise Node will fail
* if we mix `import` and `require` of the same package.
*/

import type * as LedgerErrorsT from "@ledgerhq/errors";
import type * as EvmToolsLibIndexT from "@ledgerhq/evm-tools/lib/index";
import type HwAppEthT from "@ledgerhq/hw-app-eth";
import type HwTransportNodeHidT from "@ledgerhq/hw-transport-node-hid";
import type { EIP712Message } from "@ledgerhq/types-live";

import { createRequire } from "node:module";

const require = createRequire(import.meta.url);

// Note: `typeof HwAppEthT` / `typeof HwTransportNodeHidT` are the module
// namespace types. Indexing them with ["default"] narrows to the exported
// class constructor, while `typeof HwAppEthT.default` collapses back to the
// namespace type.
type EthClass = (typeof HwAppEthT)["default"];
type LedgerService = (typeof HwAppEthT)["ledgerService"];
type TransportNodeHidClass = (typeof HwTransportNodeHidT)["default"];
Comment on lines +21 to +35
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HwAppEthT and HwTransportNodeHidT are imported with import type ... from ..., but then used in typeof HwAppEthT / typeof HwTransportNodeHidT type queries. typeof requires a value symbol, so this will fail to typecheck. Use a module type query instead (e.g. typeof import("@ledgerhq/hw-app-eth")) or switch to a namespace type import (import type * as HwAppEthT from ...) so you can safely index the module exports.

Suggested change
import type HwAppEthT from "@ledgerhq/hw-app-eth";
import type HwTransportNodeHidT from "@ledgerhq/hw-transport-node-hid";
import type { EIP712Message } from "@ledgerhq/types-live";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
// Note: `typeof HwAppEthT` / `typeof HwTransportNodeHidT` are the module
// namespace types. Indexing them with ["default"] narrows to the exported
// class constructor, while `typeof HwAppEthT.default` collapses back to the
// namespace type.
type EthClass = (typeof HwAppEthT)["default"];
type LedgerService = (typeof HwAppEthT)["ledgerService"];
type TransportNodeHidClass = (typeof HwTransportNodeHidT)["default"];
import type { EIP712Message } from "@ledgerhq/types-live";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
// Note: module type queries give us the module namespace types. Indexing them
// with ["default"] narrows to the exported class constructor.
type EthClass = (typeof import("@ledgerhq/hw-app-eth"))["default"];
type LedgerService = (typeof import("@ledgerhq/hw-app-eth"))["ledgerService"];
type TransportNodeHidClass =
(typeof import("@ledgerhq/hw-transport-node-hid"))["default"];

Copilot uses AI. Check for mistakes.
type LedgerEthTransactionResolution = NonNullable<
Parameters<InstanceType<EthClass>["signTransaction"]>[2]
>;

const ledgerErrors: typeof LedgerErrorsT = require("@ledgerhq/errors");
const evmToolsLibIndex: typeof EvmToolsLibIndexT = require("@ledgerhq/evm-tools/lib/index");
const hwAppEth: {
default: EthClass;
ledgerService: LedgerService;
} = require("@ledgerhq/hw-app-eth");
const hwTransport: {
default: TransportNodeHidClass;
} = require("@ledgerhq/hw-transport-node-hid");

const DisconnectedDevice: typeof ledgerErrors.DisconnectedDevice =
ledgerErrors.DisconnectedDevice;
const DisconnectedDeviceDuringOperation: typeof ledgerErrors.DisconnectedDeviceDuringOperation =
ledgerErrors.DisconnectedDeviceDuringOperation;
const LockedDeviceError: typeof ledgerErrors.LockedDeviceError =
ledgerErrors.LockedDeviceError;
const TransportError: typeof ledgerErrors.TransportError =
ledgerErrors.TransportError;
const TransportStatusError: typeof ledgerErrors.TransportStatusError =
ledgerErrors.TransportStatusError;

const isEIP712Message: typeof evmToolsLibIndex.isEIP712Message =
evmToolsLibIndex.isEIP712Message;

const Eth: EthClass = hwAppEth.default;
const ledgerService: LedgerService = hwAppEth.ledgerService;

const Transport: TransportNodeHidClass = hwTransport.default;

export {
DisconnectedDevice,
DisconnectedDeviceDuringOperation,
LockedDeviceError,
TransportError,
TransportStatusError,
};

export { isEIP712Message };
export type { EIP712Message, LedgerEthTransactionResolution };
export { Eth, ledgerService };
export { Transport };
export type TransportT = typeof Transport;
38 changes: 19 additions & 19 deletions packages/hardhat-ledger/src/internal/handler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { EIP712Message, TransportT } from "./cjs-imports.js";
import type { Paths, Signature, LedgerOptions } from "./types.js";
import type { EIP712Message } from "@ledgerhq/types-live";
import type {
EthereumProvider,
JsonRpcRequest,
Expand All @@ -9,16 +9,6 @@ import type * as MicroEthSignerT from "micro-eth-signer";
import type * as MicroEthSignerTypedDataT from "micro-eth-signer/typed-data";
import type * as MicroEthSignerUtilsT from "micro-eth-signer/utils";

import {
DisconnectedDevice,
DisconnectedDeviceDuringOperation,
LockedDeviceError,
TransportError,
TransportStatusError,
} from "@ledgerhq/errors";
import { isEIP712Message } from "@ledgerhq/evm-tools/lib/index";
import Eth, { ledgerService } from "@ledgerhq/hw-app-eth";
import TransportNodeHid from "@ledgerhq/hw-transport-node-hid";
import {
assertHardhatInvariant,
HardhatError,
Expand All @@ -43,6 +33,17 @@ import {
import debug from "debug";

import * as cache from "./cache.js";
import {
DisconnectedDevice,
DisconnectedDeviceDuringOperation,
Eth,
isEIP712Message,
ledgerService,
LockedDeviceError,
Transport,
TransportError,
TransportStatusError,
} from "./cjs-imports.js";
import { createTx } from "./create-tx.js";
import { getYParity } from "./get-y-parity.js";
import { PLUGIN_NAME } from "./plugin-name.js";
Expand Down Expand Up @@ -74,13 +75,13 @@ export class LedgerHandler {

readonly #provider: EthereumProvider;
readonly #displayMessage: (message: string) => Promise<void>;
readonly #ethConstructor: typeof Eth.default;
readonly #transportNodeHid: typeof TransportNodeHid.default;
readonly #ethConstructor: typeof Eth;
readonly #transportNodeHid: TransportT;
readonly #cachePath: string | undefined;
readonly #delayBeforeRetry: (seconds: number) => Promise<void>;
readonly #maxDeviceNotReadyRetries: number;

#eth: Eth.default | undefined;
#eth: InstanceType<typeof Eth> | undefined;
#chainId: bigint | undefined;

public readonly options: LedgerOptions;
Expand All @@ -93,16 +94,15 @@ export class LedgerHandler {
displayMessage: (interruptor: string, message: string) => Promise<void>,
customConfig?: {
// Allows passing a custom config, primarily used for testing
ethConstructor?: typeof Eth.default;
transportNodeHid?: typeof TransportNodeHid.default;
ethConstructor?: typeof Eth;
transportNodeHid?: TransportT;
cachePath?: string;
delayBeforeRetry?: (seconds: number) => Promise<void>;
maxDeviceNotReadyRetries?: number;
},
) {
this.#ethConstructor = customConfig?.ethConstructor ?? Eth.default;
this.#transportNodeHid =
customConfig?.transportNodeHid ?? TransportNodeHid.default;
this.#ethConstructor = customConfig?.ethConstructor ?? Eth;
this.#transportNodeHid = customConfig?.transportNodeHid ?? Transport;
this.#cachePath = customConfig?.cachePath;
this.#delayBeforeRetry = customConfig?.delayBeforeRetry ?? sleep;
this.#maxDeviceNotReadyRetries =
Expand Down
18 changes: 16 additions & 2 deletions packages/hardhat-ledger/src/internal/hook-handlers/network.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import type { LedgerHandler as LedgerHandlerT } from "../handler.js";
import type { HookContext, NetworkHooks } from "hardhat/types/hooks";
import type { ChainType, NetworkConnection } from "hardhat/types/network";
import type { JsonRpcRequest, JsonRpcResponse } from "hardhat/types/providers";

import { assertHardhatInvariant } from "@nomicfoundation/hardhat-errors";
import { AsyncMutex } from "@nomicfoundation/hardhat-utils/synchronization";

import { LedgerHandler } from "../handler.js";
import { isFailedJsonRpcResponse, isJsonRpcResponse } from "../rpc-helpers.js";

// The ledger packages have been problematic in the past, leading to errors
// and slowdowns, even when not being used, so we lazy load them now.
let LedgerHandler: typeof LedgerHandlerT | undefined;
Comment on lines +11 to +13
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LedgerHandlerT is imported with import type, so it’s a type-only symbol. Using typeof LedgerHandlerT here will not type-check (TypeScript typeof in a type position requires a value). Define a constructor type via typeof import("../handler.js").LedgerHandler (or a local type LedgerHandlerConstructor = typeof import("../handler.js")["LedgerHandler"]) and use that for the lazy-loaded variable instead.

Suggested change
// The ledger packages have been problematic in the past, leading to errors
// and slowdowns, even when not being used, so we lazy load them now.
let LedgerHandler: typeof LedgerHandlerT | undefined;
type LedgerHandlerConstructor = typeof import("../handler.js")["LedgerHandler"];
// The ledger packages have been problematic in the past, leading to errors
// and slowdowns, even when not being used, so we lazy load them now.
let LedgerHandler: LedgerHandlerConstructor | undefined;

Copilot uses AI. Check for mistakes.

export default async (): Promise<Partial<NetworkHooks>> => {
// This map is essential for managing multiple network connections in Hardhat V3.
// Since Hardhat V3 supports multiple connections, we use this map to track each one
Expand All @@ -16,7 +20,7 @@ export default async (): Promise<Partial<NetworkHooks>> => {
// See the "closeConnection" function at the end of the file for more details.
const ledgerHandlerPerConnection: WeakMap<
NetworkConnection<ChainType | string>,
LedgerHandler
LedgerHandlerT
> = new WeakMap();

const initializationMutex = new AsyncMutex();
Expand All @@ -42,11 +46,21 @@ export default async (): Promise<Partial<NetworkHooks>> => {
return next(context, networkConnection, jsonRpcRequest);
}

if (LedgerHandler === undefined) {
const handlerModule = await import("../handler.js");
LedgerHandler = handlerModule.LedgerHandler;
}

const ledgerHandler = await initializationMutex.exclusiveRun(async () => {
let handlerPerConnection =
ledgerHandlerPerConnection.get(networkConnection);

if (handlerPerConnection === undefined) {
assertHardhatInvariant(
LedgerHandler !== undefined,
"LedgerHandler should have been imported",
);

handlerPerConnection = new LedgerHandler(
networkConnection.provider,
{
Expand Down
12 changes: 7 additions & 5 deletions packages/hardhat-ledger/test/helpers/eth-mocked.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type Eth from "@ledgerhq/hw-app-eth";
import type { LedgerEthTransactionResolution } from "@ledgerhq/hw-app-eth/lib/services/types.js";
import type { EIP712Message } from "@ledgerhq/types-live";
import type {
EIP712Message,
Eth,
LedgerEthTransactionResolution,
} from "../../src/internal/cjs-imports.js";

import assert from "node:assert/strict";

Expand Down Expand Up @@ -84,7 +86,7 @@ export interface MockCallState {

export function getEthMocked(
methodsConfig: MethodsConfig,
): [typeof Eth.default, Map<string, MockCallState>] {
): [typeof Eth, Map<string, MockCallState>] {
const calls = new Map<string, MockCallState>();
Comment on lines 87 to 90
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eth is imported with import type, but the function return type and cast use typeof Eth (a value type query), which won’t typecheck. If you want the constructor type, use the imported type directly (e.g. [Eth, ...] / as unknown as Eth), or switch to a value import.

Copilot uses AI. Check for mistakes.

for (const method of [
Expand Down Expand Up @@ -269,7 +271,7 @@ export function getEthMocked(

return this.#methodsConfig.signTransaction.result;
}
} as unknown as typeof Eth.default,
} as unknown as typeof Eth,
calls,
];
}
22 changes: 22 additions & 0 deletions packages/hardhat-ledger/test/helpers/tests-cjs-imports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* @file See the comment in `src/internal/cjs-imports.ts`
*
* This is an equivalent file, but with test-only imports.
*/

import type { TransportT as TransportNodeHidT } from "../../src/internal/cjs-imports.js";
import type TransportT from "@ledgerhq/hw-transport";

import { createRequire } from "node:module";

const require = createRequire(import.meta.url);

type BaseTransportClass = (typeof TransportT)["default"];
Comment on lines +8 to +14
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TransportT is imported with import type, but then referenced in a typeof TransportT type query. This will fail to typecheck because typeof requires a value symbol. Consider replacing this with a module type query like type BaseTransportClass = (typeof import("@ledgerhq/hw-transport"))["default"]; (and remove the import type TransportT ...).

Suggested change
import type TransportT from "@ledgerhq/hw-transport";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
type BaseTransportClass = (typeof TransportT)["default"];
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
type BaseTransportClass = (typeof import("@ledgerhq/hw-transport"))["default"];

Copilot uses AI. Check for mistakes.

const hwTransport: {
default: BaseTransportClass;
} = require("@ledgerhq/hw-transport");
const BaseTransport: BaseTransportClass = hwTransport.default;

export { BaseTransport };
export { TransportNodeHidT };
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a type-only import, but it’s being re-exported as a value (export { TransportNodeHidT }). This should be a type export (export type { TransportNodeHidT }) or the file won’t compile under TypeScript’s type-only import rules.

Suggested change
export { TransportNodeHidT };
export type { TransportNodeHidT };

Copilot uses AI. Check for mistakes.
17 changes: 9 additions & 8 deletions packages/hardhat-ledger/test/helpers/transport-node-hid-mock.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,29 @@
import type TransportNodeHid from "@ledgerhq/hw-transport-node-hid";

import Transport from "@ledgerhq/hw-transport";

/**
* Mock implementation of the `TransportNodeHid` class from `@ledgerhq/hw-transport-node-hid`.
* This mock initializes a new Transport instance and exposes only the create method, which is the sole method used by hardhat-ledger.
* This mock initializes a new base `Transport` instance (from `@ledgerhq/hw-transport`) and
* exposes only the create method, which is the sole method used by hardhat-ledger.
*/

import type { TransportNodeHidT } from "./tests-cjs-imports.js";

import { BaseTransport } from "./tests-cjs-imports.js";

export interface TransportMockState {
createCount: number;
}

export function getTransportNodeHidMock(
state?: TransportMockState,
): typeof TransportNodeHid.default {
): TransportNodeHidT {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- this is a mock for testing purpose
const transportNodeHid = {
create: () => {
if (state !== undefined) {
state.createCount++;
}
return new Transport.default();
return new BaseTransport();
},
} as unknown as typeof TransportNodeHid.default;
} as unknown as TransportNodeHidT;

return transportNodeHid;
}
18 changes: 9 additions & 9 deletions packages/hardhat-ledger/test/internal/handler.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
import type Eth from "@ledgerhq/hw-app-eth";
import type { Eth } from "../../src/internal/cjs-imports.js";

import assert from "node:assert/strict";
import path from "node:path";
import { after, before, beforeEach, describe, it } from "node:test";

import {
DisconnectedDevice,
DisconnectedDeviceDuringOperation,
LockedDeviceError,
TransportStatusError,
} from "@ledgerhq/errors";
import { TransportError } from "@ledgerhq/hw-transport";
import {
assertHardhatInvariant,
HardhatError,
Expand All @@ -27,6 +20,13 @@ import {
} from "@nomicfoundation/hardhat-utils/fs";
import { numberToHexString } from "@nomicfoundation/hardhat-utils/hex";

import {
DisconnectedDevice,
DisconnectedDeviceDuringOperation,
LockedDeviceError,
TransportError,
TransportStatusError,
} from "../../src/internal/cjs-imports.js";
import { LedgerHandler } from "../../src/internal/handler.js";
import { createJsonRpcRequest } from "../helpers/create-json-rpc-request.js";
import { mockedDisplayInfo } from "../helpers/display-info-mock.js";
Expand Down Expand Up @@ -107,7 +107,7 @@ const signature =

describe("LedgerHandler", () => {
let ethereumMockedProvider: EthereumMockedProvider;
let eth: typeof Eth.default;
let eth: typeof Eth;
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eth is imported with import type, but this line uses typeof Eth (a value type query). That combination won’t typecheck. Either import Eth as a value, or keep it type-only and use the imported type directly (e.g. let eth: Eth).

Suggested change
let eth: typeof Eth;
let eth: Eth;

Copilot uses AI. Check for mistakes.
let ledgerHandler: LedgerHandler;

before(async () => {
Expand Down
Loading