From b0aaf3194db07577b78870293041030cdc8789fe Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Sun, 9 Feb 2025 23:46:57 +0000 Subject: [PATCH 01/10] Add wallet_sendCalls Validate using superstruct. --- package.json | 1 + src/index.ts | 1 + src/methods/wallet-send-calls.test.ts | 154 ++++++++++++++++++++++++++ src/methods/wallet-send-calls.ts | 71 ++++++++++++ src/utils/validation.ts | 63 +++++++++++ src/wallet.ts | 52 ++++----- yarn.lock | 1 + 7 files changed, 311 insertions(+), 32 deletions(-) create mode 100644 src/methods/wallet-send-calls.test.ts create mode 100644 src/methods/wallet-send-calls.ts create mode 100644 src/utils/validation.ts diff --git a/package.json b/package.json index 20d75e41..6b15b0e3 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@metamask/eth-sig-util": "^8.1.2", "@metamask/json-rpc-engine": "^10.0.2", "@metamask/rpc-errors": "^7.0.2", + "@metamask/superstruct": "^3.1.0", "@metamask/utils": "^11.1.0", "@types/bn.js": "^5.1.5", "bn.js": "^5.2.1", diff --git a/src/index.ts b/src/index.ts index 80efdcd0..3210bbf0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export * from './block-ref'; export * from './block-tracker-inspector'; export * from './fetch'; export * from './inflight-cache'; +export type { SendCalls } from './methods/wallet-send-calls'; export * from './providerAsMiddleware'; export * from './retryOnEmpty'; export * from './wallet'; diff --git a/src/methods/wallet-send-calls.test.ts b/src/methods/wallet-send-calls.test.ts new file mode 100644 index 00000000..54dd06fc --- /dev/null +++ b/src/methods/wallet-send-calls.test.ts @@ -0,0 +1,154 @@ +/* eslint-disable jest/expect-expect */ +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; +import { klona } from 'klona'; + +import type { ProcessSendCalls, SendCallsParams } from './wallet-send-calls'; +import { walletSendCalls } from './wallet-send-calls'; +import type { WalletMiddlewareOptions } from '../wallet'; + +type GetAccounts = WalletMiddlewareOptions['getAccounts']; + +const ADDRESS_MOCK = '0x123abc123abc123abc123abc123abc123abc123a'; +const HEX_MOCK = '0x123abc'; +const ID_MOCK = '1234-5678'; + +const REQUEST_MOCK = { + params: [ + { + from: ADDRESS_MOCK, + chainId: HEX_MOCK, + calls: [ + { + to: ADDRESS_MOCK, + data: HEX_MOCK, + value: HEX_MOCK, + }, + ], + }, + ], +} as unknown as JsonRpcRequest; + +describe('wallet_sendCalls', () => { + let request: JsonRpcRequest; + let params: SendCallsParams; + let response: PendingJsonRpcResponse; + let getAccountsMock: jest.MockedFn; + let processSendCallsMock: jest.MockedFunction; + + async function callMethod() { + return walletSendCalls(request, response, { + getAccounts: getAccountsMock, + processSendCalls: processSendCallsMock, + }); + } + + beforeEach(() => { + jest.resetAllMocks(); + + request = klona(REQUEST_MOCK); + params = request.params as SendCallsParams; + response = {} as PendingJsonRpcResponse; + + getAccountsMock = jest.fn().mockResolvedValue([ADDRESS_MOCK]); + processSendCallsMock = jest.fn().mockResolvedValue(ID_MOCK); + }); + + it('calls hook', async () => { + await callMethod(); + expect(processSendCallsMock).toHaveBeenCalledWith(params[0], request); + }); + + it('returns ID from hook', async () => { + await callMethod(); + expect(response.result).toStrictEqual(ID_MOCK); + }); + + it('throws if no params', async () => { + request.params = undefined; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + [] - Expected an array, but received: undefined] + `); + }); + + it('throws if missing properties', async () => { + params[0].from = undefined as never; + params[0].chainId = undefined as never; + params[0].calls = undefined as never; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + [0 > from] - Expected a string, but received: undefined + [0 > calls] - Expected an array value, but received: undefined] + `); + }); + + it('throws if wrong types', async () => { + params[0].from = '123' as never; + params[0].chainId = 123 as never; + params[0].calls = '123' as never; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + [0 > from] - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "123" + [0 > chainId] - Expected a string, but received: 123 + [0 > calls] - Expected an array value, but received: "123"] + `); + }); + + it('throws if calls have wrong types', async () => { + params[0].calls[0].data = 123 as never; + params[0].calls[0].to = 123 as never; + params[0].calls[0].value = 123 as never; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + [0 > calls > 0 > to] - Expected a string, but received: 123 + [0 > calls > 0 > data] - Expected a string, but received: 123 + [0 > calls > 0 > value] - Expected a string, but received: 123] + `); + }); + + it('throws if not hex', async () => { + params[0].from = '123' as never; + params[0].chainId = '123' as never; + params[0].calls[0].data = '123' as never; + params[0].calls[0].to = '123' as never; + params[0].calls[0].value = '123' as never; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + [0 > from] - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "123" + [0 > chainId] - Expected a string matching \`/^0x[0-9a-f]+$/\` but received "123" + [0 > calls > 0 > to] - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "123" + [0 > calls > 0 > data] - Expected a string matching \`/^0x[0-9a-f]+$/\` but received "123" + [0 > calls > 0 > value] - Expected a string matching \`/^0x[0-9a-f]+$/\` but received "123"] + `); + }); + + it('throws if addresses are wrong length', async () => { + params[0].from = '0x123' as never; + params[0].calls[0].to = '0x123' as never; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + [0 > from] - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "0x123" + [0 > calls > 0 > to] - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "0x123"] + `); + }); + + it('throws if from is not in accounts', async () => { + getAccountsMock.mockResolvedValueOnce([]); + + await expect(callMethod()).rejects.toMatchInlineSnapshot( + `[Error: The requested account and/or method has not been authorized by the user.]`, + ); + }); +}); diff --git a/src/methods/wallet-send-calls.ts b/src/methods/wallet-send-calls.ts new file mode 100644 index 00000000..f5b396c6 --- /dev/null +++ b/src/methods/wallet-send-calls.ts @@ -0,0 +1,71 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import type { Infer } from '@metamask/superstruct'; +import { string, array, object, optional, tuple } from '@metamask/superstruct'; +import type { + Json, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; +import { HexChecksumAddressStruct, StrictHexStruct } from '@metamask/utils'; + +import { + validateAndNormalizeKeyholder, + validateParams, +} from '../utils/validation'; + +const SendCallsStruct = tuple([ + object({ + version: optional(string()), + from: HexChecksumAddressStruct, + chainId: optional(StrictHexStruct), + calls: array( + object({ + to: optional(HexChecksumAddressStruct), + data: optional(StrictHexStruct), + value: optional(StrictHexStruct), + }), + ), + }), +]); + +export type SendCallsParams = Infer; +export type SendCalls = SendCallsParams[0]; + +export type ProcessSendCalls = ( + sendCalls: SendCalls, + req: JsonRpcRequest, +) => Promise; + +export async function walletSendCalls( + req: JsonRpcRequest, + res: PendingJsonRpcResponse, + { + getAccounts, + processSendCalls, + }: { + getAccounts: (req: JsonRpcRequest) => Promise; + processSendCalls?: ProcessSendCalls; + }, +): Promise { + if (!processSendCalls) { + throw rpcErrors.methodNotSupported(); + } + + if (!validateParams(req.params, SendCallsStruct)) { + // Not possible as throws. + return; + } + + const params = req.params[0]; + + const from = await validateAndNormalizeKeyholder(params.from, req, { + getAccounts, + }); + + const sendCalls: SendCalls = { + ...params, + from, + }; + + res.result = await processSendCalls(sendCalls, req); +} diff --git a/src/utils/validation.ts b/src/utils/validation.ts new file mode 100644 index 00000000..75458165 --- /dev/null +++ b/src/utils/validation.ts @@ -0,0 +1,63 @@ +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; +import type { Struct, StructError } from '@metamask/superstruct'; +import { validate } from '@metamask/superstruct'; +import type { Hex, JsonRpcRequest } from '@metamask/utils'; + +export async function validateAndNormalizeKeyholder( + address: Hex, + req: JsonRpcRequest, + { getAccounts }: { getAccounts: (req: JsonRpcRequest) => Promise }, +): Promise { + if ( + typeof address === 'string' && + address.length > 0 && + resemblesAddress(address) + ) { + // Ensure that an "unauthorized" error is thrown if the requester + // does not have the `eth_accounts` permission. + const accounts = await getAccounts(req); + + const normalizedAccounts: string[] = accounts.map((_address) => + _address.toLowerCase(), + ); + + const normalizedAddress = address.toLowerCase() as Hex; + + if (normalizedAccounts.includes(normalizedAddress)) { + return normalizedAddress; + } + + throw providerErrors.unauthorized(); + } + + throw rpcErrors.invalidParams({ + message: `Invalid parameters: must provide an Ethereum address.`, + }); +} + +export function validateParams( + value: unknown | ParamsType, + struct: Struct, +): value is ParamsType { + const [error] = validate(value, struct); + + if (error) { + throw rpcErrors.invalidInput( + formatValidationError(error, `Invalid params`), + ); + } + + return true; +} + +export function resemblesAddress(str: string): boolean { + // hex prefix 2 + 20 bytes + return str.length === 2 + 20 * 2; +} + +function formatValidationError(error: StructError, message: string): string { + return `${message}\n\n${error + .failures() + .map((f) => `[${f.path.join(' > ')}] - ${f.message}`) + .join('\n')}`; +} diff --git a/src/wallet.ts b/src/wallet.ts index 5216a89a..1be14df4 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -4,17 +4,24 @@ import { createAsyncMiddleware, createScaffoldMiddleware, } from '@metamask/json-rpc-engine'; -import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; -import { - isValidHexAddress, - type Json, - type JsonRpcRequest, - type PendingJsonRpcResponse, +import { rpcErrors } from '@metamask/rpc-errors'; +import { isValidHexAddress } from '@metamask/utils'; +import type { + JsonRpcRequest, + PendingJsonRpcResponse, + Json, + Hex, } from '@metamask/utils'; +import type { ProcessSendCalls } from './methods/wallet-send-calls'; +import { walletSendCalls } from './methods/wallet-send-calls'; import type { Block } from './types'; import { stripArrayTypeIfPresent } from './utils/common'; import { normalizeTypedMessage, parseTypedMessage } from './utils/normalize'; +import { + resemblesAddress, + validateAndNormalizeKeyholder as validateKeyholder, +} from './utils/validation'; /* export type TransactionParams = { @@ -83,6 +90,7 @@ export interface WalletMiddlewareOptions { req: JsonRpcRequest, version: string, ) => Promise; + processSendCalls?: ProcessSendCalls; } export function createWalletMiddleware({ @@ -95,6 +103,7 @@ export function createWalletMiddleware({ processTypedMessage, processTypedMessageV3, processTypedMessageV4, + processSendCalls, }: // }: WalletMiddlewareOptions): JsonRpcMiddleware { WalletMiddlewareOptions): JsonRpcMiddleware { if (!getAccounts) { @@ -116,6 +125,10 @@ WalletMiddlewareOptions): JsonRpcMiddleware { eth_getEncryptionPublicKey: createAsyncMiddleware(encryptionPublicKey), eth_decrypt: createAsyncMiddleware(decryptMessage), personal_ecRecover: createAsyncMiddleware(personalRecover), + // EIP-5792 + wallet_sendCalls: createAsyncMiddleware(async (params, req) => + walletSendCalls(params, req, { getAccounts, processSendCalls }), + ), }); // @@ -436,27 +449,7 @@ WalletMiddlewareOptions): JsonRpcMiddleware { address: string, req: JsonRpcRequest, ): Promise { - if ( - typeof address === 'string' && - address.length > 0 && - resemblesAddress(address) - ) { - // Ensure that an "unauthorized" error is thrown if the requester does not have the `eth_accounts` - // permission. - const accounts = await getAccounts(req); - const normalizedAccounts: string[] = accounts.map((_address) => - _address.toLowerCase(), - ); - const normalizedAddress: string = address.toLowerCase(); - - if (normalizedAccounts.includes(normalizedAddress)) { - return normalizedAddress; - } - throw providerErrors.unauthorized(); - } - throw rpcErrors.invalidParams({ - message: `Invalid parameters: must provide an Ethereum address.`, - }); + return validateKeyholder(address as Hex, req, { getAccounts }); } } @@ -502,8 +495,3 @@ function validateVerifyingContract(data: string) { throw rpcErrors.invalidInput(); } } - -function resemblesAddress(str: string): boolean { - // hex prefix 2 + 20 bytes - return str.length === 2 + 20 * 2; -} diff --git a/yarn.lock b/yarn.lock index 5ca7e5f2..10f524de 100644 --- a/yarn.lock +++ b/yarn.lock @@ -957,6 +957,7 @@ __metadata: "@metamask/eth-sig-util": ^8.1.2 "@metamask/json-rpc-engine": ^10.0.2 "@metamask/rpc-errors": ^7.0.2 + "@metamask/superstruct": ^3.1.0 "@metamask/utils": ^11.1.0 "@types/bn.js": ^5.1.5 "@types/btoa": ^1.2.3 From f3be9639379310ad07af83a764098bf4505b2537 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 10 Feb 2025 14:44:04 +0000 Subject: [PATCH 02/10] Add wallet_getCallsStatus --- src/methods/wallet-get-calls-status.ts | 78 ++++++++++++++++++++++++++ src/wallet.ts | 9 +++ 2 files changed, 87 insertions(+) create mode 100644 src/methods/wallet-get-calls-status.ts diff --git a/src/methods/wallet-get-calls-status.ts b/src/methods/wallet-get-calls-status.ts new file mode 100644 index 00000000..c33d744c --- /dev/null +++ b/src/methods/wallet-get-calls-status.ts @@ -0,0 +1,78 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import type { Infer } from '@metamask/superstruct'; +import { + optional, + mask, + string, + array, + object, + tuple, +} from '@metamask/superstruct'; +import type { + Json, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; +import { HexChecksumAddressStruct, StrictHexStruct } from '@metamask/utils'; + +import { validateParams } from '../utils/validation'; + +const GetCallsStatusStruct = tuple([string()]); + +const GetCallsStatusReceiptStruct = object({ + logs: array( + object({ + address: HexChecksumAddressStruct, + data: StrictHexStruct, + topics: array(StrictHexStruct), + }), + ), + status: StrictHexStruct, + chainId: optional(StrictHexStruct), + blockHash: StrictHexStruct, + blockNumber: StrictHexStruct, + gasUsed: StrictHexStruct, + transactionHash: StrictHexStruct, +}); + +export type GetCallsStatusParams = Infer; +export type GetCallsStatusReceipt = Infer; + +export type GetCallsStatusResult = { + status: 'PENDING' | 'CONFIRMED'; + receipts?: GetCallsStatusReceipt[]; +}; + +export type GetTransactionReceiptsByBatchIdHook = ( + batchId: string, + req: JsonRpcRequest, +) => Promise; + +export async function walletGetCallsStatus( + req: JsonRpcRequest, + res: PendingJsonRpcResponse, + { + getTransactionReceiptsByBatchId, + }: { + getTransactionReceiptsByBatchId?: GetTransactionReceiptsByBatchIdHook; + }, +): Promise { + if (!getTransactionReceiptsByBatchId) { + throw rpcErrors.methodNotSupported(); + } + + if (!validateParams(req.params, GetCallsStatusStruct)) { + return; + } + + const batchId = req.params[0]; + const rawReceipts = await getTransactionReceiptsByBatchId(batchId, req); + const isComplete = rawReceipts.every((receipt) => Boolean(receipt)); + const status = isComplete ? 'CONFIRMED' : 'PENDING'; + + const receipts = isComplete + ? rawReceipts.map((receipt) => mask(receipt, GetCallsStatusReceiptStruct)) + : null; + + res.result = { status, receipts }; +} diff --git a/src/wallet.ts b/src/wallet.ts index 1be14df4..629c7035 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -13,6 +13,8 @@ import type { Hex, } from '@metamask/utils'; +import type { GetTransactionReceiptsByBatchIdHook } from './methods/wallet-get-calls-status'; +import { walletGetCallsStatus } from './methods/wallet-get-calls-status'; import type { ProcessSendCalls } from './methods/wallet-send-calls'; import { walletSendCalls } from './methods/wallet-send-calls'; import type { Block } from './types'; @@ -55,6 +57,7 @@ export type TypedMessageV1Params = Omit & { export interface WalletMiddlewareOptions { getAccounts: (req: JsonRpcRequest) => Promise; + getTransactionReceiptsByBatchId?: GetTransactionReceiptsByBatchIdHook; processDecryptMessage?: ( msgParams: MessageParams, req: JsonRpcRequest, @@ -95,6 +98,7 @@ export interface WalletMiddlewareOptions { export function createWalletMiddleware({ getAccounts, + getTransactionReceiptsByBatchId, processDecryptMessage, processEncryptionPublicKey, processPersonalMessage, @@ -129,6 +133,11 @@ WalletMiddlewareOptions): JsonRpcMiddleware { wallet_sendCalls: createAsyncMiddleware(async (params, req) => walletSendCalls(params, req, { getAccounts, processSendCalls }), ), + wallet_getCallsStatus: createAsyncMiddleware(async (params, req) => + walletGetCallsStatus(params, req, { + getTransactionReceiptsByBatchId, + }), + ), }); // From fec5a17eee3b85b031e33528972ea75f8666e239 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 10 Feb 2025 15:17:50 +0000 Subject: [PATCH 03/10] Add validation unit tests --- src/methods/wallet-send-calls.test.ts | 32 +++---- src/utils/validation.test.ts | 128 ++++++++++++++++++++++++++ src/utils/validation.ts | 4 +- 3 files changed, 147 insertions(+), 17 deletions(-) create mode 100644 src/utils/validation.test.ts diff --git a/src/methods/wallet-send-calls.test.ts b/src/methods/wallet-send-calls.test.ts index 54dd06fc..73d38def 100644 --- a/src/methods/wallet-send-calls.test.ts +++ b/src/methods/wallet-send-calls.test.ts @@ -69,7 +69,7 @@ describe('wallet_sendCalls', () => { await expect(callMethod()).rejects.toMatchInlineSnapshot(` [Error: Invalid params - [] - Expected an array, but received: undefined] + Expected an array, but received: undefined] `); }); @@ -81,8 +81,8 @@ describe('wallet_sendCalls', () => { await expect(callMethod()).rejects.toMatchInlineSnapshot(` [Error: Invalid params - [0 > from] - Expected a string, but received: undefined - [0 > calls] - Expected an array value, but received: undefined] + 0 > from - Expected a string, but received: undefined + 0 > calls - Expected an array value, but received: undefined] `); }); @@ -94,9 +94,9 @@ describe('wallet_sendCalls', () => { await expect(callMethod()).rejects.toMatchInlineSnapshot(` [Error: Invalid params - [0 > from] - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "123" - [0 > chainId] - Expected a string, but received: 123 - [0 > calls] - Expected an array value, but received: "123"] + 0 > from - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "123" + 0 > chainId - Expected a string, but received: 123 + 0 > calls - Expected an array value, but received: "123"] `); }); @@ -108,9 +108,9 @@ describe('wallet_sendCalls', () => { await expect(callMethod()).rejects.toMatchInlineSnapshot(` [Error: Invalid params - [0 > calls > 0 > to] - Expected a string, but received: 123 - [0 > calls > 0 > data] - Expected a string, but received: 123 - [0 > calls > 0 > value] - Expected a string, but received: 123] + 0 > calls > 0 > to - Expected a string, but received: 123 + 0 > calls > 0 > data - Expected a string, but received: 123 + 0 > calls > 0 > value - Expected a string, but received: 123] `); }); @@ -124,11 +124,11 @@ describe('wallet_sendCalls', () => { await expect(callMethod()).rejects.toMatchInlineSnapshot(` [Error: Invalid params - [0 > from] - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "123" - [0 > chainId] - Expected a string matching \`/^0x[0-9a-f]+$/\` but received "123" - [0 > calls > 0 > to] - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "123" - [0 > calls > 0 > data] - Expected a string matching \`/^0x[0-9a-f]+$/\` but received "123" - [0 > calls > 0 > value] - Expected a string matching \`/^0x[0-9a-f]+$/\` but received "123"] + 0 > from - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "123" + 0 > chainId - Expected a string matching \`/^0x[0-9a-f]+$/\` but received "123" + 0 > calls > 0 > to - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "123" + 0 > calls > 0 > data - Expected a string matching \`/^0x[0-9a-f]+$/\` but received "123" + 0 > calls > 0 > value - Expected a string matching \`/^0x[0-9a-f]+$/\` but received "123"] `); }); @@ -139,8 +139,8 @@ describe('wallet_sendCalls', () => { await expect(callMethod()).rejects.toMatchInlineSnapshot(` [Error: Invalid params - [0 > from] - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "0x123" - [0 > calls > 0 > to] - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "0x123"] + 0 > from - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "0x123" + 0 > calls > 0 > to - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "0x123"] `); }); diff --git a/src/utils/validation.test.ts b/src/utils/validation.test.ts new file mode 100644 index 00000000..30807cbf --- /dev/null +++ b/src/utils/validation.test.ts @@ -0,0 +1,128 @@ +import { providerErrors } from '@metamask/rpc-errors'; +import type { StructError } from '@metamask/superstruct'; +import { any, validate } from '@metamask/superstruct'; +import type { JsonRpcRequest } from '@metamask/utils'; + +import { + resemblesAddress, + validateAndNormalizeKeyholder, + validateParams, +} from './validation'; + +jest.mock('@metamask/superstruct', () => ({ + ...jest.requireActual('@metamask/superstruct'), + validate: jest.fn(), +})); + +const ADDRESS_MOCK = '0xABCDabcdABCDabcdABCDabcdABCDabcdABCDabcd'; +const REQUEST_MOCK = {} as JsonRpcRequest; + +const STRUCT_ERROR_MOCK = { + failures: () => [ + { + path: ['test1', 'test2'], + message: 'test message', + }, + { + path: ['test3'], + message: 'test message 2', + }, + ], +} as StructError; + +describe('Validation Utils', () => { + const validateMock = jest.mocked(validate); + + let getAccountsMock: jest.MockedFn< + (req: JsonRpcRequest) => Promise + >; + + beforeEach(() => { + jest.resetAllMocks(); + + getAccountsMock = jest.fn().mockResolvedValue([ADDRESS_MOCK]); + }); + + describe('validateAndNormalizeKeyholder', () => { + it('returns lowercase address', async () => { + const result = await validateAndNormalizeKeyholder( + ADDRESS_MOCK, + REQUEST_MOCK, + { + getAccounts: getAccountsMock, + }, + ); + + expect(result).toBe(ADDRESS_MOCK.toLowerCase()); + }); + + it('throws if address not returned by get accounts hook', async () => { + getAccountsMock.mockResolvedValueOnce([]); + + await expect( + validateAndNormalizeKeyholder(ADDRESS_MOCK, REQUEST_MOCK, { + getAccounts: getAccountsMock, + }), + ).rejects.toThrow(providerErrors.unauthorized()); + }); + + it('throws if address is not string', async () => { + await expect( + validateAndNormalizeKeyholder(123 as never, REQUEST_MOCK, { + getAccounts: getAccountsMock, + }), + ).rejects.toThrow( + 'Invalid parameters: must provide an Ethereum address.', + ); + }); + + it('throws if address is empty string', async () => { + await expect( + validateAndNormalizeKeyholder('' as never, REQUEST_MOCK, { + getAccounts: getAccountsMock, + }), + ).rejects.toThrow( + 'Invalid parameters: must provide an Ethereum address.', + ); + }); + + it('throws if address length is not 40', async () => { + await expect( + validateAndNormalizeKeyholder('0x123', REQUEST_MOCK, { + getAccounts: getAccountsMock, + }), + ).rejects.toThrow( + 'Invalid parameters: must provide an Ethereum address.', + ); + }); + }); + + describe('resemblesAddress', () => { + it('returns true if valid address', () => { + expect(resemblesAddress(ADDRESS_MOCK)).toBe(true); + }); + + it('returns false if length not correct', () => { + expect(resemblesAddress('0x123')).toBe(false); + }); + }); + + describe('validateParams', () => { + it('returns true if superstruct returns no error', () => { + validateMock.mockReturnValue([undefined, undefined]); + expect(validateParams({}, any())).toBe(true); + }); + + it('throws if superstruct returns error', () => { + validateMock.mockReturnValue([STRUCT_ERROR_MOCK, undefined]); + + expect(() => validateParams({}, any())) + .toThrowErrorMatchingInlineSnapshot(` + "Invalid params + + test1 > test2 - test message + test3 - test message 2" + `); + }); + }); +}); diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 75458165..dfe1c799 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -58,6 +58,8 @@ export function resemblesAddress(str: string): boolean { function formatValidationError(error: StructError, message: string): string { return `${message}\n\n${error .failures() - .map((f) => `[${f.path.join(' > ')}] - ${f.message}`) + .map( + (f) => `${f.path.join(' > ')}${f.path.length ? ' - ' : ''}${f.message}`, + ) .join('\n')}`; } From f8010ec43933b6137f05149cf32804b60d1d57b8 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 10 Feb 2025 16:13:39 +0000 Subject: [PATCH 04/10] Add get calls status unit tests --- src/methods/wallet-get-calls-status.test.ts | 114 ++++++++++++++++++++ src/methods/wallet-get-calls-status.ts | 6 ++ src/methods/wallet-send-calls.test.ts | 1 - 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 src/methods/wallet-get-calls-status.test.ts diff --git a/src/methods/wallet-get-calls-status.test.ts b/src/methods/wallet-get-calls-status.test.ts new file mode 100644 index 00000000..727260e0 --- /dev/null +++ b/src/methods/wallet-get-calls-status.test.ts @@ -0,0 +1,114 @@ +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; +import { klona } from 'klona'; + +import type { + GetCallsStatusParams, + GetCallsStatusResult, + GetTransactionReceiptsByBatchIdHook, +} from './wallet-get-calls-status'; +import { walletGetCallsStatus } from './wallet-get-calls-status'; + +const ID_MOCK = '1234-5678'; + +const RECEIPT_MOCK = { + logs: [ + { + address: '0x123abc123abc123abc123abc123abc123abc123a', + data: '0x123abc', + topics: ['0x123abc'], + }, + ], + status: '0x1', + chainId: '0x1', + blockHash: '0x123abc', + blockNumber: '0x1', + gasUsed: '0x1', + transactionHash: '0x123abc', +}; + +const REQUEST_MOCK = { + params: [ID_MOCK], +} as unknown as JsonRpcRequest; + +describe('wallet_getCallsStatus', () => { + let request: JsonRpcRequest; + let params: GetCallsStatusParams; + let response: PendingJsonRpcResponse; + let getTransactionReceiptsByBatchIdMock: jest.MockedFunction; + + async function callMethod() { + return walletGetCallsStatus(request, response, { + getTransactionReceiptsByBatchId: getTransactionReceiptsByBatchIdMock, + }); + } + + beforeEach(() => { + jest.resetAllMocks(); + + request = klona(REQUEST_MOCK); + params = request.params as GetCallsStatusParams; + response = {} as PendingJsonRpcResponse; + + getTransactionReceiptsByBatchIdMock = jest + .fn() + .mockResolvedValue([RECEIPT_MOCK, RECEIPT_MOCK]); + }); + + it('calls hook', async () => { + await callMethod(); + expect(getTransactionReceiptsByBatchIdMock).toHaveBeenCalledWith( + params[0], + request, + ); + }); + + it('returns confirmed status if all receipts available', async () => { + await callMethod(); + expect(response.result?.status).toBe('CONFIRMED'); + }); + + it('returns pending status if missing receipts', async () => { + getTransactionReceiptsByBatchIdMock = jest + .fn() + .mockResolvedValue([RECEIPT_MOCK, undefined]); + + await callMethod(); + expect(response.result?.status).toBe('PENDING'); + }); + + it('returns receipts', async () => { + await callMethod(); + + expect(response.result?.receipts).toStrictEqual([ + RECEIPT_MOCK, + RECEIPT_MOCK, + ]); + }); + + it('returns null if no receipts', async () => { + getTransactionReceiptsByBatchIdMock = jest.fn().mockResolvedValue([]); + + await callMethod(); + expect(response.result).toBeNull(); + }); + + it('throws if no params', async () => { + request.params = undefined; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + Expected an array, but received: undefined] + `); + }); + + it('throws if wrong type', async () => { + params[0] = 123 as never; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + 0 - Expected a string, but received: 123] + `); + }); +}); diff --git a/src/methods/wallet-get-calls-status.ts b/src/methods/wallet-get-calls-status.ts index c33d744c..5af01205 100644 --- a/src/methods/wallet-get-calls-status.ts +++ b/src/methods/wallet-get-calls-status.ts @@ -67,6 +67,12 @@ export async function walletGetCallsStatus( const batchId = req.params[0]; const rawReceipts = await getTransactionReceiptsByBatchId(batchId, req); + + if (!rawReceipts.length) { + res.result = null; + return; + } + const isComplete = rawReceipts.every((receipt) => Boolean(receipt)); const status = isComplete ? 'CONFIRMED' : 'PENDING'; diff --git a/src/methods/wallet-send-calls.test.ts b/src/methods/wallet-send-calls.test.ts index 73d38def..2afa63e4 100644 --- a/src/methods/wallet-send-calls.test.ts +++ b/src/methods/wallet-send-calls.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable jest/expect-expect */ import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; import { klona } from 'klona'; From 7e705dce505261d100e88fc638fdedfb3c74dd6b Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 10 Feb 2025 16:38:12 +0000 Subject: [PATCH 05/10] Add wallet_getCapabilities --- src/methods/wallet-get-capabilities.test.ts | 91 +++++++++++++++++++++ src/methods/wallet-get-capabilities.ts | 45 ++++++++++ src/wallet.ts | 12 +++ 3 files changed, 148 insertions(+) create mode 100644 src/methods/wallet-get-capabilities.test.ts create mode 100644 src/methods/wallet-get-capabilities.ts diff --git a/src/methods/wallet-get-capabilities.test.ts b/src/methods/wallet-get-capabilities.test.ts new file mode 100644 index 00000000..312302c6 --- /dev/null +++ b/src/methods/wallet-get-capabilities.test.ts @@ -0,0 +1,91 @@ +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; +import { klona } from 'klona'; + +import type { + GetCapabilitiesHook, + GetCapabilitiesParams, + GetCapabilitiesResult, +} from './wallet-get-capabilities'; +import { walletGetCapabilities } from './wallet-get-capabilities'; + +const ADDRESS_MOCK = '0x123abc123abc123abc123abc123abc123abc123a'; + +const RESULT_MOCK = { + testCapability: { + testKey: 'testValue', + }, +}; + +const REQUEST_MOCK = { + params: [ADDRESS_MOCK], +} as unknown as JsonRpcRequest; + +describe('wallet_getCapabilities', () => { + let request: JsonRpcRequest; + let params: GetCapabilitiesParams; + let response: PendingJsonRpcResponse; + let getCapabilitiesMock: jest.MockedFunction; + + async function callMethod() { + return walletGetCapabilities(request, response, { + getCapabilities: getCapabilitiesMock, + }); + } + + beforeEach(() => { + jest.resetAllMocks(); + + request = klona(REQUEST_MOCK); + params = request.params as GetCapabilitiesParams; + response = {} as PendingJsonRpcResponse; + + getCapabilitiesMock = jest.fn().mockResolvedValue(RESULT_MOCK); + }); + + it('calls hook', async () => { + await callMethod(); + expect(getCapabilitiesMock).toHaveBeenCalledWith(params[0], request); + }); + + it('returns capabilities from hook', async () => { + await callMethod(); + + expect(response.result).toStrictEqual(RESULT_MOCK); + }); + + it('throws if no hook', async () => { + await expect( + walletGetCapabilities(request, response, {}), + ).rejects.toMatchInlineSnapshot(`[Error: Method not supported.]`); + }); + + it('throws if no params', async () => { + request.params = undefined; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + Expected an array, but received: undefined] + `); + }); + + it('throws if wrong type', async () => { + params[0] = 123 as never; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + 0 - Expected a string, but received: 123] + `); + }); + + it('throws if not hex', async () => { + params[0] = 'test' as never; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + 0 - Expected a string matching \`/^0x[0-9a-f]+$/\` but received "test"] + `); + }); +}); diff --git a/src/methods/wallet-get-capabilities.ts b/src/methods/wallet-get-capabilities.ts new file mode 100644 index 00000000..8f123c52 --- /dev/null +++ b/src/methods/wallet-get-capabilities.ts @@ -0,0 +1,45 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import type { Infer } from '@metamask/superstruct'; +import { tuple } from '@metamask/superstruct'; +import type { + Hex, + Json, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; +import { StrictHexStruct } from '@metamask/utils'; + +import { validateParams } from '../utils/validation'; + +const GetCapabilitiesStruct = tuple([StrictHexStruct]); + +export type GetCapabilitiesParams = Infer; +export type GetCapabilitiesResult = Record>; + +export type GetCapabilitiesHook = ( + address: Hex, + req: JsonRpcRequest, +) => Promise; + +export async function walletGetCapabilities( + req: JsonRpcRequest, + res: PendingJsonRpcResponse, + { + getCapabilities, + }: { + getCapabilities?: GetCapabilitiesHook; + }, +): Promise { + if (!getCapabilities) { + throw rpcErrors.methodNotSupported(); + } + + if (!validateParams(req.params, GetCapabilitiesStruct)) { + return; + } + + const address = req.params[0]; + const capabilities = await getCapabilities(address, req); + + res.result = capabilities; +} diff --git a/src/wallet.ts b/src/wallet.ts index 629c7035..9a8ef6be 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -15,6 +15,10 @@ import type { import type { GetTransactionReceiptsByBatchIdHook } from './methods/wallet-get-calls-status'; import { walletGetCallsStatus } from './methods/wallet-get-calls-status'; +import { + GetCapabilitiesHook, + walletGetCapabilities, +} from './methods/wallet-get-capabilities'; import type { ProcessSendCalls } from './methods/wallet-send-calls'; import { walletSendCalls } from './methods/wallet-send-calls'; import type { Block } from './types'; @@ -57,6 +61,7 @@ export type TypedMessageV1Params = Omit & { export interface WalletMiddlewareOptions { getAccounts: (req: JsonRpcRequest) => Promise; + getCapabilities?: GetCapabilitiesHook; getTransactionReceiptsByBatchId?: GetTransactionReceiptsByBatchIdHook; processDecryptMessage?: ( msgParams: MessageParams, @@ -98,6 +103,7 @@ export interface WalletMiddlewareOptions { export function createWalletMiddleware({ getAccounts, + getCapabilities, getTransactionReceiptsByBatchId, processDecryptMessage, processEncryptionPublicKey, @@ -118,9 +124,11 @@ WalletMiddlewareOptions): JsonRpcMiddleware { // account lookups eth_accounts: createAsyncMiddleware(lookupAccounts), eth_coinbase: createAsyncMiddleware(lookupDefaultAccount), + // tx signatures eth_sendTransaction: createAsyncMiddleware(sendTransaction), eth_signTransaction: createAsyncMiddleware(signTransaction), + // message signatures eth_signTypedData: createAsyncMiddleware(signTypedData), eth_signTypedData_v3: createAsyncMiddleware(signTypedDataV3), @@ -129,7 +137,11 @@ WalletMiddlewareOptions): JsonRpcMiddleware { eth_getEncryptionPublicKey: createAsyncMiddleware(encryptionPublicKey), eth_decrypt: createAsyncMiddleware(decryptMessage), personal_ecRecover: createAsyncMiddleware(personalRecover), + // EIP-5792 + wallet_getCapabilities: createAsyncMiddleware(async (params, req) => + walletGetCapabilities(params, req, { getCapabilities }), + ), wallet_sendCalls: createAsyncMiddleware(async (params, req) => walletSendCalls(params, req, { getAccounts, processSendCalls }), ), From 9e77943268227c259d5b20bccf4e16ed7d61c9ce Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 11 Feb 2025 01:49:14 +0000 Subject: [PATCH 06/10] Export types Add unit tests. --- src/index.ts | 17 ++++++++++++++++- src/methods/wallet-get-calls-status.test.ts | 16 ++++++++++++++++ src/methods/wallet-get-calls-status.ts | 3 ++- src/methods/wallet-get-capabilities.test.ts | 12 +++++++++++- src/methods/wallet-get-capabilities.ts | 4 ++-- src/methods/wallet-send-calls.test.ts | 15 +++++++++++++-- src/methods/wallet-send-calls.ts | 4 ++-- 7 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index 3210bbf0..14a28d0e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,22 @@ export * from './block-ref'; export * from './block-tracker-inspector'; export * from './fetch'; export * from './inflight-cache'; -export type { SendCalls } from './methods/wallet-send-calls'; +export type { + GetCallsStatusParams, + GetCallsStatusReceipt, + GetCallsStatusResult, + GetTransactionReceiptsByBatchIdHook, +} from './methods/wallet-get-calls-status'; +export type { + GetCapabilitiesHook, + GetCapabilitiesParams, + GetCapabilitiesResult, +} from './methods/wallet-get-capabilities'; +export type { + ProcessSendCallsHook, + SendCalls, + SendCallsParams, +} from './methods/wallet-send-calls'; export * from './providerAsMiddleware'; export * from './retryOnEmpty'; export * from './wallet'; diff --git a/src/methods/wallet-get-calls-status.test.ts b/src/methods/wallet-get-calls-status.test.ts index 727260e0..aa07f8ce 100644 --- a/src/methods/wallet-get-calls-status.test.ts +++ b/src/methods/wallet-get-calls-status.test.ts @@ -92,6 +92,12 @@ describe('wallet_getCallsStatus', () => { expect(response.result).toBeNull(); }); + it('throws if no hook', async () => { + await expect( + walletGetCallsStatus(request, response, {}), + ).rejects.toMatchInlineSnapshot(`[Error: Method not supported.]`); + }); + it('throws if no params', async () => { request.params = undefined; @@ -111,4 +117,14 @@ describe('wallet_getCallsStatus', () => { 0 - Expected a string, but received: 123] `); }); + + it('throws if empty', async () => { + params[0] = ''; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + 0 - Expected a nonempty string but received an empty one] + `); + }); }); diff --git a/src/methods/wallet-get-calls-status.ts b/src/methods/wallet-get-calls-status.ts index 5af01205..c0ff92db 100644 --- a/src/methods/wallet-get-calls-status.ts +++ b/src/methods/wallet-get-calls-status.ts @@ -1,6 +1,7 @@ import { rpcErrors } from '@metamask/rpc-errors'; import type { Infer } from '@metamask/superstruct'; import { + nonempty, optional, mask, string, @@ -17,7 +18,7 @@ import { HexChecksumAddressStruct, StrictHexStruct } from '@metamask/utils'; import { validateParams } from '../utils/validation'; -const GetCallsStatusStruct = tuple([string()]); +const GetCallsStatusStruct = tuple([nonempty(string())]); const GetCallsStatusReceiptStruct = object({ logs: array( diff --git a/src/methods/wallet-get-capabilities.test.ts b/src/methods/wallet-get-capabilities.test.ts index 312302c6..577103a4 100644 --- a/src/methods/wallet-get-capabilities.test.ts +++ b/src/methods/wallet-get-capabilities.test.ts @@ -85,7 +85,17 @@ describe('wallet_getCapabilities', () => { await expect(callMethod()).rejects.toMatchInlineSnapshot(` [Error: Invalid params - 0 - Expected a string matching \`/^0x[0-9a-f]+$/\` but received "test"] + 0 - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "test"] + `); + }); + + it('throws if wrong length', async () => { + params[0] = '0x123' as never; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + 0 - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "0x123"] `); }); }); diff --git a/src/methods/wallet-get-capabilities.ts b/src/methods/wallet-get-capabilities.ts index 8f123c52..24b610cc 100644 --- a/src/methods/wallet-get-capabilities.ts +++ b/src/methods/wallet-get-capabilities.ts @@ -7,11 +7,11 @@ import type { JsonRpcRequest, PendingJsonRpcResponse, } from '@metamask/utils'; -import { StrictHexStruct } from '@metamask/utils'; +import { HexChecksumAddressStruct } from '@metamask/utils'; import { validateParams } from '../utils/validation'; -const GetCapabilitiesStruct = tuple([StrictHexStruct]); +const GetCapabilitiesStruct = tuple([HexChecksumAddressStruct]); export type GetCapabilitiesParams = Infer; export type GetCapabilitiesResult = Record>; diff --git a/src/methods/wallet-send-calls.test.ts b/src/methods/wallet-send-calls.test.ts index 2afa63e4..a2461211 100644 --- a/src/methods/wallet-send-calls.test.ts +++ b/src/methods/wallet-send-calls.test.ts @@ -1,7 +1,10 @@ import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; import { klona } from 'klona'; -import type { ProcessSendCalls, SendCallsParams } from './wallet-send-calls'; +import type { + ProcessSendCallsHook, + SendCallsParams, +} from './wallet-send-calls'; import { walletSendCalls } from './wallet-send-calls'; import type { WalletMiddlewareOptions } from '../wallet'; @@ -32,7 +35,7 @@ describe('wallet_sendCalls', () => { let params: SendCallsParams; let response: PendingJsonRpcResponse; let getAccountsMock: jest.MockedFn; - let processSendCallsMock: jest.MockedFunction; + let processSendCallsMock: jest.MockedFunction; async function callMethod() { return walletSendCalls(request, response, { @@ -62,6 +65,14 @@ describe('wallet_sendCalls', () => { expect(response.result).toStrictEqual(ID_MOCK); }); + it('throws if no hook', async () => { + await expect( + walletSendCalls(request, response, { + getAccounts: getAccountsMock, + }), + ).rejects.toMatchInlineSnapshot(`[Error: Method not supported.]`); + }); + it('throws if no params', async () => { request.params = undefined; diff --git a/src/methods/wallet-send-calls.ts b/src/methods/wallet-send-calls.ts index f5b396c6..7b6b33b9 100644 --- a/src/methods/wallet-send-calls.ts +++ b/src/methods/wallet-send-calls.ts @@ -31,7 +31,7 @@ const SendCallsStruct = tuple([ export type SendCallsParams = Infer; export type SendCalls = SendCallsParams[0]; -export type ProcessSendCalls = ( +export type ProcessSendCallsHook = ( sendCalls: SendCalls, req: JsonRpcRequest, ) => Promise; @@ -44,7 +44,7 @@ export async function walletSendCalls( processSendCalls, }: { getAccounts: (req: JsonRpcRequest) => Promise; - processSendCalls?: ProcessSendCalls; + processSendCalls?: ProcessSendCallsHook; }, ): Promise { if (!processSendCalls) { From 7f607ad2c09a1dd77c6fea200b83880d09d87260 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 11 Feb 2025 01:50:33 +0000 Subject: [PATCH 07/10] Fix unit tests --- src/wallet.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/wallet.ts b/src/wallet.ts index 9a8ef6be..b2307b7a 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -15,11 +15,9 @@ import type { import type { GetTransactionReceiptsByBatchIdHook } from './methods/wallet-get-calls-status'; import { walletGetCallsStatus } from './methods/wallet-get-calls-status'; -import { - GetCapabilitiesHook, - walletGetCapabilities, -} from './methods/wallet-get-capabilities'; -import type { ProcessSendCalls } from './methods/wallet-send-calls'; +import type { GetCapabilitiesHook } from './methods/wallet-get-capabilities'; +import { walletGetCapabilities } from './methods/wallet-get-capabilities'; +import type { ProcessSendCallsHook } from './methods/wallet-send-calls'; import { walletSendCalls } from './methods/wallet-send-calls'; import type { Block } from './types'; import { stripArrayTypeIfPresent } from './utils/common'; @@ -98,7 +96,7 @@ export interface WalletMiddlewareOptions { req: JsonRpcRequest, version: string, ) => Promise; - processSendCalls?: ProcessSendCalls; + processSendCalls?: ProcessSendCallsHook; } export function createWalletMiddleware({ From b8f410da78870d50da61c1379f2994bcdb7b010c Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 11 Feb 2025 13:30:56 +0000 Subject: [PATCH 08/10] Allow capabilities --- src/methods/wallet-send-calls.test.ts | 12 ++++++++++++ src/methods/wallet-send-calls.ts | 13 +++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/methods/wallet-send-calls.test.ts b/src/methods/wallet-send-calls.test.ts index a2461211..9df51e0b 100644 --- a/src/methods/wallet-send-calls.test.ts +++ b/src/methods/wallet-send-calls.test.ts @@ -65,6 +65,18 @@ describe('wallet_sendCalls', () => { expect(response.result).toStrictEqual(ID_MOCK); }); + it('supports version', async () => { + params[0].version = '1.0.0'; + await callMethod(); + expect(processSendCallsMock).toHaveBeenCalledWith(params[0], request); + }); + + it('supports capabilities', async () => { + params[0].capabilities = { test: 'value' }; + await callMethod(); + expect(processSendCallsMock).toHaveBeenCalledWith(params[0], request); + }); + it('throws if no hook', async () => { await expect( walletSendCalls(request, response, { diff --git a/src/methods/wallet-send-calls.ts b/src/methods/wallet-send-calls.ts index 7b6b33b9..aadc49ce 100644 --- a/src/methods/wallet-send-calls.ts +++ b/src/methods/wallet-send-calls.ts @@ -1,6 +1,14 @@ import { rpcErrors } from '@metamask/rpc-errors'; import type { Infer } from '@metamask/superstruct'; -import { string, array, object, optional, tuple } from '@metamask/superstruct'; +import { + nonempty, + type, + string, + array, + object, + optional, + tuple, +} from '@metamask/superstruct'; import type { Json, JsonRpcRequest, @@ -15,7 +23,7 @@ import { const SendCallsStruct = tuple([ object({ - version: optional(string()), + version: optional(nonempty(string())), from: HexChecksumAddressStruct, chainId: optional(StrictHexStruct), calls: array( @@ -25,6 +33,7 @@ const SendCallsStruct = tuple([ value: optional(StrictHexStruct), }), ), + capabilities: optional(type({})), }), ]); From 64c48e29500aaa22188dd6f6bc3111ccdb911383 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 11 Feb 2025 21:57:55 +0000 Subject: [PATCH 09/10] Require version Add unit test. Update types. --- src/methods/wallet-get-calls-status.test.ts | 15 +++++++++++ src/methods/wallet-get-calls-status.ts | 28 ++++++++++----------- src/methods/wallet-get-capabilities.ts | 4 +-- src/methods/wallet-send-calls.test.ts | 7 +----- src/methods/wallet-send-calls.ts | 7 ++---- src/utils/validation.test.ts | 4 +-- src/utils/validation.ts | 4 +-- 7 files changed, 36 insertions(+), 33 deletions(-) diff --git a/src/methods/wallet-get-calls-status.test.ts b/src/methods/wallet-get-calls-status.test.ts index aa07f8ce..08f20963 100644 --- a/src/methods/wallet-get-calls-status.test.ts +++ b/src/methods/wallet-get-calls-status.test.ts @@ -74,6 +74,7 @@ describe('wallet_getCallsStatus', () => { await callMethod(); expect(response.result?.status).toBe('PENDING'); + expect(response.result?.receipts).toBeNull(); }); it('returns receipts', async () => { @@ -127,4 +128,18 @@ describe('wallet_getCallsStatus', () => { 0 - Expected a nonempty string but received an empty one] `); }); + + it('removes excess properties from receipts', async () => { + getTransactionReceiptsByBatchIdMock.mockResolvedValue([ + { + ...RECEIPT_MOCK, + extra: 'value1', + logs: [{ ...RECEIPT_MOCK.logs[0], extra2: 'value2' }], + } as never, + ]); + + await callMethod(); + + expect(response.result?.receipts).toStrictEqual([RECEIPT_MOCK]); + }); }); diff --git a/src/methods/wallet-get-calls-status.ts b/src/methods/wallet-get-calls-status.ts index c0ff92db..a2a24652 100644 --- a/src/methods/wallet-get-calls-status.ts +++ b/src/methods/wallet-get-calls-status.ts @@ -21,19 +21,21 @@ import { validateParams } from '../utils/validation'; const GetCallsStatusStruct = tuple([nonempty(string())]); const GetCallsStatusReceiptStruct = object({ - logs: array( - object({ - address: HexChecksumAddressStruct, - data: StrictHexStruct, - topics: array(StrictHexStruct), - }), + logs: optional( + array( + object({ + address: optional(HexChecksumAddressStruct), + data: optional(StrictHexStruct), + topics: optional(array(StrictHexStruct)), + }), + ), ), - status: StrictHexStruct, + status: optional(StrictHexStruct), chainId: optional(StrictHexStruct), - blockHash: StrictHexStruct, - blockNumber: StrictHexStruct, - gasUsed: StrictHexStruct, - transactionHash: StrictHexStruct, + blockHash: optional(StrictHexStruct), + blockNumber: optional(StrictHexStruct), + gasUsed: optional(StrictHexStruct), + transactionHash: optional(StrictHexStruct), }); export type GetCallsStatusParams = Infer; @@ -62,9 +64,7 @@ export async function walletGetCallsStatus( throw rpcErrors.methodNotSupported(); } - if (!validateParams(req.params, GetCallsStatusStruct)) { - return; - } + validateParams(req.params, GetCallsStatusStruct); const batchId = req.params[0]; const rawReceipts = await getTransactionReceiptsByBatchId(batchId, req); diff --git a/src/methods/wallet-get-capabilities.ts b/src/methods/wallet-get-capabilities.ts index 24b610cc..32906b9b 100644 --- a/src/methods/wallet-get-capabilities.ts +++ b/src/methods/wallet-get-capabilities.ts @@ -34,9 +34,7 @@ export async function walletGetCapabilities( throw rpcErrors.methodNotSupported(); } - if (!validateParams(req.params, GetCapabilitiesStruct)) { - return; - } + validateParams(req.params, GetCapabilitiesStruct); const address = req.params[0]; const capabilities = await getCapabilities(address, req); diff --git a/src/methods/wallet-send-calls.test.ts b/src/methods/wallet-send-calls.test.ts index 9df51e0b..7a98a8de 100644 --- a/src/methods/wallet-send-calls.test.ts +++ b/src/methods/wallet-send-calls.test.ts @@ -17,6 +17,7 @@ const ID_MOCK = '1234-5678'; const REQUEST_MOCK = { params: [ { + version: '1.0', from: ADDRESS_MOCK, chainId: HEX_MOCK, calls: [ @@ -65,12 +66,6 @@ describe('wallet_sendCalls', () => { expect(response.result).toStrictEqual(ID_MOCK); }); - it('supports version', async () => { - params[0].version = '1.0.0'; - await callMethod(); - expect(processSendCallsMock).toHaveBeenCalledWith(params[0], request); - }); - it('supports capabilities', async () => { params[0].capabilities = { test: 'value' }; await callMethod(); diff --git a/src/methods/wallet-send-calls.ts b/src/methods/wallet-send-calls.ts index aadc49ce..3b12d375 100644 --- a/src/methods/wallet-send-calls.ts +++ b/src/methods/wallet-send-calls.ts @@ -23,7 +23,7 @@ import { const SendCallsStruct = tuple([ object({ - version: optional(nonempty(string())), + version: nonempty(string()), from: HexChecksumAddressStruct, chainId: optional(StrictHexStruct), calls: array( @@ -60,10 +60,7 @@ export async function walletSendCalls( throw rpcErrors.methodNotSupported(); } - if (!validateParams(req.params, SendCallsStruct)) { - // Not possible as throws. - return; - } + validateParams(req.params, SendCallsStruct); const params = req.params[0]; diff --git a/src/utils/validation.test.ts b/src/utils/validation.test.ts index 30807cbf..d781790d 100644 --- a/src/utils/validation.test.ts +++ b/src/utils/validation.test.ts @@ -108,9 +108,9 @@ describe('Validation Utils', () => { }); describe('validateParams', () => { - it('returns true if superstruct returns no error', () => { + it('does now throw if superstruct returns no error', () => { validateMock.mockReturnValue([undefined, undefined]); - expect(validateParams({}, any())).toBe(true); + expect(() => validateParams({}, any())).not.toThrow(); }); it('throws if superstruct returns error', () => { diff --git a/src/utils/validation.ts b/src/utils/validation.ts index dfe1c799..9600a395 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -38,7 +38,7 @@ export async function validateAndNormalizeKeyholder( export function validateParams( value: unknown | ParamsType, struct: Struct, -): value is ParamsType { +): asserts value is ParamsType { const [error] = validate(value, struct); if (error) { @@ -46,8 +46,6 @@ export function validateParams( formatValidationError(error, `Invalid params`), ); } - - return true; } export function resemblesAddress(str: string): boolean { From 0077d6ef78f658079f45c032e560e967a9b33c3c Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 11 Feb 2025 22:06:28 +0000 Subject: [PATCH 10/10] Update capabilities result type --- src/methods/wallet-get-capabilities.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/methods/wallet-get-capabilities.ts b/src/methods/wallet-get-capabilities.ts index 32906b9b..35e85c80 100644 --- a/src/methods/wallet-get-capabilities.ts +++ b/src/methods/wallet-get-capabilities.ts @@ -14,7 +14,7 @@ import { validateParams } from '../utils/validation'; const GetCapabilitiesStruct = tuple([HexChecksumAddressStruct]); export type GetCapabilitiesParams = Infer; -export type GetCapabilitiesResult = Record>; +export type GetCapabilitiesResult = Record>; export type GetCapabilitiesHook = ( address: Hex,