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
19 changes: 19 additions & 0 deletions packages/keyring-api/src/api/address.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Infer } from '@metamask/superstruct';
import { object, string } from '@metamask/superstruct';

/**
* An account's address that has been resolved from a signing request.
*/
export const ResolvedAccountAddressStruct = object({
/**
* Account main address.
*/
address: string(),
});

/**
* Resolve account's address object.
*
* See {@link ResolvedAccountAddressStruct}.
*/
export type ResolvedAccountAddress = Infer<typeof ResolvedAccountAddressStruct>;
1 change: 1 addition & 0 deletions packages/keyring-api/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './account';
export * from './address';
export * from './asset';
export * from './balance';
export * from './export';
Expand Down
26 changes: 25 additions & 1 deletion packages/keyring-api/src/api/keyring.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
/* eslint-disable @typescript-eslint/no-redundant-type-constituents */
// This rule seems to be triggering a false positive on the `KeyringAccount`.

import type { Json, CaipAssetType, CaipAssetTypeOrId } from '@metamask/utils';
import type { JsonRpcRequest } from '@metamask/keyring-utils';
import type {
Json,
CaipChainId,
CaipAssetType,
CaipAssetTypeOrId,
} from '@metamask/utils';

import type { KeyringAccount } from './account';
import type { ResolvedAccountAddress } from './address';
import type { Balance } from './balance';
import type { KeyringAccountData } from './export';
import type { Paginated, Pagination } from './pagination';
Expand Down Expand Up @@ -109,6 +116,23 @@ export type Keyring = {
assets: CaipAssetType[],
): Promise<Record<CaipAssetType, Balance>>;

/**
* Resolves the address of an account from a signing request.
*
* This is required by the routing system of MetaMask to dispatch
* incoming non-EVM dapp signing requests.
*
* @param scope - Request's scope (CAIP-2).
* @param request - Signing request object.
* @returns A Promise that resolves to the account address that must
* be used to process this signing request, or null if none candidates
* could be found.
*/
resolveAccountAddress?(
scope: CaipChainId,
request: JsonRpcRequest,
): Promise<ResolvedAccountAddress | null>;

/**
* Filter supported chains for a given account.
*
Expand Down
35 changes: 34 additions & 1 deletion packages/keyring-api/src/rpc.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { object, UuidStruct } from '@metamask/keyring-utils';
import {
object,
UuidStruct,
JsonRpcRequestStruct,
} from '@metamask/keyring-utils';
import type { Infer } from '@metamask/superstruct';
import {
array,
literal,
nullable,
number,
record,
string,
Expand All @@ -12,6 +17,7 @@ import {
JsonStruct,
CaipAssetTypeStruct,
CaipAssetTypeOrIdStruct,
CaipChainIdStruct,
} from '@metamask/utils';

import {
Expand All @@ -34,6 +40,7 @@ export enum KeyringRpcMethod {
ListAccountAssets = 'keyring_listAccountAssets',
ListAccountTransactions = 'keyring_listAccountTransactions',
GetAccountBalances = 'keyring_getAccountBalances',
ResolveAccountAddress = 'keyring_resolveAccountAddress',
FilterAccountChains = 'keyring_filterAccountChains',
UpdateAccount = 'keyring_updateAccount',
DeleteAccount = 'keyring_deleteAccount',
Expand Down Expand Up @@ -178,6 +185,32 @@ export type GetAccountBalancesResponse = Infer<
typeof GetAccountBalancesResponseStruct
>;

// ----------------------------------------------------------------------------
// Resolve account address

export const ResolveAccountAddressRequestStruct = object({
...CommonHeader,
method: literal('keyring_resolveAccountAddress'),
params: object({
scope: CaipChainIdStruct,
request: JsonRpcRequestStruct,
}),
});

export type ResolveAccountAddressRequest = Infer<
typeof ResolveAccountAddressRequestStruct
>;

export const ResolveAccountAddressResponseStruct = nullable(
object({
address: string(),
}),
);

export type ResolveAccountAddressResponse = Infer<
typeof ResolveAccountAddressResponseStruct
>;

// ----------------------------------------------------------------------------
// Filter account chains

Expand Down
22 changes: 22 additions & 0 deletions packages/keyring-snap-bridge/src/SnapIdMap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,28 @@ describe('SnapIdMap', () => {
});
});

describe('hasSnapId', () => {
it('returns false when the snapId is not in the map', () => {
const map = new SnapIdMap<{ snapId: SnapId; value: number }>();
const hasSnapId = map.hasSnapId(SNAP_1_ID);
expect(hasSnapId).toBe(false);
});

it('returns false when the snapId does not match', () => {
const map = new SnapIdMap<{ snapId: SnapId; value: number }>();
map.set('foo', { snapId: SNAP_1_ID, value: 1 });
const hasSnapId = map.hasSnapId(SNAP_2_ID);
expect(hasSnapId).toBe(false);
});

it('returns true when the snapId matches', () => {
const map = new SnapIdMap<{ snapId: SnapId; value: number }>();
map.set('foo', { snapId: SNAP_1_ID, value: 1 });
const hasSnapId = map.hasSnapId(SNAP_1_ID);
expect(hasSnapId).toBe(true);
});
});

describe('has', () => {
it('returns false when the key is not in the map', () => {
const map = new SnapIdMap<{ snapId: SnapId; value: number }>();
Expand Down
12 changes: 12 additions & 0 deletions packages/keyring-snap-bridge/src/SnapIdMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,18 @@ export class SnapIdMap<Value extends { snapId: SnapId }> {
return this.get(snapId, key) !== undefined;
}

/**
* Checks if a snap ID exists in the map.
*
* @param snapId - Snap ID present in the value to check.
* @returns `true` if the snap ID is present in the map, `false` otherwise.
*/
hasSnapId(snapId: SnapId): boolean {
// We could use a reverse-mapping to map Snap ID to their actual key too, but
// for now, this will do the trick.
return [...this.#map.values()].some((value) => value.snapId === snapId);
}

/**
* Deletes a key from the map.
*
Expand Down
159 changes: 158 additions & 1 deletion packages/keyring-snap-bridge/src/SnapKeyring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
BtcScope,
SolScope,
} from '@metamask/keyring-api';
import type { JsonRpcRequest } from '@metamask/keyring-utils';
import type { SnapId } from '@metamask/snaps-sdk';
import { KnownCaipNamespace, toCaipChainId } from '@metamask/utils';

Expand All @@ -37,7 +38,7 @@ import type {
} from './SnapKeyringMessenger';

const regexForUUIDInRequiredSyncErrorMessage =
/Request '[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}' to snap 'local:snap.mock' is pending and noPending is true/u;
/Request '[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}' to Snap 'local:snap.mock' is pending and noPending is true/u;

const ETH_4337_METHODS = [
EthMethod.PatchUserOperation,
Expand Down Expand Up @@ -1764,6 +1765,162 @@ describe('SnapKeyring', () => {
});
});

describe('resolveAccountAddress', () => {
const address = '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb';
const scope = toCaipChainId(
KnownCaipNamespace.Eip155,
executionContext.chainId,
);
const request: JsonRpcRequest = {
id: '3d8a0bda-285c-4551-abe8-f52af39d3095',
jsonrpc: '2.0',
method: EthMethod.SignTransaction,
params: {
from: address,
to: 'someone-else',
},
};

it('returns a resolved address', async () => {
mockMessenger.handleRequest.mockReturnValueOnce({
address,
});

const resolved = await keyring.resolveAccountAddress(
snapId,
scope,
request,
);

expect(resolved).toStrictEqual({ address });
expect(mockMessenger.handleRequest).toHaveBeenCalledWith({
handler: 'onKeyringRequest',
origin: 'metamask',
request: {
id: expect.any(String),
jsonrpc: '2.0',
method: 'keyring_resolveAccountAddress',
params: {
scope,
request,
},
},
snapId,
});
});

it('returns `null` if no address has been resolved', async () => {
mockMessenger.handleRequest.mockReturnValueOnce(null);

const resolved = await keyring.resolveAccountAddress(
snapId,
scope,
request,
);

expect(resolved).toBeNull();
});

it('throws an error if the Snap ID is not know from the keyring', async () => {
const badSnapId = 'local:bad-snap-id' as SnapId;

await expect(
keyring.resolveAccountAddress(badSnapId, scope, request),
).rejects.toThrow(
`Unable to resolve account address: unknown Snap ID: ${badSnapId}`,
);
});
});

describe('submitRequest', () => {
const account = ethEoaAccount1;
const scope = EthScope.Testnet;
const method = EthMethod.SignTransaction;
const params = {
from: 'me',
to: 'you',
};

it('submits a request', async () => {
mockMessenger.handleRequest.mockResolvedValue({
pending: false,
result: null,
});

await keyring.submitRequest({
account: account.id,
method,
params,
scope,
});

expect(mockMessenger.handleRequest).toHaveBeenCalledWith({
handler: 'onKeyringRequest',
origin: 'metamask',
request: {
id: expect.any(String),
jsonrpc: '2.0',
method: 'keyring_submitRequest',
params: {
id: expect.any(String),
scope,
account: account.id,
request: {
method,
params,
},
},
},
snapId,
});
});

it('throws an error for asynchronous request', async () => {
mockMessenger.handleRequest.mockResolvedValue({
pending: true,
});

await expect(
keyring.submitRequest({
account: account.id,
method,
params,
scope,
}),
).rejects.toThrow(regexForUUIDInRequiredSyncErrorMessage);
});

it('throws an error when using an unknown account id', async () => {
const unknownAccountId = 'unknown-account-id';

await expect(
keyring.submitRequest({
account: unknownAccountId,
method,
params,
scope,
}),
).rejects.toThrow(
`Unable to get account: unknown account ID: '${unknownAccountId}'`,
);
});

it('throws an error when the method is not supported by the account', async () => {
const unknownAccountMethod = EthMethod.PrepareUserOperation; // Not available for EOAs.

await expect(
keyring.submitRequest({
account: account.id,
method: unknownAccountMethod,
params,
scope,
}),
).rejects.toThrow(
`Method '${unknownAccountMethod}' not supported for account ${account.address}`,
);
});
});

describe('prepareUserOperation', () => {
const mockIntents = [
{
Expand Down
Loading
Loading