Skip to content

Commit ab1f690

Browse files
committed
Merge remote-tracking branch 'origin' into feat/ledger-use-hardware-wallet-error
2 parents bc365d1 + f4f52c8 commit ab1f690

17 files changed

Lines changed: 1434 additions & 20 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ linkStyle default opacity:0.5
6161
eth_ledger_bridge_keyring --> keyring_api;
6262
eth_ledger_bridge_keyring --> keyring_utils;
6363
eth_ledger_bridge_keyring --> account_api;
64+
eth_qr_keyring --> keyring_api;
6465
eth_qr_keyring --> keyring_utils;
66+
eth_qr_keyring --> account_api;
6567
eth_simple_keyring --> keyring_api;
6668
eth_simple_keyring --> keyring_utils;
6769
eth_trezor_keyring --> keyring_api;

packages/keyring-api/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919

2020
### Changed
2121

22+
- Change `KeyringWrapper.capabilities` from a readonly property to a getter ([#447](https://github.com/MetaMask/accounts/pull/447))
23+
- Allows subclasses to override and return capabilities dynamically based on runtime state.
2224
- Refine `EthAddressStruct` in order to make it compatible with the `Hex` type from `@metamask/utils` ([#405](https://github.com/MetaMask/accounts/pull/405))
2325

2426
## [21.3.0]

packages/keyring-api/src/api/v2/wrapper/keyring-wrapper.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export abstract class KeyringWrapper<
5151
{
5252
readonly type: `${KeyringType}`;
5353

54-
readonly capabilities: KeyringCapabilities;
54+
readonly #capabilities: KeyringCapabilities;
5555

5656
protected readonly inner: InnerKeyring;
5757

@@ -74,7 +74,19 @@ export abstract class KeyringWrapper<
7474
constructor(options: KeyringWrapperOptions<InnerKeyring>) {
7575
this.inner = options.inner;
7676
this.type = `${options.type}`;
77-
this.capabilities = options.capabilities;
77+
this.#capabilities = options.capabilities;
78+
}
79+
80+
/**
81+
* Get the capabilities of this keyring.
82+
*
83+
* Subclasses can override this getter to return capabilities dynamically
84+
* based on runtime state.
85+
*
86+
* @returns The keyring's capabilities.
87+
*/
88+
get capabilities(): KeyringCapabilities {
89+
return this.#capabilities;
7890
}
7991

8092
/**

packages/keyring-eth-ledger-bridge/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- Add `LedgerKeyringV2` class implementing `KeyringV2` interface ([#416](https://github.com/MetaMask/accounts/pull/416))
13+
14+
### Fixed
15+
16+
- Normalize signature `v` value from Ledger devices for proper recovery ([#449](https://github.com/MetaMask/accounts/pull/449))
1317
- Wraps legacy `LedgerKeyring` to expose accounts via the unified `KeyringV2` API and the `KeyringAccount` type.
1418
- Extends `EthKeyringWrapper` for common Ethereum logic.
1519
- Integrate `@metamask/hw-wallet-sdk` for standardized hardware wallet error handling ([#446](https://github.com/MetaMask/accounts/pull/446))

packages/keyring-eth-ledger-bridge/jest.config.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ module.exports = merge(baseConfig, {
2626
// An object that configures minimum threshold enforcement for coverage results
2727
coverageThreshold: {
2828
global: {
29-
branches: 93.75,
30-
functions: 98.26,
31-
lines: 97.84,
29+
branches: 93.78,
30+
functions: 98.27,
31+
lines: 97.83,
3232
statements: 97.85,
3333
},
3434
},

packages/keyring-eth-ledger-bridge/src/ledger-keyring.test.ts

Lines changed: 133 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -947,6 +947,82 @@ describe('LedgerKeyring', function () {
947947
keyring.signPersonalMessage(fakeAccounts[0], 'some message'),
948948
).rejects.toThrow('Some other transport error');
949949
});
950+
951+
it('normalizes v=0 to v=27 for proper signature recovery', async function () {
952+
await basicSetupToUnlockOneAccount();
953+
jest
954+
.spyOn(keyring.bridge, 'deviceSignMessage')
955+
.mockResolvedValue({ v: 0, r: 'aabbccdd', s: '11223344' });
956+
957+
jest
958+
.spyOn(sigUtil, 'recoverPersonalSignature')
959+
.mockReturnValue(fakeAccounts[0]);
960+
961+
const result = await keyring.signPersonalMessage(
962+
fakeAccounts[0],
963+
'some message',
964+
);
965+
966+
// v=0 should be normalized to v=27 (0x1b)
967+
expect(result).toBe('0xaabbccdd112233441b');
968+
});
969+
970+
it('normalizes v=1 to v=28 for proper signature recovery', async function () {
971+
await basicSetupToUnlockOneAccount();
972+
jest
973+
.spyOn(keyring.bridge, 'deviceSignMessage')
974+
.mockResolvedValue({ v: 1, r: 'aabbccdd', s: '11223344' });
975+
976+
jest
977+
.spyOn(sigUtil, 'recoverPersonalSignature')
978+
.mockReturnValue(fakeAccounts[0]);
979+
980+
const result = await keyring.signPersonalMessage(
981+
fakeAccounts[0],
982+
'some message',
983+
);
984+
985+
// v=1 should be normalized to v=28 (0x1c)
986+
expect(result).toBe('0xaabbccdd112233441c');
987+
});
988+
989+
it('preserves v=27 unchanged', async function () {
990+
await basicSetupToUnlockOneAccount();
991+
jest
992+
.spyOn(keyring.bridge, 'deviceSignMessage')
993+
.mockResolvedValue({ v: 27, r: 'aabbccdd', s: '11223344' });
994+
995+
jest
996+
.spyOn(sigUtil, 'recoverPersonalSignature')
997+
.mockReturnValue(fakeAccounts[0]);
998+
999+
const result = await keyring.signPersonalMessage(
1000+
fakeAccounts[0],
1001+
'some message',
1002+
);
1003+
1004+
// v=27 should remain as 0x1b
1005+
expect(result).toBe('0xaabbccdd112233441b');
1006+
});
1007+
1008+
it('preserves v=28 unchanged', async function () {
1009+
await basicSetupToUnlockOneAccount();
1010+
jest
1011+
.spyOn(keyring.bridge, 'deviceSignMessage')
1012+
.mockResolvedValue({ v: 28, r: 'aabbccdd', s: '11223344' });
1013+
1014+
jest
1015+
.spyOn(sigUtil, 'recoverPersonalSignature')
1016+
.mockReturnValue(fakeAccounts[0]);
1017+
1018+
const result = await keyring.signPersonalMessage(
1019+
fakeAccounts[0],
1020+
'some message',
1021+
);
1022+
1023+
// v=28 should remain as 0x1c
1024+
expect(result).toBe('0xaabbccdd112233441c');
1025+
});
9501026
});
9511027

9521028
describe('signMessage', function () {
@@ -1279,7 +1355,9 @@ describe('LedgerKeyring', function () {
12791355
).rejects.toThrow('Ledger: Unknown error while signing message');
12801356
});
12811357

1282-
it('returns signature when recoveryId length < 2', async function () {
1358+
it('normalizes v=0 to v=27 for proper signature recovery', async function () {
1359+
// Ledger may return v as 0 or 1 (modern format), but signature
1360+
// recovery expects 27 or 28 (legacy format)
12831361
jest.spyOn(keyring.bridge, 'deviceSignTypedData').mockResolvedValue({
12841362
v: 0,
12851363
r: '72d4e38a0e582e09a620fd38e236fe687a1ec782206b56d576f579c026a7e5b9',
@@ -1292,8 +1370,45 @@ describe('LedgerKeyring', function () {
12921370
version: sigUtil.SignTypedDataVersion.V4,
12931371
},
12941372
);
1373+
// v=0 should be normalized to v=27 (0x1b)
1374+
expect(result).toBe(
1375+
'0x72d4e38a0e582e09a620fd38e236fe687a1ec782206b56d576f579c026a7e5b946759735981cd0c3efb02d36df28bb2feedfec3d90e408efc93f45b894946e321b',
1376+
);
1377+
});
1378+
1379+
it('normalizes v=1 to v=28 for proper signature recovery', async function () {
1380+
jest.spyOn(keyring.bridge, 'deviceSignTypedData').mockResolvedValue({
1381+
v: 1,
1382+
r: '72d4e38a0e582e09a620fd38e236fe687a1ec782206b56d576f579c026a7e5b9',
1383+
s: '46759735981cd0c3efb02d36df28bb2feedfec3d90e408efc93f45b894946e32',
1384+
});
1385+
1386+
// v=1 should be normalized to v=28 (0x1c), but this will fail
1387+
// address recovery since the correct v for this signature is 27
1388+
await expect(
1389+
keyring.signTypedData(fakeAccounts[15], fixtureData, {
1390+
version: sigUtil.SignTypedDataVersion.V4,
1391+
}),
1392+
).rejects.toThrow(
1393+
'Ledger: The signature doesnt match the right address',
1394+
);
1395+
});
1396+
1397+
it('preserves v=27 unchanged', async function () {
1398+
jest.spyOn(keyring.bridge, 'deviceSignTypedData').mockResolvedValue({
1399+
v: 27,
1400+
r: '72d4e38a0e582e09a620fd38e236fe687a1ec782206b56d576f579c026a7e5b9',
1401+
s: '46759735981cd0c3efb02d36df28bb2feedfec3d90e408efc93f45b894946e32',
1402+
});
1403+
const result = await keyring.signTypedData(
1404+
fakeAccounts[15],
1405+
fixtureData,
1406+
{
1407+
version: sigUtil.SignTypedDataVersion.V4,
1408+
},
1409+
);
12951410
expect(result).toBe(
1296-
'0x72d4e38a0e582e09a620fd38e236fe687a1ec782206b56d576f579c026a7e5b946759735981cd0c3efb02d36df28bb2feedfec3d90e408efc93f45b894946e3200',
1411+
'0x72d4e38a0e582e09a620fd38e236fe687a1ec782206b56d576f579c026a7e5b946759735981cd0c3efb02d36df28bb2feedfec3d90e408efc93f45b894946e321b',
12971412
);
12981413
});
12991414

@@ -1421,6 +1536,22 @@ describe('LedgerKeyring', function () {
14211536
expect(result).toStrictEqual(mockResponse);
14221537
expect(result.arbitraryDataEnabled).toBe(0);
14231538
});
1539+
1540+
it('handles TransportStatusError when getting app configuration', async function () {
1541+
const transportError = {
1542+
statusCode: 27013,
1543+
message: 'Ledger device: (denied by the user?) (0x6985)',
1544+
name: 'TransportStatusError',
1545+
};
1546+
Object.setPrototypeOf(transportError, TransportStatusError.prototype);
1547+
jest
1548+
.spyOn(keyring.bridge, 'getAppConfiguration')
1549+
.mockRejectedValue(transportError);
1550+
1551+
await expect(keyring.getAppConfiguration()).rejects.toThrow(
1552+
'User rejected action on device',
1553+
);
1554+
});
14241555
});
14251556

14261557
describe('destroy', function () {

packages/keyring-eth-ledger-bridge/src/ledger-keyring.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,14 @@ export class LedgerKeyring implements Keyring {
346346
}
347347

348348
async getAppConfiguration(): Promise<AppConfigurationResponse> {
349-
return await this.bridge.getAppConfiguration();
349+
try {
350+
return await this.bridge.getAppConfiguration();
351+
} catch (error: unknown) {
352+
return handleLedgerTransportError(
353+
error,
354+
'Ledger: Unknown error while getting app configuration',
355+
);
356+
}
350357
}
351358

352359
// tx is an instance of the ethereumjs-transaction class.
@@ -478,10 +485,9 @@ export class LedgerKeyring implements Keyring {
478485
);
479486
}
480487

481-
let modifiedV = parseInt(String(payload.v), 10).toString(16);
482-
if (modifiedV.length < 2) {
483-
modifiedV = `0${modifiedV}`;
484-
}
488+
const modifiedV = this.#normalizeRecoveryParam(
489+
parseInt(String(payload.v), 10),
490+
);
485491

486492
const signature = `0x${payload.r}${payload.s}${modifiedV}`;
487493
const addressSignedWith = recoverPersonalSignature({
@@ -571,10 +577,9 @@ export class LedgerKeyring implements Keyring {
571577
);
572578
}
573579

574-
let recoveryId = parseInt(String(payload.v), 10).toString(16);
575-
if (recoveryId.length < 2) {
576-
recoveryId = `0${recoveryId}`;
577-
}
580+
const recoveryId = this.#normalizeRecoveryParam(
581+
parseInt(String(payload.v), 10),
582+
);
578583
const signature = `0x${payload.r}${payload.s}${recoveryId}`;
579584
const addressSignedWith = recoverTypedSignature({
580585
data,
@@ -717,4 +722,19 @@ export class LedgerKeyring implements Keyring {
717722
#getChecksumHexAddress(address: string): Hex {
718723
return getChecksumAddress(add0x(address));
719724
}
725+
726+
/**
727+
* Normalizes the signature recovery parameter (v) to legacy format.
728+
* Ledger devices may return v as 0 or 1 (modern format), but signature
729+
* recovery expects 27 or 28 (legacy format per EIP-191/EIP-712).
730+
*
731+
* @param recoveryParam - The recovery parameter from Ledger.
732+
* @returns The normalized recovery parameter as a hex string.
733+
*/
734+
#normalizeRecoveryParam(recoveryParam: number): string {
735+
if (recoveryParam === 0 || recoveryParam === 1) {
736+
return (recoveryParam + 27).toString(16);
737+
}
738+
return recoveryParam.toString(16);
739+
}
720740
}

packages/keyring-eth-qr/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add `QrKeyringV2` class implementing `KeyringV2` interface ([#411](https://github.com/MetaMask/accounts/pull/411)), ([#447](https://github.com/MetaMask/accounts/pull/447))
13+
- Wraps legacy `QrKeyring` to expose accounts via the unified `KeyringV2` API and the `KeyringAccount` type.
14+
- Extends `EthKeyringWrapper` for common Ethereum logic.
15+
1016
## [1.1.0]
1117

1218
### Added

packages/keyring-eth-qr/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"@ethereumjs/util": "^9.1.0",
5454
"@keystonehq/bc-ur-registry-eth": "^0.19.1",
5555
"@metamask/eth-sig-util": "^8.2.0",
56+
"@metamask/keyring-api": "workspace:^",
5657
"@metamask/keyring-utils": "workspace:^",
5758
"@metamask/utils": "^11.1.0",
5859
"async-mutex": "^0.5.0",
@@ -63,6 +64,7 @@
6364
"@ethereumjs/common": "^4.4.0",
6465
"@keystonehq/metamask-airgapped-keyring": "^0.15.2",
6566
"@lavamoat/allow-scripts": "^3.2.1",
67+
"@metamask/account-api": "workspace:^",
6668
"@metamask/auto-changelog": "^3.4.4",
6769
"@types/hdkey": "^2.0.1",
6870
"@types/jest": "^29.5.12",

packages/keyring-eth-qr/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,8 @@ export {
1717
type SerializedQrKeyringState,
1818
type SerializedUR,
1919
} from './qr-keyring';
20+
export {
21+
QrKeyringV2,
22+
type QrKeyringV2Options,
23+
type QrAccountModeCreateOptions,
24+
} from './qr-keyring-v2';

0 commit comments

Comments
 (0)