From 7275dbcf64037e6a87cd8a4ae1adca7734e571c0 Mon Sep 17 00:00:00 2001 From: Xiaoming Wang Date: Tue, 21 Apr 2026 10:10:16 +0800 Subject: [PATCH 1/5] fix(ledger-bridge): fall back to blind signing when clear-sign fails Mirror metamask-extension PR #41948: try clearSignTransaction first, then signTransaction(..., null) when resolution fails. Re-throw user rejection (0x6985) without fallback. Add @ledgerhq/hw-transport as a runtime dependency. --- .../keyring-eth-ledger-bridge/CHANGELOG.md | 4 ++ .../keyring-eth-ledger-bridge/package.json | 2 +- .../src/ledger-mobile-bridge.test.ts | 53 ++++++++++++++++++- .../src/ledger-mobile-bridge.ts | 46 +++++++++++++--- 4 files changed, 97 insertions(+), 8 deletions(-) diff --git a/packages/keyring-eth-ledger-bridge/CHANGELOG.md b/packages/keyring-eth-ledger-bridge/CHANGELOG.md index 0cbb65e96..e694dbafd 100644 --- a/packages/keyring-eth-ledger-bridge/CHANGELOG.md +++ b/packages/keyring-eth-ledger-bridge/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Fall back to blind signing on mobile when `clearSignTransaction` fails, except when the user rejects on the device ([#TODO](https://github.com/MetaMask/accounts/pull/TODO)) + ## [12.0.1] ### Changed diff --git a/packages/keyring-eth-ledger-bridge/package.json b/packages/keyring-eth-ledger-bridge/package.json index 4a1cbca22..1667f0a76 100644 --- a/packages/keyring-eth-ledger-bridge/package.json +++ b/packages/keyring-eth-ledger-bridge/package.json @@ -72,6 +72,7 @@ "@ethereumjs/tx": "^5.4.0", "@ethereumjs/util": "^9.1.0", "@ledgerhq/hw-app-eth": "^6.42.0", + "@ledgerhq/hw-transport": "^6.31.3", "@metamask/eth-sig-util": "^8.2.0", "@metamask/hw-wallet-sdk": "^0.8.0", "@metamask/keyring-api": "^23.0.1", @@ -82,7 +83,6 @@ "@ethereumjs/common": "^4.4.0", "@lavamoat/allow-scripts": "^3.2.1", "@lavamoat/preinstall-always-fail": "^2.1.0", - "@ledgerhq/hw-transport": "^6.31.3", "@ledgerhq/types-cryptoassets": "^7.15.1", "@ledgerhq/types-devices": "^6.25.3", "@ledgerhq/types-live": "^6.52.0", diff --git a/packages/keyring-eth-ledger-bridge/src/ledger-mobile-bridge.test.ts b/packages/keyring-eth-ledger-bridge/src/ledger-mobile-bridge.test.ts index dea82502c..79e1b98af 100644 --- a/packages/keyring-eth-ledger-bridge/src/ledger-mobile-bridge.test.ts +++ b/packages/keyring-eth-ledger-bridge/src/ledger-mobile-bridge.test.ts @@ -1,7 +1,7 @@ import { Common, Chain, Hardfork } from '@ethereumjs/common'; import { TransactionFactory } from '@ethereumjs/tx'; import { bytesToHex } from '@ethereumjs/util'; -import Transport from '@ledgerhq/hw-transport'; +import Transport, { TransportStatusError } from '@ledgerhq/hw-transport'; import { EIP712Message } from '@ledgerhq/types-live'; import { remove0x } from '@metamask/utils'; @@ -25,6 +25,7 @@ describe('LedgerMobileBridge', function () { const mockEthApp = { signEIP712Message: jest.fn(), clearSignTransaction: jest.fn(), + signTransaction: jest.fn(), getAddress: jest.fn(), signPersonalMessage: jest.fn(), openEthApp: jest.fn(), @@ -248,6 +249,56 @@ describe('LedgerMobileBridge', function () { nft: false, }); }); + + it('falls back to blind signing when clear-sign fails for unsupported resolution', async function () { + const hdPath = "m/44'/60'/0'/0/0"; + const tx = + 'f86d8202b38477359400825208944592d8f8d7b001e72cb26a73e4fa1806a51ac79d880de0b6b3a7640000802ba0699ff162205967ccbabae13e07cdd4284258d46ec1051a70a51be51ec2bc69f3a04e6944d508244ea54a62ebf9a72683eeadacb73ad7c373ee542f1998147b220e'; + const blindSignResult = { + v: '2b', + r: '0699ff162205967ccbabae13e07cdd4284258d46ec1051a70a51be51ec2bc69f3', + s: '04e6944d508244ea54a62ebf9a72683eeadacb73ad7c373ee542f1998147b220e', + }; + mockEthApp.clearSignTransaction.mockRejectedValueOnce( + new Error('resolution failed'), + ); + mockEthApp.signTransaction.mockResolvedValueOnce(blindSignResult); + + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const result = await bridge.deviceSignTransaction({ + hdPath, + tx, + }); + + expect(result).toStrictEqual(blindSignResult); + expect(mockEthApp.clearSignTransaction).toHaveBeenCalledTimes(1); + expect(mockEthApp.signTransaction).toHaveBeenCalledTimes(1); + expect(mockEthApp.signTransaction).toHaveBeenCalledWith(hdPath, tx, null); + expect(consoleSpy).toHaveBeenCalledTimes(1); + expect(consoleSpy.mock.calls[0]?.[0]).toBe( + 'Ledger clear-sign failed; falling back to blind signing.', + ); + + consoleSpy.mockRestore(); + }); + + it('does not fall back when the user rejects on the device', async function () { + const hdPath = "m/44'/60'/0'/0/0"; + const tx = + 'f86d8202b38477359400825208944592d8f8d7b001e72cb26a73e4fa1806a51ac79d880de0b6b3a7640000802ba0699ff162205967ccbabae13e07cdd4284258d46ec1051a70a51be51ec2bc69f3a04e6944d508244ea54a62ebf9a72683eeadacb73ad7c373ee542f1998147b220e'; + const rejection = new TransportStatusError(0x6985); + mockEthApp.clearSignTransaction.mockRejectedValueOnce(rejection); + + await expect( + bridge.deviceSignTransaction({ + hdPath, + tx, + }), + ).rejects.toThrow(rejection); + + expect(mockEthApp.signTransaction).not.toHaveBeenCalled(); + }); }); describe('deviceSignTypedData', function () { diff --git a/packages/keyring-eth-ledger-bridge/src/ledger-mobile-bridge.ts b/packages/keyring-eth-ledger-bridge/src/ledger-mobile-bridge.ts index 152854cfe..ae1ac2a9a 100644 --- a/packages/keyring-eth-ledger-bridge/src/ledger-mobile-bridge.ts +++ b/packages/keyring-eth-ledger-bridge/src/ledger-mobile-bridge.ts @@ -1,4 +1,4 @@ -import type Transport from '@ledgerhq/hw-transport'; +import Transport, { TransportStatusError } from '@ledgerhq/hw-transport'; import { ERC20_WRITE_SELECTORS, NFT_ONLY_SELECTORS } from './constants'; import { @@ -19,6 +19,9 @@ import { TransportMiddleware } from './ledger-transport-middleware'; import { LedgerMobileBridgeOptions } from './type'; import { getTransactionSelector } from './utils'; +/** Ledger APDU: CONDITIONS_OF_USE_NOT_SATISFIED (user rejected on device). */ +const LEDGER_USER_REJECTION_STATUS = 0x6985; + // MobileBridge Type will always use LedgerBridge with LedgerMobileBridgeOptions export type MobileBridge = LedgerBridge & { openEthApp(): Promise; @@ -102,6 +105,9 @@ export class LedgerMobileBridge implements MobileBridge { * Method to sign a transaction * Sending the hexadecimal transaction message to the device and returning the signed transaction. * + * Tries clear-signing first. If resolution fails (e.g. no Ledger plugin for the chain or contract), + * falls back to blind signing via `signTransaction(..., null)` unless the user rejected on the device. + * * @param params - An object contains tx, hdPath. * @param params.tx - The raw ethereum transaction in hexadecimal to sign. * @param params.hdPath - The BIP 32 path of the account. @@ -115,11 +121,25 @@ export class LedgerMobileBridge implements MobileBridge { const nft = Boolean(selector && NFT_ONLY_SELECTORS.has(selector)); const erc20 = Boolean(selector && ERC20_WRITE_SELECTORS.has(selector)); - return this.#getEthApp().clearSignTransaction(hdPath, tx, { - externalPlugins: true, - erc20, - nft, - }); + const ethApp = this.#getEthApp(); + + try { + return await ethApp.clearSignTransaction(hdPath, tx, { + externalPlugins: true, + erc20, + nft, + }); + } catch (error: unknown) { + if (LedgerMobileBridge.#isLedgerUserRejection(error)) { + throw error; + } + + console.warn( + 'Ledger clear-sign failed; falling back to blind signing.', + error, + ); + return ethApp.signTransaction(hdPath, tx, null); + } } /** @@ -235,4 +255,18 @@ export class LedgerMobileBridge implements MobileBridge { #getEthApp(): MetaMaskLedgerHwAppEth { return this.#getTransportMiddleWare().getEthApp(); } + + /** + * Detects an explicit on-device rejection so we do not fall back to blind signing + * and re-prompt the user. + * + * @param error - Error from Ledger transport or hw-app-eth. + * @returns True when the user rejected the action on the device. + */ + static #isLedgerUserRejection(error: unknown): boolean { + return ( + error instanceof TransportStatusError && + error.statusCode === LEDGER_USER_REJECTION_STATUS + ); + } } From ae693bdfe121db6a2aa22a9ee59cac791282c2f7 Mon Sep 17 00:00:00 2001 From: Xiaoming Wang Date: Wed, 6 May 2026 15:05:07 +0800 Subject: [PATCH 2/5] Update CHANGELOG.md to reflect the addition of a fix for mobile blind signing fallback and bump @metamask/keyring-sdk version to 2.1.1. --- packages/keyring-eth-ledger-bridge/CHANGELOG.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/keyring-eth-ledger-bridge/CHANGELOG.md b/packages/keyring-eth-ledger-bridge/CHANGELOG.md index 9cef64b41..4fcaa8f91 100644 --- a/packages/keyring-eth-ledger-bridge/CHANGELOG.md +++ b/packages/keyring-eth-ledger-bridge/CHANGELOG.md @@ -7,13 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Fixed - -- Fall back to blind signing on mobile when `clearSignTransaction` fails, except when the user rejects on the device ([#TODO](https://github.com/MetaMask/accounts/pull/TODO)) ### Changed - Bump `@metamask/keyring-sdk` from `^2.0.2` to `^2.1.1` ([#544](https://github.com/MetaMask/accounts/pull/544), [#546](https://github.com/MetaMask/accounts/pull/546)) +### Fixed + +- Fall back to blind signing on mobile when `clearSignTransaction` fails, except when the user rejects on the device ([#TODO](https://github.com/MetaMask/accounts/pull/TODO)) + ## [12.0.2] ### Changed From 6f574568d77d4d3b7b6c577af76c6786611c629d Mon Sep 17 00:00:00 2001 From: Xiaoming Wang Date: Wed, 6 May 2026 16:07:30 +0800 Subject: [PATCH 3/5] chore: replace TODO PR placeholder with #522 in ledger-bridge changelog The Check Changelog CI parses the Unreleased section and requires the entry to link to the actual PR number. Replace the `#TODO` placeholder with the real PR link so the check passes. Co-authored-by: Cursor --- packages/keyring-eth-ledger-bridge/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/keyring-eth-ledger-bridge/CHANGELOG.md b/packages/keyring-eth-ledger-bridge/CHANGELOG.md index 4fcaa8f91..a2451114b 100644 --- a/packages/keyring-eth-ledger-bridge/CHANGELOG.md +++ b/packages/keyring-eth-ledger-bridge/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Fall back to blind signing on mobile when `clearSignTransaction` fails, except when the user rejects on the device ([#TODO](https://github.com/MetaMask/accounts/pull/TODO)) +- Fall back to blind signing on mobile when `clearSignTransaction` fails, except when the user rejects on the device ([#522](https://github.com/MetaMask/accounts/pull/522)) ## [12.0.2] From 37fa711cbb9dc68ce12feb8a6374ccd54670e61f Mon Sep 17 00:00:00 2001 From: Xiaoming Wang Date: Wed, 6 May 2026 16:18:14 +0800 Subject: [PATCH 4/5] Revert to main version --- packages/keyring-eth-ledger-bridge/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/keyring-eth-ledger-bridge/package.json b/packages/keyring-eth-ledger-bridge/package.json index 4fef234bc..cee9ea818 100644 --- a/packages/keyring-eth-ledger-bridge/package.json +++ b/packages/keyring-eth-ledger-bridge/package.json @@ -72,7 +72,6 @@ "@ethereumjs/tx": "^5.4.0", "@ethereumjs/util": "^9.1.0", "@ledgerhq/hw-app-eth": "^6.42.0", - "@ledgerhq/hw-transport": "^6.31.3", "@metamask/eth-sig-util": "^8.2.0", "@metamask/hw-wallet-sdk": "^0.8.0", "@metamask/keyring-api": "^23.1.0", @@ -83,6 +82,7 @@ "@ethereumjs/common": "^4.4.0", "@lavamoat/allow-scripts": "^3.2.1", "@lavamoat/preinstall-always-fail": "^2.1.0", + "@ledgerhq/hw-transport": "^6.31.3", "@ledgerhq/types-cryptoassets": "^7.15.1", "@ledgerhq/types-devices": "^6.25.3", "@ledgerhq/types-live": "^6.52.0", From b71f9a7d48369cce780a5ff9e261b6a39bc5754b Mon Sep 17 00:00:00 2001 From: Xiaoming Wang Date: Wed, 6 May 2026 17:03:05 +0800 Subject: [PATCH 5/5] chore: update package dependencies and import structure in keyring-eth-ledger-bridge - Added `@ledgerhq/hw-transport` dependency to `package.json`. - Modified import statement in `ledger-mobile-bridge.ts` to use type-only import for `Transport` from `@ledgerhq/hw-transport`. --- packages/keyring-eth-ledger-bridge/package.json | 2 +- packages/keyring-eth-ledger-bridge/src/ledger-mobile-bridge.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/keyring-eth-ledger-bridge/package.json b/packages/keyring-eth-ledger-bridge/package.json index cee9ea818..4fef234bc 100644 --- a/packages/keyring-eth-ledger-bridge/package.json +++ b/packages/keyring-eth-ledger-bridge/package.json @@ -72,6 +72,7 @@ "@ethereumjs/tx": "^5.4.0", "@ethereumjs/util": "^9.1.0", "@ledgerhq/hw-app-eth": "^6.42.0", + "@ledgerhq/hw-transport": "^6.31.3", "@metamask/eth-sig-util": "^8.2.0", "@metamask/hw-wallet-sdk": "^0.8.0", "@metamask/keyring-api": "^23.1.0", @@ -82,7 +83,6 @@ "@ethereumjs/common": "^4.4.0", "@lavamoat/allow-scripts": "^3.2.1", "@lavamoat/preinstall-always-fail": "^2.1.0", - "@ledgerhq/hw-transport": "^6.31.3", "@ledgerhq/types-cryptoassets": "^7.15.1", "@ledgerhq/types-devices": "^6.25.3", "@ledgerhq/types-live": "^6.52.0", diff --git a/packages/keyring-eth-ledger-bridge/src/ledger-mobile-bridge.ts b/packages/keyring-eth-ledger-bridge/src/ledger-mobile-bridge.ts index ae1ac2a9a..105c79c81 100644 --- a/packages/keyring-eth-ledger-bridge/src/ledger-mobile-bridge.ts +++ b/packages/keyring-eth-ledger-bridge/src/ledger-mobile-bridge.ts @@ -1,4 +1,5 @@ -import Transport, { TransportStatusError } from '@ledgerhq/hw-transport'; +import { TransportStatusError } from '@ledgerhq/hw-transport'; +import type Transport from '@ledgerhq/hw-transport'; import { ERC20_WRITE_SELECTORS, NFT_ONLY_SELECTORS } from './constants'; import {