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
23 changes: 23 additions & 0 deletions packages/keyring-eth-hd/src/hd-keyring-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,29 @@ describe('HdKeyringV2', () => {
// Should be a different instance after deserialize
expect(accounts2[0]).not.toBe(accounts1[0]);
});

it('properly repopulates registry after deserialize with deterministic IDs', async () => {
await inner.addAccounts(2);
const accounts1 = await wrapper.getAccounts();
const firstAccountId = accounts1[0]?.id;

// Create a fresh wrapper with the same mnemonic but only 1 account
const newInner = new HdKeyring();
const newWrapper = new HdKeyringV2({
legacyKeyring: newInner,
entropySource: TEST_ENTROPY_SOURCE_ID,
});
await newWrapper.deserialize({
mnemonic: Array.from(Buffer.from(TEST_MNEMONIC, 'utf8')),
numberOfAccounts: 1,
hdPath: "m/44'/60'/0'/0",
});

const accounts2 = await newWrapper.getAccounts();
expect(accounts2).toHaveLength(1);
// Same mnemonic + same path + same index -> same deterministic ID
expect(accounts2[0]?.id).toBe(firstAccountId);
});
});

describe('createAccounts', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,27 @@ describe('LedgerKeyringV2', () => {
// The accounts should be new objects (cache was cleared)
expect(accountsAfter[0]).not.toBe(accountsBefore[0]);
});

it('properly repopulates registry after deserialize with deterministic IDs', async () => {
const { wrapper } = await createWrapperWithAccounts(2);
const accounts1 = await wrapper.getAccounts();
const firstAccountId = accounts1[0]?.id;

// Re-deserialize with same 2 accounts
await wrapper.deserialize({
hdPath: `m/44'/60'/0'`,
accounts: [EXPECTED_ACCOUNTS[0], EXPECTED_ACCOUNTS[1]],
accountDetails: {
[EXPECTED_ACCOUNTS[0]]: { bip44: false, hdPath: `m/44'/60'/0'/0` },
[EXPECTED_ACCOUNTS[1]]: { bip44: false, hdPath: `m/44'/60'/0'/1` },
},
});

const accounts2 = await wrapper.getAccounts();
expect(accounts2).toHaveLength(2);
// Same address -> same deterministic ID
expect(accounts2[0]?.id).toBe(firstAccountId);
});
});

describe('createAccounts', () => {
Expand Down
14 changes: 14 additions & 0 deletions packages/keyring-eth-qr/src/qr-keyring-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,20 @@ describe('QrKeyringV2', () => {
},
});
});

it('properly repopulates registry after deserialize with deterministic IDs', async () => {
const { wrapper } = await createWrapperWithAccounts(3);
const accounts1 = await wrapper.getAccounts();
const firstAccountId = accounts1[0]?.id;

// Re-deserialize with the same data
await wrapper.deserialize(HDKEY_SERIALIZED_KEYRING_WITH_ACCOUNTS);

const accounts2 = await wrapper.getAccounts();
expect(accounts2).toHaveLength(3);
// Same address -> same deterministic ID
expect(accounts2[0]?.id).toBe(firstAccountId);
});
});

describe('createAccounts', () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/keyring-eth-simple/src/simple-keyring-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ describe('SimpleKeyringV2', () => {
expect(accounts).toHaveLength(0);
});

it('properly repopulates registry after deserialize', async () => {
it('properly repopulates registry after deserialize with deterministic IDs', async () => {
await inner.deserialize([TEST_PRIVATE_KEY_1, TEST_PRIVATE_KEY_2]);

const accounts1 = await wrapper.getAccounts();
Expand All @@ -174,7 +174,7 @@ describe('SimpleKeyringV2', () => {
// Old account IDs should no longer be in registry
const accounts2 = await wrapper.getAccounts();
expect(accounts2).toHaveLength(1);
expect(accounts2[0]?.id).not.toBe(firstAccountId);
expect(accounts2[0]?.id).toBe(firstAccountId);
});
});

Expand Down
56 changes: 44 additions & 12 deletions packages/keyring-eth-trezor/src/trezor-keyring-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,28 @@ type TrezorAccount = Bip44Account<KeyringAccount>;
* Test addresses derived from the fake HD key.
*/
const EXPECTED_ACCOUNTS = [
'0xF30952A1c534CDE7bC471380065726fa8686dfB3',
'0x44fe3Cf56CaF651C4bD34Ae6dbcffa34e9e3b84B',
'0x8Ee3374Fa705C1F939715871faf91d4348D5b906',
'0xEF69e24dE9CdEe93C4736FE29791E45d5D4CFd6A',
'0xC668a5116A045e9162902795021907Cb15aa2620',
'0xbF519F7a6D8E72266825D770C60dbac55a3baeb9',
'0x1c96099350f13D558464eC79B9bE4445AA0eF579',
'0x1b00AeD43a693F3a957F9FeB5cC08AFA031E37a0',
'0x8C9bA4F86ae12250eE1c3676ee925c77426D0B68',
'0xfFe45DbC6C1bEe8f211dA2ec961F73B82e9ab42c',
'0xb8a13c465c9a0a46f262a1ad666a752923e65b8c',
'0xBB9603A1660F796182BA28819Dee475AFC356Dcf',
] as const;

const fakeXPubKey =
'xpub6FnCn6nSzZAw5Tw7cgR9bi15UV96gLZhjDstkXXxvCLsUXBGXPdSnLFbdpq8p9HmGsApME5hQTZ3emM2rnY5agb9rXpVGyy3bdW6EEgAtqt';
const fakeHdKey = HDKey.fromExtendedKey(fakeXPubKey);
// Test mnemonic: "finish oppose decorate face calm tragic certain desk hour urge dinosaur mango".
const fakeXPubKeys = {
[BIP44_HD_PATH_PREFIX]:
'xpub6FCjt1vawWNJRoqG4EPJmG2vA3VNry6MfSTbXKDLqT2RkK9R1h13EW6k5ajXNCtCY9ygsmcB2kKPKpKkSkouzRvFyQLA3yn1N86c7Cau7G1',
[LEGACY_MEW_PATH_PREFIX]:
'xpub6FnCn6nSzZAw5Tw7cgR9bi15UV96gLZhjDstkXXxvCLsUXBGXPdSnLFbdpq8p9HmGsApME5hQTZ3emM2rnY5agb9rXpVGyy3bdW6EEgAtqt',
[SLIP0044_TESTNET_PATH_PREFIX]:
'xpub6ED6Su5fxghtTbgPrWgSXaxz9qubTjopMwYToxPNMtrtgGgoKbefjRMxZjMx1VNfMtxjGQpf1VwqNv7UiB71rMCkbea5aNoWFSXKhsZxwpv',
};

const fakeHdKey = HDKey.fromExtendedKey(fakeXPubKeys[BIP44_HD_PATH_PREFIX]);
const fakeMewHdKey = HDKey.fromExtendedKey(
fakeXPubKeys[LEGACY_MEW_PATH_PREFIX],
);

/**
* Fake entropy source representing the device fingerprint.
Expand Down Expand Up @@ -364,6 +375,25 @@ describe('TrezorKeyringV2', () => {
expect(account.address).toBe(EXPECTED_ACCOUNTS[index]);
});
});

it('properly repopulates registry after deserialize with deterministic IDs', async () => {
const { wrapper } = await createWrapperWithAccounts(2);
const accounts1 = await wrapper.getAccounts();
const firstAccountId = accounts1[0]?.id;

// Re-deserialize with only the first account
await wrapper.deserialize({
hdPath: `m/44'/60'/0'/0`,
accounts: [EXPECTED_ACCOUNTS[0]],
page: 0,
perPage: 5,
});

const accounts2 = await wrapper.getAccounts();
expect(accounts2).toHaveLength(1);
// Same address -> same deterministic ID
expect(accounts2[0]?.id).toBe(firstAccountId);
});
});

describe('createAccounts', () => {
Expand Down Expand Up @@ -688,7 +718,7 @@ describe('TrezorKeyringV2', () => {
it('uses the correct derivation path from the inner keyring', async () => {
const inner = createInnerKeyring();
inner.setHdPath(LEGACY_MEW_PATH_PREFIX);
inner.hdk = fakeHdKey; // Reset after setHdPath clears it
inner.hdk = fakeMewHdKey; // Reset after setHdPath clears it
inner.setAccountToUnlock(0);
await inner.addAccounts(1);

Expand Down Expand Up @@ -731,10 +761,12 @@ describe('TrezorKeyringV2', () => {
const originalSetHdPath = inner.setHdPath.bind(inner);
jest.spyOn(inner, 'setHdPath').mockImplementation((path) => {
originalSetHdPath(path);
inner.hdk = fakeHdKey;
inner.hdk = fakeMewHdKey;
});

await wrapper.createAccounts(derivePathOptions(`m/44'/60'/0'/0`));
await wrapper.createAccounts(
derivePathOptions(`${LEGACY_MEW_PATH_PREFIX}/0`),
);

// Verify registry.clear was called
expect(clearSpy).toHaveBeenCalled();
Expand Down
1 change: 1 addition & 0 deletions packages/keyring-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Add `generateId` option to `KeyringAccountRegistry` ([#503](https://github.com/MetaMask/accounts/pull/503))
- Add `generateEthAccountId` to generate deterministic account IDs for EVM addresses, and use it by default in `EthKeyringWrapper` ([#504](https://github.com/MetaMask/accounts/pull/504))

## [1.1.0]

Expand Down
1 change: 1 addition & 0 deletions packages/keyring-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@metamask/superstruct": "^3.1.0",
"@metamask/utils": "^11.10.0",
"async-mutex": "^0.5.0",
"ethereum-cryptography": "^2.1.2",
"uuid": "^9.0.1"
},
"devDependencies": {
Expand Down
35 changes: 35 additions & 0 deletions packages/keyring-sdk/src/eth/account-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { generateEthAccountId } from './account-id';

const MOCK_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678';

describe('generateEthAccountId', () => {
it('returns a UUID v4 string', () => {
const id = generateEthAccountId(MOCK_ADDRESS);

expect(id).toMatch(
/^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/u,
);
});

it('is deterministic for the same address', () => {
expect(generateEthAccountId(MOCK_ADDRESS)).toBe(
generateEthAccountId(MOCK_ADDRESS),
);
});

it('produces different IDs for different addresses', () => {
const other = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef';

expect(generateEthAccountId(MOCK_ADDRESS)).not.toBe(
generateEthAccountId(other),
);
});

it('normalizes checksum-cased and lowercase addresses to the same ID', () => {
const checksummed = '0x1234567890AbCdEf1234567890aBcDeF12345678';

expect(generateEthAccountId(checksummed)).toBe(
generateEthAccountId(MOCK_ADDRESS),
);
});
});
25 changes: 25 additions & 0 deletions packages/keyring-sdk/src/eth/account-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { normalize } from '@metamask/eth-sig-util';
import type { AccountId } from '@metamask/keyring-utils';
import { hexToBytes } from '@metamask/utils';
import { sha256 } from 'ethereum-cryptography/sha256';
import { v4 as uuidv4 } from 'uuid';

/**
* Generates a deterministic account ID for a given Ethereum address.
*
* The address is first normalized (lowercased, `0x`-prefixed) so that
* checksum-cased and lowercase variants of the same address produce the
* same ID. The ID is then a UUID v4 derived by seeding the UUID generator
* with the first 16 bytes of the SHA-256 hash of the normalized address
* bytes.
*
* @param address - The Ethereum address (hex string) to generate the account
* ID from.
* @returns A deterministic UUID v4 string to use as the account ID.
*/
export function generateEthAccountId(address: string): AccountId {
const normalized = normalize(address) as string;
return uuidv4({
random: sha256(hexToBytes(normalized)).slice(0, 16),
});
}
41 changes: 41 additions & 0 deletions packages/keyring-sdk/src/eth/eth-keyring-wrapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import type { Keyring } from '@metamask/keyring-utils';
import type { Hex, Json } from '@metamask/utils';

import { generateEthAccountId } from './account-id';
import { EthKeyringMethod, EthKeyringWrapper } from './eth-keyring-wrapper';

const MOCK_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678' as Hex;
Expand Down Expand Up @@ -134,7 +135,47 @@ function createMockRequest(
};
}

/**
* Minimal EthKeyringWrapper subclass that exposes registry.register for testing.
*/
class TestEthKeyringWrapperWithRegister extends EthKeyringWrapper<Keyring> {
constructor(inner: Keyring) {
super({
type: KeyringType.Hd,
inner,
capabilities: { scopes: [EthScope.Eoa] },
});
}

async getAccounts(): Promise<KeyringAccount[]> {
return [];
}

async createAccounts(_opts: CreateAccountOptions): Promise<KeyringAccount[]> {
return [];
}

async deleteAccount(_id: string): Promise<void> {
// noop
}

testRegister(address: string): string {
return this.registry.register(address);
}
}

describe('EthKeyringWrapper', () => {
describe('createRegistry', () => {
it('uses generateEthAccountId for deterministic account IDs', () => {
const wrapper = new TestEthKeyringWrapperWithRegister(
createMockKeyring(),
);
const id = wrapper.testRegister(MOCK_ADDRESS);

expect(id).toBe(generateEthAccountId(MOCK_ADDRESS));
});
});

describe('toHexAddress', () => {
it('adds 0x prefix to address without prefix', () => {
const wrapper = new TestEthKeyringWrapper(createMockKeyring());
Expand Down
22 changes: 19 additions & 3 deletions packages/keyring-sdk/src/eth/eth-keyring-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { EthKeyring } from '@metamask/keyring-utils';
import { assert } from '@metamask/superstruct';
import { add0x, type Hex, type Json } from '@metamask/utils';

import { generateEthAccountId } from './account-id';
import { KeyringWrapper, type KeyringWrapperOptions } from '../keyring-wrapper';

/**
Expand All @@ -32,10 +33,16 @@ export enum EthKeyringMethod {
}

/**
* Options for constructing an EthKeyringWrapper.
* Options for constructing an {@link EthKeyringWrapper}.
*
* `registryOptions` is intentionally omitted: the registry is always
* configured with {@link generateEthAccountId} to produce deterministic
* account IDs for Ethereum addresses.
*/
export type EthKeyringWrapperOptions<InnerKeyring extends EthKeyring> =
KeyringWrapperOptions<InnerKeyring>;
export type EthKeyringWrapperOptions<InnerKeyring extends EthKeyring> = Omit<
KeyringWrapperOptions<InnerKeyring>,
'registryOptions'
>;

/**
* Abstract wrapper for Ethereum-based keyrings that extends KeyringWrapper, that itself implements KeyringV2.
Expand All @@ -54,6 +61,15 @@ export abstract class EthKeyringWrapper<
InnerKeyring extends EthKeyring,
KeyringAccountType extends KeyringAccount = KeyringAccount,
> extends KeyringWrapper<InnerKeyring, KeyringAccountType> {
constructor(options: EthKeyringWrapperOptions<InnerKeyring>) {
super({
...options,
// We use a custom ID generator to ensure that account IDs are deterministic based on
// the Ethereum address.
registryOptions: { generateId: generateEthAccountId },
});
}

/**
* Helper method to safely cast a KeyringAccount address to Hex type.
* The KeyringAccount.address is typed as string, but for Ethereum accounts
Expand Down
1 change: 1 addition & 0 deletions packages/keyring-sdk/src/eth/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './account-id';
export * from './eth-keyring-wrapper';
Loading
Loading