From cd302a5a48a7bfa07177eb2e2410a7a529f152a7 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 16 Jun 2026 12:06:43 +0100 Subject: [PATCH 1/9] fix: add context to RPC provider errors --- packages/transaction-controller/CHANGELOG.md | 4 + .../src/utils/provider.test.ts | 40 ++++++- .../src/utils/provider.ts | 84 ++++++++++++-- .../transaction-pay-controller/CHANGELOG.md | 1 + .../src/utils/provider.test.ts | 104 +++++++++++++++++- .../src/utils/provider.ts | 75 ++++++++++++- 6 files changed, 291 insertions(+), 17 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index e08cc10730..521e8c2cd7 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 ([#0000](https://github.com/MetaMask/core/pull/0000)) + ## [68.0.0] ### Changed diff --git a/packages/transaction-controller/src/utils/provider.test.ts b/packages/transaction-controller/src/utils/provider.test.ts index 09e39134ea..8ec2aecd42 100644 --- a/packages/transaction-controller/src/utils/provider.test.ts +++ b/packages/transaction-controller/src/utils/provider.test.ts @@ -1,4 +1,5 @@ import type { NetworkClientId, Provider } from '@metamask/network-controller'; +import { RpcEndpointType } from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; import type { TransactionControllerMessenger } from '../TransactionController'; @@ -9,6 +10,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 +22,23 @@ 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, + rpcEndpoints: [ + { + networkClientId, + type: + networkClientId === 'infuraNetworkClientId' + ? RpcEndpointType.Infura + : RpcEndpointType.Custom, + }, + ], + }, + provider: mockProvider, + }; } if (action === 'NetworkController:findNetworkClientIdByChainId') { @@ -106,6 +124,26 @@ 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('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..05df5493c3 100644 --- a/packages/transaction-controller/src/utils/provider.ts +++ b/packages/transaction-controller/src/utils/provider.ts @@ -1,4 +1,5 @@ import type { NetworkClientId, Provider } from '@metamask/network-controller'; +import { RpcEndpointType } from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger, projectLogger } from '../logger'; @@ -8,6 +9,14 @@ const log = createModuleLogger(projectLogger, 'provider'); type ProviderRequestParams = Parameters[0]['params']; +type NetworkClient = { + configuration: { + chainId: Hex; + rpcEndpoints?: { networkClientId: NetworkClientId; type?: string }[]; + }; + provider: Provider; +}; + /** * Get a provider for the specified chain or network client. * Resolves chainId to networkClientId if needed, then gets the provider. @@ -32,11 +41,7 @@ export function getProvider({ chainId, networkClientId, }); - return ( - messenger.call('NetworkController:getNetworkClientById', resolvedId) as { - provider: Provider; - } - ).provider; + return getNetworkClient(messenger, resolvedId).provider; } /** @@ -63,9 +68,25 @@ export async function rpcRequest({ method: string; params?: ProviderRequestParams; }): Promise { - const provider = getProvider({ messenger, chainId, networkClientId }); + const resolvedNetworkClientId = getNetworkClientId({ + messenger, + chainId, + networkClientId, + }); + const networkClient = getNetworkClient(messenger, resolvedNetworkClientId); + const { provider } = networkClient; - const response = await provider.request({ method, params }); + let response: unknown; + try { + response = await provider.request({ method, params }); + } catch (error) { + throwWithRpcContext(error, { + chainId: networkClient.configuration.chainId, + method, + networkClient, + networkClientId: resolvedNetworkClientId, + }); + } log(method, { params, response }); @@ -128,3 +149,52 @@ export function getChainId({ } ).configuration.chainId; } + +function getNetworkClient( + messenger: TransactionControllerMessenger, + networkClientId: NetworkClientId, +): NetworkClient { + return messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ) as NetworkClient; +} + +function throwWithRpcContext( + error: unknown, + { + chainId, + method, + networkClient, + networkClientId, + }: { + chainId: Hex; + method: string; + networkClient: NetworkClient; + networkClientId: NetworkClientId; + }, +): never { + const message = error instanceof Error ? error.message : String(error); + const prefix = `RPC ${chainId} ${getEndpointLabel( + networkClient, + networkClientId, + )} ${method}`; + + if (error instanceof Error) { + error.message = `${prefix}: ${message}`; + throw error; + } + + throw new Error(`${prefix}: ${message}`); +} + +function getEndpointLabel( + networkClient: NetworkClient, + networkClientId: NetworkClientId, +): 'Infura' | 'Custom' { + const endpoint = networkClient.configuration.rpcEndpoints?.find( + (rpcEndpoint) => rpcEndpoint.networkClientId === networkClientId, + ); + + return endpoint?.type === RpcEndpointType.Infura ? 'Infura' : 'Custom'; +} diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 9bc8d7c228..eb469bf075 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Add RPC method, chain ID, and endpoint type context to transaction pay provider errors ([#0000](https://github.com/MetaMask/core/pull/0000)) - Bump `@metamask/keyring-controller` from `^27.0.0` to `^27.1.0` ([#9129](https://github.com/MetaMask/core/pull/9129)) ## [23.7.0] diff --git a/packages/transaction-pay-controller/src/utils/provider.test.ts b/packages/transaction-pay-controller/src/utils/provider.test.ts index 95f8046d7b..4111fccf1d 100644 --- a/packages/transaction-pay-controller/src/utils/provider.test.ts +++ b/packages/transaction-pay-controller/src/utils/provider.test.ts @@ -11,6 +11,27 @@ 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, +) { + return { + configuration: { + chainId: CHAIN_ID_MOCK, + rpcEndpoints: [ + { + networkClientId, + type: + networkClientId === INFURA_NETWORK_CLIENT_ID_MOCK + ? RpcEndpointType.Infura + : RpcEndpointType.Custom, + }, + ], + }, + provider, + }; +} + describe('provider utils', () => { const { messenger, @@ -26,9 +47,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 +136,15 @@ 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, + rpcEndpoints: [ + { + networkClientId: DEFAULT_NETWORK_CLIENT_ID_MOCK, + type: RpcEndpointType.Custom, + }, + ], + }, provider: { request: requestMock }, } as never); @@ -135,6 +165,15 @@ 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, + rpcEndpoints: [ + { + networkClientId: DEFAULT_NETWORK_CLIENT_ID_MOCK, + type: RpcEndpointType.Custom, + }, + ], + }, provider: { request: requestMock }, } as never); @@ -150,10 +189,19 @@ 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, + rpcEndpoints: [ + { + networkClientId: DEFAULT_NETWORK_CLIENT_ID_MOCK, + type: RpcEndpointType.Custom, + }, + ], + }, provider: { request: requestMock }, } as never); @@ -164,6 +212,45 @@ 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, + 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 Infura network client when preferInfura is true', async () => { @@ -178,6 +265,15 @@ describe('provider utils', () => { const requestMock = jest.fn().mockResolvedValue('0x1'); getNetworkClientByIdMock.mockReturnValue({ + configuration: { + chainId: CHAIN_ID_MOCK, + 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..800ac99811 100644 --- a/packages/transaction-pay-controller/src/utils/provider.ts +++ b/packages/transaction-pay-controller/src/utils/provider.ts @@ -10,6 +10,14 @@ const log = createModuleLogger(projectLogger, 'provider'); type ProviderRequestParams = Parameters[0]['params']; +type NetworkClient = { + configuration: { + chainId: Hex; + rpcEndpoints?: { networkClientId: NetworkClientId; type?: string }[]; + }; + provider: Provider; +}; + /** * Options for network client resolution. */ @@ -107,14 +115,71 @@ 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, { + chainId, + method, + networkClient, + networkClientId, + }); + } 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, + { + chainId, + method, + networkClient, + networkClientId, + }: { + chainId: Hex; + method: string; + networkClient: NetworkClient; + networkClientId: NetworkClientId; + }, +): never { + const message = error instanceof Error ? error.message : String(error); + const prefix = `RPC ${chainId} ${getEndpointLabel( + networkClient, + networkClientId, + )} ${method}`; + + if (error instanceof Error) { + error.message = `${prefix}: ${message}`; + throw error; + } + + throw new Error(`${prefix}: ${message}`); +} + +function getEndpointLabel( + networkClient: NetworkClient, + networkClientId: NetworkClientId, +): 'Infura' | 'Custom' { + const endpoint = networkClient.configuration.rpcEndpoints?.find( + (rpcEndpoint) => rpcEndpoint.networkClientId === networkClientId, + ); + + return endpoint?.type === RpcEndpointType.Infura ? 'Infura' : 'Custom'; +} From 4e099c45ae4f8112d4910a54037cc881a4fc08be Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 16 Jun 2026 12:08:00 +0100 Subject: [PATCH 2/9] chore: update changelog links --- packages/transaction-controller/CHANGELOG.md | 2 +- packages/transaction-pay-controller/CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 521e8c2cd7..76b3c71889 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Add RPC method, chain ID, and endpoint type context to transaction provider errors ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Add RPC method, chain ID, and endpoint type context to transaction provider errors ([#9144](https://github.com/MetaMask/core/pull/9144)) ## [68.0.0] diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index eb469bf075..097d3a0940 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Add RPC method, chain ID, and endpoint type context to transaction pay provider errors ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Add RPC method, chain ID, and endpoint type context to transaction pay provider errors ([#9144](https://github.com/MetaMask/core/pull/9144)) - Bump `@metamask/keyring-controller` from `^27.0.0` to `^27.1.0` ([#9129](https://github.com/MetaMask/core/pull/9129)) ## [23.7.0] From 3ba774b05aa845eaef9d2bdf5c3a80234b8958cd Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 16 Jun 2026 12:22:04 +0100 Subject: [PATCH 3/9] fix: refine RPC error context wrapping --- .../src/utils/provider.test.ts | 34 ++++++++++ .../src/utils/provider.ts | 66 ++++++++++-------- .../src/utils/provider.test.ts | 51 ++++++++++++++ .../src/utils/provider.ts | 67 +++++++++++-------- 4 files changed, 162 insertions(+), 56 deletions(-) diff --git a/packages/transaction-controller/src/utils/provider.test.ts b/packages/transaction-controller/src/utils/provider.test.ts index 8ec2aecd42..edce9d7397 100644 --- a/packages/transaction-controller/src/utils/provider.test.ts +++ b/packages/transaction-controller/src/utils/provider.test.ts @@ -1,4 +1,5 @@ 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'; @@ -27,6 +28,10 @@ describe('provider utils', () => { return { configuration: { chainId: chainIdMock, + type: + networkClientId === 'infuraNetworkClientId' + ? NetworkClientType.Infura + : NetworkClientType.Custom, rpcEndpoints: [ { networkClientId, @@ -146,6 +151,35 @@ describe('provider utils', () => { ); }); + it('prefixes provider errors when the original message is read-only', async () => { + const error = new Error('RPC failed') as Error & { code?: string }; + error.code = 'UNAUTHORIZED'; + Object.defineProperty(error, 'message', { + value: 'RPC failed', + writable: false, + }); + requestMock.mockRejectedValue(error); + + let thrownError: (Error & { code?: string }) | undefined; + try { + await rpcRequest({ + messenger: messengerMock, + networkClientId: 'networkClientIdA' as NetworkClientId, + method: 'eth_getBalance', + params: ['0x123', 'latest'], + }); + } catch (caughtError) { + thrownError = caughtError as Error & { code?: string }; + } + + expect(thrownError).not.toBe(error); + expect(thrownError?.message).toBe( + 'RPC 0xa4b1 Custom eth_getBalance: RPC failed', + ); + expect(thrownError?.code).toBe('UNAUTHORIZED'); + expect(error.message).toBe('RPC failed'); + }); + it('works when params are undefined', async () => { requestMock.mockResolvedValue('0x10'); diff --git a/packages/transaction-controller/src/utils/provider.ts b/packages/transaction-controller/src/utils/provider.ts index 05df5493c3..17a92634a7 100644 --- a/packages/transaction-controller/src/utils/provider.ts +++ b/packages/transaction-controller/src/utils/provider.ts @@ -1,5 +1,9 @@ -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 } from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger, projectLogger } from '../logger'; @@ -9,12 +13,8 @@ const log = createModuleLogger(projectLogger, 'provider'); type ProviderRequestParams = Parameters[0]['params']; -type NetworkClient = { - configuration: { - chainId: Hex; - rpcEndpoints?: { networkClientId: NetworkClientId; type?: string }[]; - }; - provider: Provider; +type NetworkClientWithId = NetworkClient & { + id: NetworkClientId; }; /** @@ -41,6 +41,7 @@ export function getProvider({ chainId, networkClientId, }); + return getNetworkClient(messenger, resolvedId).provider; } @@ -73,6 +74,7 @@ export async function rpcRequest({ chainId, networkClientId, }); + const networkClient = getNetworkClient(messenger, resolvedNetworkClientId); const { provider } = networkClient; @@ -81,10 +83,8 @@ export async function rpcRequest({ response = await provider.request({ method, params }); } catch (error) { throwWithRpcContext(error, { - chainId: networkClient.configuration.chainId, method, networkClient, - networkClientId: resolvedNetworkClientId, }); } @@ -153,48 +153,58 @@ export function getChainId({ function getNetworkClient( messenger: TransactionControllerMessenger, networkClientId: NetworkClientId, -): NetworkClient { - return messenger.call( +): NetworkClientWithId { + const networkClient = messenger.call( 'NetworkController:getNetworkClientById', networkClientId, ) as NetworkClient; + + return { ...networkClient, id: networkClientId }; } function throwWithRpcContext( error: unknown, { - chainId, method, networkClient, - networkClientId, }: { - chainId: Hex; method: string; - networkClient: NetworkClient; - networkClientId: NetworkClientId; + networkClient: NetworkClientWithId; }, ): never { const message = error instanceof Error ? error.message : String(error); - const prefix = `RPC ${chainId} ${getEndpointLabel( + const prefix = `RPC ${networkClient.configuration.chainId} ${getEndpointLabel( networkClient, - networkClientId, )} ${method}`; + const prefixedMessage = `${prefix}: ${message}`; if (error instanceof Error) { - error.message = `${prefix}: ${message}`; - throw error; + if (setErrorMessage(error, prefixedMessage)) { + throw error; + } + + const wrappedError = Object.assign(new Error(prefixedMessage), error); + + wrappedError.message = prefixedMessage; + throw wrappedError; } - throw new Error(`${prefix}: ${message}`); + throw new Error(prefixedMessage); } function getEndpointLabel( - networkClient: NetworkClient, - networkClientId: NetworkClientId, + networkClient: NetworkClientWithId, ): 'Infura' | 'Custom' { - const endpoint = networkClient.configuration.rpcEndpoints?.find( - (rpcEndpoint) => rpcEndpoint.networkClientId === networkClientId, - ); + return networkClient.configuration.type === NetworkClientType.Infura + ? 'Infura' + : 'Custom'; +} - return endpoint?.type === RpcEndpointType.Infura ? 'Infura' : 'Custom'; +function setErrorMessage(error: Error, message: string): boolean { + try { + error.message = message; + return error.message === message; + } catch { + return false; + } } diff --git a/packages/transaction-pay-controller/src/utils/provider.test.ts b/packages/transaction-pay-controller/src/utils/provider.test.ts index 4111fccf1d..76552e2399 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 { NetworkClientType } from '@metamask/network-controller'; import { RpcEndpointType } from '@metamask/network-controller'; import type { NetworkConfiguration } from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; @@ -18,6 +19,10 @@ function buildNetworkClient( return { configuration: { chainId: CHAIN_ID_MOCK, + type: + networkClientId === INFURA_NETWORK_CLIENT_ID_MOCK + ? NetworkClientType.Infura + : NetworkClientType.Custom, rpcEndpoints: [ { networkClientId, @@ -138,6 +143,7 @@ describe('provider utils', () => { getNetworkClientByIdMock.mockReturnValue({ configuration: { chainId: CHAIN_ID_MOCK, + type: NetworkClientType.Custom, rpcEndpoints: [ { networkClientId: DEFAULT_NETWORK_CLIENT_ID_MOCK, @@ -167,6 +173,7 @@ describe('provider utils', () => { getNetworkClientByIdMock.mockReturnValue({ configuration: { chainId: CHAIN_ID_MOCK, + type: NetworkClientType.Custom, rpcEndpoints: [ { networkClientId: DEFAULT_NETWORK_CLIENT_ID_MOCK, @@ -195,6 +202,7 @@ describe('provider utils', () => { getNetworkClientByIdMock.mockReturnValue({ configuration: { chainId: CHAIN_ID_MOCK, + type: NetworkClientType.Custom, rpcEndpoints: [ { networkClientId: DEFAULT_NETWORK_CLIENT_ID_MOCK, @@ -230,6 +238,7 @@ describe('provider utils', () => { getNetworkClientByIdMock.mockReturnValue({ configuration: { chainId: CHAIN_ID_MOCK, + type: NetworkClientType.Infura, rpcEndpoints: [ { networkClientId: INFURA_NETWORK_CLIENT_ID_MOCK, @@ -253,6 +262,47 @@ describe('provider utils', () => { ); }); + it('prefixes provider errors when the original message is read-only', async () => { + const error = new Error('RPC failed') as Error & { code?: string }; + error.code = 'UNAUTHORIZED'; + Object.defineProperty(error, 'message', { + value: 'RPC failed', + writable: false, + }); + 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); + + let thrownError: (Error & { code?: string }) | undefined; + try { + await rpcRequest({ + messenger, + chainId: CHAIN_ID_MOCK, + method: 'eth_blockNumber', + }); + } catch (caughtError) { + thrownError = caughtError as Error & { code?: string }; + } + + expect(thrownError).not.toBe(error); + expect(thrownError?.message).toBe( + 'RPC 0x1 Custom eth_blockNumber: RPC failed', + ); + expect(thrownError?.code).toBe('UNAUTHORIZED'); + expect(error.message).toBe('RPC failed'); + }); + it('uses Infura network client when preferInfura is true', async () => { getNetworkConfigurationByChainIdMock.mockReturnValue({ rpcEndpoints: [ @@ -267,6 +317,7 @@ describe('provider utils', () => { getNetworkClientByIdMock.mockReturnValue({ configuration: { chainId: CHAIN_ID_MOCK, + type: NetworkClientType.Infura, rpcEndpoints: [ { networkClientId: INFURA_NETWORK_CLIENT_ID_MOCK, diff --git a/packages/transaction-pay-controller/src/utils/provider.ts b/packages/transaction-pay-controller/src/utils/provider.ts index 800ac99811..6d3e4a6620 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'; @@ -10,12 +17,8 @@ const log = createModuleLogger(projectLogger, 'provider'); type ProviderRequestParams = Parameters[0]['params']; -type NetworkClient = { - configuration: { - chainId: Hex; - rpcEndpoints?: { networkClientId: NetworkClientId; type?: string }[]; - }; - provider: Provider; +type NetworkClientWithId = NetworkClient & { + id: NetworkClientId; }; /** @@ -123,10 +126,8 @@ export async function rpcRequest({ response = await provider.request({ method, params }); } catch (error) { throwWithRpcContext(error, { - chainId, method, networkClient, - networkClientId, }); } @@ -138,48 +139,58 @@ export async function rpcRequest({ function getNetworkClient( messenger: TransactionPayControllerMessenger, networkClientId: NetworkClientId, -): NetworkClient { - return messenger.call( +): NetworkClientWithId { + const networkClient = messenger.call( 'NetworkController:getNetworkClientById', networkClientId, ) as NetworkClient; + + return { ...networkClient, id: networkClientId }; } function throwWithRpcContext( error: unknown, { - chainId, method, networkClient, - networkClientId, }: { - chainId: Hex; method: string; - networkClient: NetworkClient; - networkClientId: NetworkClientId; + networkClient: NetworkClientWithId; }, ): never { const message = error instanceof Error ? error.message : String(error); - const prefix = `RPC ${chainId} ${getEndpointLabel( + const prefix = `RPC ${networkClient.configuration.chainId} ${getEndpointLabel( networkClient, - networkClientId, )} ${method}`; + const prefixedMessage = `${prefix}: ${message}`; if (error instanceof Error) { - error.message = `${prefix}: ${message}`; - throw error; + if (setErrorMessage(error, prefixedMessage)) { + throw error; + } + + const wrappedError = Object.assign(new Error(prefixedMessage), error); + + wrappedError.message = prefixedMessage; + throw wrappedError; } - throw new Error(`${prefix}: ${message}`); + throw new Error(prefixedMessage); } function getEndpointLabel( - networkClient: NetworkClient, - networkClientId: NetworkClientId, + networkClient: NetworkClientWithId, ): 'Infura' | 'Custom' { - const endpoint = networkClient.configuration.rpcEndpoints?.find( - (rpcEndpoint) => rpcEndpoint.networkClientId === networkClientId, - ); + return networkClient.configuration.type === NetworkClientType.Infura + ? 'Infura' + : 'Custom'; +} - return endpoint?.type === RpcEndpointType.Infura ? 'Infura' : 'Custom'; +function setErrorMessage(error: Error, message: string): boolean { + try { + error.message = message; + return error.message === message; + } catch { + return false; + } } From 2ecf32f9f84ccf2cd0f72c08cf22ff0e119eca40 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 16 Jun 2026 12:40:10 +0100 Subject: [PATCH 4/9] chore: remove unused network client id wrapper --- .../transaction-controller/src/utils/provider.ts | 16 ++++------------ .../src/utils/provider.ts | 16 ++++------------ 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/packages/transaction-controller/src/utils/provider.ts b/packages/transaction-controller/src/utils/provider.ts index 17a92634a7..c9316cf17a 100644 --- a/packages/transaction-controller/src/utils/provider.ts +++ b/packages/transaction-controller/src/utils/provider.ts @@ -13,10 +13,6 @@ const log = createModuleLogger(projectLogger, 'provider'); type ProviderRequestParams = Parameters[0]['params']; -type NetworkClientWithId = NetworkClient & { - id: NetworkClientId; -}; - /** * Get a provider for the specified chain or network client. * Resolves chainId to networkClientId if needed, then gets the provider. @@ -153,13 +149,11 @@ export function getChainId({ function getNetworkClient( messenger: TransactionControllerMessenger, networkClientId: NetworkClientId, -): NetworkClientWithId { - const networkClient = messenger.call( +): NetworkClient { + return messenger.call( 'NetworkController:getNetworkClientById', networkClientId, ) as NetworkClient; - - return { ...networkClient, id: networkClientId }; } function throwWithRpcContext( @@ -169,7 +163,7 @@ function throwWithRpcContext( networkClient, }: { method: string; - networkClient: NetworkClientWithId; + networkClient: NetworkClient; }, ): never { const message = error instanceof Error ? error.message : String(error); @@ -192,9 +186,7 @@ function throwWithRpcContext( throw new Error(prefixedMessage); } -function getEndpointLabel( - networkClient: NetworkClientWithId, -): 'Infura' | 'Custom' { +function getEndpointLabel(networkClient: NetworkClient): 'Infura' | 'Custom' { return networkClient.configuration.type === NetworkClientType.Infura ? 'Infura' : 'Custom'; diff --git a/packages/transaction-pay-controller/src/utils/provider.ts b/packages/transaction-pay-controller/src/utils/provider.ts index 6d3e4a6620..b39dd06d29 100644 --- a/packages/transaction-pay-controller/src/utils/provider.ts +++ b/packages/transaction-pay-controller/src/utils/provider.ts @@ -17,10 +17,6 @@ const log = createModuleLogger(projectLogger, 'provider'); type ProviderRequestParams = Parameters[0]['params']; -type NetworkClientWithId = NetworkClient & { - id: NetworkClientId; -}; - /** * Options for network client resolution. */ @@ -139,13 +135,11 @@ export async function rpcRequest({ function getNetworkClient( messenger: TransactionPayControllerMessenger, networkClientId: NetworkClientId, -): NetworkClientWithId { - const networkClient = messenger.call( +): NetworkClient { + return messenger.call( 'NetworkController:getNetworkClientById', networkClientId, ) as NetworkClient; - - return { ...networkClient, id: networkClientId }; } function throwWithRpcContext( @@ -155,7 +149,7 @@ function throwWithRpcContext( networkClient, }: { method: string; - networkClient: NetworkClientWithId; + networkClient: NetworkClient; }, ): never { const message = error instanceof Error ? error.message : String(error); @@ -178,9 +172,7 @@ function throwWithRpcContext( throw new Error(prefixedMessage); } -function getEndpointLabel( - networkClient: NetworkClientWithId, -): 'Infura' | 'Custom' { +function getEndpointLabel(networkClient: NetworkClient): 'Infura' | 'Custom' { return networkClient.configuration.type === NetworkClientType.Infura ? 'Infura' : 'Custom'; From f31385411b4f1bb568dc1661a8eb89e45d6014cc Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 16 Jun 2026 13:52:09 +0100 Subject: [PATCH 5/9] fix: update RPC context test coverage --- .../src/utils/provider.test.ts | 13 ++++++++++ .../src/utils/provider.test.ts | 25 +++++++++++++++++++ .../src/utils/transaction.test.ts | 13 +++++++--- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/transaction-controller/src/utils/provider.test.ts b/packages/transaction-controller/src/utils/provider.test.ts index edce9d7397..70d6e76617 100644 --- a/packages/transaction-controller/src/utils/provider.test.ts +++ b/packages/transaction-controller/src/utils/provider.test.ts @@ -180,6 +180,19 @@ describe('provider utils', () => { expect(error.message).toBe('RPC failed'); }); + it('prefixes non-Error provider errors', async () => { + requestMock.mockRejectedValue('RPC failed'); + + await expect( + rpcRequest({ + messenger: messengerMock, + networkClientId: 'networkClientIdA' as NetworkClientId, + method: 'eth_getBalance', + params: ['0x123', 'latest'], + }), + ).rejects.toThrow('RPC 0xa4b1 Custom eth_getBalance: RPC failed'); + }); + it('works when params are undefined', async () => { requestMock.mockResolvedValue('0x10'); diff --git a/packages/transaction-pay-controller/src/utils/provider.test.ts b/packages/transaction-pay-controller/src/utils/provider.test.ts index 76552e2399..2fad6f1884 100644 --- a/packages/transaction-pay-controller/src/utils/provider.test.ts +++ b/packages/transaction-pay-controller/src/utils/provider.test.ts @@ -303,6 +303,31 @@ describe('provider utils', () => { expect(error.message).toBe('RPC failed'); }); + it('prefixes non-Error provider errors', async () => { + const requestMock = jest.fn().mockRejectedValue('RPC failed'); + 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.toThrow('RPC 0x1 Custom eth_blockNumber: RPC failed'); + }); + it('uses Infura network client when preferInfura is true', async () => { getNetworkConfigurationByChainIdMock.mockReturnValue({ rpcEndpoints: [ 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'); }); }); From b1cd593909d1440b2126eebcaff2936afff990b1 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 16 Jun 2026 14:13:03 +0100 Subject: [PATCH 6/9] fix: add return type to buildNetworkClient test helper --- .../transaction-pay-controller/src/utils/provider.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/transaction-pay-controller/src/utils/provider.test.ts b/packages/transaction-pay-controller/src/utils/provider.test.ts index 2fad6f1884..a7dcbc5dc1 100644 --- a/packages/transaction-pay-controller/src/utils/provider.test.ts +++ b/packages/transaction-pay-controller/src/utils/provider.test.ts @@ -1,4 +1,4 @@ -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'; @@ -15,7 +15,7 @@ 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, From b150db9b7f22a0fa12a896c7423a9259616790b4 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 16 Jun 2026 22:24:46 +0100 Subject: [PATCH 7/9] fix: preserve RPC context on submit errors --- packages/transaction-controller/CHANGELOG.md | 2 +- .../src/TransactionController.test.ts | 29 +++++-------- .../src/TransactionController.ts | 42 +++++++------------ .../src/utils/provider.test.ts | 19 +++++++++ .../src/utils/provider.ts | 9 +++- .../src/utils/provider.test.ts | 31 ++++++++++++++ .../src/utils/provider.ts | 9 +++- 7 files changed, 92 insertions(+), 49 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 76b3c71889..b0da348a79 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Add RPC method, chain ID, and endpoint type context to transaction provider errors ([#9144](https://github.com/MetaMask/core/pull/9144)) +- 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] 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 70d6e76617..37ff5fd9ab 100644 --- a/packages/transaction-controller/src/utils/provider.test.ts +++ b/packages/transaction-controller/src/utils/provider.test.ts @@ -151,6 +151,25 @@ describe('provider utils', () => { ); }); + 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('prefixes provider errors when the original message is read-only', async () => { const error = new Error('RPC failed') as Error & { code?: string }; error.code = 'UNAUTHORIZED'; diff --git a/packages/transaction-controller/src/utils/provider.ts b/packages/transaction-controller/src/utils/provider.ts index c9316cf17a..4f671824d4 100644 --- a/packages/transaction-controller/src/utils/provider.ts +++ b/packages/transaction-controller/src/utils/provider.ts @@ -166,7 +166,14 @@ function throwWithRpcContext( networkClient: NetworkClient; }, ): never { - const message = error instanceof Error ? error.message : String(error); + const errorObject = error as + | { + data?: { message?: string }; + message?: string; + } + | undefined; + const message = + errorObject?.data?.message ?? errorObject?.message ?? String(error); const prefix = `RPC ${networkClient.configuration.chainId} ${getEndpointLabel( networkClient, )} ${method}`; diff --git a/packages/transaction-pay-controller/src/utils/provider.test.ts b/packages/transaction-pay-controller/src/utils/provider.test.ts index a7dcbc5dc1..9e81db208c 100644 --- a/packages/transaction-pay-controller/src/utils/provider.test.ts +++ b/packages/transaction-pay-controller/src/utils/provider.test.ts @@ -262,6 +262,37 @@ describe('provider utils', () => { ); }); + 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('prefixes provider errors when the original message is read-only', async () => { const error = new Error('RPC failed') as Error & { code?: string }; error.code = 'UNAUTHORIZED'; diff --git a/packages/transaction-pay-controller/src/utils/provider.ts b/packages/transaction-pay-controller/src/utils/provider.ts index b39dd06d29..f4545e85a0 100644 --- a/packages/transaction-pay-controller/src/utils/provider.ts +++ b/packages/transaction-pay-controller/src/utils/provider.ts @@ -152,7 +152,14 @@ function throwWithRpcContext( networkClient: NetworkClient; }, ): never { - const message = error instanceof Error ? error.message : String(error); + const errorObject = error as + | { + data?: { message?: string }; + message?: string; + } + | undefined; + const message = + errorObject?.data?.message ?? errorObject?.message ?? String(error); const prefix = `RPC ${networkClient.configuration.chainId} ${getEndpointLabel( networkClient, )} ${method}`; From 29064602385044f9acd42be484bda30e0ca096c8 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 16 Jun 2026 22:33:43 +0100 Subject: [PATCH 8/9] refactor: simplify RPC context error mutation --- .../src/utils/provider.test.ts | 42 ------------ .../src/utils/provider.ts | 36 ++-------- .../src/utils/provider.test.ts | 66 ------------------- .../src/utils/provider.ts | 36 ++-------- 4 files changed, 14 insertions(+), 166 deletions(-) diff --git a/packages/transaction-controller/src/utils/provider.test.ts b/packages/transaction-controller/src/utils/provider.test.ts index 37ff5fd9ab..248124da35 100644 --- a/packages/transaction-controller/src/utils/provider.test.ts +++ b/packages/transaction-controller/src/utils/provider.test.ts @@ -170,48 +170,6 @@ describe('provider utils', () => { ); }); - it('prefixes provider errors when the original message is read-only', async () => { - const error = new Error('RPC failed') as Error & { code?: string }; - error.code = 'UNAUTHORIZED'; - Object.defineProperty(error, 'message', { - value: 'RPC failed', - writable: false, - }); - requestMock.mockRejectedValue(error); - - let thrownError: (Error & { code?: string }) | undefined; - try { - await rpcRequest({ - messenger: messengerMock, - networkClientId: 'networkClientIdA' as NetworkClientId, - method: 'eth_getBalance', - params: ['0x123', 'latest'], - }); - } catch (caughtError) { - thrownError = caughtError as Error & { code?: string }; - } - - expect(thrownError).not.toBe(error); - expect(thrownError?.message).toBe( - 'RPC 0xa4b1 Custom eth_getBalance: RPC failed', - ); - expect(thrownError?.code).toBe('UNAUTHORIZED'); - expect(error.message).toBe('RPC failed'); - }); - - it('prefixes non-Error provider errors', async () => { - requestMock.mockRejectedValue('RPC failed'); - - await expect( - rpcRequest({ - messenger: messengerMock, - networkClientId: 'networkClientIdA' as NetworkClientId, - method: 'eth_getBalance', - params: ['0x123', 'latest'], - }), - ).rejects.toThrow('RPC 0xa4b1 Custom eth_getBalance: RPC failed'); - }); - it('works when params are undefined', async () => { requestMock.mockResolvedValue('0x10'); diff --git a/packages/transaction-controller/src/utils/provider.ts b/packages/transaction-controller/src/utils/provider.ts index 4f671824d4..0187a15852 100644 --- a/packages/transaction-controller/src/utils/provider.ts +++ b/packages/transaction-controller/src/utils/provider.ts @@ -166,31 +166,18 @@ function throwWithRpcContext( networkClient: NetworkClient; }, ): never { - const errorObject = error as - | { - data?: { message?: string }; - message?: string; - } - | undefined; - const message = - errorObject?.data?.message ?? errorObject?.message ?? String(error); + 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}`; - if (error instanceof Error) { - if (setErrorMessage(error, prefixedMessage)) { - throw error; - } - - const wrappedError = Object.assign(new Error(prefixedMessage), error); - - wrappedError.message = prefixedMessage; - throw wrappedError; - } - - throw new Error(prefixedMessage); + errorObject.message = prefixedMessage; + throw error; } function getEndpointLabel(networkClient: NetworkClient): 'Infura' | 'Custom' { @@ -198,12 +185,3 @@ function getEndpointLabel(networkClient: NetworkClient): 'Infura' | 'Custom' { ? 'Infura' : 'Custom'; } - -function setErrorMessage(error: Error, message: string): boolean { - try { - error.message = message; - return error.message === message; - } catch { - return false; - } -} diff --git a/packages/transaction-pay-controller/src/utils/provider.test.ts b/packages/transaction-pay-controller/src/utils/provider.test.ts index 9e81db208c..8510fd9179 100644 --- a/packages/transaction-pay-controller/src/utils/provider.test.ts +++ b/packages/transaction-pay-controller/src/utils/provider.test.ts @@ -293,72 +293,6 @@ describe('provider utils', () => { ); }); - it('prefixes provider errors when the original message is read-only', async () => { - const error = new Error('RPC failed') as Error & { code?: string }; - error.code = 'UNAUTHORIZED'; - Object.defineProperty(error, 'message', { - value: 'RPC failed', - writable: false, - }); - 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); - - let thrownError: (Error & { code?: string }) | undefined; - try { - await rpcRequest({ - messenger, - chainId: CHAIN_ID_MOCK, - method: 'eth_blockNumber', - }); - } catch (caughtError) { - thrownError = caughtError as Error & { code?: string }; - } - - expect(thrownError).not.toBe(error); - expect(thrownError?.message).toBe( - 'RPC 0x1 Custom eth_blockNumber: RPC failed', - ); - expect(thrownError?.code).toBe('UNAUTHORIZED'); - expect(error.message).toBe('RPC failed'); - }); - - it('prefixes non-Error provider errors', async () => { - const requestMock = jest.fn().mockRejectedValue('RPC failed'); - 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.toThrow('RPC 0x1 Custom eth_blockNumber: RPC failed'); - }); - it('uses Infura network client when preferInfura is true', async () => { getNetworkConfigurationByChainIdMock.mockReturnValue({ rpcEndpoints: [ diff --git a/packages/transaction-pay-controller/src/utils/provider.ts b/packages/transaction-pay-controller/src/utils/provider.ts index f4545e85a0..ed793fb3fc 100644 --- a/packages/transaction-pay-controller/src/utils/provider.ts +++ b/packages/transaction-pay-controller/src/utils/provider.ts @@ -152,31 +152,18 @@ function throwWithRpcContext( networkClient: NetworkClient; }, ): never { - const errorObject = error as - | { - data?: { message?: string }; - message?: string; - } - | undefined; - const message = - errorObject?.data?.message ?? errorObject?.message ?? String(error); + 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}`; - if (error instanceof Error) { - if (setErrorMessage(error, prefixedMessage)) { - throw error; - } - - const wrappedError = Object.assign(new Error(prefixedMessage), error); - - wrappedError.message = prefixedMessage; - throw wrappedError; - } - - throw new Error(prefixedMessage); + errorObject.message = prefixedMessage; + throw error; } function getEndpointLabel(networkClient: NetworkClient): 'Infura' | 'Custom' { @@ -184,12 +171,3 @@ function getEndpointLabel(networkClient: NetworkClient): 'Infura' | 'Custom' { ? 'Infura' : 'Custom'; } - -function setErrorMessage(error: Error, message: string): boolean { - try { - error.message = message; - return error.message === message; - } catch { - return false; - } -} From ce2b94d584f6c237413874b021d7712c9287c220 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 16 Jun 2026 22:43:22 +0100 Subject: [PATCH 9/9] fix: keep pay RPC changelog entry unreleased --- packages/transaction-pay-controller/CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 5cbb1e89ba..eb61b7f368 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,11 +7,14 @@ 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 -- Add RPC method, chain ID, and endpoint type context to transaction pay provider errors ([#9144](https://github.com/MetaMask/core/pull/9144)) - Bump `@metamask/keyring-controller` from `^27.0.0` to `^27.1.0` ([#9129](https://github.com/MetaMask/core/pull/9129)) ### Fixed