diff --git a/packages/keyring-api/src/api/address.ts b/packages/keyring-api/src/api/address.ts new file mode 100644 index 000000000..f21130186 --- /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'; + +/** + * 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; 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..edf05b537 100644 --- a/packages/keyring-api/src/api/keyring.ts +++ b/packages/keyring-api/src/api/keyring.ts @@ -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'; @@ -109,6 +116,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?( + scope: CaipChainId, + 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-bridge/src/SnapIdMap.test.ts b/packages/keyring-snap-bridge/src/SnapIdMap.test.ts index 637e33a62..d62c6ea2e 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..5435dc1bc 100644 --- a/packages/keyring-snap-bridge/src/SnapIdMap.ts +++ b/packages/keyring-snap-bridge/src/SnapIdMap.ts @@ -157,6 +157,18 @@ 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 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. * diff --git a/packages/keyring-snap-bridge/src/SnapKeyring.test.ts b/packages/keyring-snap-bridge/src/SnapKeyring.test.ts index 5db8d9d6f..e2833cec0 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'; @@ -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, @@ -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 = [ { diff --git a/packages/keyring-snap-bridge/src/SnapKeyring.ts b/packages/keyring-snap-bridge/src/SnapKeyring.ts index c3d5bab47..75ae4844a 100644 --- a/packages/keyring-snap-bridge/src/SnapKeyring.ts +++ b/packages/keyring-snap-bridge/src/SnapKeyring.ts @@ -21,20 +21,21 @@ import { import type { KeyringAccount, KeyringExecutionContext, - KeyringResponse, BtcMethod, EthBaseTransaction, EthBaseUserOperation, EthUserOperation, EthUserOperationPatch, + 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'; import { assert, mask, object, string } from '@metamask/superstruct'; -import type { Json } from '@metamask/utils'; +import type { Json, CaipChainId } from '@metamask/utils'; import { bigIntToHex, KnownCaipNamespace, @@ -507,6 +508,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. * @@ -535,13 +554,88 @@ export class SnapKeyring extends EventEmitter { } /** - * Submit a request to a Snap. + * 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 address: unknown Snap ID: ${snapId}`, + ); + } + + return await this.#snapClient + .withSnapId(snapId) + .resolveAccountAddress(scope, request); + } + + /** + * Submit a request to a Snap from an account ID. + * + * This request cannot be an asynchronous keyring request. + * + * @param opts - Request options. + * @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({ + account: accountId, + method, + params, + scope, + }: { + // 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(accountId); + + 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, + }); + } + + /** + * Submit a request to a Snap from an account address. * * @param opts - Request options. * @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. */ @@ -549,89 +643,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 { + }): 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({ + return await this.#submitSnapRequest({ snapId, - requestId, account, method: method as AccountMethod, params, - chainId, + scope, + noPending, }); - - // 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 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; } /** @@ -639,33 +669,49 @@ 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. + * @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. */ - async #submitSnapRequest({ + async #submitSnapRequest({ snapId, - requestId, account, method, params, - chainId, + scope, + noPending, }: { snapId: SnapId; - requestId: string; account: KeyringAccount; method: AccountMethod; params?: Json[] | Record | undefined; - chainId: string; - }): Promise { + scope: string; + noPending: boolean; + }): Promise { + 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, @@ -675,7 +721,31 @@ 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); + + // 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 }); @@ -685,6 +755,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. * @@ -705,13 +802,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; } /** @@ -792,7 +890,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 @@ -849,7 +947,7 @@ export class SnapKeyring extends EventEmitter { ...(chainId === undefined ? {} : { - chainId: toCaipChainId(KnownCaipNamespace.Eip155, `${chainId}`), + scope: toCaipChainId(KnownCaipNamespace.Eip155, `${chainId}`), }), }), EthBytesStruct, @@ -915,7 +1013,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, ); @@ -942,7 +1040,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, ); @@ -967,7 +1065,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, ); 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..ea3a93610 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,17 @@ 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 +136,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..9f180e855 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,72 @@ 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('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 bc53bdbb2..ba54f16b8 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,17 @@ async function dispatchRequest( ); } + case `${KeyringRpcMethod.ResolveAccountAddress}`: { + if (keyring.resolveAccountAddress === undefined) { + throw new MethodNotSupportedError(request.method); + } + assert(request, ResolveAccountAddressRequestStruct); + return keyring.resolveAccountAddress( + request.params.scope, + request.params.request, + ); + } + case `${KeyringRpcMethod.FilterAccountChains}`: { assert(request, FilterAccountChainsStruct); return keyring.filterAccountChains(