Skip to content
Open
Show file tree
Hide file tree
Changes from 15 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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,11 @@ linkStyle default opacity:0.5
account_api --> keyring_api;
account_api --> keyring_utils;
keyring_api --> keyring_utils;
eth_hd_keyring --> keyring_api;
eth_hd_keyring --> keyring_utils;
eth_ledger_bridge_keyring --> keyring_utils;
eth_qr_keyring --> keyring_utils;
eth_simple_keyring --> keyring_api;
eth_simple_keyring --> keyring_utils;
eth_trezor_keyring --> keyring_utils;
keyring_internal_api --> keyring_api;
Expand Down
5 changes: 5 additions & 0 deletions packages/keyring-api/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `KeyringWrapper` base class to adapt legacy keyrings to `KeyringV2` ([#398](https://github.com/MetaMask/accounts/pull/398))
- Add unified `KeyringV2` interface ([#397](https://github.com/MetaMask/accounts/pull/397))

## [21.2.0]

### Added
Expand Down
4 changes: 4 additions & 0 deletions packages/keyring-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,10 @@ A unified keyring interface, designed to work for both native (EVM) keyrings and
- Interface name: `KeyringV2`
- Location: `@metamask/keyring-api/src/api/v2/keyring.ts`

### Keyring wrapper

The `KeyringWrapper` helper adapts existing keyrings that implement the legacy `Keyring` interface to the new `KeyringV2` interface. It is intended to be subclassed in concrete keyrings, overriding the account management and request-handling methods to delegate to the underlying implementation.

## Migrating from 0.1.x to 0.2.x

The following changes were made to the API, which may require changes to your
Expand Down
4 changes: 3 additions & 1 deletion packages/keyring-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@
"@metamask/keyring-utils": "workspace:^",
"@metamask/superstruct": "^3.1.0",
"@metamask/utils": "^11.1.0",
"bitcoin-address-validation": "^2.2.3"
"@types/uuid": "^9.0.8",
"bitcoin-address-validation": "^2.2.3",
"uuid": "^9.0.1"
},
"devDependencies": {
"@lavamoat/allow-scripts": "^3.2.1",
Expand Down
1 change: 1 addition & 0 deletions packages/keyring-api/src/api/v2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './keyring-type';
export * from './create-account';
export * from './export-account';
export * from './private-key';
export * from './wrapper';
2 changes: 2 additions & 0 deletions packages/keyring-api/src/api/v2/wrapper/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './keyring-wrapper';
export * from './keyring-address-resolver';
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { InMemoryKeyringAddressResolver } from './keyring-address-resolver';

describe('InMemoryKeyringAddressResolver', () => {
it('registers and resolves account IDs and addresses', () => {
const resolver = new InMemoryKeyringAddressResolver();

const address = '0xaBc';
const id = resolver.register(address);

expect(typeof id).toBe('string');

const resolvedAddress = resolver.getAddress(id);
expect(resolvedAddress).toBe(address);

const resolvedId = resolver.getAccountId(address);
expect(resolvedId).toBe(id);
});

it('reuses the same ID when registering the same address', () => {
const resolver = new InMemoryKeyringAddressResolver();

const address = '0xaBc';
const firstId = resolver.register(address);
const secondId = resolver.register(address);

expect(firstId).toBe(secondId);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { AccountId } from '@metamask/keyring-utils';
import { v4 as uuidv4 } from 'uuid';

/**
* Mapping between an internal AccountId and the underlying keyring address.
*/
export type KeyringAddressResolver = {
/**
* Resolve an AccountId into an underlying address.
*/
getAddress(accountId: AccountId): string | undefined;

/**
* Resolve an underlying address into an AccountId.
*/
getAccountId(address: string): AccountId | undefined;

/**
* Register a new mapping. Implementations are free to decide how the
* AccountId is generated (e.g. UUIDv4).
*/
register(address: string): AccountId;
};

/**
* Simple in-memory AccountId/address resolver used by default. This is mostly
* intended for controller-managed lifecycles where wrappers live in memory.
*/
export class InMemoryKeyringAddressResolver implements KeyringAddressResolver {
readonly #idByAddress = new Map<string, AccountId>();

readonly #addressById = new Map<AccountId, string>();

getAddress(accountId: AccountId): string | undefined {
return this.#addressById.get(accountId);
}

getAccountId(address: string): AccountId | undefined {
return this.#idByAddress.get(address);
}

register(address: string): AccountId {
const existing = this.#idByAddress.get(address);
if (existing) {
return existing;
}
const id = uuidv4();

this.#idByAddress.set(address, id);
this.#addressById.set(id, address);

return id;
}
}
203 changes: 203 additions & 0 deletions packages/keyring-api/src/api/v2/wrapper/keyring-wrapper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import type { Keyring, AccountId } from '@metamask/keyring-utils';
import type { Hex, Json } from '@metamask/utils';
import { v4 as uuidv4 } from 'uuid';

import { InMemoryKeyringAddressResolver } from './keyring-address-resolver';
import { KeyringWrapper } from './keyring-wrapper';
import type { KeyringAccount } from '../../account';
import type { KeyringCapabilities } from '../keyring-capabilities';
import { KeyringType } from '../keyring-type';

class TestKeyringWrapper extends KeyringWrapper<TestKeyring> {
async getAccounts(): Promise<KeyringAccount[]> {
const addresses = await this.inner.getAccounts();
const scopes = this.capabilities.scopes ?? ['eip155:1'];

return addresses.map((address) => {
const id = this.resolver.register(address);

const account: KeyringAccount = {
id,
type: 'eip155:eoa',
address,
scopes,
options: {},
methods: [],
};

return account;
});
}

public deletedAccountIds: AccountId[] = [];

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

async deleteAccount(accountId: AccountId): Promise<void> {
this.deletedAccountIds.push(accountId);
}

async submitRequest(): Promise<any> {
return {};
}
}

class TestKeyring implements Keyring {
static type = 'Test Keyring';

public type = TestKeyring.type;

readonly #accounts: Hex[];

public lastDeserializedState: Json | undefined;

constructor(addresses: Hex[] = []) {
this.#accounts = addresses;
}

async serialize(): Promise<Json> {
return { accounts: this.#accounts };
}

async deserialize(state: Json): Promise<void> {
this.lastDeserializedState = state;
}

async getAccounts(): Promise<Hex[]> {
return this.#accounts;
}

async addAccounts(_numberOfAccounts = 1): Promise<Hex[]> {
return this.#accounts;
}
}

const capabilities: KeyringCapabilities = {
scopes: ['eip155:10'],
};

const entropySourceId = 'test-entropy-source';

describe('KeyringWrapper', () => {
it('serializes and deserializes via the inner keyring', async () => {
const inner = new TestKeyring(['0x1']);
const wrapper = new TestKeyringWrapper({
inner,
type: KeyringType.Hd,
capabilities,
entropySourceId,
});

const state = await wrapper.serialize();
expect(state).toStrictEqual({ accounts: ['0x1'] });

await wrapper.deserialize(state);
expect(inner.lastDeserializedState).toStrictEqual(state);
});

it('returns accounts with stable IDs and correct addresses', async () => {
const addresses = ['0x1' as const, '0x2' as const];
const inner = new TestKeyring(addresses);
const wrapper = new TestKeyringWrapper({
inner,
type: KeyringType.Hd,
capabilities,
entropySourceId,
});

const accounts = await wrapper.getAccounts();

expect(accounts).toHaveLength(addresses.length);

const ids = new Set<string>();
accounts.forEach((account: KeyringAccount, index) => {
expect(account.address).toBe(addresses[index]);
expect(account.type).toBe('eip155:eoa');
expect(account.scopes).toStrictEqual(capabilities.scopes);
expect(account.options).toStrictEqual({});
expect(account.methods).toStrictEqual([]);

ids.add(account.id);
});

// Ensure IDs are unique
expect(ids.size).toBe(addresses.length);

// getAccount should resolve by ID
const [firstAccount] = accounts;
expect(firstAccount).toBeDefined();
if (!firstAccount) {
throw new Error('Expected at least one account from the wrapper');
}
const resolved = await wrapper.getAccount(firstAccount.id);
expect(resolved.address).toBe(firstAccount.address);
});

it('throws when requesting an unknown account', async () => {
const inner = new TestKeyring([]);
const wrapper = new TestKeyringWrapper({
inner,
type: KeyringType.Hd,
capabilities,
entropySourceId,
});

await expect(wrapper.getAccount(uuidv4())).rejects.toThrow(
'Account not found for id',
);
});

it('throws when account mapping exists but account object cannot be found', async () => {
const addresses = ['0x1' as const];
const inner = new TestKeyring(addresses);
const resolver = new InMemoryKeyringAddressResolver();
const wrapper = new TestKeyringWrapper({
inner,
type: KeyringType.Hd,
capabilities,
resolver,
entropySourceId,
});

// Prime the resolver by calling getAccounts once
await wrapper.getAccounts();

// Now, simulate a missing account object by clearing the underlying
// accounts of the inner keyring.
const emptyInner = new TestKeyring([] as Hex[]);
const inconsistentWrapper = new TestKeyringWrapper({
inner: emptyInner,
type: KeyringType.Hd,
capabilities,
resolver,
entropySourceId,
});

const accountId = resolver.getAccountId(
addresses[0] as string,
) as AccountId;

await expect(inconsistentWrapper.getAccount(accountId)).rejects.toThrow(
'Account not found for id',
);
});

it('falls back to mainnet scope when capabilities.scopes is not set', async () => {
const addresses = ['0x1' as const];
const inner = new TestKeyring(addresses);
const wrapper = new TestKeyringWrapper({
inner,
type: KeyringType.Hd,
entropySourceId,
// Explicitly omit scopes to exercise the default fallback.
capabilities: {} as KeyringCapabilities,
});

const accounts = await wrapper.getAccounts();

expect(accounts).toHaveLength(1);
expect(accounts[0]?.scopes).toStrictEqual(['eip155:1']);
});
});
Loading
Loading