diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 5936d76f04..d3b96ca2ea 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Export `decodeAuthorizationSignature` utility that decodes a 65-byte EIP-7702 authorization signature into RLP-canonical `r`, `s`, and `yParity` ([#8656](https://github.com/MetaMask/core/pull/8656)) + - All `eth_sendRawTransaction` failures are prefixed `RPC submit:` for failure-surface attribution in error metrics + ### Changed - Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632)) diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index 5d8f630935..ea2b6da60d 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -3924,7 +3924,9 @@ describe('TransactionController', () => { rpcRequestMock.mockRejectedValueOnce(error); - await expect(controller.stopTransaction('2')).rejects.toThrow(error); + await expect(controller.stopTransaction('2')).rejects.toThrow( + 'RPC submit: Another reason', + ); const sendRawTransactionCalls = rpcRequestMock.mock.calls.filter( ([request]) => request.method === 'eth_sendRawTransaction', @@ -4276,7 +4278,9 @@ describe('TransactionController', () => { rpcRequestMock.mockRejectedValueOnce(error); - await expect(controller.speedUpTransaction('2')).rejects.toThrow(error); + await expect(controller.speedUpTransaction('2')).rejects.toThrow( + 'RPC submit: Another reason', + ); const sendRawTransactionCalls = rpcRequestMock.mock.calls.filter( ([request]) => request.method === 'eth_sendRawTransaction', @@ -4285,6 +4289,39 @@ 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' }, + }; + const { controller } = setupController({ + options: { + state: { + transactions: [ + { + id: '2', + chainId: toHex(5), + networkClientId: NETWORK_CLIENT_ID_MOCK, + status: TransactionStatus.submitted, + type: TransactionType.retry, + time: 123456789, + txParams: { + from: ACCOUNT_MOCK, + gasPrice: '0x1', + }, + }, + ], + }, + }, + }); + + rpcRequestMock.mockRejectedValueOnce(error); + + await expect(controller.speedUpTransaction('2')).rejects.toThrow( + 'RPC submit: Nested rpc error message', + ); + }); + it('creates additional transaction with increased gas', async () => { const { controller } = setupController({ network: MOCK_LINEA_MAINNET_NETWORK, diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 646842e68f..83dbe27899 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -3372,7 +3372,7 @@ export class TransactionController extends BaseController< const errorMessage = errorObject?.data?.message ?? errorObject?.message ?? String(error); - throw new Error(errorMessage); + throw new Error(`RPC submit: ${errorMessage}`); } } diff --git a/packages/transaction-controller/src/index.ts b/packages/transaction-controller/src/index.ts index b699986dc9..a005fbabea 100644 --- a/packages/transaction-controller/src/index.ts +++ b/packages/transaction-controller/src/index.ts @@ -129,6 +129,7 @@ export { WalletDevice, } from './types'; export { mergeGasFeeEstimates } from './utils/gas-flow'; +export { decodeAuthorizationSignature } from './utils/eip7702'; export { isEIP1559Transaction, normalizeTransactionParams, diff --git a/packages/transaction-controller/src/utils/eip7702.test.ts b/packages/transaction-controller/src/utils/eip7702.test.ts index b23f9ac217..e6b0a18986 100644 --- a/packages/transaction-controller/src/utils/eip7702.test.ts +++ b/packages/transaction-controller/src/utils/eip7702.test.ts @@ -18,6 +18,7 @@ import type { AuthorizationList } from '../types'; import type { TransactionMeta } from '../types'; import { DELEGATION_PREFIX, + decodeAuthorizationSignature, doesAccountSupportEIP7702, doesChainSupportEIP7702, generateEIP7702BatchTransaction, @@ -257,6 +258,93 @@ describe('EIP-7702 Utils', () => { expect(result?.[1]?.nonce).toBe('0x125'); expect(result?.[2]?.nonce).toBe('0x126'); }); + + it('strips leading zeroes from signature r and s to produce RLP-canonical hex', async () => { + const signatureWithLeadingZeros = + `0x0abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456781122334455667788990011223344556677889900112233445566778899001122${'1c'}` as Hex; + + signAuthorizationMock + .mockReset() + .mockResolvedValueOnce(signatureWithLeadingZeros); + + const result = await signAuthorizationList({ + authorizationList: AUTHORIZATION_LIST_MOCK, + messenger: controllerMessenger, + transactionMeta: TRANSACTION_META_MOCK, + }); + + expect(result?.[0]?.r).toBe( + '0xabcdef0123456789abcdef0123456789abcdef0123456789abcdef012345678', + ); + expect(result?.[0]?.s).toBe( + '0x1122334455667788990011223344556677889900112233445566778899001122', + ); + expect(result?.[0]?.yParity).toBe('0x1'); + }); + }); + + describe('decodeAuthorizationSignature', () => { + it('decodes a signature with no leading zeros into r, s, and yParity', () => { + const result = decodeAuthorizationSignature(AUTHORIZATION_SIGNATURE_MOCK); + + expect(result).toStrictEqual({ + r: '0xf85c827a6994663f3ad617193148711d28f5334ee4ed070166028080a040e292', + s: '0xda533253143f134643a03405f1af1de1d305526f44ed27e62061368d4ea051cf', + yParity: '0x1', + }); + }); + + it('strips a single leading zero nibble from r', () => { + const signature = + `0x0abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345678${'1122334455667788990011223344556677889900112233445566778899001122'}1b` as Hex; + + const result = decodeAuthorizationSignature(signature); + + expect(result.r).toBe( + '0xabcdef0123456789abcdef0123456789abcdef0123456789abcdef012345678', + ); + expect(result.s).toBe( + '0x1122334455667788990011223344556677889900112233445566778899001122', + ); + }); + + it('strips multiple leading zero bytes from r', () => { + const signature = + `0x000000abcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd${'1122334455667788990011223344556677889900112233445566778899001122'}1b` as Hex; + + const result = decodeAuthorizationSignature(signature); + + expect(result.r).toBe( + '0xabcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd', + ); + }); + + it('returns 0x0 when r is all zeroes (canonical zero)', () => { + const signature = + `0x0000000000000000000000000000000000000000000000000000000000000000${'1122334455667788990011223344556677889900112233445566778899001122'}1b` as Hex; + + const result = decodeAuthorizationSignature(signature); + + expect(result.r).toBe('0x0'); + }); + + it('returns yParity 0x0 when v is 27', () => { + const signature = + `0xf85c827a6994663f3ad617193148711d28f5334ee4ed070166028080a040e292da533253143f134643a03405f1af1de1d305526f44ed27e62061368d4ea051cf1b` as Hex; + + const result = decodeAuthorizationSignature(signature); + + expect(result.yParity).toBe('0x0'); + }); + + it('returns yParity 0x1 when v is 28', () => { + const signature = + `0xf85c827a6994663f3ad617193148711d28f5334ee4ed070166028080a040e292da533253143f134643a03405f1af1de1d305526f44ed27e62061368d4ea051cf1c` as Hex; + + const result = decodeAuthorizationSignature(signature); + + expect(result.yParity).toBe('0x1'); + }); }); describe('doesChainSupportEIP7702', () => { diff --git a/packages/transaction-controller/src/utils/eip7702.ts b/packages/transaction-controller/src/utils/eip7702.ts index 36e5eb1ce1..889b173d25 100644 --- a/packages/transaction-controller/src/utils/eip7702.ts +++ b/packages/transaction-controller/src/utils/eip7702.ts @@ -247,6 +247,43 @@ export async function signAuthorizationList({ return signedAuthorizationList; } +/** + * Decode a 65-byte EIP-7702 authorization signature into RLP-canonical + * `r`, `s`, and `yParity` (no leading zero nibbles, `0x0` for zero). + * + * @param signature - The 65-byte signature. + * @returns The decoded authorization fields. + */ +export function decodeAuthorizationSignature(signature: Hex): { + r: Hex; + s: Hex; + yParity: Hex; +} { + // eslint-disable-next-line id-length + const r = toCanonicalHex(signature.slice(0, 66)); + // eslint-disable-next-line id-length + const s = toCanonicalHex(signature.slice(66, 130)); + // eslint-disable-next-line id-length + const v = parseInt(signature.slice(130, 132), 16); + const yParity = toCanonicalHex(toHex(v - 27 === 0 ? 0 : 1)); + + return { r, s, yParity }; +} + +/** + * Strip leading zero nibbles from a hex string to produce its RLP-canonical + * form. Accepts input with or without a `0x` prefix; always returns + * `0x`-prefixed. An all-zero input is preserved as `0x0`. + * + * @param value - Hex string with or without a `0x` prefix. + * @returns The canonical `0x`-prefixed hex string. + */ +function toCanonicalHex(value: string): Hex { + const raw = value.startsWith('0x') ? value.slice(2) : value; + const stripped = raw.replace(/^0+/u, ''); + return stripped.length === 0 ? '0x0' : `0x${stripped}`; +} + /** * Signs an authorization. * @@ -284,13 +321,7 @@ async function signAuthorization( }, ); - // eslint-disable-next-line id-length - const r = signature.slice(0, 66) as Hex; - // eslint-disable-next-line id-length - const s = add0x(signature.slice(66, 130)); - // eslint-disable-next-line id-length - const v = parseInt(signature.slice(130, 132), 16); - const yParity = toHex(v - 27 === 0 ? 0 : 1); + const { r, s, yParity } = decodeAuthorizationSignature(signature as Hex); const result: Required = { address, diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 52aab1aac8..c627bfce43 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -10,6 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Stop synthesising a native gas-fee required token in `parseRequiredTokens`, only token-transfer assets are returned now ([#8554](https://github.com/MetaMask/core/pull/8554)) +- Add layered submission error prefixes for failure-surface attribution in error metrics ([#8656](https://github.com/MetaMask/core/pull/8656)) + - `MetaMask Pay:` wraps all errors from the Pay publish hook + - `Relay submit:` wraps all errors from the relay strategy + - `Relay execute:` cascades inside `Relay submit:` for `/execute` POST failures + - Relay non-OK responses now surface as ` - ` (or just ``), replacing the previous URL-leaking generic fetch failure message ## [20.1.0] diff --git a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts index cc11468619..ec8fa0d011 100644 --- a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts +++ b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts @@ -190,9 +190,25 @@ describe('TransactionPayPublishHook', () => { ); }); - it('throws errors from submit', async () => { + it('throws errors from submit prefixed with MetaMask Pay', async () => { executeMock.mockRejectedValue(new Error('Test error')); - await expect(runHook()).rejects.toThrow('Test error'); + await expect(runHook()).rejects.toThrow('MetaMask Pay: Test error'); + }); + + it('cascades MetaMask Pay prefix on top of strategy-level prefixes', async () => { + executeMock.mockRejectedValue( + new Error('Relay submit: Relay execute: backend boom'), + ); + + await expect(runHook()).rejects.toThrow( + 'MetaMask Pay: Relay submit: Relay execute: backend boom', + ); + }); + + it('wraps non-Error throws with the MetaMask Pay prefix', async () => { + executeMock.mockRejectedValue('boom'); + + await expect(runHook()).rejects.toThrow('MetaMask Pay: boom'); }); }); diff --git a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts index 4988a742df..48d616fa0c 100644 --- a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts +++ b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts @@ -47,7 +47,8 @@ export class TransactionPayPublishHook { return await this.#publishHook(transactionMeta, _signedTx); } catch (error) { log('Error', error); - throw error; + const message = error instanceof Error ? error.message : String(error); + throw new Error(`MetaMask Pay: ${message}`); } } diff --git a/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts index 01460383d0..0d44f891d8 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts @@ -108,4 +108,38 @@ describe('RelayStrategy', () => { }); expect(submitRelayQuotesMock).toHaveBeenCalledWith(executeRequest); }); + + it('wraps execute errors with the Relay submit prefix', async () => { + const executeRequest = { + messenger, + quotes: [], + transaction: request.transaction, + isSmartTransaction: jest.fn(), + } as PayStrategyExecuteRequest; + + submitRelayQuotesMock.mockRejectedValue( + new Error('Relay execute: 422 - Insufficient liquidity'), + ); + + const strategy = new RelayStrategy(); + await expect(strategy.execute(executeRequest)).rejects.toThrow( + 'Relay submit: Relay execute: 422 - Insufficient liquidity', + ); + }); + + it('wraps non-Error throws with the Relay submit prefix', async () => { + const executeRequest = { + messenger, + quotes: [], + transaction: request.transaction, + isSmartTransaction: jest.fn(), + } as PayStrategyExecuteRequest; + + submitRelayQuotesMock.mockRejectedValue('boom'); + + const strategy = new RelayStrategy(); + await expect(strategy.execute(executeRequest)).rejects.toThrow( + 'Relay submit: boom', + ); + }); }); diff --git a/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts index 22f6d9c31f..8ed8d23c54 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts @@ -24,6 +24,11 @@ export class RelayStrategy implements PayStrategy { async execute( request: PayStrategyExecuteRequest, ): ReturnType['execute']> { - return await submitRelayQuotes(request); + try { + return await submitRelayQuotes(request); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Relay submit: ${message}`); + } } } diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-api.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-api.test.ts index 69a3204d3e..40ded0e790 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-api.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-api.test.ts @@ -1,5 +1,3 @@ -import { successfulFetch } from '@metamask/controller-utils'; - import type { FeatureFlags } from '../../utils/feature-flags'; import { getFeatureFlags } from '../../utils/feature-flags'; import { RELAY_STATUS_URL } from './constants'; @@ -12,14 +10,23 @@ import type { RelayQuoteRequest } from './types'; jest.mock('../../utils/feature-flags'); -jest.mock('@metamask/controller-utils', () => ({ - ...jest.requireActual('@metamask/controller-utils'), - successfulFetch: jest.fn(), -})); - -const successfulFetchMock = jest.mocked(successfulFetch); const getFeatureFlagsMock = jest.mocked(getFeatureFlags); +let fetchMock: jest.SpyInstance; + +const mockOkResponse = (body: unknown): jest.SpyInstance => + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => body, + } as Response); + +const mockErrorResponse = (status: number, body: unknown): jest.SpyInstance => + fetchMock.mockResolvedValueOnce({ + ok: false, + status, + json: async () => body, + } as Response); + const QUOTE_URL_MOCK = 'https://proxy.test/relay/quote'; const EXECUTE_URL_MOCK = 'https://proxy.test/relay/execute'; @@ -27,7 +34,7 @@ const MESSENGER_MOCK = {} as Parameters[0]; describe('relay-api', () => { beforeEach(() => { - jest.resetAllMocks(); + fetchMock = jest.spyOn(global, 'fetch'); getFeatureFlagsMock.mockReturnValue({ relayQuoteUrl: QUOTE_URL_MOCK, @@ -35,6 +42,10 @@ describe('relay-api', () => { } as FeatureFlags); }); + afterEach(() => { + fetchMock.mockRestore(); + }); + describe('fetchRelayQuote', () => { const QUOTE_REQUEST_MOCK: RelayQuoteRequest = { amount: '1000000', @@ -53,13 +64,11 @@ describe('relay-api', () => { }; it('posts to the quote URL from feature flags', async () => { - successfulFetchMock.mockResolvedValue({ - json: async () => QUOTE_RESPONSE_MOCK, - } as Response); + mockOkResponse(QUOTE_RESPONSE_MOCK); await fetchRelayQuote(MESSENGER_MOCK, QUOTE_REQUEST_MOCK); - expect(successfulFetchMock).toHaveBeenCalledWith(QUOTE_URL_MOCK, { + expect(fetchMock).toHaveBeenCalledWith(QUOTE_URL_MOCK, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(QUOTE_REQUEST_MOCK), @@ -67,9 +76,7 @@ describe('relay-api', () => { }); it('attaches the request body to the returned quote', async () => { - successfulFetchMock.mockResolvedValue({ - json: async () => ({ ...QUOTE_RESPONSE_MOCK }), - } as Response); + mockOkResponse({ ...QUOTE_RESPONSE_MOCK }); const quote = await fetchRelayQuote(MESSENGER_MOCK, QUOTE_REQUEST_MOCK); @@ -77,9 +84,7 @@ describe('relay-api', () => { }); it('returns the parsed quote', async () => { - successfulFetchMock.mockResolvedValue({ - json: async () => QUOTE_RESPONSE_MOCK, - } as Response); + mockOkResponse(QUOTE_RESPONSE_MOCK); const quote = await fetchRelayQuote(MESSENGER_MOCK, QUOTE_REQUEST_MOCK); @@ -87,9 +92,7 @@ describe('relay-api', () => { }); it('forwards the abort signal to the underlying fetch', async () => { - successfulFetchMock.mockResolvedValue({ - json: async () => QUOTE_RESPONSE_MOCK, - } as Response); + mockOkResponse(QUOTE_RESPONSE_MOCK); const controller = new AbortController(); await fetchRelayQuote( @@ -98,11 +101,41 @@ describe('relay-api', () => { controller.signal, ); - expect(successfulFetchMock).toHaveBeenCalledWith( + expect(fetchMock).toHaveBeenCalledWith( QUOTE_URL_MOCK, expect.objectContaining({ signal: controller.signal }), ); }); + + it('throws an error containing status code and the response body message field on non-OK', async () => { + mockErrorResponse(422, { message: 'Insufficient liquidity' }); + + await expect( + fetchRelayQuote(MESSENGER_MOCK, QUOTE_REQUEST_MOCK), + ).rejects.toThrow('422 - Insufficient liquidity'); + }); + + it('falls back to the response body error field when message is absent', async () => { + mockErrorResponse(429, { error: 'rate limit exceeded' }); + + await expect( + fetchRelayQuote(MESSENGER_MOCK, QUOTE_REQUEST_MOCK), + ).rejects.toThrow('429 - rate limit exceeded'); + }); + + it('falls back to the status code only when the body has neither message nor error', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => { + throw new Error('not json'); + }, + } as Response); + + await expect( + fetchRelayQuote(MESSENGER_MOCK, QUOTE_REQUEST_MOCK), + ).rejects.toThrow('500'); + }); }); describe('submitRelayExecute', () => { @@ -124,13 +157,11 @@ describe('relay-api', () => { }; it('posts to the execute URL from feature flags', async () => { - successfulFetchMock.mockResolvedValue({ - json: async () => EXECUTE_RESPONSE_MOCK, - } as Response); + mockOkResponse(EXECUTE_RESPONSE_MOCK); await submitRelayExecute(MESSENGER_MOCK, EXECUTE_REQUEST_MOCK); - expect(successfulFetchMock).toHaveBeenCalledWith(EXECUTE_URL_MOCK, { + expect(fetchMock).toHaveBeenCalledWith(EXECUTE_URL_MOCK, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(EXECUTE_REQUEST_MOCK), @@ -138,9 +169,7 @@ describe('relay-api', () => { }); it('returns the parsed response', async () => { - successfulFetchMock.mockResolvedValue({ - json: async () => EXECUTE_RESPONSE_MOCK, - } as Response); + mockOkResponse(EXECUTE_RESPONSE_MOCK); const result = await submitRelayExecute( MESSENGER_MOCK, @@ -160,22 +189,18 @@ describe('relay-api', () => { }; it('fetches the status URL with the request ID', async () => { - successfulFetchMock.mockResolvedValue({ - json: async () => STATUS_RESPONSE_MOCK, - } as Response); + mockOkResponse(STATUS_RESPONSE_MOCK); await getRelayStatus(REQUEST_ID_MOCK); - expect(successfulFetchMock).toHaveBeenCalledWith( + expect(fetchMock).toHaveBeenCalledWith( `${RELAY_STATUS_URL}?requestId=${REQUEST_ID_MOCK}`, { method: 'GET' }, ); }); it('returns the parsed status', async () => { - successfulFetchMock.mockResolvedValue({ - json: async () => STATUS_RESPONSE_MOCK, - } as Response); + mockOkResponse(STATUS_RESPONSE_MOCK); const result = await getRelayStatus(REQUEST_ID_MOCK); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-api.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-api.ts index 627f1b952d..8f42d3715e 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-api.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-api.ts @@ -1,5 +1,3 @@ -import { successfulFetch } from '@metamask/controller-utils'; - import type { TransactionPayControllerMessenger } from '../../types'; import { getFeatureFlags } from '../../utils/feature-flags'; import { RELAY_STATUS_URL } from './constants'; @@ -26,7 +24,7 @@ export async function fetchRelayQuote( ): Promise { const { relayQuoteUrl } = getFeatureFlags(messenger); - const response = await successfulFetch(relayQuoteUrl, { + const response = await relayFetch(relayQuoteUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -52,7 +50,7 @@ export async function submitRelayExecute( ): Promise { const { relayExecuteUrl } = getFeatureFlags(messenger); - const response = await successfulFetch(relayExecuteUrl, { + const response = await relayFetch(relayExecuteUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -72,7 +70,39 @@ export async function getRelayStatus( ): Promise { const url = `${RELAY_STATUS_URL}?requestId=${requestId}`; - const response = await successfulFetch(url, { method: 'GET' }); + const response = await relayFetch(url, { method: 'GET' }); return (await response.json()) as RelayStatusResponse; } + +/** + * Fetch a Relay endpoint, throwing an error containing the response body's + * `message` or `error` field (or status code) on non-OK responses, so the + * Relay server's actual reason is preserved without leaking the request URL + * via the default `successfulFetch` message. + * + * @param url - The Relay endpoint to fetch. + * @param init - Fetch init options. + * @returns The successful response. + */ +async function relayFetch(url: string, init?: RequestInit): Promise { + const response = await fetch(url, init); + + if (!response.ok) { + let detail: string | undefined; + try { + const body = (await response.json()) as { + message?: string; + error?: string; + }; + detail = body.message ?? body.error; + } catch { + // Body wasn't JSON; fall through to status-only error. + } + throw new Error( + detail ? `${response.status} - ${detail}` : String(response.status), + ); + } + + return response; +} diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 3ea0a32fb2..54b46d0732 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -1,4 +1,4 @@ -import { successfulFetch, toHex } from '@metamask/controller-utils'; +import { toHex } from '@metamask/controller-utils'; import { TransactionType } from '@metamask/transaction-controller'; import type { GasFeeToken, @@ -59,11 +59,6 @@ jest.mock('../../utils/feature-flags', () => ({ getSlippage: jest.fn(), })); -jest.mock('@metamask/controller-utils', () => ({ - ...jest.requireActual('@metamask/controller-utils'), - successfulFetch: jest.fn(), -})); - const TRANSACTION_META_MOCK = { txParams: {} } as TransactionMeta; const PREDICT_WITHDRAW_TRANSACTION_MOCK = { txParams: {}, @@ -168,7 +163,7 @@ const TOKEN_TRANSFER_DATA_MOCK = '0xa9059cbb0000000000000000000000005678901234567890123456789012345678901234000000000000000000000000000000000000000000000000000000000000007b' as Hex; describe('Relay Quotes Utils', () => { - const successfulFetchMock = jest.mocked(successfulFetch); + let successfulFetchMock: jest.SpyInstance; const getTokenFiatRateMock = jest.mocked(getTokenFiatRate); const calculateGasCostMock = jest.mocked(calculateGasCost); const calculateGasFeeTokenCostMock = jest.mocked(calculateGasFeeTokenCost); @@ -193,6 +188,8 @@ describe('Relay Quotes Utils', () => { beforeEach(() => { jest.resetAllMocks(); + successfulFetchMock = jest.spyOn(global, 'fetch'); + getKeyringControllerStateMock.mockReturnValue({ isUnlocked: true, keyrings: [ @@ -236,9 +233,14 @@ describe('Relay Quotes Utils', () => { findNetworkClientIdByChainIdMock.mockReturnValue(NETWORK_CLIENT_ID_MOCK); }); + afterEach(() => { + successfulFetchMock.mockRestore(); + }); + describe('getRelayQuotes', () => { it('returns quotes from Relay', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -258,6 +260,7 @@ describe('Relay Quotes Utils', () => { it('sends request to Relay', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -297,6 +300,7 @@ describe('Relay Quotes Utils', () => { isRelayExecuteEnabledMock.mockReturnValue(true); successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -319,6 +323,7 @@ describe('Relay Quotes Utils', () => { isEIP7702ChainMock.mockReturnValue(false); successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -340,6 +345,7 @@ describe('Relay Quotes Utils', () => { isRelayExecuteEnabledMock.mockReturnValue(true); successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -359,6 +365,7 @@ describe('Relay Quotes Utils', () => { it('sends request with EXACT_INPUT trade type when isMaxAmount is true', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -401,6 +408,7 @@ describe('Relay Quotes Utils', () => { it('includes transactions in request', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -448,6 +456,7 @@ describe('Relay Quotes Utils', () => { it('includes request in quote', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -473,6 +482,7 @@ describe('Relay Quotes Utils', () => { it('skips delegation for token transfers', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -493,6 +503,7 @@ describe('Relay Quotes Utils', () => { it('extracts recipient from token transfer', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -517,6 +528,7 @@ describe('Relay Quotes Utils', () => { it('includes transactions from nested transactions', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -570,6 +582,7 @@ describe('Relay Quotes Utils', () => { it('skips delegation for token transfers in nested transactions', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -595,6 +608,7 @@ describe('Relay Quotes Utils', () => { it('extracts recipient from token transfer in nested transactions', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -624,6 +638,7 @@ describe('Relay Quotes Utils', () => { it('extracts recipient and sets refundTo when nested transactions include token transfer with delegation', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -657,6 +672,7 @@ describe('Relay Quotes Utils', () => { it('skips delegation for Hypercore deposits', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -682,6 +698,7 @@ describe('Relay Quotes Utils', () => { it('does not extract recipient for Hypercore deposits with token transfer signature', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -711,6 +728,7 @@ describe('Relay Quotes Utils', () => { it('sends request to url from feature flag', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -742,6 +760,7 @@ describe('Relay Quotes Utils', () => { it('ignores gas fee token requests (target=0 and source=0)', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -763,6 +782,7 @@ describe('Relay Quotes Utils', () => { it('processes post-quote requests', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -791,6 +811,7 @@ describe('Relay Quotes Utils', () => { it('sets refundTo in request body for post-quote when provided', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -819,6 +840,7 @@ describe('Relay Quotes Utils', () => { it('does not set refundTo in request body for post-quote when not provided', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -844,6 +866,7 @@ describe('Relay Quotes Utils', () => { it('estimates only relay transactions for post-quote', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -879,6 +902,7 @@ describe('Relay Quotes Utils', () => { it('adds original transaction gas to single relay gas limit for post-quote', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -915,6 +939,7 @@ describe('Relay Quotes Utils', () => { it('prefers nestedTransactions gas over txParams.gas for post-quote', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -971,6 +996,7 @@ describe('Relay Quotes Utils', () => { } as RelayQuote; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => multiStepQuote, } as never); @@ -1031,6 +1057,7 @@ describe('Relay Quotes Utils', () => { } as RelayQuote; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => multiStepQuote, } as never); @@ -1074,6 +1101,7 @@ describe('Relay Quotes Utils', () => { it('skips original transaction gas when txParams.gas is missing for post-quote', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -1112,6 +1140,7 @@ describe('Relay Quotes Utils', () => { delete noGasQuote.steps[0].items[0].data.gas; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => noGasQuote, } as never); @@ -1157,6 +1186,7 @@ describe('Relay Quotes Utils', () => { it('does not prepend original transaction for post-quote when txParams.to is missing', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -1183,6 +1213,7 @@ describe('Relay Quotes Utils', () => { delete quoteMock.steps[0].items[0].data.gas; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -1217,6 +1248,7 @@ describe('Relay Quotes Utils', () => { it('ignores relay params.gas and estimates when fromOverride is set for single path', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -1263,6 +1295,7 @@ describe('Relay Quotes Utils', () => { } as never); successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -1309,6 +1342,7 @@ describe('Relay Quotes Utils', () => { } as never); successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -1345,6 +1379,7 @@ describe('Relay Quotes Utils', () => { delete quoteMock.steps[0].items[0].data.gas; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -1382,6 +1417,7 @@ describe('Relay Quotes Utils', () => { delete quoteMock.steps[0].items[0].data.gas; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -1417,6 +1453,7 @@ describe('Relay Quotes Utils', () => { } as never); successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -1441,6 +1478,7 @@ describe('Relay Quotes Utils', () => { it('sets isSourceGasFeeToken for predictWithdraw post-quote when insufficient native balance', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -1466,6 +1504,7 @@ describe('Relay Quotes Utils', () => { it('simulates with proxy address and scales gas fee token for predictWithdraw post-quote', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -1500,6 +1539,7 @@ describe('Relay Quotes Utils', () => { it('falls back to native gas cost for predictWithdraw post-quote when simulation returns no matching token', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -1525,6 +1565,7 @@ describe('Relay Quotes Utils', () => { it('skips proxy simulation for non-predictWithdraw post-quote even with refundTo', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -1561,8 +1602,14 @@ describe('Relay Quotes Utils', () => { }; successfulFetchMock - .mockResolvedValueOnce({ json: async () => QUOTE_MOCK } as never) - .mockResolvedValueOnce({ json: async () => phase2Mock } as never); + .mockResolvedValueOnce({ + ok: true, + json: async () => QUOTE_MOCK, + } as never) + .mockResolvedValueOnce({ + ok: true, + json: async () => phase2Mock, + } as never); getTokenBalanceMock.mockReturnValue('0'); getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); @@ -1607,6 +1654,7 @@ describe('Relay Quotes Utils', () => { it('returns phase 1 quote when gas cost exceeds source amount for post-quote flows', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -1641,6 +1689,7 @@ describe('Relay Quotes Utils', () => { it('falls back to native cost when gas station simulation fails for post-quote', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -1666,7 +1715,10 @@ describe('Relay Quotes Utils', () => { it('falls back to phase 1 quote when phase 2 fetch fails for post-quote flows', async () => { successfulFetchMock - .mockResolvedValueOnce({ json: async () => QUOTE_MOCK } as never) + .mockResolvedValueOnce({ + ok: true, + json: async () => QUOTE_MOCK, + } as never) .mockRejectedValueOnce(new Error('Relay API error')); getTokenBalanceMock.mockReturnValue('0'); @@ -1700,8 +1752,14 @@ describe('Relay Quotes Utils', () => { it('falls back to phase 1 when phase 2 loses gas fee token for post-quote flows', async () => { successfulFetchMock - .mockResolvedValueOnce({ json: async () => QUOTE_MOCK } as never) - .mockResolvedValueOnce({ json: async () => QUOTE_MOCK } as never); + .mockResolvedValueOnce({ + ok: true, + json: async () => QUOTE_MOCK, + } as never) + .mockResolvedValueOnce({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); getTokenBalanceMock .mockReturnValueOnce('0') @@ -1739,6 +1797,7 @@ describe('Relay Quotes Utils', () => { it('includes duration in quote', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -1754,6 +1813,7 @@ describe('Relay Quotes Utils', () => { it('includes zero metaMask fee when app fee is absent', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -1776,6 +1836,7 @@ describe('Relay Quotes Utils', () => { quoteMock.fees.app = { amountUsd: '0.75' }; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -1798,6 +1859,7 @@ describe('Relay Quotes Utils', () => { quoteMock.fees.app = { amountUsd: '0.75' }; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -1816,6 +1878,7 @@ describe('Relay Quotes Utils', () => { it('includes provider fee', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -1847,6 +1910,7 @@ describe('Relay Quotes Utils', () => { }; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -1878,6 +1942,7 @@ describe('Relay Quotes Utils', () => { }; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -1896,6 +1961,7 @@ describe('Relay Quotes Utils', () => { it('includes dust in quote', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -1915,6 +1981,7 @@ describe('Relay Quotes Utils', () => { describe('includes source network fee', () => { it('in quote', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -1946,6 +2013,7 @@ describe('Relay Quotes Utils', () => { delete quoteMock.steps[0].items[0].data.gas; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -1999,6 +2067,7 @@ describe('Relay Quotes Utils', () => { } as never); successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); estimateGasBatchMock.mockResolvedValue({ @@ -2020,6 +2089,7 @@ describe('Relay Quotes Utils', () => { it('using gas fee token cost if insufficient native balance', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -2064,6 +2134,7 @@ describe('Relay Quotes Utils', () => { } as never); successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quote, } as never); estimateGasBatchMock.mockResolvedValue({ @@ -2093,6 +2164,7 @@ describe('Relay Quotes Utils', () => { it('uses proxy simulation and scales gas fee token amount for post-quote with a single relay param', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -2137,6 +2209,7 @@ describe('Relay Quotes Utils', () => { it('not using gas fee token if sufficient native balance', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -2169,6 +2242,7 @@ describe('Relay Quotes Utils', () => { it('not using gas fee token if source token not found', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -2203,6 +2277,7 @@ describe('Relay Quotes Utils', () => { it('not using gas fee token if calculation fails', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -2239,6 +2314,7 @@ describe('Relay Quotes Utils', () => { quote.steps[0].items[0].data.value = undefined as never; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quote, } as never); @@ -2261,6 +2337,7 @@ describe('Relay Quotes Utils', () => { it('not using gas fee token cost if chain disabled in feature flag', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -2307,6 +2384,7 @@ describe('Relay Quotes Utils', () => { }; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -2346,6 +2424,7 @@ describe('Relay Quotes Utils', () => { quoteMock.metamask.isExecute = true; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -2367,6 +2446,7 @@ describe('Relay Quotes Utils', () => { quoteMock.metamask.isExecute = true; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -2382,6 +2462,7 @@ describe('Relay Quotes Utils', () => { it('does not zero source network fees when quote does not have isExecute', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -2403,6 +2484,7 @@ describe('Relay Quotes Utils', () => { quoteMock.metamask.isExecute = true; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -2429,6 +2511,7 @@ describe('Relay Quotes Utils', () => { it('overrides source chain and token to HyperCore', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -2449,6 +2532,7 @@ describe('Relay Quotes Utils', () => { it('shifts source amount by 2 decimals (8→6)', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -2468,6 +2552,7 @@ describe('Relay Quotes Utils', () => { it('zeroes source network fees (gasless)', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -2486,6 +2571,7 @@ describe('Relay Quotes Utils', () => { it('uses Arbitrum USDC fiat rate for source', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -2506,6 +2592,7 @@ describe('Relay Quotes Utils', () => { it('includes target network fee in quote', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -2524,6 +2611,7 @@ describe('Relay Quotes Utils', () => { it('includes target amount in quote', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -2555,6 +2643,7 @@ describe('Relay Quotes Utils', () => { }; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -2573,6 +2662,7 @@ describe('Relay Quotes Utils', () => { it('uses amountFormatted as usd for target amount when target is a stablecoin', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -2597,6 +2687,7 @@ describe('Relay Quotes Utils', () => { it('uses amountUsd for target amount when target is not a stablecoin', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -2630,6 +2721,7 @@ describe('Relay Quotes Utils', () => { getTokenFiatRateMock.mockReturnValue(undefined); successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -2651,6 +2743,7 @@ describe('Relay Quotes Utils', () => { }; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -2684,6 +2777,7 @@ describe('Relay Quotes Utils', () => { }; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -2716,6 +2810,7 @@ describe('Relay Quotes Utils', () => { }; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -2745,6 +2840,7 @@ describe('Relay Quotes Utils', () => { }; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -2771,6 +2867,7 @@ describe('Relay Quotes Utils', () => { delete quoteMock.steps[0].items[0].data.gas; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -2806,6 +2903,7 @@ describe('Relay Quotes Utils', () => { delete quoteMock.steps[0].items[0].data.gas; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -2828,6 +2926,7 @@ describe('Relay Quotes Utils', () => { delete quoteMock.steps[0].items[0].data.gas; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -2862,6 +2961,7 @@ describe('Relay Quotes Utils', () => { } as never); successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -2910,6 +3010,7 @@ describe('Relay Quotes Utils', () => { } as never); successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -2945,6 +3046,7 @@ describe('Relay Quotes Utils', () => { } as never); successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -2966,6 +3068,7 @@ describe('Relay Quotes Utils', () => { it('includes gas limits in quote', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => QUOTE_MOCK, } as never); @@ -2988,6 +3091,7 @@ describe('Relay Quotes Utils', () => { delete quoteMock.steps[0].items[0].data.gas; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -3011,6 +3115,7 @@ describe('Relay Quotes Utils', () => { } as never); successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -3034,6 +3139,7 @@ describe('Relay Quotes Utils', () => { ]; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -3053,6 +3159,7 @@ describe('Relay Quotes Utils', () => { delete quoteMock.steps[0].items[0].data.gas; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -3088,6 +3195,7 @@ describe('Relay Quotes Utils', () => { } as never); successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -3124,6 +3232,7 @@ describe('Relay Quotes Utils', () => { } as never); successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -3160,6 +3269,7 @@ describe('Relay Quotes Utils', () => { } as never); successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); @@ -3195,6 +3305,7 @@ describe('Relay Quotes Utils', () => { } as never); successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => quoteMock, } as never); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index afc32821ff..f93a005c20 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts @@ -1,4 +1,4 @@ -import { ORIGIN_METAMASK, successfulFetch } from '@metamask/controller-utils'; +import { ORIGIN_METAMASK } from '@metamask/controller-utils'; import { TransactionType } from '@metamask/transaction-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; @@ -32,11 +32,6 @@ jest.mock('../../utils/transaction'); jest.mock('../../utils/feature-flags'); jest.mock('./hyperliquid-withdraw'); -jest.mock('@metamask/controller-utils', () => ({ - ...jest.requireActual('@metamask/controller-utils'), - successfulFetch: jest.fn(), -})); - const NETWORK_CLIENT_ID_MOCK = 'networkClientIdMock'; const TRANSACTION_HASH_MOCK = '0x1234'; const SOURCE_HASH_MOCK = '0xsourcehash'; @@ -132,7 +127,7 @@ const REQUEST_MOCK: PayStrategyExecuteRequest = { describe('Relay Submit Utils', () => { const updateTransactionMock = jest.mocked(updateTransaction); - const successfulFetchMock = jest.mocked(successfulFetch); + let successfulFetchMock: jest.SpyInstance; const getTransactionMock = jest.mocked(getTransaction); const collectTransactionIdsMock = jest.mocked(collectTransactionIds); const getFeatureFlagsMock = jest.mocked(getFeatureFlags); @@ -158,6 +153,8 @@ describe('Relay Submit Utils', () => { beforeEach(() => { jest.resetAllMocks(); + successfulFetchMock = jest.spyOn(global, 'fetch'); + getRelayPollingIntervalMock.mockReturnValue(1); getRelayPollingTimeoutMock.mockReturnValue(undefined); @@ -189,6 +186,7 @@ describe('Relay Submit Utils', () => { ); successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => STATUS_RESPONSE_MOCK, } as Response); @@ -196,6 +194,10 @@ describe('Relay Submit Utils', () => { request.messenger = messenger; }); + afterEach(() => { + successfulFetchMock.mockRestore(); + }); + describe('submitRelayQuotes', () => { it('adds transaction', async () => { await submitRelayQuotes(request); @@ -593,6 +595,7 @@ describe('Relay Submit Utils', () => { it('waits for relay status to be success', async () => { successfulFetchMock.mockResolvedValueOnce({ + ok: true, json: async () => ({ status: 'pending' }), } as Response); @@ -635,7 +638,7 @@ describe('Relay Submit Utils', () => { ); }); - it('throws if transaction fails to confirm', async () => { + it('rethrows confirmation failures bare (the Relay submit prefix is applied at RelayStrategy.execute)', async () => { waitForTransactionConfirmedMock.mockRejectedValue( new Error('Transaction failed'), ); @@ -645,10 +648,21 @@ describe('Relay Submit Utils', () => { ); }); + it('rethrows addTransaction failures bare (the Relay submit prefix is applied at RelayStrategy.execute)', async () => { + addTransactionMock.mockRejectedValueOnce( + new Error('addTransaction boom'), + ); + + await expect(submitRelayQuotes(request)).rejects.toThrow( + 'addTransaction boom', + ); + }); + it.each(['failure', 'refund', 'refunded'])( 'throws if relay status is %s', async (status) => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => ({ status }), } as Response); @@ -660,6 +674,7 @@ describe('Relay Submit Utils', () => { it('throws if relay returns unrecognized status', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => ({ status: 'unknown_status' }), } as Response); @@ -673,9 +688,11 @@ describe('Relay Submit Utils', () => { async (pendingStatus) => { successfulFetchMock .mockResolvedValueOnce({ + ok: true, json: async () => ({ status: pendingStatus }), } as Response) .mockResolvedValue({ + ok: true, json: async () => STATUS_RESPONSE_MOCK, } as Response); @@ -695,6 +712,7 @@ describe('Relay Submit Utils', () => { successfulFetchMock .mockRejectedValueOnce(new Error('Network error')) .mockResolvedValue({ + ok: true, json: async () => STATUS_RESPONSE_MOCK, } as Response); @@ -727,6 +745,7 @@ describe('Relay Submit Utils', () => { successfulFetchMock .mockResolvedValueOnce({ + ok: true, json: async () => ({ status: 'pending' }), } as Response) .mockImplementation(() => { @@ -746,9 +765,11 @@ describe('Relay Submit Utils', () => { successfulFetchMock .mockResolvedValueOnce({ + ok: true, json: async () => ({ status: 'pending' }), } as Response) .mockResolvedValue({ + ok: true, json: async () => STATUS_RESPONSE_MOCK, } as Response); @@ -762,9 +783,11 @@ describe('Relay Submit Utils', () => { successfulFetchMock .mockResolvedValueOnce({ + ok: true, json: async () => ({ status: 'pending' }), } as Response) .mockResolvedValue({ + ok: true, json: async () => STATUS_RESPONSE_MOCK, } as Response); @@ -806,6 +829,7 @@ describe('Relay Submit Utils', () => { it('returns fallback hash if none included', async () => { successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => ({ ...STATUS_RESPONSE_MOCK, txHashes: [], @@ -1214,6 +1238,7 @@ describe('Relay Submit Utils', () => { request.quotes[0].request.isHyperliquidSource = true; successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => STATUS_RESPONSE_MOCK, } as Response); @@ -1277,9 +1302,11 @@ describe('Relay Submit Utils', () => { successfulFetchMock .mockResolvedValueOnce({ + ok: true, json: async () => EXECUTE_RESPONSE_MOCK, } as Response) .mockResolvedValue({ + ok: true, json: async () => STATUS_RESPONSE_MOCK, } as Response); }); @@ -1374,6 +1401,30 @@ describe('Relay Submit Utils', () => { ); }); + it('wraps /execute submission failures with the Relay execute prefix (Relay submit prefix is applied at RelayStrategy.execute)', async () => { + successfulFetchMock.mockReset(); + successfulFetchMock.mockResolvedValueOnce({ + ok: false, + status: 422, + json: async () => ({ + message: 'failed to decode param in array[0] invalid JSON input', + }), + } as Response); + + await expect(submitRelayQuotes(request)).rejects.toThrow( + 'Relay execute: 422 - failed to decode param in array[0] invalid JSON input', + ); + }); + + it('wraps non-Error throws from /execute with the Relay execute prefix', async () => { + successfulFetchMock.mockReset(); + successfulFetchMock.mockRejectedValueOnce('network down'); + + await expect(submitRelayQuotes(request)).rejects.toThrow( + 'Relay execute: network down', + ); + }); + it('omits authorizationList when delegation has none', async () => { getDelegationTransactionMock.mockResolvedValue({ ...DELEGATION_RESULT_MOCK, @@ -1553,6 +1604,7 @@ describe('Relay Submit Utils', () => { successfulFetchMock.mockReset(); successfulFetchMock.mockResolvedValue({ + ok: true, json: async () => STATUS_RESPONSE_MOCK, } as Response); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index 9694ce83a9..0082b6af66 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -454,7 +454,13 @@ async function submitViaRelayExecute( log('Submitting via Relay execute', { executeBody, from }); - const result = await submitRelayExecute(messenger, executeBody); + let result; + try { + result = await submitRelayExecute(messenger, executeBody); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Relay execute: ${message}`); + } log('Relay execute response', result);