Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
feat: default to bip44 accounts and simplify flows
  • Loading branch information
mathieuartu committed Dec 10, 2025
commit c14f22b36c6bec4e20734c6a8bc9237e9457f5db
1 change: 1 addition & 0 deletions packages/keyring-eth-hd/src/hd-keyring-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export class HdKeyringV2
groupIndex: addressIndex,
derivationPath: `${this.inner.hdPath}/${addressIndex}`,
},
exportable: true,
},
};

Expand Down
143 changes: 83 additions & 60 deletions packages/keyring-eth-qr/src/qr-keyring-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
KeyringAccountEntropyTypeOption,
KeyringType,
} from '@metamask/keyring-api';
import type { Hex } from '@metamask/utils';

import type { QrKeyringBridge } from '.';
import { QrKeyring } from '.';
Expand All @@ -21,6 +20,8 @@
KNOWN_HDKEY_UR,
} from '../test/fixtures';

const entropySource = HDKEY_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS.xfp;

/**
* Get a mock bridge for the QrKeyring.
*
Expand Down Expand Up @@ -48,7 +49,7 @@
});
await inner.addAccounts(accountCount);

const wrapper = new QrKeyringV2({ legacyKeyring: inner });
const wrapper = new QrKeyringV2({ legacyKeyring: inner, entropySource });
return { wrapper, inner };
}

Expand All @@ -59,7 +60,7 @@
bridge: getMockBridge(),
ur: KNOWN_HDKEY_UR,
});
const wrapper = new QrKeyringV2({ legacyKeyring: inner });
const wrapper = new QrKeyringV2({ legacyKeyring: inner, entropySource });

expect(wrapper.type).toBe(KeyringType.Qr);
expect(wrapper.capabilities).toStrictEqual({
Expand All @@ -76,7 +77,7 @@
describe('getAccounts', () => {
it('returns empty array when no device is paired', async () => {
const inner = new QrKeyring({ bridge: getMockBridge() });
const wrapper = new QrKeyringV2({ legacyKeyring: inner });
const wrapper = new QrKeyringV2({ legacyKeyring: inner, entropySource });

const accounts = await wrapper.getAccounts();

Expand Down Expand Up @@ -151,12 +152,52 @@
?.groupIndex,
).toBe(2);
});

it('throws error in HD mode when address is not in indexes map', async () => {
const inner = new QrKeyring({
bridge: getMockBridge(),
ur: KNOWN_HDKEY_UR,
});
await inner.addAccounts(1);
const wrapper = new QrKeyringV2({ legacyKeyring: inner, entropySource });

// Mock serialize to return an inconsistent state
// where the keyring mode is HD but the indexes map is empty
jest.spyOn(inner, 'serialize').mockResolvedValue({
...HDKEY_SERIALIZED_KEYRING_WITH_ACCOUNTS,
indexes: {}, // Empty indexes - inconsistent with accounts
});

await expect(wrapper.getAccounts()).rejects.toThrow(
/not found in device indexes/u,
);
});

it('uses empty string for derivationPath when getPathFromAddress returns undefined', async () => {
const inner = new QrKeyring({
bridge: getMockBridge(),
ur: KNOWN_HDKEY_UR,
});
await inner.addAccounts(1);
const wrapper = new QrKeyringV2({ legacyKeyring: inner, entropySource });

// Mock getPathFromAddress to return undefined
jest.spyOn(inner, 'getPathFromAddress').mockReturnValue(undefined);

const accounts = await wrapper.getAccounts();

expect(accounts).toHaveLength(1);
expect(
(accounts[0] as Bip44Account<KeyringAccount>).options.entropy
.derivationPath,
).toBe('');
});
});

describe('deserialize', () => {
it('deserializes the legacy keyring state', async () => {
const inner = new QrKeyring({ bridge: getMockBridge() });
const wrapper = new QrKeyringV2({ legacyKeyring: inner });
const wrapper = new QrKeyringV2({ legacyKeyring: inner, entropySource });

await wrapper.deserialize(HDKEY_SERIALIZED_KEYRING_WITH_ACCOUNTS);

Expand All @@ -170,7 +211,7 @@
});

it('clears the cache and rebuilds it', async () => {
const { wrapper, inner } = await createWrapperWithAccounts(2);

Check failure on line 214 in packages/keyring-eth-qr/src/qr-keyring-v2.test.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (22.x)

'inner' is assigned a value but never used

const accountsBefore = await wrapper.getAccounts();
expect(accountsBefore).toHaveLength(2);
Expand All @@ -187,14 +228,12 @@
});

describe('createAccounts', () => {
const entropySource = HDKEY_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS.xfp;

it('creates the first account at index 0', async () => {
const inner = new QrKeyring({
bridge: getMockBridge(),
ur: KNOWN_HDKEY_UR,
});
const wrapper = new QrKeyringV2({ legacyKeyring: inner });
const wrapper = new QrKeyringV2({ legacyKeyring: inner, entropySource });

const newAccounts = await wrapper.createAccounts({
type: 'bip44:derive-index',
Expand Down Expand Up @@ -244,7 +283,7 @@
privateKey: '0xabc',
}),
).rejects.toThrow(
'Unsupported account creation type for QrKeyring in HD mode: private-key:import',
'Unsupported account creation type for QrKeyring: private-key:import',
);
});

Expand All @@ -262,7 +301,7 @@

it('throws error when no device is paired', async () => {
const inner = new QrKeyring({ bridge: getMockBridge() });
const wrapper = new QrKeyringV2({ legacyKeyring: inner });
const wrapper = new QrKeyringV2({ legacyKeyring: inner, entropySource });

await expect(
wrapper.createAccounts({
Expand All @@ -273,26 +312,34 @@
).rejects.toThrow('No device paired. Cannot create accounts.');
});

it('throws when trying to skip indices', async () => {
const { wrapper } = await createWrapperWithAccounts(0);
it('allows deriving accounts at any index (non-sequential)', async () => {
const inner = new QrKeyring({
bridge: getMockBridge(),
ur: KNOWN_HDKEY_UR,
});
const wrapper = new QrKeyringV2({ legacyKeyring: inner, entropySource });

await expect(
wrapper.createAccounts({
type: 'bip44:derive-index',
entropySource,
groupIndex: 5,
}),
).rejects.toThrow(
'Can only create the next account in sequence. Expected groupIndex 0, got 5.',
);
// Skip index 0 and go directly to index 5
const accounts = await wrapper.createAccounts({
type: 'bip44:derive-index',
entropySource,
groupIndex: 5,
});

expect(accounts).toHaveLength(1);
expect(accounts[0]?.address).toBe(EXPECTED_ACCOUNTS[5]);
expect(
(accounts[0] as Bip44Account<KeyringAccount>).options.entropy
.groupIndex,
).toBe(5);
});

it('creates multiple accounts sequentially', async () => {
const inner = new QrKeyring({
bridge: getMockBridge(),
ur: KNOWN_HDKEY_UR,
});
const wrapper = new QrKeyringV2({ legacyKeyring: inner });
const wrapper = new QrKeyringV2({ legacyKeyring: inner, entropySource });

const account1 = await wrapper.createAccounts({
type: 'bip44:derive-index',
Expand Down Expand Up @@ -320,7 +367,7 @@
bridge: getMockBridge(),
ur: KNOWN_HDKEY_UR,
});
const wrapper = new QrKeyringV2({ legacyKeyring: inner });
const wrapper = new QrKeyringV2({ legacyKeyring: inner, entropySource });

// Mock addAccounts to return empty array
jest.spyOn(inner, 'addAccounts').mockResolvedValueOnce([]);
Expand All @@ -343,12 +390,12 @@
expect(accountsBefore).toHaveLength(3);

const accountToDelete = accountsBefore[1];
await wrapper.deleteAccount(accountToDelete!.id);

Check failure on line 393 in packages/keyring-eth-qr/src/qr-keyring-v2.test.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (22.x)

Forbidden non-null assertion

const accountsAfter = await wrapper.getAccounts();
expect(accountsAfter).toHaveLength(2);
expect(accountsAfter.map((a) => a.address)).not.toContain(
accountToDelete!.address,

Check failure on line 398 in packages/keyring-eth-qr/src/qr-keyring-v2.test.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (22.x)

Forbidden non-null assertion
);
});

Expand All @@ -358,10 +405,10 @@
const accounts = await wrapper.getAccounts();
const accountToDelete = accounts[0];

await wrapper.deleteAccount(accountToDelete!.id);

Check failure on line 408 in packages/keyring-eth-qr/src/qr-keyring-v2.test.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (22.x)

Forbidden non-null assertion

// The account should not be found by ID
await expect(wrapper.getAccount(accountToDelete!.id)).rejects.toThrow(

Check failure on line 411 in packages/keyring-eth-qr/src/qr-keyring-v2.test.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (22.x)

Forbidden non-null assertion
/Account not found/u,
);
});
Expand All @@ -380,7 +427,7 @@
const { wrapper } = await createWrapperWithAccounts(2);

const accounts = await wrapper.getAccounts();
const account = await wrapper.getAccount(accounts[0]!.id);

Check failure on line 430 in packages/keyring-eth-qr/src/qr-keyring-v2.test.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (22.x)

Forbidden non-null assertion

expect(account).toBe(accounts[0]);
});
Expand All @@ -406,9 +453,8 @@
});

describe('Account Mode (CryptoAccount)', () => {
const accountModeAddress =
'0x2043858DA83bCD92Ae342C1bAaD4D5F5B5C328B3' as Hex;
const accountModeEntropySourceId =
const accountModeAddress = '0x2043858DA83bCD92Ae342C1bAaD4D5F5B5C328B3';
const accountModeEntropySource =
ACCOUNT_SERIALIZED_KEYRING_WITH_ACCOUNTS.xfp;

/**
Expand All @@ -429,52 +475,29 @@
await inner.addAccounts(1);
}

const wrapper = new QrKeyringV2({ legacyKeyring: inner });
const wrapper = new QrKeyringV2({
legacyKeyring: inner,
entropySource: accountModeEntropySource,
});
return { wrapper, inner };
}

describe('getAccounts', () => {
it('returns accounts with PrivateKey entropy type', async () => {
it('returns accounts with Mnemonic entropy type (BIP-44 derived)', async () => {
const { wrapper } = await createAccountModeWrapper();

const accounts = await wrapper.getAccounts();

expect(accounts).toHaveLength(1);
expect(accounts[0]?.address).toBe(accountModeAddress);
expect(accounts[0]?.options?.entropy).toStrictEqual({
type: KeyringAccountEntropyTypeOption.PrivateKey,
});
});

it('does not include BIP-44 derivation fields', async () => {
const { wrapper } = await createAccountModeWrapper();

const accounts = await wrapper.getAccounts();
const entropy = accounts[0]?.options?.entropy as Record<
string,
unknown
>;

// Account mode accounts should NOT have these BIP-44 fields
expect(entropy).not.toHaveProperty('id');
expect(entropy).not.toHaveProperty('groupIndex');
expect(entropy).not.toHaveProperty('derivationPath');
});
});

describe('createAccounts', () => {
it('throws error when trying to create accounts in Account mode', async () => {
const { wrapper } = await createAccountModeWrapper(false);

await expect(
wrapper.createAccounts({
type: 'bip44:derive-index',
entropySource: accountModeEntropySourceId,
groupIndex: 0,
}),
).rejects.toThrow(
'Cannot create accounts in Account mode. Accounts are pre-defined by the device.',
const account = accounts[0] as Bip44Account<KeyringAccount>;
expect(account.options.entropy.type).toBe(
KeyringAccountEntropyTypeOption.Mnemonic,
);
expect(account.options.entropy.id).toBe(accountModeEntropySource);
expect(account.options.entropy.groupIndex).toBe(0);
expect(account.options.entropy.derivationPath).toBeDefined();
});
});

Expand All @@ -485,7 +508,7 @@
const accounts = await wrapper.getAccounts();
expect(accounts).toHaveLength(1);

await wrapper.deleteAccount(accounts[0]!.id);

Check failure on line 511 in packages/keyring-eth-qr/src/qr-keyring-v2.test.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (22.x)

Forbidden non-null assertion

const remainingAddresses = await inner.getAccounts();
expect(remainingAddresses).toHaveLength(0);
Expand Down
Loading
Loading