diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index e08cc10730..b0da348a79 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Add RPC method, chain ID, and endpoint type context to transaction provider errors, including raw transaction submission failures ([#9144](https://github.com/MetaMask/core/pull/9144)) + ## [68.0.0] ### Changed diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index 9b55f8c3a9..6d7e69acb0 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -2785,7 +2785,8 @@ describe('TransactionController', () => { ); }); - it('throws with data message if publish fails', async () => { + it('throws with contextual rpcRequest message if publish fails', async () => { + const rpcErrorMessage = `RPC 0x5 Custom eth_sendRawTransaction: ${ERROR_MESSAGE_MOCK}`; const { controller } = setupController({ messengerOptions: { addTransactionApprovalRequest: { @@ -2796,12 +2797,7 @@ describe('TransactionController', () => { rpcRequestMock.mockImplementation(async ({ method }) => { if (method === 'eth_sendRawTransaction') { - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw { - data: { - message: ERROR_MESSAGE_MOCK, - }, - }; + throw new Error(rpcErrorMessage); } if (method === 'eth_getBalance') { @@ -2824,7 +2820,7 @@ describe('TransactionController', () => { }, ); - await expect(result).rejects.toThrow(ERROR_MESSAGE_MOCK); + await expect(result).rejects.toThrow(rpcErrorMessage); }); it('throws with standard message if publish fails', async () => { @@ -3774,7 +3770,7 @@ describe('TransactionController', () => { rpcRequestMock.mockRejectedValueOnce(error); await expect(controller.stopTransaction('2')).rejects.toThrow( - 'RPC submit: Another reason', + 'Another reason', ); const sendRawTransactionCalls = rpcRequestMock.mock.calls.filter( @@ -4136,7 +4132,7 @@ describe('TransactionController', () => { rpcRequestMock.mockRejectedValueOnce(error); await expect(controller.speedUpTransaction('2')).rejects.toThrow( - 'RPC submit: Another reason', + 'Another reason', ); const sendRawTransactionCalls = rpcRequestMock.mock.calls.filter( @@ -4146,11 +4142,10 @@ describe('TransactionController', () => { expect(controller.state.transactions).toHaveLength(1); }); - it('extracts nested data.message and prefixes it with RPC submit', async () => { - const error = { - message: 'Outer message', - data: { message: 'Nested rpc error message' }, - }; + it('propagates contextual rpcRequest errors without masking the message', async () => { + const error = new Error( + 'RPC 0x5 Custom eth_sendRawTransaction: Nested rpc error message', + ); const { controller } = setupController({ options: { state: { @@ -4174,9 +4169,7 @@ describe('TransactionController', () => { rpcRequestMock.mockRejectedValueOnce(error); - await expect(controller.speedUpTransaction('2')).rejects.toThrow( - 'RPC submit: Nested rpc error message', - ); + await expect(controller.speedUpTransaction('2')).rejects.toBe(error); }); it('creates additional transaction with increased gas', async () => { diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 646399c373..a81a7ce500 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -3139,38 +3139,24 @@ export class TransactionController extends BaseController< transactionMeta: TransactionMeta, { skipSubmitHistory }: { skipSubmitHistory?: boolean } = {}, ): Promise { - try { - const { networkClientId, rawTx } = transactionMeta; - - if (!rawTx) { - throw new Error('Missing raw transaction'); - } - - const transactionHash = (await rpcRequest({ - messenger: this.messenger, - networkClientId, - method: 'eth_sendRawTransaction', - params: [rawTx], - })) as string; - - if (skipSubmitHistory !== true) { - this.#updateSubmitHistory(transactionMeta, transactionHash); - } + const { networkClientId, rawTx } = transactionMeta; - return transactionHash; - } catch (error: unknown) { - const errorObject = error as - | { - data?: { message?: string }; - message?: string; - } - | undefined; + if (!rawTx) { + throw new Error('Missing raw transaction'); + } - const errorMessage = - errorObject?.data?.message ?? errorObject?.message ?? String(error); + const transactionHash = (await rpcRequest({ + messenger: this.messenger, + networkClientId, + method: 'eth_sendRawTransaction', + params: [rawTx], + })) as string; - throw new Error(`RPC submit: ${errorMessage}`); + if (skipSubmitHistory !== true) { + this.#updateSubmitHistory(transactionMeta, transactionHash); } + + return transactionHash; } /** diff --git a/packages/transaction-controller/src/utils/provider.test.ts b/packages/transaction-controller/src/utils/provider.test.ts index 09e39134ea..248124da35 100644 --- a/packages/transaction-controller/src/utils/provider.test.ts +++ b/packages/transaction-controller/src/utils/provider.test.ts @@ -1,4 +1,6 @@ import type { NetworkClientId, Provider } from '@metamask/network-controller'; +import { NetworkClientType } from '@metamask/network-controller'; +import { RpcEndpointType } from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; import type { TransactionControllerMessenger } from '../TransactionController'; @@ -9,6 +11,7 @@ describe('provider utils', () => { const mockProvider = { request: requestMock, } as unknown as Provider; + const chainIdMock = '0xa4b1' as Hex; let messengerCallMock: jest.Mock; let messengerMock: TransactionControllerMessenger; @@ -20,7 +23,27 @@ describe('provider utils', () => { .fn() .mockImplementation((action: string, ...args: unknown[]) => { if (action === 'NetworkController:getNetworkClientById') { - return { provider: mockProvider }; + const networkClientId = args[0] as NetworkClientId; + + return { + configuration: { + chainId: chainIdMock, + type: + networkClientId === 'infuraNetworkClientId' + ? NetworkClientType.Infura + : NetworkClientType.Custom, + rpcEndpoints: [ + { + networkClientId, + type: + networkClientId === 'infuraNetworkClientId' + ? RpcEndpointType.Infura + : RpcEndpointType.Custom, + }, + ], + }, + provider: mockProvider, + }; } if (action === 'NetworkController:findNetworkClientIdByChainId') { @@ -106,6 +129,45 @@ describe('provider utils', () => { params: ['0x123', 'latest'], }), ).rejects.toBe(error); + expect(error.message).toBe( + 'RPC 0xa4b1 Custom eth_getBalance: RPC failed', + ); + }); + + it('identifies Infura provider.request errors', async () => { + const error = new Error('Unauthorized.'); + requestMock.mockRejectedValue(error); + + await expect( + rpcRequest({ + messenger: messengerMock, + networkClientId: 'infuraNetworkClientId' as NetworkClientId, + method: 'eth_getBalance', + params: ['0x123', 'latest'], + }), + ).rejects.toBe(error); + expect(error.message).toBe( + 'RPC 0xa4b1 Infura eth_getBalance: Unauthorized.', + ); + }); + + it('uses nested RPC data messages when available', async () => { + const error = Object.assign(new Error('Outer message'), { + data: { message: 'Nested rpc error message' }, + }); + requestMock.mockRejectedValue(error); + + await expect( + rpcRequest({ + messenger: messengerMock, + networkClientId: 'networkClientIdA' as NetworkClientId, + method: 'eth_getBalance', + params: ['0x123', 'latest'], + }), + ).rejects.toBe(error); + expect(error.message).toBe( + 'RPC 0xa4b1 Custom eth_getBalance: Nested rpc error message', + ); }); it('works when params are undefined', async () => { diff --git a/packages/transaction-controller/src/utils/provider.ts b/packages/transaction-controller/src/utils/provider.ts index 1b4e7fabb1..0187a15852 100644 --- a/packages/transaction-controller/src/utils/provider.ts +++ b/packages/transaction-controller/src/utils/provider.ts @@ -1,4 +1,9 @@ -import type { NetworkClientId, Provider } from '@metamask/network-controller'; +import type { + NetworkClient, + NetworkClientId, + Provider, +} from '@metamask/network-controller'; +import { NetworkClientType } from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger, projectLogger } from '../logger'; @@ -32,11 +37,8 @@ export function getProvider({ chainId, networkClientId, }); - return ( - messenger.call('NetworkController:getNetworkClientById', resolvedId) as { - provider: Provider; - } - ).provider; + + return getNetworkClient(messenger, resolvedId).provider; } /** @@ -63,9 +65,24 @@ export async function rpcRequest({ method: string; params?: ProviderRequestParams; }): Promise { - const provider = getProvider({ messenger, chainId, networkClientId }); + const resolvedNetworkClientId = getNetworkClientId({ + messenger, + chainId, + networkClientId, + }); - const response = await provider.request({ method, params }); + const networkClient = getNetworkClient(messenger, resolvedNetworkClientId); + const { provider } = networkClient; + + let response: unknown; + try { + response = await provider.request({ method, params }); + } catch (error) { + throwWithRpcContext(error, { + method, + networkClient, + }); + } log(method, { params, response }); @@ -128,3 +145,43 @@ export function getChainId({ } ).configuration.chainId; } + +function getNetworkClient( + messenger: TransactionControllerMessenger, + networkClientId: NetworkClientId, +): NetworkClient { + return messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ) as NetworkClient; +} + +function throwWithRpcContext( + error: unknown, + { + method, + networkClient, + }: { + method: string; + networkClient: NetworkClient; + }, +): never { + const errorObject = error as { + data?: { message?: string }; + message?: string; + }; + const message = errorObject.data?.message ?? errorObject.message; + const prefix = `RPC ${networkClient.configuration.chainId} ${getEndpointLabel( + networkClient, + )} ${method}`; + const prefixedMessage = `${prefix}: ${message}`; + + errorObject.message = prefixedMessage; + throw error; +} + +function getEndpointLabel(networkClient: NetworkClient): 'Infura' | 'Custom' { + return networkClient.configuration.type === NetworkClientType.Infura + ? 'Infura' + : 'Custom'; +} diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 17116e6f03..eb61b7f368 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Add RPC method, chain ID, and endpoint type context to transaction pay provider errors ([#9144](https://github.com/MetaMask/core/pull/9144)) + ## [23.8.0] ### Changed diff --git a/packages/transaction-pay-controller/src/utils/provider.test.ts b/packages/transaction-pay-controller/src/utils/provider.test.ts index 95f8046d7b..8510fd9179 100644 --- a/packages/transaction-pay-controller/src/utils/provider.test.ts +++ b/packages/transaction-pay-controller/src/utils/provider.test.ts @@ -1,4 +1,5 @@ -import type { Provider } from '@metamask/network-controller'; +import type { NetworkClient, Provider } from '@metamask/network-controller'; +import { NetworkClientType } from '@metamask/network-controller'; import { RpcEndpointType } from '@metamask/network-controller'; import type { NetworkConfiguration } from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; @@ -11,6 +12,31 @@ const DEFAULT_NETWORK_CLIENT_ID_MOCK = 'default-client-id'; const INFURA_NETWORK_CLIENT_ID_MOCK = 'mainnet'; const PROVIDER_MOCK = { request: jest.fn() } as unknown as Provider; +function buildNetworkClient( + provider: Provider, + networkClientId = DEFAULT_NETWORK_CLIENT_ID_MOCK, +): Pick { + return { + configuration: { + chainId: CHAIN_ID_MOCK, + type: + networkClientId === INFURA_NETWORK_CLIENT_ID_MOCK + ? NetworkClientType.Infura + : NetworkClientType.Custom, + rpcEndpoints: [ + { + networkClientId, + type: + networkClientId === INFURA_NETWORK_CLIENT_ID_MOCK + ? RpcEndpointType.Infura + : RpcEndpointType.Custom, + }, + ], + }, + provider, + }; +} + describe('provider utils', () => { const { messenger, @@ -26,9 +52,9 @@ describe('provider utils', () => { DEFAULT_NETWORK_CLIENT_ID_MOCK, ); - getNetworkClientByIdMock.mockReturnValue({ - provider: PROVIDER_MOCK, - } as never); + getNetworkClientByIdMock.mockImplementation((networkClientId) => + buildNetworkClient(PROVIDER_MOCK, networkClientId), + ); getNetworkConfigurationByChainIdMock.mockReturnValue(undefined); }); @@ -115,6 +141,16 @@ describe('provider utils', () => { it('calls provider.request with method and params', async () => { const requestMock = jest.fn().mockResolvedValue('0xabc'); getNetworkClientByIdMock.mockReturnValue({ + configuration: { + chainId: CHAIN_ID_MOCK, + type: NetworkClientType.Custom, + rpcEndpoints: [ + { + networkClientId: DEFAULT_NETWORK_CLIENT_ID_MOCK, + type: RpcEndpointType.Custom, + }, + ], + }, provider: { request: requestMock }, } as never); @@ -135,6 +171,16 @@ describe('provider utils', () => { it('calls provider.request without params when omitted', async () => { const requestMock = jest.fn().mockResolvedValue('0x10'); getNetworkClientByIdMock.mockReturnValue({ + configuration: { + chainId: CHAIN_ID_MOCK, + type: NetworkClientType.Custom, + rpcEndpoints: [ + { + networkClientId: DEFAULT_NETWORK_CLIENT_ID_MOCK, + type: RpcEndpointType.Custom, + }, + ], + }, provider: { request: requestMock }, } as never); @@ -150,10 +196,20 @@ describe('provider utils', () => { }); }); - it('propagates provider errors', async () => { + it('prefixes provider errors with custom endpoint context', async () => { const error = new Error('RPC failed'); const requestMock = jest.fn().mockRejectedValue(error); getNetworkClientByIdMock.mockReturnValue({ + configuration: { + chainId: CHAIN_ID_MOCK, + type: NetworkClientType.Custom, + rpcEndpoints: [ + { + networkClientId: DEFAULT_NETWORK_CLIENT_ID_MOCK, + type: RpcEndpointType.Custom, + }, + ], + }, provider: { request: requestMock }, } as never); @@ -164,6 +220,77 @@ describe('provider utils', () => { method: 'eth_blockNumber', }), ).rejects.toBe(error); + expect(error.message).toBe('RPC 0x1 Custom eth_blockNumber: RPC failed'); + }); + + it('prefixes provider errors with Infura endpoint context', async () => { + getNetworkConfigurationByChainIdMock.mockReturnValue({ + rpcEndpoints: [ + { + type: RpcEndpointType.Infura, + networkClientId: INFURA_NETWORK_CLIENT_ID_MOCK, + }, + ], + } as NetworkConfiguration); + + const error = new Error('Unauthorized.'); + const requestMock = jest.fn().mockRejectedValue(error); + getNetworkClientByIdMock.mockReturnValue({ + configuration: { + chainId: CHAIN_ID_MOCK, + type: NetworkClientType.Infura, + rpcEndpoints: [ + { + networkClientId: INFURA_NETWORK_CLIENT_ID_MOCK, + type: RpcEndpointType.Infura, + }, + ], + }, + provider: { request: requestMock }, + } as never); + + await expect( + rpcRequest({ + messenger, + chainId: CHAIN_ID_MOCK, + method: 'eth_getBalance', + options: { preferInfura: true }, + }), + ).rejects.toBe(error); + expect(error.message).toBe( + 'RPC 0x1 Infura eth_getBalance: Unauthorized.', + ); + }); + + it('uses nested RPC data messages when available', async () => { + const error = Object.assign(new Error('Outer message'), { + data: { message: 'Nested rpc error message' }, + }); + const requestMock = jest.fn().mockRejectedValue(error); + getNetworkClientByIdMock.mockReturnValue({ + configuration: { + chainId: CHAIN_ID_MOCK, + type: NetworkClientType.Custom, + rpcEndpoints: [ + { + networkClientId: DEFAULT_NETWORK_CLIENT_ID_MOCK, + type: RpcEndpointType.Custom, + }, + ], + }, + provider: { request: requestMock }, + } as never); + + await expect( + rpcRequest({ + messenger, + chainId: CHAIN_ID_MOCK, + method: 'eth_blockNumber', + }), + ).rejects.toBe(error); + expect(error.message).toBe( + 'RPC 0x1 Custom eth_blockNumber: Nested rpc error message', + ); }); it('uses Infura network client when preferInfura is true', async () => { @@ -178,6 +305,16 @@ describe('provider utils', () => { const requestMock = jest.fn().mockResolvedValue('0x1'); getNetworkClientByIdMock.mockReturnValue({ + configuration: { + chainId: CHAIN_ID_MOCK, + type: NetworkClientType.Infura, + rpcEndpoints: [ + { + networkClientId: INFURA_NETWORK_CLIENT_ID_MOCK, + type: RpcEndpointType.Infura, + }, + ], + }, provider: { request: requestMock }, } as never); diff --git a/packages/transaction-pay-controller/src/utils/provider.ts b/packages/transaction-pay-controller/src/utils/provider.ts index d0d86ac87d..ed793fb3fc 100644 --- a/packages/transaction-pay-controller/src/utils/provider.ts +++ b/packages/transaction-pay-controller/src/utils/provider.ts @@ -1,5 +1,12 @@ -import type { NetworkClientId, Provider } from '@metamask/network-controller'; -import { RpcEndpointType } from '@metamask/network-controller'; +import type { + NetworkClient, + NetworkClientId, + Provider, +} from '@metamask/network-controller'; +import { + NetworkClientType, + RpcEndpointType, +} from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; @@ -107,14 +114,60 @@ export async function rpcRequest({ }: RpcRequestParams): Promise { const networkClientId = getNetworkClientId(messenger, chainId, options); - const { provider } = messenger.call( - 'NetworkController:getNetworkClientById', - networkClientId, - ); + const networkClient = getNetworkClient(messenger, networkClientId); + const { provider } = networkClient; - const response = await provider.request({ method, params }); + let response: unknown; + try { + response = await provider.request({ method, params }); + } catch (error) { + throwWithRpcContext(error, { + method, + networkClient, + }); + } log(method, { chainId, networkClientId, params, response }); return response as TResponse; } + +function getNetworkClient( + messenger: TransactionPayControllerMessenger, + networkClientId: NetworkClientId, +): NetworkClient { + return messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ) as NetworkClient; +} + +function throwWithRpcContext( + error: unknown, + { + method, + networkClient, + }: { + method: string; + networkClient: NetworkClient; + }, +): never { + const errorObject = error as { + data?: { message?: string }; + message?: string; + }; + const message = errorObject.data?.message ?? errorObject.message; + const prefix = `RPC ${networkClient.configuration.chainId} ${getEndpointLabel( + networkClient, + )} ${method}`; + const prefixedMessage = `${prefix}: ${message}`; + + errorObject.message = prefixedMessage; + throw error; +} + +function getEndpointLabel(networkClient: NetworkClient): 'Infura' | 'Custom' { + return networkClient.configuration.type === NetworkClientType.Infura + ? 'Infura' + : 'Custom'; +} diff --git a/packages/transaction-pay-controller/src/utils/transaction.test.ts b/packages/transaction-pay-controller/src/utils/transaction.test.ts index 6189adb600..20e4c8b566 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.test.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.test.ts @@ -1,5 +1,6 @@ import { Interface } from '@ethersproject/abi'; import { abiERC20 } from '@metamask/metamask-eth-abis'; +import { NetworkClientType } from '@metamask/network-controller'; import { TransactionStatus, TransactionType, @@ -707,6 +708,10 @@ describe('getTransferredAmountFromTxHash', () => { receiptFindNetworkMock.mockReturnValue(NETWORK_CLIENT_ID_RECEIPT_MOCK); receiptGetNetworkMock.mockReturnValue({ + configuration: { + chainId: CHAIN_ID_RECEIPT_MOCK, + type: NetworkClientType.Custom, + }, provider: PROVIDER_RECEIPT_MOCK, } as never); }); @@ -1075,11 +1080,13 @@ describe('getTransferredAmountFromTxHash', () => { tokenAddress: ERC20_ADDRESS_RECEIPT_MOCK, walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, }), - ).rejects.toThrow('RPC error'); + ).rejects.toThrow('RPC 0x1 Custom eth_getTransactionReceipt: RPC error'); }); it('propagates provider errors for native when both trace and getTransaction fail', async () => { - PROVIDER_RECEIPT_MOCK.request.mockRejectedValue(new Error('RPC error')); + PROVIDER_RECEIPT_MOCK.request + .mockRejectedValueOnce(new Error('RPC error')) + .mockRejectedValueOnce(new Error('RPC error')); await expect( getTransferredAmountFromTxHash({ @@ -1089,6 +1096,6 @@ describe('getTransferredAmountFromTxHash', () => { tokenAddress: NATIVE_TOKEN_ADDRESS, walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, }), - ).rejects.toThrow('RPC error'); + ).rejects.toThrow('RPC 0x1 Custom eth_getTransactionByHash: RPC error'); }); });