diff --git a/package.json b/package.json index 0041f3ec..ec4b11c5 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,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..14a28d0e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,22 @@ export * from './block-ref'; export * from './block-tracker-inspector'; export * from './fetch'; export * from './inflight-cache'; +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 new file mode 100644 index 00000000..08f20963 --- /dev/null +++ b/src/methods/wallet-get-calls-status.test.ts @@ -0,0 +1,145 @@ +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'); + expect(response.result?.receipts).toBeNull(); + }); + + 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 hook', async () => { + await expect( + walletGetCallsStatus(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 empty', async () => { + params[0] = ''; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + 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 new file mode 100644 index 00000000..a2a24652 --- /dev/null +++ b/src/methods/wallet-get-calls-status.ts @@ -0,0 +1,85 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import type { Infer } from '@metamask/superstruct'; +import { + nonempty, + 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([nonempty(string())]); + +const GetCallsStatusReceiptStruct = object({ + logs: optional( + array( + object({ + address: optional(HexChecksumAddressStruct), + data: optional(StrictHexStruct), + topics: optional(array(StrictHexStruct)), + }), + ), + ), + status: optional(StrictHexStruct), + chainId: optional(StrictHexStruct), + blockHash: optional(StrictHexStruct), + blockNumber: optional(StrictHexStruct), + gasUsed: optional(StrictHexStruct), + transactionHash: optional(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(); + } + + validateParams(req.params, GetCallsStatusStruct); + + 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'; + + const receipts = isComplete + ? rawReceipts.map((receipt) => mask(receipt, GetCallsStatusReceiptStruct)) + : null; + + res.result = { status, receipts }; +} diff --git a/src/methods/wallet-get-capabilities.test.ts b/src/methods/wallet-get-capabilities.test.ts new file mode 100644 index 00000000..577103a4 --- /dev/null +++ b/src/methods/wallet-get-capabilities.test.ts @@ -0,0 +1,101 @@ +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-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 new file mode 100644 index 00000000..35e85c80 --- /dev/null +++ b/src/methods/wallet-get-capabilities.ts @@ -0,0 +1,43 @@ +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 { HexChecksumAddressStruct } from '@metamask/utils'; + +import { validateParams } from '../utils/validation'; + +const GetCapabilitiesStruct = tuple([HexChecksumAddressStruct]); + +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(); + } + + validateParams(req.params, GetCapabilitiesStruct); + + const address = req.params[0]; + const capabilities = await getCapabilities(address, req); + + res.result = capabilities; +} diff --git a/src/methods/wallet-send-calls.test.ts b/src/methods/wallet-send-calls.test.ts new file mode 100644 index 00000000..7a98a8de --- /dev/null +++ b/src/methods/wallet-send-calls.test.ts @@ -0,0 +1,171 @@ +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; +import { klona } from 'klona'; + +import type { + ProcessSendCallsHook, + 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: [ + { + version: '1.0', + 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('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, { + getAccounts: getAccountsMock, + }), + ).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 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..3b12d375 --- /dev/null +++ b/src/methods/wallet-send-calls.ts @@ -0,0 +1,77 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import type { Infer } from '@metamask/superstruct'; +import { + nonempty, + type, + 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: nonempty(string()), + from: HexChecksumAddressStruct, + chainId: optional(StrictHexStruct), + calls: array( + object({ + to: optional(HexChecksumAddressStruct), + data: optional(StrictHexStruct), + value: optional(StrictHexStruct), + }), + ), + capabilities: optional(type({})), + }), +]); + +export type SendCallsParams = Infer; +export type SendCalls = SendCallsParams[0]; + +export type ProcessSendCallsHook = ( + sendCalls: SendCalls, + req: JsonRpcRequest, +) => Promise; + +export async function walletSendCalls( + req: JsonRpcRequest, + res: PendingJsonRpcResponse, + { + getAccounts, + processSendCalls, + }: { + getAccounts: (req: JsonRpcRequest) => Promise; + processSendCalls?: ProcessSendCallsHook; + }, +): Promise { + if (!processSendCalls) { + throw rpcErrors.methodNotSupported(); + } + + validateParams(req.params, SendCallsStruct); + + 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.test.ts b/src/utils/validation.test.ts new file mode 100644 index 00000000..d781790d --- /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('does now throw if superstruct returns no error', () => { + validateMock.mockReturnValue([undefined, undefined]); + expect(() => validateParams({}, any())).not.toThrow(); + }); + + 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 new file mode 100644 index 00000000..9600a395 --- /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, +): asserts value is ParamsType { + const [error] = validate(value, struct); + + if (error) { + throw rpcErrors.invalidInput( + formatValidationError(error, `Invalid params`), + ); + } +} + +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.path.length ? ' - ' : ''}${f.message}`, + ) + .join('\n')}`; +} diff --git a/src/wallet.ts b/src/wallet.ts index 5216a89a..b2307b7a 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -4,17 +4,28 @@ 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 { GetTransactionReceiptsByBatchIdHook } from './methods/wallet-get-calls-status'; +import { walletGetCallsStatus } from './methods/wallet-get-calls-status'; +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'; import { normalizeTypedMessage, parseTypedMessage } from './utils/normalize'; +import { + resemblesAddress, + validateAndNormalizeKeyholder as validateKeyholder, +} from './utils/validation'; /* export type TransactionParams = { @@ -48,6 +59,8 @@ export type TypedMessageV1Params = Omit & { export interface WalletMiddlewareOptions { getAccounts: (req: JsonRpcRequest) => Promise; + getCapabilities?: GetCapabilitiesHook; + getTransactionReceiptsByBatchId?: GetTransactionReceiptsByBatchIdHook; processDecryptMessage?: ( msgParams: MessageParams, req: JsonRpcRequest, @@ -83,10 +96,13 @@ export interface WalletMiddlewareOptions { req: JsonRpcRequest, version: string, ) => Promise; + processSendCalls?: ProcessSendCallsHook; } export function createWalletMiddleware({ getAccounts, + getCapabilities, + getTransactionReceiptsByBatchId, processDecryptMessage, processEncryptionPublicKey, processPersonalMessage, @@ -95,6 +111,7 @@ export function createWalletMiddleware({ processTypedMessage, processTypedMessageV3, processTypedMessageV4, + processSendCalls, }: // }: WalletMiddlewareOptions): JsonRpcMiddleware { WalletMiddlewareOptions): JsonRpcMiddleware { if (!getAccounts) { @@ -105,9 +122,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), @@ -116,6 +135,19 @@ 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 }), + ), + wallet_getCallsStatus: createAsyncMiddleware(async (params, req) => + walletGetCallsStatus(params, req, { + getTransactionReceiptsByBatchId, + }), + ), }); // @@ -436,27 +468,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 +514,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 8878fa20..956b953c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1010,6 +1010,7 @@ __metadata: "@metamask/json-rpc-engine": ^10.0.2 "@metamask/network-controller": 22.2.0 "@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