Skip to content

Commit 8abb329

Browse files
authored
Merge branch 'main' into cc/feat/snap-keyring-createAccounts
2 parents 33ef01f + f4f52c8 commit 8abb329

18 files changed

Lines changed: 1413 additions & 22 deletions

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ linkStyle default opacity:0.5
6060
eth_ledger_bridge_keyring --> keyring_api;
6161
eth_ledger_bridge_keyring --> keyring_utils;
6262
eth_ledger_bridge_keyring --> account_api;
63+
eth_qr_keyring --> keyring_api;
6364
eth_qr_keyring --> keyring_utils;
65+
eth_qr_keyring --> account_api;
6466
eth_simple_keyring --> keyring_api;
6567
eth_simple_keyring --> keyring_utils;
6668
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

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ module.exports = merge(baseConfig, {
2323
// An object that configures minimum threshold enforcement for coverage results
2424
coverageThreshold: {
2525
global: {
26-
branches: 93.58,
27-
functions: 98.16,
28-
lines: 97.75,
26+
branches: 93.61,
27+
functions: 98.18,
28+
lines: 97.74,
2929
statements: 97.76,
3030
},
3131
},

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

Lines changed: 117 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(transportError);
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)
12951374
expect(result).toBe(
1296-
'0x72d4e38a0e582e09a620fd38e236fe687a1ec782206b56d576f579c026a7e5b946759735981cd0c3efb02d36df28bb2feedfec3d90e408efc93f45b894946e3200',
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+
);
1410+
expect(result).toBe(
1411+
'0x72d4e38a0e582e09a620fd38e236fe687a1ec782206b56d576f579c026a7e5b946759735981cd0c3efb02d36df28bb2feedfec3d90e408efc93f45b894946e321b',
12971412
);
12981413
});
12991414

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

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -458,10 +458,9 @@ export class LedgerKeyring implements Keyring {
458458
);
459459
}
460460

461-
let modifiedV = parseInt(String(payload.v), 10).toString(16);
462-
if (modifiedV.length < 2) {
463-
modifiedV = `0${modifiedV}`;
464-
}
461+
const modifiedV = this.#normalizeRecoveryParam(
462+
parseInt(String(payload.v), 10),
463+
);
465464

466465
const signature = `0x${payload.r}${payload.s}${modifiedV}`;
467466
const addressSignedWith = recoverPersonalSignature({
@@ -551,10 +550,9 @@ export class LedgerKeyring implements Keyring {
551550
);
552551
}
553552

554-
let recoveryId = parseInt(String(payload.v), 10).toString(16);
555-
if (recoveryId.length < 2) {
556-
recoveryId = `0${recoveryId}`;
557-
}
553+
const recoveryId = this.#normalizeRecoveryParam(
554+
parseInt(String(payload.v), 10),
555+
);
558556
const signature = `0x${payload.r}${payload.s}${recoveryId}`;
559557
const addressSignedWith = recoverTypedSignature({
560558
data,
@@ -697,4 +695,19 @@ export class LedgerKeyring implements Keyring {
697695
#getChecksumHexAddress(address: string): Hex {
698696
return getChecksumAddress(add0x(address));
699697
}
698+
699+
/**
700+
* Normalizes the signature recovery parameter (v) to legacy format.
701+
* Ledger devices may return v as 0 or 1 (modern format), but signature
702+
* recovery expects 27 or 28 (legacy format per EIP-191/EIP-712).
703+
*
704+
* @param recoveryParam - The recovery parameter from Ledger.
705+
* @returns The normalized recovery parameter as a hex string.
706+
*/
707+
#normalizeRecoveryParam(recoveryParam: number): string {
708+
if (recoveryParam === 0 || recoveryParam === 1) {
709+
return (recoveryParam + 27).toString(16);
710+
}
711+
return recoveryParam.toString(16);
712+
}
700713
}

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)