Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
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` helper 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.toLowerCase());

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.toUpperCase());

expect(firstId).toBe(secondId);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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.toLowerCase());
}

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

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

return id;
}
}
163 changes: 163 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,163 @@
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> {
public deletedAccountIds: AccountId[] = [];

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

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

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

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:1'],
};

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,
});

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,
});

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(['eip155:1']);
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,
});

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,
});

// 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,
});

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

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