From e611f250cc767434946d2d278c37dbbf5386e013 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 22 Jan 2025 18:50:49 +0100 Subject: [PATCH 01/18] feat: add resolveAccountAddress method --- packages/keyring-api/src/api/address.ts | 19 +++ packages/keyring-api/src/api/caip.test.ts | 122 ++++++++++++++++++ packages/keyring-api/src/api/caip.ts | 52 ++++++++ packages/keyring-api/src/api/index.ts | 1 + packages/keyring-api/src/api/keyring.ts | 20 +++ packages/keyring-api/src/rpc.ts | 35 ++++- .../src/KeyringClient.test.ts | 73 ++++++++++- .../keyring-snap-client/src/KeyringClient.ts | 19 ++- .../keyring-snap-sdk/src/rpc-handler.test.ts | 42 +++++- packages/keyring-snap-sdk/src/rpc-handler.ts | 9 ++ 10 files changed, 387 insertions(+), 5 deletions(-) create mode 100644 packages/keyring-api/src/api/address.ts create mode 100644 packages/keyring-api/src/api/caip.test.ts create mode 100644 packages/keyring-api/src/api/caip.ts diff --git a/packages/keyring-api/src/api/address.ts b/packages/keyring-api/src/api/address.ts new file mode 100644 index 000000000..0adb28e3e --- /dev/null +++ b/packages/keyring-api/src/api/address.ts @@ -0,0 +1,19 @@ +import type { Infer } from '@metamask/superstruct'; +import { object, string } from '@metamask/superstruct'; + +/** + * The resolved address of an account. + */ +export const ResolvedAccountAddressStruct = object({ + /** + * Account main address. + */ + address: string(), +}); + +/** + * Resolve account's address object. + * + * See {@link ResolvedAccountAddressStruct}. + */ +export type ResolvedAccountAddress = Infer; diff --git a/packages/keyring-api/src/api/caip.test.ts b/packages/keyring-api/src/api/caip.test.ts new file mode 100644 index 000000000..4fde5cbf4 --- /dev/null +++ b/packages/keyring-api/src/api/caip.test.ts @@ -0,0 +1,122 @@ +import { isCaipAssetId, isCaipAssetType, isCaipChainId } from './caip'; + +describe('isCaipChainId', () => { + // Imported from: https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md#test-cases + it.each([ + 'eip155:1', + 'bip122:000000000019d6689c085ae165831e93', + 'bip122:12a765e31ffd4059bada1e25190f6e98', + 'bip122:fdbe99b90c90bae7505796461471d89a', + 'cosmos:cosmoshub-2', + 'cosmos:cosmoshub-3', + 'cosmos:Binance-Chain-Tigris', + 'cosmos:iov-mainnet', + 'starknet:SN_GOERLI', + 'lip9:9ee11e9df416b18b', + 'chainstd:8c3444cf8970a9e41a706fab93e7a6c4', + ])('returns true for a valid chain id %s', (id) => { + expect(isCaipChainId(id)).toBe(true); + }); + + it.each([ + true, + false, + null, + undefined, + 1, + {}, + [], + '', + '!@#$%^&*()', + 'foo', + 'eip155', + 'eip155:', + ])('returns false for an invalid chain id %s', (id) => { + expect(isCaipChainId(id)).toBe(false); + }); +}); + +describe('isCaipAssetType', () => { + // Imported from: https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-19.md#test-cases + it.each([ + 'eip155:1/slip44:60', + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + 'cosmos:cosmoshub-3/slip44:118', + 'bip122:12a765e31ffd4059bada1e25190f6e98/slip44:2', + 'cosmos:Binance-Chain-Tigris/slip44:714', + 'cosmos:iov-mainnet/slip44:234', + 'lip9:9ee11e9df416b18b/slip44:134', + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + 'eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d', + ])('returns true for a valid asset type %s', (id) => { + expect(isCaipAssetType(id)).toBe(true); + }); + + it.each([ + true, + false, + null, + undefined, + 1, + {}, + [], + '', + '!@#$%^&*()', + 'foo', + 'eip155', + 'eip155:', + 'eip155:1', + 'eip155:1:', + 'eip155:1:0x0000000000000000000000000000000000000000:2', + 'bip122', + 'bip122:', + 'bip122:000000000019d6689c085ae165831e93', + 'bip122:000000000019d6689c085ae165831e93/', + 'bip122:000000000019d6689c085ae165831e93/tooooooolong', + 'bip122:000000000019d6689c085ae165831e93/tooooooolong:asset', + 'eip155:1/erc721', + 'eip155:1/erc721:', + 'eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d/', + ])('returns false for an invalid asset type %s', (id) => { + expect(isCaipAssetType(id)).toBe(false); + }); +}); + +describe('isCaipAssetId', () => { + // Imported from: https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-19.md#test-cases + it.each([ + 'eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d/771769', + 'hedera:mainnet/nft:0.0.55492/12', + ])('returns true for a valid asset id %s', (id) => { + expect(isCaipAssetId(id)).toBe(true); + }); + + it.each([ + true, + false, + null, + undefined, + 1, + {}, + [], + '', + '!@#$%^&*()', + 'foo', + 'eip155', + 'eip155:', + 'eip155:1', + 'eip155:1:', + 'eip155:1:0x0000000000000000000000000000000000000000:2', + 'bip122', + 'bip122:', + 'bip122:000000000019d6689c085ae165831e93', + 'bip122:000000000019d6689c085ae165831e93/', + 'bip122:000000000019d6689c085ae165831e93/tooooooolong', + 'bip122:000000000019d6689c085ae165831e93/tooooooolong:asset', + 'eip155:1/erc721', + 'eip155:1/erc721:', + 'eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d/', + ])('returns false for an invalid asset id %s', (id) => { + expect(isCaipAssetType(id)).toBe(false); + }); +}); diff --git a/packages/keyring-api/src/api/caip.ts b/packages/keyring-api/src/api/caip.ts new file mode 100644 index 000000000..c87b750d6 --- /dev/null +++ b/packages/keyring-api/src/api/caip.ts @@ -0,0 +1,52 @@ +import { definePattern } from '@metamask/keyring-utils'; +import { type Infer } from '@metamask/superstruct'; +import type { CaipChainId, CaipAssetId, CaipAssetType } from '@metamask/utils'; +import { + CAIP_CHAIN_ID_REGEX, + CAIP_ASSET_TYPE_REGEX, + CAIP_ASSET_ID_REGEX, + isCaipChainId, + isCaipAssetType, + isCaipAssetId, +} from '@metamask/utils'; + +const CAIP_ASSET_TYPE_OR_ID_REGEX = + /^(?(?[-a-z0-9]{3,8}):(?[-_a-zA-Z0-9]{1,32}))\/(?[-a-z0-9]{3,8}):(?[-.%a-zA-Z0-9]{1,128})(\/(?[-.%a-zA-Z0-9]{1,78}))?$/u; + +/** + * A CAIP-2 chain ID, i.e., a human-readable agnostic chain ID. + */ +export const CaipChainIdStruct = definePattern( + 'CaipChainId', + CAIP_CHAIN_ID_REGEX, +); +export type { CaipChainId }; +export { isCaipChainId }; + +/** + * A CAIP-19 asset type identifier, i.e., a human-readable type of asset identifier. + */ +export const CaipAssetTypeStruct = definePattern( + 'CaipAssetType', + CAIP_ASSET_TYPE_REGEX, +); +export type { CaipAssetType }; +export { isCaipAssetType }; + +/** + * A CAIP-19 asset ID identifier, i.e., a human-readable type of asset ID. + */ +export const CaipAssetIdStruct = definePattern( + 'CaipAssetId', + CAIP_ASSET_ID_REGEX, +); +export type { CaipAssetId }; +export { isCaipAssetId }; + +/** + * A CAIP-19 asset type or asset ID identifier, i.e., a human-readable type of asset identifier. + */ +export const CaipAssetTypeOrIdStruct = definePattern< + CaipAssetType | CaipAssetId +>('CaipAssetTypeOrId', CAIP_ASSET_TYPE_OR_ID_REGEX); +export type CaipAssetTypeOrId = Infer; diff --git a/packages/keyring-api/src/api/index.ts b/packages/keyring-api/src/api/index.ts index b61cd3830..fdace7470 100644 --- a/packages/keyring-api/src/api/index.ts +++ b/packages/keyring-api/src/api/index.ts @@ -1,4 +1,5 @@ export * from './account'; +export * from './address'; export * from './asset'; export * from './balance'; export * from './export'; diff --git a/packages/keyring-api/src/api/keyring.ts b/packages/keyring-api/src/api/keyring.ts index 2f0538f85..588d5a111 100644 --- a/packages/keyring-api/src/api/keyring.ts +++ b/packages/keyring-api/src/api/keyring.ts @@ -1,9 +1,11 @@ /* eslint-disable @typescript-eslint/no-redundant-type-constituents */ // This rule seems to be triggering a false positive on the `KeyringAccount`. +import type { JsonRpcRequest } from '@metamask/keyring-utils'; import type { Json, 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'; @@ -11,6 +13,7 @@ import type { KeyringRequest } from './request'; import type { KeyringResponse } from './response'; import type { Transaction } from './transaction'; + /** * Keyring interface. * @@ -109,6 +112,23 @@ export type Keyring = { assets: CaipAssetType[], ): Promise>; + /** + * 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( + id: string, + request: JsonRpcRequest, + ): Promise; + /** * Filter supported chains for a given account. * diff --git a/packages/keyring-api/src/rpc.ts b/packages/keyring-api/src/rpc.ts index bee19d89a..eadda7708 100644 --- a/packages/keyring-api/src/rpc.ts +++ b/packages/keyring-api/src/rpc.ts @@ -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, @@ -12,6 +17,7 @@ import { JsonStruct, CaipAssetTypeStruct, CaipAssetTypeOrIdStruct, + CaipChainIdStruct, } from '@metamask/utils'; import { @@ -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', @@ -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 diff --git a/packages/keyring-snap-client/src/KeyringClient.test.ts b/packages/keyring-snap-client/src/KeyringClient.test.ts index 2cdb44170..4b7014aac 100644 --- a/packages/keyring-snap-client/src/KeyringClient.test.ts +++ b/packages/keyring-snap-client/src/KeyringClient.test.ts @@ -3,8 +3,13 @@ import type { KeyringRequest, KeyringResponse, } from '@metamask/keyring-api'; -import { KeyringRpcMethod } from '@metamask/keyring-api'; -import type { CaipAssetType, CaipAssetTypeOrId } from '@metamask/utils'; +import { BtcMethod, KeyringRpcMethod } from '@metamask/keyring-api'; +import type { JsonRpcRequest } from '@metamask/keyring-utils'; +import type { + CaipChainId, + CaipAssetType, + CaipAssetTypeOrId, +} from '@metamask/utils'; import { KeyringClient } from '.'; // Import from `index.ts` to test the public API @@ -410,6 +415,70 @@ describe('KeyringClient', () => { }); }); + describe('resolveAccountAddress', () => { + const scope: CaipChainId = 'bip122:000000000019d6689c085ae165831e93'; + const request: JsonRpcRequest = { + id: '71621d8d-62a4-4bf4-97cc-fb8f243679b0', + jsonrpc: '2.0', + method: BtcMethod.SendBitcoin, + params: { + recipients: { + address: '0.1', + }, + replaceable: true, + }, + }; + + it('should send a request to resolve an account address from a signing request and return the response', async () => { + const expectedResponse = { + address: 'tb1qspc3kwj3zfnltjpucn7ugahr8lhrgg86wzpvs3', + }; + + mockSender.send.mockResolvedValue(expectedResponse); + const account = await keyring.resolveAccountAddress(scope, request); + expect(mockSender.send).toHaveBeenCalledWith({ + jsonrpc: '2.0', + id: expect.any(String), + method: 'keyring_resolveAccountAddress', + params: { + scope, + request, + }, + }); + expect(account).toStrictEqual(expectedResponse); + }); + + it('should send a request to resolve an account address from a signing request and return null if the address cannot be resolved', async () => { + const expectedResponse = null; + + mockSender.send.mockResolvedValue(expectedResponse); + const account = await keyring.resolveAccountAddress(scope, request); + expect(mockSender.send).toHaveBeenCalledWith({ + jsonrpc: '2.0', + id: expect.any(String), + method: 'keyring_resolveAccountAddress', + params: { + scope, + request, + }, + }); + expect(account).toStrictEqual(expectedResponse); + }); + + it('should throw an exception if the response is malformed', async () => { + const expectedResponse = { + not: 'good', + }; + + mockSender.send.mockResolvedValue(expectedResponse); + await expect( + keyring.resolveAccountAddress(scope, request), + ).rejects.toThrow( + 'At path: address -- Expected a string, but received: undefined', + ); + }); + }); + describe('filterAccountChains', () => { it('should send a request to filter the chains supported by an account and return the response', async () => { const id = '49116980-0712-4fa5-b045-e4294f1d440e'; diff --git a/packages/keyring-snap-client/src/KeyringClient.ts b/packages/keyring-snap-client/src/KeyringClient.ts index 477f07e6c..6efe7ab5c 100644 --- a/packages/keyring-snap-client/src/KeyringClient.ts +++ b/packages/keyring-snap-client/src/KeyringClient.ts @@ -7,6 +7,7 @@ import type { Balance, TransactionsPage, Pagination, + ResolvedAccountAddress, } from '@metamask/keyring-api'; import { ApproveRequestResponseStruct, @@ -25,11 +26,12 @@ import { SubmitRequestResponseStruct, UpdateAccountResponseStruct, KeyringRpcMethod, + ResolveAccountAddressResponseStruct, } from '@metamask/keyring-api'; import type { JsonRpcRequest } from '@metamask/keyring-utils'; import { strictMask } from '@metamask/keyring-utils'; import { assert } from '@metamask/superstruct'; -import type { CaipAssetType, CaipAssetTypeOrId, Json } from '@metamask/utils'; +import type { CaipChainId, CaipAssetType, CaipAssetTypeOrId, Json } from '@metamask/utils'; import { v4 as uuid } from 'uuid'; export type Sender = { @@ -129,6 +131,21 @@ export class KeyringClient implements Keyring { ); } + async resolveAccountAddress( + scope: CaipChainId, + request: JsonRpcRequest, + // FIXME: eslint is complaning about `ResolvedAccountAddress` being `any`, so disable this for now: + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + ): Promise { + return strictMask( + await this.#send({ + method: KeyringRpcMethod.ResolveAccountAddress, + params: { scope, request }, + }), + ResolveAccountAddressResponseStruct, + ); + } + async filterAccountChains(id: string, chains: string[]): Promise { return strictMask( await this.#send({ diff --git a/packages/keyring-snap-sdk/src/rpc-handler.test.ts b/packages/keyring-snap-sdk/src/rpc-handler.test.ts index c8108881d..9da461458 100644 --- a/packages/keyring-snap-sdk/src/rpc-handler.test.ts +++ b/packages/keyring-snap-sdk/src/rpc-handler.test.ts @@ -1,4 +1,8 @@ -import { KeyringRpcMethod, isKeyringRpcMethod } from '@metamask/keyring-api'; +import { + BtcMethod, + KeyringRpcMethod, + isKeyringRpcMethod, +} from '@metamask/keyring-api'; import type { Keyring, GetAccountBalancesRequest } from '@metamask/keyring-api'; import type { JsonRpcRequest } from '@metamask/keyring-utils'; @@ -12,6 +16,7 @@ describe('handleKeyringRequest', () => { listAccountTransactions: jest.fn(), listAccountAssets: jest.fn(), getAccountBalances: jest.fn(), + resolveAccountAddress: jest.fn(), filterAccountChains: jest.fn(), updateAccount: jest.fn(), deleteAccount: jest.fn(), @@ -192,6 +197,41 @@ describe('handleKeyringRequest', () => { ); }); + it('calls `keyring_resolveAccountAddress`', async () => { + const scope = 'bip122:000000000019d6689c085ae165831e93'; + const signingRequest = { + id: '71621d8d-62a4-4bf4-97cc-fb8f243679b0', + jsonrpc: '2.0', + method: BtcMethod.SendBitcoin, + params: { + recipients: { + address: '0.1', + }, + replaceable: true, + }, + }; + const request: JsonRpcRequest = { + jsonrpc: '2.0', + id: '7c507ff0-365f-4de0-8cd5-eb83c30ebda4', + method: 'keyring_resolveAccountAddress', + params: { + scope, + request: signingRequest, + }, + }; + + keyring.resolveAccountAddress.mockResolvedValue( + 'ResolveAccountAddress result', + ); + const result = await handleKeyringRequest(keyring, request); + + expect(keyring.resolveAccountAddress).toHaveBeenCalledWith( + scope, + signingRequest, + ); + expect(result).toBe('ResolveAccountAddress result'); + }); + it('calls `keyring_filterAccountChains`', async () => { const request: JsonRpcRequest = { jsonrpc: '2.0', diff --git a/packages/keyring-snap-sdk/src/rpc-handler.ts b/packages/keyring-snap-sdk/src/rpc-handler.ts index bc53bdbb2..4ed28d8a2 100644 --- a/packages/keyring-snap-sdk/src/rpc-handler.ts +++ b/packages/keyring-snap-sdk/src/rpc-handler.ts @@ -16,6 +16,7 @@ import { ListRequestsRequestStruct, GetAccountBalancesRequestStruct, ListAccountAssetsRequestStruct, + ResolveAccountAddressRequestStruct, } from '@metamask/keyring-api'; import type { JsonRpcRequest } from '@metamask/keyring-utils'; import { JsonRpcRequestStruct } from '@metamask/keyring-utils'; @@ -93,6 +94,14 @@ async function dispatchRequest( ); } + case `${KeyringRpcMethod.ResolveAccountAddress}`: { + assert(request, ResolveAccountAddressRequestStruct); + return keyring.resolveAccountAddress( + request.params.scope, + request.params.request, + ); + } + case `${KeyringRpcMethod.FilterAccountChains}`: { assert(request, FilterAccountChainsStruct); return keyring.filterAccountChains( From 88a34963b4af9a96aec52dcddb579758610ce708 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 27 Jan 2025 18:38:11 +0100 Subject: [PATCH 02/18] feat(keyring-snap-bridge): add SnapIdMap.hasSnapId method --- .../keyring-snap-bridge/src/SnapIdMap.test.ts | 22 +++++++++++++++++++ packages/keyring-snap-bridge/src/SnapIdMap.ts | 11 ++++++++++ 2 files changed, 33 insertions(+) diff --git a/packages/keyring-snap-bridge/src/SnapIdMap.test.ts b/packages/keyring-snap-bridge/src/SnapIdMap.test.ts index 637e33a62..3bdc334dd 100644 --- a/packages/keyring-snap-bridge/src/SnapIdMap.test.ts +++ b/packages/keyring-snap-bridge/src/SnapIdMap.test.ts @@ -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 }>(); diff --git a/packages/keyring-snap-bridge/src/SnapIdMap.ts b/packages/keyring-snap-bridge/src/SnapIdMap.ts index c23f8e505..ba78fc16e 100644 --- a/packages/keyring-snap-bridge/src/SnapIdMap.ts +++ b/packages/keyring-snap-bridge/src/SnapIdMap.ts @@ -157,6 +157,17 @@ export class SnapIdMap { 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 used a reverse-mapping to map Snap ID to their actual key too. For now, this will do the trick. + return [...this.#map.values()].some((value) => value.snapId === snapId); + } + /** * Deletes a key from the map. * From abe1e3369fa70f2e2522996bcb5d483d0797d422 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 27 Jan 2025 18:38:47 +0100 Subject: [PATCH 03/18] feat(keyring-snap-bridge): add SnapKeyring.{hasSnapId,resolveAccountAddress} methods --- .../src/SnapKeyring.test.ts | 65 +++++++++++++++++++ .../keyring-snap-bridge/src/SnapKeyring.ts | 39 +++++++++++ 2 files changed, 104 insertions(+) diff --git a/packages/keyring-snap-bridge/src/SnapKeyring.test.ts b/packages/keyring-snap-bridge/src/SnapKeyring.test.ts index 5db8d9d6f..87946e0d5 100644 --- a/packages/keyring-snap-bridge/src/SnapKeyring.test.ts +++ b/packages/keyring-snap-bridge/src/SnapKeyring.test.ts @@ -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'; @@ -1764,6 +1765,70 @@ describe('SnapKeyring', () => { }); }); + describe('resolveAccountAddress', () => { + const address = '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb'; + const scope = toCaipChainId(EthScopes.Namespace, 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's address: unknown Snap ID: ${badSnapId}`, + ); + }); + }); + describe('prepareUserOperation', () => { const mockIntents = [ { diff --git a/packages/keyring-snap-bridge/src/SnapKeyring.ts b/packages/keyring-snap-bridge/src/SnapKeyring.ts index c3d5bab47..0f3d2b9f4 100644 --- a/packages/keyring-snap-bridge/src/SnapKeyring.ts +++ b/packages/keyring-snap-bridge/src/SnapKeyring.ts @@ -27,9 +27,12 @@ import type { EthBaseUserOperation, EthUserOperation, EthUserOperationPatch, + CaipChainId, + ResolvedAccountAddress, } from '@metamask/keyring-api'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import { KeyringInternalSnapClient } from '@metamask/keyring-internal-snap-client'; +import type { JsonRpcRequest } from '@metamask/keyring-utils'; import { strictMask } from '@metamask/keyring-utils'; import type { SnapId } from '@metamask/snaps-sdk'; import { type Snap } from '@metamask/snaps-utils'; @@ -534,6 +537,42 @@ export class SnapKeyring extends EventEmitter { ); } + /** + * Checks if a Snap ID is known from the keyring. + * + * @param snapId - Snap ID. + * @returns `true` if the Snap ID is known, `false` otherwise. + */ + hasSnapId(snapId: SnapId): boolean { + return this.#accounts.hasSnapId(snapId); + } + + /** + * Resolve the Snap account's address associated from a signing request. + * + * @param snapId - Snap ID. + * @param scope - CAIP-2 chain ID. + * @param request - Signing request object. + * @throws An error if the Snap ID is not known from the keyring. + * @returns The resolved address if found, `null` otherwise. + */ + async resolveAccountAddress( + snapId: SnapId, + scope: CaipChainId, + request: JsonRpcRequest, + ): Promise { + // We do check that the incoming Snap ID is known by the keyring. + if (!this.hasSnapId(snapId)) { + throw new Error( + `Unable to resolve account's address: unknown Snap ID: ${snapId}`, + ); + } + + return await this.#snapClient + .withSnapId(snapId) + .resolveAccountAddress(scope, request); + } + /** * Submit a request to a Snap. * From eb5e984e8c2235e01aad9aeb56162c48bdd8977c Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Tue, 28 Jan 2025 11:22:03 +0100 Subject: [PATCH 04/18] refactor(keyring-snap-bridge): moving #submitSnapRequest closer to #submitRequest --- .../keyring-snap-bridge/src/SnapKeyring.ts | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/keyring-snap-bridge/src/SnapKeyring.ts b/packages/keyring-snap-bridge/src/SnapKeyring.ts index 0f3d2b9f4..fcd64e101 100644 --- a/packages/keyring-snap-bridge/src/SnapKeyring.ts +++ b/packages/keyring-snap-bridge/src/SnapKeyring.ts @@ -646,33 +646,6 @@ export class SnapKeyring extends EventEmitter { return promise.promise; } - /** - * Check if an account supports the given method. - * - * @param account - The account object to check for method support. - * @param method - The Ethereum method to validate. - * @returns `true` if the method is supported, `false` otherwise. - */ - #hasMethod(account: KeyringAccount, method: AccountMethod): boolean { - return (account.methods as AccountMethod[]).includes(method); - } - - /** - * Creates a promise for a request and adds it to the map of requests. - * - * @param requestId - The unique identifier for the request. - * @param snapId - The Snap ID associated with the request. - * @returns A DeferredPromise instance. - */ - #createRequestPromise( - requestId: string, - snapId: SnapId, - ): DeferredPromise { - const promise = new DeferredPromise(); - this.#requests.set(requestId, { promise, snapId }); - return promise; - } - /** * Submits a request to a Snap. * @@ -724,6 +697,33 @@ export class SnapKeyring extends EventEmitter { } } + /** + * Check if an account supports the given method. + * + * @param account - The account object to check for method support. + * @param method - The Ethereum method to validate. + * @returns `true` if the method is supported, `false` otherwise. + */ + #hasMethod(account: KeyringAccount, method: AccountMethod): boolean { + return (account.methods as AccountMethod[]).includes(method); + } + + /** + * Creates a promise for a request and adds it to the map of requests. + * + * @param requestId - The unique identifier for the request. + * @param snapId - The Snap ID associated with the request. + * @returns A DeferredPromise instance. + */ + #createRequestPromise( + requestId: string, + snapId: SnapId, + ): DeferredPromise { + const promise = new DeferredPromise(); + this.#requests.set(requestId, { promise, snapId }); + return promise; + } + /** * Clear a promise for a request and delete it from the map of requests. * From 045b6fa31bf42594f289ed3ede8a714206233feb Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Tue, 28 Jan 2025 13:06:21 +0100 Subject: [PATCH 05/18] feat(keyring-snap-bridge): add public submitRequest --- .../src/SnapKeyring.test.ts | 93 ++++++++++- .../keyring-snap-bridge/src/SnapKeyring.ts | 153 +++++++++++++----- 2 files changed, 206 insertions(+), 40 deletions(-) diff --git a/packages/keyring-snap-bridge/src/SnapKeyring.test.ts b/packages/keyring-snap-bridge/src/SnapKeyring.test.ts index 87946e0d5..40e4fda0b 100644 --- a/packages/keyring-snap-bridge/src/SnapKeyring.test.ts +++ b/packages/keyring-snap-bridge/src/SnapKeyring.test.ts @@ -38,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, @@ -1829,6 +1829,97 @@ describe('SnapKeyring', () => { }); }); + describe('submitRequest', () => { + const account = ethEoaAccount1; + const scope = EthScopes.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({ + id: 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({ + id: account.id, + method, + params, + scope, + }), + ).rejects.toThrow( + `Request for Snap '${snapId}' with method '${method}' must be synchronous.`, + ); + }); + + it('throws an error when using an unknown account id', async () => { + const unknownAccountId = 'unknown-account-id'; + + await expect( + keyring.submitRequest({ + id: 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({ + id: account.id, + method: unknownAccountMethod, + params, + scope, + }), + ).rejects.toThrow( + `Method '${unknownAccountMethod}' not supported for account ${account.address}`, + ); + }); + }); + describe('prepareUserOperation', () => { const mockIntents = [ { diff --git a/packages/keyring-snap-bridge/src/SnapKeyring.ts b/packages/keyring-snap-bridge/src/SnapKeyring.ts index fcd64e101..078406e6d 100644 --- a/packages/keyring-snap-bridge/src/SnapKeyring.ts +++ b/packages/keyring-snap-bridge/src/SnapKeyring.ts @@ -510,6 +510,24 @@ export class SnapKeyring extends EventEmitter { this.#accounts = SnapIdMap.fromObject(accounts); } + /** + * Get an account and its associated Snap ID from its ID. + * + * @param id - Account ID. + * @throws An error if the account could not be found. + * @returns The account associated with the given account ID in this keyring. + */ + #getAccount(id: string): { account: KeyringAccount; snapId: SnapId } { + const found = [...this.#accounts.values()].find( + (entry) => entry.account.id === id, + ); + + if (!found) { + throw new Error(`Unable to get account: unknown account ID: '${id}'`); + } + return found; + } + /** * Get the addresses of the accounts in this keyring. * @@ -573,6 +591,49 @@ export class SnapKeyring extends EventEmitter { .resolveAccountAddress(scope, request); } + /** + * Submit a request to a Snap. + * + * This request cannot be an asynchronous keyring request. + * + * @param opts - Request options. + * @param opts.id - Account ID. + * @param opts.method - Method to call. + * @param opts.params - Method parameters. + * @param opts.scope - Selected chain ID (CAIP-2). + * @returns Promise that resolves to the result of the method call. + */ + async submitRequest({ + id, + method, + params, + scope, + }: { + id: string; + method: string; + params?: Json[] | Record; + scope: string; + }): Promise { + const { account, snapId } = this.#getAccount(id); + + const { requestId, response } = await this.#submitSnapRequest({ + snapId, + account, + method: method as AccountMethod, + params, + scope, + }); + + // For now, we enforce responses to be synchronous. + if (response.pending) { + throw new Error( + `Request for Snap '${snapId}' with method '${method}' must be synchronous.`, + ); + } + + return this.#handleSyncResponse(response, requestId, snapId); + } + /** * Submit a request to a Snap. * @@ -580,7 +641,7 @@ export class SnapKeyring extends EventEmitter { * @param opts.address - Account address. * @param opts.method - Method to call. * @param opts.params - Method parameters. - * @param opts.chainId - Selected chain ID (CAIP-2). + * @param opts.scope - Selected chain ID (CAIP-2). * @param opts.noPending - Whether the response is allowed to be pending. * @returns Promise that resolves to the result of the method call. */ @@ -588,36 +649,25 @@ export class SnapKeyring extends EventEmitter { address, method, params, - chainId = '', + scope = '', noPending = false, }: { address: string; method: string; params?: Json[] | Record; - chainId?: string; + scope?: string; noPending?: boolean; }): Promise { const { account, snapId } = this.#resolveAddress(address); - if (!this.#hasMethod(account, method as AccountMethod)) { - throw new Error( - `Method '${method}' not supported for account ${account.address}`, - ); - } - const requestId = uuid(); - - // Create the promise before calling the Snap to prevent a race condition - // where the Snap responds before we have a chance to create it. - const promise = this.#createRequestPromise(requestId, snapId); - - const response = await this.#submitSnapRequest({ - snapId, - requestId, - account, - method: method as AccountMethod, - params, - chainId, - }); + const { requestId, requestPromise, response } = + await this.#submitSnapRequest({ + snapId, + account, + method: method as AccountMethod, + params, + scope, + }); // Some methods, like the ones used to prepare and patch user operations, // require the Snap to answer synchronously in order to work with the @@ -628,7 +678,7 @@ export class SnapKeyring extends EventEmitter { this.#clearRequestPromise(requestId, snapId); throw new Error( - `Request '${requestId}' to snap '${snapId}' is pending and noPending is true.`, + `Request '${requestId}' to Snap '${snapId}' is pending and noPending is true.`, ); } @@ -643,7 +693,7 @@ export class SnapKeyring extends EventEmitter { await this.#handleAsyncResponse(response.redirect, snapId); } - return promise.promise; + return requestPromise.promise; } /** @@ -651,33 +701,50 @@ export class SnapKeyring extends EventEmitter { * * @param options - The options for the Snap request. * @param options.snapId - The Snap ID to submit the request to. - * @param options.requestId - The unique identifier for the request. * @param options.account - The account to use for the request. * @param options.method - The Ethereum method to call. * @param options.params - The parameters to pass to the method, can be undefined. - * @param options.chainId - The chain ID to use for the request, can be an empty string. + * @param options.scope - The chain ID to use for the request, can be an empty string. * @returns A promise that resolves to the keyring response from the Snap. * @throws An error if the Snap fails to respond or if there's an issue with the request submission. */ - async #submitSnapRequest({ + async #submitSnapRequest({ snapId, - requestId, account, method, params, - chainId, + scope, }: { snapId: SnapId; - requestId: string; account: KeyringAccount; method: AccountMethod; params?: Json[] | Record | undefined; - chainId: string; - }): Promise { + scope: string; + }): Promise<{ + requestId: string; + requestPromise: DeferredPromise; + response: KeyringResponse; + }> { + if (!this.#hasMethod(account, method)) { + throw new Error( + `Method '${method}' not supported for account ${account.address}`, + ); + } + + // Generate a new random request ID to keep track of the request execution flow. + const requestId = uuid(); + + // Create the promise before calling the Snap to prevent a race condition + // where the Snap responds before we have a chance to create it. + const requestPromise = this.#createRequestPromise( + requestId, + snapId, + ); + try { const request = { id: requestId, - scope: chainId, + scope, account: account.id, request: { method, @@ -687,7 +754,15 @@ export class SnapKeyring extends EventEmitter { log('Submit Snap request: ', request); - return await this.#snapClient.withSnapId(snapId).submitRequest(request); + const response = await this.#snapClient + .withSnapId(snapId) + .submitRequest(request); + + return { + requestId, + requestPromise, + response, + }; } catch (error) { log('Snap Request failed: ', { requestId }); @@ -831,7 +906,7 @@ export class SnapKeyring extends EventEmitter { address, method: EthMethod.SignTransaction, params: [tx], - chainId: toCaipChainId(KnownCaipNamespace.Eip155, `${chainId}`), + scope: toCaipChainId(KnownCaipNamespace.Eip155, `${chainId}`), }); // ! It's *** CRITICAL *** that we mask the signature here, otherwise the @@ -888,7 +963,7 @@ export class SnapKeyring extends EventEmitter { ...(chainId === undefined ? {} : { - chainId: toCaipChainId(KnownCaipNamespace.Eip155, `${chainId}`), + scope: toCaipChainId(KnownCaipNamespace.Eip155, `${chainId}`), }), }), EthBytesStruct, @@ -954,7 +1029,7 @@ export class SnapKeyring extends EventEmitter { params: toJson(transactions), noPending: true, // We assume the chain ID is already well formatted - chainId: toCaipChainId(KnownCaipNamespace.Eip155, context.chainId), + scope: toCaipChainId(KnownCaipNamespace.Eip155, context.chainId), }), EthBaseUserOperationStruct, ); @@ -981,7 +1056,7 @@ export class SnapKeyring extends EventEmitter { params: toJson([userOp]), noPending: true, // We assume the chain ID is already well formatted - chainId: toCaipChainId(KnownCaipNamespace.Eip155, context.chainId), + scope: toCaipChainId(KnownCaipNamespace.Eip155, context.chainId), }), EthUserOperationPatchStruct, ); @@ -1006,7 +1081,7 @@ export class SnapKeyring extends EventEmitter { method: EthMethod.SignUserOperation, params: toJson([userOp]), // We assume the chain ID is already well formatted - chainId: toCaipChainId(KnownCaipNamespace.Eip155, context.chainId), + scope: toCaipChainId(KnownCaipNamespace.Eip155, context.chainId), }), EthBytesStruct, ); From a9b8ef55450123cb9563410c99fb1432730ea66c Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 29 Jan 2025 10:49:38 +0100 Subject: [PATCH 06/18] chore: better jsdoc --- packages/keyring-api/src/api/address.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/keyring-api/src/api/address.ts b/packages/keyring-api/src/api/address.ts index 0adb28e3e..f21130186 100644 --- a/packages/keyring-api/src/api/address.ts +++ b/packages/keyring-api/src/api/address.ts @@ -2,7 +2,7 @@ import type { Infer } from '@metamask/superstruct'; import { object, string } from '@metamask/superstruct'; /** - * The resolved address of an account. + * An account's address that has been resolved from a signing request. */ export const ResolvedAccountAddressStruct = object({ /** From 0ea69692dbba5f23ccdae932b781edad16bfdd81 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 29 Jan 2025 12:18:06 +0100 Subject: [PATCH 07/18] chore: lint --- packages/keyring-api/src/api/keyring.ts | 1 - packages/keyring-snap-bridge/src/SnapKeyring.ts | 3 +-- packages/keyring-snap-client/src/KeyringClient.ts | 7 ++++++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/keyring-api/src/api/keyring.ts b/packages/keyring-api/src/api/keyring.ts index 588d5a111..8d978b056 100644 --- a/packages/keyring-api/src/api/keyring.ts +++ b/packages/keyring-api/src/api/keyring.ts @@ -13,7 +13,6 @@ import type { KeyringRequest } from './request'; import type { KeyringResponse } from './response'; import type { Transaction } from './transaction'; - /** * Keyring interface. * diff --git a/packages/keyring-snap-bridge/src/SnapKeyring.ts b/packages/keyring-snap-bridge/src/SnapKeyring.ts index 078406e6d..3e06ac2b2 100644 --- a/packages/keyring-snap-bridge/src/SnapKeyring.ts +++ b/packages/keyring-snap-bridge/src/SnapKeyring.ts @@ -27,7 +27,6 @@ import type { EthBaseUserOperation, EthUserOperation, EthUserOperationPatch, - CaipChainId, ResolvedAccountAddress, } from '@metamask/keyring-api'; import type { InternalAccount } from '@metamask/keyring-internal-api'; @@ -37,7 +36,7 @@ import { strictMask } from '@metamask/keyring-utils'; import type { SnapId } from '@metamask/snaps-sdk'; import { type Snap } from '@metamask/snaps-utils'; import { assert, mask, object, string } from '@metamask/superstruct'; -import type { Json } from '@metamask/utils'; +import type { Json, CaipChainId } from '@metamask/utils'; import { bigIntToHex, KnownCaipNamespace, diff --git a/packages/keyring-snap-client/src/KeyringClient.ts b/packages/keyring-snap-client/src/KeyringClient.ts index 6efe7ab5c..ea3a93610 100644 --- a/packages/keyring-snap-client/src/KeyringClient.ts +++ b/packages/keyring-snap-client/src/KeyringClient.ts @@ -31,7 +31,12 @@ import { import type { JsonRpcRequest } from '@metamask/keyring-utils'; import { strictMask } from '@metamask/keyring-utils'; import { assert } from '@metamask/superstruct'; -import type { CaipChainId, CaipAssetType, CaipAssetTypeOrId, Json } from '@metamask/utils'; +import type { + CaipChainId, + CaipAssetType, + CaipAssetTypeOrId, + Json, +} from '@metamask/utils'; import { v4 as uuid } from 'uuid'; export type Sender = { From 6f3851762878b21685a922e62f58cd37eb738f2e Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 29 Jan 2025 12:19:42 +0100 Subject: [PATCH 08/18] chore: re-remove CAIP files (after rebase) --- packages/keyring-api/src/api/caip.test.ts | 122 ---------------------- packages/keyring-api/src/api/caip.ts | 52 --------- 2 files changed, 174 deletions(-) delete mode 100644 packages/keyring-api/src/api/caip.test.ts delete mode 100644 packages/keyring-api/src/api/caip.ts diff --git a/packages/keyring-api/src/api/caip.test.ts b/packages/keyring-api/src/api/caip.test.ts deleted file mode 100644 index 4fde5cbf4..000000000 --- a/packages/keyring-api/src/api/caip.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { isCaipAssetId, isCaipAssetType, isCaipChainId } from './caip'; - -describe('isCaipChainId', () => { - // Imported from: https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md#test-cases - it.each([ - 'eip155:1', - 'bip122:000000000019d6689c085ae165831e93', - 'bip122:12a765e31ffd4059bada1e25190f6e98', - 'bip122:fdbe99b90c90bae7505796461471d89a', - 'cosmos:cosmoshub-2', - 'cosmos:cosmoshub-3', - 'cosmos:Binance-Chain-Tigris', - 'cosmos:iov-mainnet', - 'starknet:SN_GOERLI', - 'lip9:9ee11e9df416b18b', - 'chainstd:8c3444cf8970a9e41a706fab93e7a6c4', - ])('returns true for a valid chain id %s', (id) => { - expect(isCaipChainId(id)).toBe(true); - }); - - it.each([ - true, - false, - null, - undefined, - 1, - {}, - [], - '', - '!@#$%^&*()', - 'foo', - 'eip155', - 'eip155:', - ])('returns false for an invalid chain id %s', (id) => { - expect(isCaipChainId(id)).toBe(false); - }); -}); - -describe('isCaipAssetType', () => { - // Imported from: https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-19.md#test-cases - it.each([ - 'eip155:1/slip44:60', - 'bip122:000000000019d6689c085ae165831e93/slip44:0', - 'cosmos:cosmoshub-3/slip44:118', - 'bip122:12a765e31ffd4059bada1e25190f6e98/slip44:2', - 'cosmos:Binance-Chain-Tigris/slip44:714', - 'cosmos:iov-mainnet/slip44:234', - 'lip9:9ee11e9df416b18b/slip44:134', - 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', - 'eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d', - ])('returns true for a valid asset type %s', (id) => { - expect(isCaipAssetType(id)).toBe(true); - }); - - it.each([ - true, - false, - null, - undefined, - 1, - {}, - [], - '', - '!@#$%^&*()', - 'foo', - 'eip155', - 'eip155:', - 'eip155:1', - 'eip155:1:', - 'eip155:1:0x0000000000000000000000000000000000000000:2', - 'bip122', - 'bip122:', - 'bip122:000000000019d6689c085ae165831e93', - 'bip122:000000000019d6689c085ae165831e93/', - 'bip122:000000000019d6689c085ae165831e93/tooooooolong', - 'bip122:000000000019d6689c085ae165831e93/tooooooolong:asset', - 'eip155:1/erc721', - 'eip155:1/erc721:', - 'eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d/', - ])('returns false for an invalid asset type %s', (id) => { - expect(isCaipAssetType(id)).toBe(false); - }); -}); - -describe('isCaipAssetId', () => { - // Imported from: https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-19.md#test-cases - it.each([ - 'eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d/771769', - 'hedera:mainnet/nft:0.0.55492/12', - ])('returns true for a valid asset id %s', (id) => { - expect(isCaipAssetId(id)).toBe(true); - }); - - it.each([ - true, - false, - null, - undefined, - 1, - {}, - [], - '', - '!@#$%^&*()', - 'foo', - 'eip155', - 'eip155:', - 'eip155:1', - 'eip155:1:', - 'eip155:1:0x0000000000000000000000000000000000000000:2', - 'bip122', - 'bip122:', - 'bip122:000000000019d6689c085ae165831e93', - 'bip122:000000000019d6689c085ae165831e93/', - 'bip122:000000000019d6689c085ae165831e93/tooooooolong', - 'bip122:000000000019d6689c085ae165831e93/tooooooolong:asset', - 'eip155:1/erc721', - 'eip155:1/erc721:', - 'eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d/', - ])('returns false for an invalid asset id %s', (id) => { - expect(isCaipAssetType(id)).toBe(false); - }); -}); diff --git a/packages/keyring-api/src/api/caip.ts b/packages/keyring-api/src/api/caip.ts deleted file mode 100644 index c87b750d6..000000000 --- a/packages/keyring-api/src/api/caip.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { definePattern } from '@metamask/keyring-utils'; -import { type Infer } from '@metamask/superstruct'; -import type { CaipChainId, CaipAssetId, CaipAssetType } from '@metamask/utils'; -import { - CAIP_CHAIN_ID_REGEX, - CAIP_ASSET_TYPE_REGEX, - CAIP_ASSET_ID_REGEX, - isCaipChainId, - isCaipAssetType, - isCaipAssetId, -} from '@metamask/utils'; - -const CAIP_ASSET_TYPE_OR_ID_REGEX = - /^(?(?[-a-z0-9]{3,8}):(?[-_a-zA-Z0-9]{1,32}))\/(?[-a-z0-9]{3,8}):(?[-.%a-zA-Z0-9]{1,128})(\/(?[-.%a-zA-Z0-9]{1,78}))?$/u; - -/** - * A CAIP-2 chain ID, i.e., a human-readable agnostic chain ID. - */ -export const CaipChainIdStruct = definePattern( - 'CaipChainId', - CAIP_CHAIN_ID_REGEX, -); -export type { CaipChainId }; -export { isCaipChainId }; - -/** - * A CAIP-19 asset type identifier, i.e., a human-readable type of asset identifier. - */ -export const CaipAssetTypeStruct = definePattern( - 'CaipAssetType', - CAIP_ASSET_TYPE_REGEX, -); -export type { CaipAssetType }; -export { isCaipAssetType }; - -/** - * A CAIP-19 asset ID identifier, i.e., a human-readable type of asset ID. - */ -export const CaipAssetIdStruct = definePattern( - 'CaipAssetId', - CAIP_ASSET_ID_REGEX, -); -export type { CaipAssetId }; -export { isCaipAssetId }; - -/** - * A CAIP-19 asset type or asset ID identifier, i.e., a human-readable type of asset identifier. - */ -export const CaipAssetTypeOrIdStruct = definePattern< - CaipAssetType | CaipAssetId ->('CaipAssetTypeOrId', CAIP_ASSET_TYPE_OR_ID_REGEX); -export type CaipAssetTypeOrId = Infer; From 2449ac7fa9be7fef058225f1cd9e1236d1bb7d25 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 29 Jan 2025 12:22:23 +0100 Subject: [PATCH 09/18] refactor: snapID -> snapId --- packages/keyring-snap-bridge/src/SnapIdMap.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/keyring-snap-bridge/src/SnapIdMap.test.ts b/packages/keyring-snap-bridge/src/SnapIdMap.test.ts index 3bdc334dd..d62c6ea2e 100644 --- a/packages/keyring-snap-bridge/src/SnapIdMap.test.ts +++ b/packages/keyring-snap-bridge/src/SnapIdMap.test.ts @@ -98,7 +98,7 @@ describe('SnapIdMap', () => { }); describe('hasSnapId', () => { - it('returns false when the snapID is not in the map', () => { + 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); From b2ac34d7155323b751461567b229b27a8e9d51dc Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 29 Jan 2025 12:27:14 +0100 Subject: [PATCH 10/18] chore: better comment --- packages/keyring-snap-bridge/src/SnapIdMap.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/keyring-snap-bridge/src/SnapIdMap.ts b/packages/keyring-snap-bridge/src/SnapIdMap.ts index ba78fc16e..5435dc1bc 100644 --- a/packages/keyring-snap-bridge/src/SnapIdMap.ts +++ b/packages/keyring-snap-bridge/src/SnapIdMap.ts @@ -164,7 +164,8 @@ export class SnapIdMap { * @returns `true` if the snap ID is present in the map, `false` otherwise. */ hasSnapId(snapId: SnapId): boolean { - // We could used a reverse-mapping to map Snap ID to their actual key too. For now, this will do the trick. + // 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); } From 16fdd9b0734a995b5a7ead98493ca76e7be9a84f Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 29 Jan 2025 12:54:39 +0100 Subject: [PATCH 11/18] test: fix build --- packages/keyring-snap-bridge/src/SnapKeyring.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/keyring-snap-bridge/src/SnapKeyring.test.ts b/packages/keyring-snap-bridge/src/SnapKeyring.test.ts index 40e4fda0b..eee9026b1 100644 --- a/packages/keyring-snap-bridge/src/SnapKeyring.test.ts +++ b/packages/keyring-snap-bridge/src/SnapKeyring.test.ts @@ -1767,7 +1767,10 @@ describe('SnapKeyring', () => { describe('resolveAccountAddress', () => { const address = '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb'; - const scope = toCaipChainId(EthScopes.Namespace, executionContext.chainId); + const scope = toCaipChainId( + KnownCaipNamespace.Eip155, + executionContext.chainId, + ); const request: JsonRpcRequest = { id: '3d8a0bda-285c-4551-abe8-f52af39d3095', jsonrpc: '2.0', @@ -1831,7 +1834,7 @@ describe('SnapKeyring', () => { describe('submitRequest', () => { const account = ethEoaAccount1; - const scope = EthScopes.Testnet; + const scope = EthScope.Testnet; const method = EthMethod.SignTransaction; const params = { from: 'me', From 98a9c57e1d6f4a4a0235714e1c6a2b2ea2d747c5 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 29 Jan 2025 14:09:12 +0100 Subject: [PATCH 12/18] fix(keyring-api): fix resolveAccountAddress parameter type Co-authored-by: Daniel Rocha <68558152+danroc@users.noreply.github.com> --- packages/keyring-api/src/api/keyring.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/keyring-api/src/api/keyring.ts b/packages/keyring-api/src/api/keyring.ts index 8d978b056..7f468079f 100644 --- a/packages/keyring-api/src/api/keyring.ts +++ b/packages/keyring-api/src/api/keyring.ts @@ -124,7 +124,7 @@ export type Keyring = { * could be found. */ resolveAccountAddress( - id: string, + scope: CaipChainId, request: JsonRpcRequest, ): Promise; From 15ce4cf8ef2b0924b8dc29f254e48449b80b43af Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 29 Jan 2025 14:09:52 +0100 Subject: [PATCH 13/18] chore: typo Co-authored-by: Daniel Rocha <68558152+danroc@users.noreply.github.com> --- packages/keyring-snap-bridge/src/SnapKeyring.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/keyring-snap-bridge/src/SnapKeyring.ts b/packages/keyring-snap-bridge/src/SnapKeyring.ts index 3e06ac2b2..5ac2df8a2 100644 --- a/packages/keyring-snap-bridge/src/SnapKeyring.ts +++ b/packages/keyring-snap-bridge/src/SnapKeyring.ts @@ -581,7 +581,7 @@ export class SnapKeyring extends EventEmitter { // We do check that the incoming Snap ID is known by the keyring. if (!this.hasSnapId(snapId)) { throw new Error( - `Unable to resolve account's address: unknown Snap ID: ${snapId}`, + `Unable to resolve account address: unknown Snap ID: ${snapId}`, ); } From 6633e0c239b7f0d96c0f322d7d218d1d7dd0e64c Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 29 Jan 2025 14:12:35 +0100 Subject: [PATCH 14/18] fix(keyring-api): add missing CaipChainId import --- packages/keyring-api/src/api/keyring.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/keyring-api/src/api/keyring.ts b/packages/keyring-api/src/api/keyring.ts index 7f468079f..7fb580438 100644 --- a/packages/keyring-api/src/api/keyring.ts +++ b/packages/keyring-api/src/api/keyring.ts @@ -2,7 +2,12 @@ // This rule seems to be triggering a false positive on the `KeyringAccount`. import type { JsonRpcRequest } from '@metamask/keyring-utils'; -import type { Json, CaipAssetType, CaipAssetTypeOrId } from '@metamask/utils'; +import type { + Json, + CaipChainId, + CaipAssetType, + CaipAssetTypeOrId, +} from '@metamask/utils'; import type { KeyringAccount } from './account'; import type { ResolvedAccountAddress } from './address'; From a303a964a72007747f558795ddb09b1d37c07b27 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 29 Jan 2025 15:16:16 +0100 Subject: [PATCH 15/18] test: fix error message --- packages/keyring-snap-bridge/src/SnapKeyring.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/keyring-snap-bridge/src/SnapKeyring.test.ts b/packages/keyring-snap-bridge/src/SnapKeyring.test.ts index eee9026b1..b7e960645 100644 --- a/packages/keyring-snap-bridge/src/SnapKeyring.test.ts +++ b/packages/keyring-snap-bridge/src/SnapKeyring.test.ts @@ -1827,7 +1827,7 @@ describe('SnapKeyring', () => { await expect( keyring.resolveAccountAddress(badSnapId, scope, request), ).rejects.toThrow( - `Unable to resolve account's address: unknown Snap ID: ${badSnapId}`, + `Unable to resolve account address: unknown Snap ID: ${badSnapId}`, ); }); }); From 50268a008be4bdcd403b9280898c00a4ab9ccf99 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 29 Jan 2025 15:19:28 +0100 Subject: [PATCH 16/18] refactor(keyring-snap-bridge): re-use #submitSnapRequest for both submitRequest methods --- .../src/SnapKeyring.test.ts | 4 +- .../keyring-snap-bridge/src/SnapKeyring.ts | 105 ++++++++---------- 2 files changed, 45 insertions(+), 64 deletions(-) diff --git a/packages/keyring-snap-bridge/src/SnapKeyring.test.ts b/packages/keyring-snap-bridge/src/SnapKeyring.test.ts index b7e960645..0a4d8a066 100644 --- a/packages/keyring-snap-bridge/src/SnapKeyring.test.ts +++ b/packages/keyring-snap-bridge/src/SnapKeyring.test.ts @@ -1887,9 +1887,7 @@ describe('SnapKeyring', () => { params, scope, }), - ).rejects.toThrow( - `Request for Snap '${snapId}' with method '${method}' must be synchronous.`, - ); + ).rejects.toThrow(regexForUUIDInRequiredSyncErrorMessage); }); it('throws an error when using an unknown account id', async () => { diff --git a/packages/keyring-snap-bridge/src/SnapKeyring.ts b/packages/keyring-snap-bridge/src/SnapKeyring.ts index 5ac2df8a2..c2261fec0 100644 --- a/packages/keyring-snap-bridge/src/SnapKeyring.ts +++ b/packages/keyring-snap-bridge/src/SnapKeyring.ts @@ -21,7 +21,6 @@ import { import type { KeyringAccount, KeyringExecutionContext, - KeyringResponse, BtcMethod, EthBaseTransaction, EthBaseUserOperation, @@ -591,7 +590,7 @@ export class SnapKeyring extends EventEmitter { } /** - * Submit a request to a Snap. + * Submit a request to a Snap from an account ID. * * This request cannot be an asynchronous keyring request. * @@ -615,26 +614,20 @@ export class SnapKeyring extends EventEmitter { }): Promise { const { account, snapId } = this.#getAccount(id); - const { requestId, response } = await this.#submitSnapRequest({ + return await this.#submitSnapRequest({ snapId, account, method: method as AccountMethod, params, scope, + // For non-EVM (in the context of the multichain API and SIP-26), we enforce responses + // to be synchronous. + noPending: true, }); - - // For now, we enforce responses to be synchronous. - if (response.pending) { - throw new Error( - `Request for Snap '${snapId}' with method '${method}' must be synchronous.`, - ); - } - - return this.#handleSyncResponse(response, requestId, snapId); } /** - * Submit a request to a Snap. + * Submit a request to a Snap from an account address. * * @param opts - Request options. * @param opts.address - Account address. @@ -656,43 +649,17 @@ export class SnapKeyring extends EventEmitter { params?: Json[] | Record; scope?: string; noPending?: boolean; - }): Promise { + }): Promise { const { account, snapId } = this.#resolveAddress(address); - const { requestId, requestPromise, response } = - await this.#submitSnapRequest({ - snapId, - account, - method: method as AccountMethod, - params, - scope, - }); - - // Some methods, like the ones used to prepare and patch user operations, - // require the Snap to answer synchronously in order to work with the - // confirmation flow. This check lets the caller enforce this behavior. - if (noPending && response.pending) { - // If the Snap is not allowed to execute asynchronous request, delete - // the promise to prevent a leak. - this.#clearRequestPromise(requestId, snapId); - - throw new Error( - `Request '${requestId}' to Snap '${snapId}' is pending and noPending is true.`, - ); - } - - // If the Snap answers synchronously, the promise must be removed from the - // map to prevent a leak. - if (!response.pending) { - return this.#handleSyncResponse(response, requestId, snapId); - } - - // If the Snap answers asynchronously, we will inform the user with a redirect - if (response.redirect?.message || response.redirect?.url) { - await this.#handleAsyncResponse(response.redirect, snapId); - } - - return requestPromise.promise; + return await this.#submitSnapRequest({ + snapId, + account, + method: method as AccountMethod, + params, + scope, + noPending, + }); } /** @@ -704,6 +671,7 @@ export class SnapKeyring extends EventEmitter { * @param options.method - The Ethereum method to call. * @param options.params - The parameters to pass to the method, can be undefined. * @param options.scope - The chain ID to use for the request, can be an empty string. + * @param options.noPending - Whether the response is allowed to be pending. * @returns A promise that resolves to the keyring response from the Snap. * @throws An error if the Snap fails to respond or if there's an issue with the request submission. */ @@ -713,17 +681,15 @@ export class SnapKeyring extends EventEmitter { method, params, scope, + noPending, }: { snapId: SnapId; account: KeyringAccount; method: AccountMethod; params?: Json[] | Record | undefined; scope: string; - }): Promise<{ - requestId: string; - requestPromise: DeferredPromise; - response: KeyringResponse; - }> { + noPending: boolean; + }): Promise { if (!this.#hasMethod(account, method)) { throw new Error( `Method '${method}' not supported for account ${account.address}`, @@ -757,11 +723,27 @@ export class SnapKeyring extends EventEmitter { .withSnapId(snapId) .submitRequest(request); - return { - requestId, - requestPromise, - response, - }; + // Some methods, like the ones used to prepare and patch user operations, + // require the Snap to answer synchronously in order to work with the + // confirmation flow. This check lets the caller enforce this behavior. + if (noPending && response.pending) { + throw new Error( + `Request '${requestId}' to Snap '${snapId}' is pending and noPending is true.`, + ); + } + + // If the Snap answers synchronously, the promise must be removed from the + // map to prevent a leak. + if (!response.pending) { + return this.#handleSyncResponse(response, requestId, snapId); + } + + // If the Snap answers asynchronously, we will inform the user with a redirect + if (response.redirect?.message || response.redirect?.url) { + await this.#handleAsyncResponse(response.redirect, snapId); + } + + return requestPromise.promise; } catch (error) { log('Snap Request failed: ', { requestId }); @@ -818,13 +800,14 @@ export class SnapKeyring extends EventEmitter { * @param snapId - The Snap ID associated with the request. * @returns The result from the Snap response. */ - #handleSyncResponse( + #handleSyncResponse( response: { pending: false; result: Json }, requestId: string, snapId: SnapId, - ): Json { + ): Response { this.#requests.delete(snapId, requestId); - return response.result; + // We consider `Response` to be compatible with `result` here. + return response.result as Response; } /** From 7ac71e2e13e1b53a8ac11319f51da5042460b789 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 29 Jan 2025 16:43:16 +0100 Subject: [PATCH 17/18] refactor(keyring-snap-bridge): submitRequest: id -> account --- packages/keyring-snap-bridge/src/SnapKeyring.test.ts | 8 ++++---- packages/keyring-snap-bridge/src/SnapKeyring.ts | 10 ++++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/keyring-snap-bridge/src/SnapKeyring.test.ts b/packages/keyring-snap-bridge/src/SnapKeyring.test.ts index 0a4d8a066..e2833cec0 100644 --- a/packages/keyring-snap-bridge/src/SnapKeyring.test.ts +++ b/packages/keyring-snap-bridge/src/SnapKeyring.test.ts @@ -1848,7 +1848,7 @@ describe('SnapKeyring', () => { }); await keyring.submitRequest({ - id: account.id, + account: account.id, method, params, scope, @@ -1882,7 +1882,7 @@ describe('SnapKeyring', () => { await expect( keyring.submitRequest({ - id: account.id, + account: account.id, method, params, scope, @@ -1895,7 +1895,7 @@ describe('SnapKeyring', () => { await expect( keyring.submitRequest({ - id: unknownAccountId, + account: unknownAccountId, method, params, scope, @@ -1910,7 +1910,7 @@ describe('SnapKeyring', () => { await expect( keyring.submitRequest({ - id: account.id, + account: account.id, method: unknownAccountMethod, params, scope, diff --git a/packages/keyring-snap-bridge/src/SnapKeyring.ts b/packages/keyring-snap-bridge/src/SnapKeyring.ts index c2261fec0..75ae4844a 100644 --- a/packages/keyring-snap-bridge/src/SnapKeyring.ts +++ b/packages/keyring-snap-bridge/src/SnapKeyring.ts @@ -595,24 +595,26 @@ export class SnapKeyring extends EventEmitter { * This request cannot be an asynchronous keyring request. * * @param opts - Request options. - * @param opts.id - Account ID. + * @param opts.account - Account ID. * @param opts.method - Method to call. * @param opts.params - Method parameters. * @param opts.scope - Selected chain ID (CAIP-2). * @returns Promise that resolves to the result of the method call. */ async submitRequest({ - id, + account: accountId, method, params, scope, }: { - id: string; + // NOTE: We use `account` here rather than `id` to avoid ambiguity with a "request ID". + // We already use this same field name for `KeyringAccount`s. + account: string; method: string; params?: Json[] | Record; scope: string; }): Promise { - const { account, snapId } = this.#getAccount(id); + const { account, snapId } = this.#getAccount(accountId); return await this.#submitSnapRequest({ snapId, From 15c087cefe1d4fa976fae86a44af8cd9af5f997a Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 29 Jan 2025 17:40:47 +0100 Subject: [PATCH 18/18] refactor: make resolveAccountAddress optional --- packages/keyring-api/src/api/keyring.ts | 2 +- .../keyring-snap-sdk/src/rpc-handler.test.ts | 31 +++++++++++++++++++ packages/keyring-snap-sdk/src/rpc-handler.ts | 3 ++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/keyring-api/src/api/keyring.ts b/packages/keyring-api/src/api/keyring.ts index 7fb580438..edf05b537 100644 --- a/packages/keyring-api/src/api/keyring.ts +++ b/packages/keyring-api/src/api/keyring.ts @@ -128,7 +128,7 @@ export type Keyring = { * be used to process this signing request, or null if none candidates * could be found. */ - resolveAccountAddress( + resolveAccountAddress?( scope: CaipChainId, request: JsonRpcRequest, ): Promise; diff --git a/packages/keyring-snap-sdk/src/rpc-handler.test.ts b/packages/keyring-snap-sdk/src/rpc-handler.test.ts index 9da461458..9f180e855 100644 --- a/packages/keyring-snap-sdk/src/rpc-handler.test.ts +++ b/packages/keyring-snap-sdk/src/rpc-handler.test.ts @@ -232,6 +232,37 @@ describe('handleKeyringRequest', () => { expect(result).toBe('ResolveAccountAddress result'); }); + it('throws an error if `keyring_resolveAccountAddress` is not implemented', async () => { + const scope = 'bip122:000000000019d6689c085ae165831e93'; + const signingRequest = { + id: '71621d8d-62a4-4bf4-97cc-fb8f243679b0', + jsonrpc: '2.0', + method: BtcMethod.SendBitcoin, + params: { + recipients: { + address: '0.1', + }, + replaceable: true, + }, + }; + const request: JsonRpcRequest = { + jsonrpc: '2.0', + id: '7c507ff0-365f-4de0-8cd5-eb83c30ebda4', + method: 'keyring_resolveAccountAddress', + params: { + scope, + request: signingRequest, + }, + }; + + const partialKeyring: Keyring = { ...keyring }; + delete partialKeyring.resolveAccountAddress; + + await expect(handleKeyringRequest(partialKeyring, request)).rejects.toThrow( + 'Method not supported: keyring_resolveAccountAddress', + ); + }); + it('calls `keyring_filterAccountChains`', async () => { const request: JsonRpcRequest = { jsonrpc: '2.0', diff --git a/packages/keyring-snap-sdk/src/rpc-handler.ts b/packages/keyring-snap-sdk/src/rpc-handler.ts index 4ed28d8a2..ba54f16b8 100644 --- a/packages/keyring-snap-sdk/src/rpc-handler.ts +++ b/packages/keyring-snap-sdk/src/rpc-handler.ts @@ -95,6 +95,9 @@ async function dispatchRequest( } case `${KeyringRpcMethod.ResolveAccountAddress}`: { + if (keyring.resolveAccountAddress === undefined) { + throw new MethodNotSupportedError(request.method); + } assert(request, ResolveAccountAddressRequestStruct); return keyring.resolveAccountAddress( request.params.scope,