From ac3602e848edcffb1f6eda687258dea12f35c019 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Tue, 17 Mar 2026 15:23:19 +0100 Subject: [PATCH 1/8] Update `BridgeController` to expose all methods through messenger --- packages/bridge-controller/CHANGELOG.md | 13 + packages/bridge-controller/package.json | 2 + .../bridge-controller-method-action-types.ts | 53 +++ .../src/bridge-controller.sse.test.ts | 109 ++++-- .../src/bridge-controller.test.ts | 366 +++++++++++------- .../src/bridge-controller.ts | 37 +- packages/bridge-controller/src/index.ts | 10 + packages/bridge-controller/src/types.ts | 9 +- yarn.lock | 288 ++++++++++++++ 9 files changed, 693 insertions(+), 194 deletions(-) create mode 100644 packages/bridge-controller/src/bridge-controller-method-action-types.ts diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index d2742e02b34..c7908a2e985 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Expose all public `BridgeController` methods through its messenger + - The following actions are now available: + - `BridgeController:updateBridgeQuoteRequestParams` + - `BridgeController:fetchQuotes` + - `BridgeController:stopPollingForQuotes` + - `BridgeController:setLocation` + - `BridgeController:resetState` + - `BridgeController:setChainIntervalLength` + - `BridgeController:trackUnifiedSwapBridgeEvent` + - Corresponding action types are now exported (e.g. `BridgeControllerResetStateAction`) + ### Changed - Bump `@metamask/accounts-controller` from `^37.1.1` to `^37.2.0` ([#8363](https://github.com/MetaMask/core/pull/8363)) diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index 013bba26ced..227e7443147 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -36,6 +36,7 @@ ], "scripts": { "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/bridge-controller", @@ -85,6 +86,7 @@ "lodash": "^4.17.21", "nock": "^13.3.1", "ts-jest": "^29.2.5", + "tsx": "^4.21.0", "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" diff --git a/packages/bridge-controller/src/bridge-controller-method-action-types.ts b/packages/bridge-controller/src/bridge-controller-method-action-types.ts new file mode 100644 index 00000000000..e12b4350f09 --- /dev/null +++ b/packages/bridge-controller/src/bridge-controller-method-action-types.ts @@ -0,0 +1,53 @@ +/** + * This file is auto generated by `scripts/generate-method-action-types.ts`. + * Do not edit manually. + */ + +import type { BridgeController } from './bridge-controller'; + +export type BridgeControllerUpdateBridgeQuoteRequestParamsAction = { + type: `BridgeController:updateBridgeQuoteRequestParams`; + handler: BridgeController['updateBridgeQuoteRequestParams']; +}; + +export type BridgeControllerFetchQuotesAction = { + type: `BridgeController:fetchQuotes`; + handler: BridgeController['fetchQuotes']; +}; + +export type BridgeControllerStopPollingForQuotesAction = { + type: `BridgeController:stopPollingForQuotes`; + handler: BridgeController['stopPollingForQuotes']; +}; + +export type BridgeControllerSetLocationAction = { + type: `BridgeController:setLocation`; + handler: BridgeController['setLocation']; +}; + +export type BridgeControllerResetStateAction = { + type: `BridgeController:resetState`; + handler: BridgeController['resetState']; +}; + +export type BridgeControllerSetChainIntervalLengthAction = { + type: `BridgeController:setChainIntervalLength`; + handler: BridgeController['setChainIntervalLength']; +}; + +export type BridgeControllerTrackUnifiedSwapBridgeEventAction = { + type: `BridgeController:trackUnifiedSwapBridgeEvent`; + handler: BridgeController['trackUnifiedSwapBridgeEvent']; +}; + +/** + * Union of all BridgeController action types. + */ +export type BridgeControllerMethodActions = + | BridgeControllerUpdateBridgeQuoteRequestParamsAction + | BridgeControllerFetchQuotesAction + | BridgeControllerStopPollingForQuotesAction + | BridgeControllerSetLocationAction + | BridgeControllerResetStateAction + | BridgeControllerSetChainIntervalLengthAction + | BridgeControllerTrackUnifiedSwapBridgeEventAction; diff --git a/packages/bridge-controller/src/bridge-controller.sse.test.ts b/packages/bridge-controller/src/bridge-controller.sse.test.ts index 89ab63e6d54..e3bf9ffeea6 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.test.ts @@ -1,6 +1,12 @@ import { BigNumber } from '@ethersproject/bignumber'; import * as ethersContractUtils from '@ethersproject/contracts'; import { SolScope } from '@metamask/keyring-api'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; import { abiERC20 } from '@metamask/metamask-eth-abis'; import { BridgeController } from './bridge-controller'; @@ -34,6 +40,62 @@ import { mockSseServerError, } from '../tests/mock-sse'; +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +const BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS = [ + 'AccountsController:getAccountByAddress', + 'AuthenticationController:getBearerToken', + 'CurrencyRateController:getState', + 'TokenRatesController:getState', + 'MultichainAssetsRatesController:getState', + 'SnapController:handleRequest', + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getNetworkClientById', + 'RemoteFeatureFlagController:getState', + 'AssetsController:getExchangeRatesForBridge', +] as const; + +const messengerCallMock = jest.fn(); + +function buildController( + options: Partial[0]> = {}, +): { controller: BridgeController; rootMessenger: RootMessenger } { + const newRootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + const messenger: BridgeControllerMessenger = new Messenger({ + namespace: 'BridgeController', + parent: newRootMessenger, + }); + + newRootMessenger.delegate({ + messenger, + actions: [...BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS], + }); + + for (const action of BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS) { + newRootMessenger.registerActionHandler(action, (...args) => + messengerCallMock(action, ...args), + ); + } + + const controller = new BridgeController({ + messenger, + getLayer1GasFee: jest.fn().mockResolvedValue('0x1'), + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + trackMetaMetricsFn: jest.fn(), + clientVersion: '13.8.0', + ...options, + }); + + return { controller, rootMessenger: newRootMessenger }; +} + const FIRST_FETCH_DELAY = 4000; const SECOND_FETCH_DELAY = 9000; const THIRD_FETCH_DELAY = 2000; @@ -75,12 +137,7 @@ describe('BridgeController SSE', function () { fetchBridgeQuotesSpy: jest.SpyInstance, consoleLogSpy: jest.SpyInstance; - const messengerMock = { - call: jest.fn(), - registerActionHandler: jest.fn(), - registerInitialEventPayload: jest.fn(), - publish: jest.fn(), - } as unknown as jest.Mocked; + let rootMessenger: RootMessenger; const getLayer1GasFeeMock = jest.fn(); const mockFetchFn = jest.fn(); const trackMetaMetricsFn = jest.fn(); @@ -98,7 +155,7 @@ describe('BridgeController SSE', function () { }, }); getLayer1GasFeeMock.mockResolvedValue('0x1'); - messengerMock.call.mockImplementation( + messengerCallMock.mockImplementation( (...args: Parameters) => { switch (args[0]) { case 'AuthenticationController:getBearerToken': @@ -136,14 +193,12 @@ describe('BridgeController SSE', function () { chainRanking: [{ chainId: 'eip155:1' as const, name: 'Ethereum' }], }); - bridgeController = new BridgeController({ - messenger: messengerMock, + ({ controller: bridgeController, rootMessenger } = buildController({ getLayer1GasFee: getLayer1GasFeeMock, - clientId: BridgeClientId.EXTENSION, fetchFn: mockFetchFn, trackMetaMetricsFn, clientVersion: '13.8.0', - }); + })); jest.useFakeTimers(); stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); @@ -159,7 +214,8 @@ describe('BridgeController SSE', function () { mockFetchFn.mockImplementationOnce(async () => { return mockSseEventSource(mockBridgeQuotesNativeErc20 as QuoteResponse[]); }); - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', quoteRequest, metricsContext, ); @@ -302,7 +358,8 @@ describe('BridgeController SSE', function () { destChainId, }; - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', usdtQuoteRequest, metricsContext, ); @@ -390,7 +447,7 @@ describe('BridgeController SSE', function () { ); it('should use resetApproval and insufficientBal fallback values if provider is not found', async function () { - messengerMock.call.mockImplementation( + messengerCallMock.mockImplementation( (...args: Parameters) => { if (args[0] === 'AuthenticationController:getBearerToken') { return 'AUTH_TOKEN'; @@ -436,7 +493,8 @@ describe('BridgeController SSE', function () { srcChainId: '0x1', }; - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', usdtQuoteRequest, metricsContext, ); @@ -534,7 +592,8 @@ describe('BridgeController SSE', function () { SECOND_FETCH_DELAY, ); }); - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', quoteRequest, metricsContext, ); @@ -623,7 +682,8 @@ describe('BridgeController SSE', function () { ); }); mockFetchFn.mockRejectedValueOnce('Network error'); - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', quoteRequest, metricsContext, ); @@ -716,7 +776,8 @@ describe('BridgeController SSE', function () { ); }); - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', quoteRequest, metricsContext, ); @@ -762,7 +823,8 @@ describe('BridgeController SSE', function () { assetExchangeRates: {}, }; // Start new quote request - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', { ...quoteRequest, srcTokenAmount: '10' }, { stx_enabled: true, @@ -889,7 +951,8 @@ describe('BridgeController SSE', function () { FOURTH_FETCH_DELAY, ); }); - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', quoteRequest, metricsContext, ); @@ -927,7 +990,8 @@ describe('BridgeController SSE', function () { expect(consoleLogSpy).toHaveBeenCalledTimes(1); // Start new quote request - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', { ...quoteRequest, srcTokenAmount: '10' }, { stx_enabled: true, @@ -1069,7 +1133,8 @@ describe('BridgeController SSE', function () { mockFetchFn.mockImplementationOnce(async () => { return mockSseServerError('timeout from server'); }); - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', quoteRequest, metricsContext, ); diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index fdf271ecbfa..35a18eafae9 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -7,6 +7,12 @@ import { SolAccountType, SolScope, } from '@metamask/keyring-api'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; import nock from 'nock'; import { BridgeController } from './bridge-controller'; @@ -50,13 +56,6 @@ jest.mock('uuid', () => ({ v4: (): string => 'test-uuid-1234', })); -const messengerMock = { - call: jest.fn(), - registerActionHandler: jest.fn(), - registerInitialEventPayload: jest.fn(), - publish: jest.fn(), -} as unknown as jest.Mocked; - jest.mock('@ethersproject/contracts', () => { return { ...jest.requireActual('@ethersproject/contracts'), @@ -100,20 +99,66 @@ const metricsContext = { warnings: [], }; +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +const BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS = [ + 'AccountsController:getAccountByAddress', + 'AuthenticationController:getBearerToken', + 'CurrencyRateController:getState', + 'TokenRatesController:getState', + 'MultichainAssetsRatesController:getState', + 'SnapController:handleRequest', + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getNetworkClientById', + 'RemoteFeatureFlagController:getState', + 'AssetsController:getExchangeRatesForBridge', +] as const; + +const messengerCallMock = jest.fn(); + +function buildController( + options: Partial[0]> = {}, +): { controller: BridgeController; rootMessenger: RootMessenger } { + const newRootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + const messenger: BridgeControllerMessenger = new Messenger({ + namespace: 'BridgeController', + parent: newRootMessenger, + }); + newRootMessenger.delegate({ + messenger, + actions: [...BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS], + }); + for (const action of BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS) { + newRootMessenger.registerActionHandler(action, (...args) => + messengerCallMock(action, ...args), + ); + } + const controller = new BridgeController({ + messenger, + getLayer1GasFee: getLayer1GasFeeMock, + clientId: BridgeClientId.EXTENSION, + clientVersion: '13.7.0', + fetchFn: mockFetchFn, + trackMetaMetricsFn, + ...options, + }); + return { controller, rootMessenger: newRootMessenger }; +} + describe('BridgeController', function () { let bridgeController: BridgeController; + let rootMessenger: RootMessenger; beforeEach(function () { jest.clearAllMocks(); jest.clearAllTimers(); - bridgeController = new BridgeController({ - messenger: messengerMock, - getLayer1GasFee: getLayer1GasFeeMock, - clientId: BridgeClientId.EXTENSION, - clientVersion: '13.7.0', - fetchFn: mockFetchFn, - trackMetaMetricsFn, - }); + ({ controller: bridgeController, rootMessenger } = buildController()); nock(BRIDGE_PROD_API_BASE_URL) .get('/getTokens?chainId=10') @@ -146,7 +191,7 @@ describe('BridgeController', function () { usd: '100', }, }); - bridgeController.resetState(); + rootMessenger.call('BridgeController:resetState'); }); it('constructor should setup correctly', function () { @@ -166,7 +211,7 @@ describe('BridgeController', function () { marketData: {}, currentCurrency: 'USD', }; - messengerMock.call.mockImplementation( + messengerCallMock.mockImplementation( (actionType: string): ReturnType => { if (actionType === 'AuthenticationController:getBearerToken') { return 'AUTH_TOKEN'; @@ -191,7 +236,8 @@ describe('BridgeController', function () { 'selectIsAssetExchangeRateInState', ); - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', { srcChainId: '0x1', destChainId: '0xa', @@ -207,16 +253,16 @@ describe('BridgeController', function () { jest.advanceTimersToNextTimer(); await flushPromises(); - expect(messengerMock.call).toHaveBeenCalledWith( + expect(messengerCallMock).toHaveBeenCalledWith( 'MultichainAssetsRatesController:getState', ); - expect(messengerMock.call).toHaveBeenCalledWith( + expect(messengerCallMock).toHaveBeenCalledWith( 'CurrencyRateController:getState', ); - expect(messengerMock.call).toHaveBeenCalledWith( + expect(messengerCallMock).toHaveBeenCalledWith( 'TokenRatesController:getState', ); - expect(messengerMock.call).not.toHaveBeenCalledWith( + expect(messengerCallMock).not.toHaveBeenCalledWith( 'AssetsController:getExchangeRatesForBridge', ); @@ -234,16 +280,10 @@ describe('BridgeController', function () { it('calls AssetsController:getExchangeRatesForBridge when getUseAssetsControllerForRates returns true', async function () { jest.useFakeTimers(); - const controllerWithAssetsRates = new BridgeController({ - messenger: messengerMock, - getLayer1GasFee: getLayer1GasFeeMock, - clientId: BridgeClientId.EXTENSION, - clientVersion: '13.7.0', - fetchFn: mockFetchFn, - trackMetaMetricsFn, + const { rootMessenger: assetsRatesRootMessenger } = buildController({ getUseAssetsControllerForRates: (): boolean => true, }); - controllerWithAssetsRates.resetState(); + assetsRatesRootMessenger.call('BridgeController:resetState'); const hasSufficientBalanceSpy = jest .spyOn(balanceUtils, 'hasSufficientBalance') @@ -255,7 +295,7 @@ describe('BridgeController', function () { marketData: {}, currentCurrency: 'EUR', }; - messengerMock.call.mockImplementation( + messengerCallMock.mockImplementation( (actionType: string): ReturnType => { if (actionType === 'AuthenticationController:getBearerToken') { return 'AUTH_TOKEN'; @@ -276,7 +316,8 @@ describe('BridgeController', function () { 'selectIsAssetExchangeRateInState', ); - await controllerWithAssetsRates.updateBridgeQuoteRequestParams( + await assetsRatesRootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', { srcChainId: '0x1', destChainId: '0xa', @@ -292,10 +333,10 @@ describe('BridgeController', function () { jest.advanceTimersToNextTimer(); await flushPromises(); - expect(messengerMock.call).toHaveBeenCalledWith( + expect(messengerCallMock).toHaveBeenCalledWith( 'AssetsController:getExchangeRatesForBridge', ); - expect(messengerMock.call).not.toHaveBeenCalledWith( + expect(messengerCallMock).not.toHaveBeenCalledWith( 'MultichainAssetsRatesController:getState', ); @@ -315,7 +356,7 @@ describe('BridgeController', function () { .spyOn(balanceUtils, 'hasSufficientBalance') .mockResolvedValue(true); - messengerMock.call.mockImplementation( + messengerCallMock.mockImplementation( (actionType: string): ReturnType => { if (actionType === 'AuthenticationController:getBearerToken') { return 'AUTH_TOKEN'; @@ -355,7 +396,8 @@ describe('BridgeController', function () { slippage: 0.5, }; - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', quoteParams, metricsContext, ); @@ -435,22 +477,23 @@ describe('BridgeController', function () { bridgeController, 'setIntervalLength', ); - (messengerMock.call as jest.Mock).mockImplementation(() => { + messengerCallMock.mockImplementation(() => { return remoteFeatureFlagControllerState; }); - bridgeController.setChainIntervalLength(); + rootMessenger.call('BridgeController:setChainIntervalLength'); expect(setIntervalLengthSpy).toHaveBeenCalledTimes(1); expect(setIntervalLengthSpy).toHaveBeenCalledWith(3); }); it('updateBridgeQuoteRequestParams should update the quoteRequest state', async function () { - messengerMock.call.mockReturnValue({ + messengerCallMock.mockReturnValue({ currentCurrency: 'usd', } as never); - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', { srcChainId: 1, walletAddress: '0x123' }, metricsContext, ); @@ -461,7 +504,8 @@ describe('BridgeController', function () { }); expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', { destChainId: 10, walletAddress: '0x123' }, metricsContext, ); @@ -471,7 +515,8 @@ describe('BridgeController', function () { srcTokenAddress: '0x0000000000000000000000000000000000000000', }); - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', { destChainId: undefined, walletAddress: '0x123abc', @@ -484,7 +529,8 @@ describe('BridgeController', function () { srcTokenAddress: '0x0000000000000000000000000000000000000000', }); - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', { srcTokenAddress: undefined, walletAddress: '0x123', @@ -496,7 +542,8 @@ describe('BridgeController', function () { srcTokenAddress: undefined, }); - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', { srcTokenAmount: '100000', destTokenAddress: '0x123', @@ -514,7 +561,8 @@ describe('BridgeController', function () { srcTokenAddress: '0x0000000000000000000000000000000000000000', }); - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', { srcTokenAddress: '0x2ABC', walletAddress: '0x123', @@ -526,7 +574,7 @@ describe('BridgeController', function () { srcTokenAddress: '0x2ABC', }); - bridgeController.resetState(); + rootMessenger.call('BridgeController:resetState'); expect(bridgeController.state.quoteRequest).toStrictEqual({ srcTokenAddress: '0x0000000000000000000000000000000000000000', }); @@ -544,7 +592,7 @@ describe('BridgeController', function () { .spyOn(balanceUtils, 'hasSufficientBalance') .mockResolvedValue(true); - messengerMock.call.mockReturnValue({ + messengerCallMock.mockReturnValue({ address: '0x123', provider: jest.fn(), currencyRates: {}, @@ -582,7 +630,8 @@ describe('BridgeController', function () { const quoteRequest = { ...quoteParams, }; - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', quoteParams, metricsContext, ); @@ -623,7 +672,7 @@ describe('BridgeController', function () { const hasSufficientBalanceSpy = jest .spyOn(balanceUtils, 'hasSufficientBalance') .mockResolvedValue(true); - messengerMock.call.mockImplementation( + messengerCallMock.mockImplementation( (...args: Parameters) => { switch (args[0]) { case 'AuthenticationController:getBearerToken': @@ -712,7 +761,8 @@ describe('BridgeController', function () { const quoteRequest = { ...quoteParams, }; - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', quoteParams, metricsContext, ); @@ -846,7 +896,8 @@ describe('BridgeController', function () { // Incoming request update aborts current polling jest.advanceTimersToNextTimer(); await flushPromises(); - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', { ...quoteRequest, srcTokenAmount: '10', insufficientBal: false }, { stx_enabled: true, @@ -905,7 +956,7 @@ describe('BridgeController', function () { .mockImplementation(jest.fn()); const setupMessengerMock = (shouldMinBalanceFail = false): void => { - messengerMock.call.mockImplementation( + messengerCallMock.mockImplementation( ( ...args: Parameters ): ReturnType => { @@ -1016,7 +1067,8 @@ describe('BridgeController', function () { /* Set quote request with Solana srcChainId */ - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', quoteParams, metricsContext, ); @@ -1072,7 +1124,7 @@ describe('BridgeController', function () { ); expect(consoleErrorSpy).not.toHaveBeenCalled(); expect( - messengerMock.call.mock.calls.filter(([action]) => + messengerCallMock.mock.calls.filter(([action]) => action.includes('SnapController'), ), ).toHaveLength(3); @@ -1080,7 +1132,8 @@ describe('BridgeController', function () { /* Update quote request params to EVM and back to Solana */ - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', { ...quoteParams, srcChainId: '0x1' }, metricsContext, ); @@ -1096,7 +1149,8 @@ describe('BridgeController', function () { /* Add destWalletAddress */ - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', { ...quoteParams, destWalletAddress: 'SolanaWalletAddres1234' }, metricsContext, ); @@ -1109,7 +1163,8 @@ describe('BridgeController', function () { }), ); - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', quoteParams, metricsContext, ); @@ -1144,7 +1199,7 @@ describe('BridgeController', function () { ); expect(consoleErrorSpy).not.toHaveBeenCalled(); expect( - messengerMock.call.mock.calls.filter(([action]) => + messengerCallMock.mock.calls.filter(([action]) => action.includes('SnapController'), ), ).toHaveLength(9); @@ -1153,7 +1208,8 @@ describe('BridgeController', function () { Test min balance fetch failure */ setupMessengerMock(true); - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', { ...quoteParams, srcTokenAmount: '11111' }, metricsContext, ); @@ -1193,12 +1249,12 @@ describe('BridgeController', function () { // Verify error handling expect(consoleErrorSpy.mock.calls).toMatchSnapshot(); expect( - messengerMock.call.mock.calls.filter(([action]) => + messengerCallMock.mock.calls.filter(([action]) => action.includes('SnapController'), ), ).toHaveLength(12); expect( - messengerMock.call.mock.calls.filter(([action]) => + messengerCallMock.mock.calls.filter(([action]) => action.includes('SnapController'), ), ).toMatchSnapshot(); @@ -1216,7 +1272,7 @@ describe('BridgeController', function () { const hasSufficientBalanceSpy = jest .spyOn(balanceUtils, 'hasSufficientBalance') .mockResolvedValue(false); - messengerMock.call.mockImplementation( + messengerCallMock.mockImplementation( (...args: Parameters) => { switch (args[0]) { case 'AuthenticationController:getBearerToken': @@ -1276,7 +1332,8 @@ describe('BridgeController', function () { const quoteRequest = { ...quoteParams, }; - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', quoteParams, metricsContext, ); @@ -1358,7 +1415,8 @@ describe('BridgeController', function () { ); const firstFetchTime = bridgeController.state.quotesLastFetched; expect(firstFetchTime).toBeGreaterThan(0); - bridgeController.trackUnifiedSwapBridgeEvent( + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', UnifiedSwapBridgeEventName.QuotesReceived, { warnings: ['low_return'], @@ -1408,7 +1466,7 @@ describe('BridgeController', function () { .spyOn(balanceUtils, 'hasSufficientBalance') .mockResolvedValue(false); - messengerMock.call.mockImplementation( + messengerCallMock.mockImplementation( ( ...args: Parameters ): ReturnType => { @@ -1489,7 +1547,8 @@ describe('BridgeController', function () { const quoteRequest = { ...quoteParams, }; - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', quoteParams, metricsContext, ); @@ -1533,12 +1592,13 @@ describe('BridgeController', function () { it('updateBridgeQuoteRequestParams should not trigger quote polling if request is invalid', async function () { const stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); - messengerMock.call.mockReturnValue({ + messengerCallMock.mockReturnValue({ address: '0x123WalletAddress', provider: jest.fn(), } as never); - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', { walletAddress: '0x123WalletAddress', srcChainId: 1, @@ -1574,12 +1634,13 @@ describe('BridgeController', function () { it('updateBridgeQuoteRequestParams should not trigger quote polling if bridging to or from solana and destWalletAddress is undefined', async function () { const stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); - messengerMock.call.mockReturnValue({ + messengerCallMock.mockReturnValue({ address: '0xabcWalletAddress', provider: jest.fn(), } as never); - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', { walletAddress: '0xabcWalletAddress', srcChainId: 1, @@ -1615,7 +1676,7 @@ describe('BridgeController', function () { it('updateBridgeQuoteRequestParams should include undefined Authentication header if getBearerToken throws an error', async function () { jest.useFakeTimers(); const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); - messengerMock.call.mockImplementation( + messengerCallMock.mockImplementation( (...args: Parameters) => { switch (args[0]) { case 'AuthenticationController:getBearerToken': @@ -1661,7 +1722,8 @@ describe('BridgeController', function () { slippage: 0.5, }; - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', quoteParams, metricsContext, ); @@ -1675,7 +1737,7 @@ describe('BridgeController', function () { it('updateBridgeQuoteRequestParams should include auth token as Authentication header', async function () { jest.useFakeTimers(); const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); - messengerMock.call.mockImplementation( + messengerCallMock.mockImplementation( (...args: Parameters) => { switch (args[0]) { case 'AuthenticationController:getBearerToken': @@ -1719,7 +1781,8 @@ describe('BridgeController', function () { slippage: 0.5, }; - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', quoteParams, metricsContext, ); @@ -1781,7 +1844,7 @@ describe('BridgeController', function () { const hasSufficientBalanceSpy = jest .spyOn(balanceUtils, 'hasSufficientBalance') .mockResolvedValue(false); - messengerMock.call.mockImplementation( + messengerCallMock.mockImplementation( (...args: Parameters) => { switch (args[0]) { case 'AuthenticationController:getBearerToken': @@ -1841,7 +1904,8 @@ describe('BridgeController', function () { const quoteRequest = { ...quoteParams, }; - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', quoteParams, metricsContext, ); @@ -1940,7 +2004,7 @@ describe('BridgeController', function () { it('should handle errors from fetchBridgeQuotes', async () => { jest.useFakeTimers(); const fetchBridgeQuotesSpy = jest.spyOn(fetchUtils, 'fetchBridgeQuotes'); - messengerMock.call.mockReturnValue({ + messengerCallMock.mockReturnValue({ address: '0x123', provider: jest.fn(), } as never); @@ -1991,7 +2055,8 @@ describe('BridgeController', function () { walletAddress: 'eip:id/id:id/0x123', }; - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', quoteParams, metricsContext, ); @@ -2009,13 +2074,14 @@ describe('BridgeController', function () { expect(bridgeController.state.quotes).toStrictEqual([]); // Verify state is reset - bridgeController.resetState(); + rootMessenger.call('BridgeController:resetState'); expect(bridgeController.state.quoteFetchError).toBeNull(); expect(bridgeController.state.quotesLoadingStatus).toBeNull(); expect(bridgeController.state.quotes).toStrictEqual([]); // Verify quotes are fetched - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', quoteParams, metricsContext, ); @@ -2069,7 +2135,7 @@ describe('BridgeController', function () { ); // Minimal messenger/env setup to allow polling to start - messengerMock.call.mockReturnValue({ + messengerCallMock.mockReturnValue({ address: '0x123', provider: jest.fn(), currencyRates: {}, @@ -2089,7 +2155,8 @@ describe('BridgeController', function () { slippage: 0.5, }; - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', quoteParams, metricsContext, ); @@ -2173,7 +2240,7 @@ describe('BridgeController', function () { .spyOn(balanceUtils, 'hasSufficientBalance') .mockResolvedValue(false); - messengerMock.call.mockImplementation( + messengerCallMock.mockImplementation( ( ...args: Parameters ): ReturnType => { @@ -2291,7 +2358,8 @@ describe('BridgeController', function () { slippage: 0.5, }; - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', quoteParams, metricsContext, ); @@ -2343,7 +2411,7 @@ describe('BridgeController', function () { }); // Verify snap interaction - const snapCalls = messengerMock.call.mock.calls.filter( + const snapCalls = messengerCallMock.mock.calls.filter( ([methodName]) => methodName === 'SnapController:handleRequest', ); @@ -2375,7 +2443,7 @@ describe('BridgeController', function () { }, })) as unknown as QuoteResponse[]; - messengerMock.call.mockImplementation( + messengerCallMock.mockImplementation( ( ...args: Parameters ): ReturnType => { @@ -2451,7 +2519,8 @@ describe('BridgeController', function () { slippage: 0.5, }; - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', quoteParams, metricsContext, ); @@ -2493,7 +2562,7 @@ describe('BridgeController', function () { .spyOn(console, 'error') .mockImplementation(jest.fn()); - messengerMock.call.mockImplementation( + messengerCallMock.mockImplementation( ( ...args: Parameters ): ReturnType => { @@ -2548,7 +2617,8 @@ describe('BridgeController', function () { slippage: 0.5, }; - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', quoteParams, metricsContext, ); @@ -2590,7 +2660,8 @@ describe('BridgeController', function () { // Ignore console.warn for this test bc there will be expected asset rate fetching warnings jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); // Add walletAddress to the quoteRequest because it's required for some events - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', { walletAddress: '0x123', }, @@ -2603,7 +2674,7 @@ describe('BridgeController', function () { }, ); jest.clearAllMocks(); - messengerMock.call.mockImplementationOnce( + messengerCallMock.mockImplementationOnce( (): ReturnType => { return { provider: jest.fn() as never, @@ -2614,7 +2685,7 @@ describe('BridgeController', function () { } as never; }, ); - messengerMock.call.mockImplementationOnce( + messengerCallMock.mockImplementationOnce( (): ReturnType => { return { provider: jest.fn() as never, @@ -2625,7 +2696,7 @@ describe('BridgeController', function () { } as never; }, ); - messengerMock.call.mockImplementationOnce( + messengerCallMock.mockImplementationOnce( (): ReturnType => { return { type: EthAccountType.Eoa, @@ -2649,7 +2720,8 @@ describe('BridgeController', function () { }); it('should track the ButtonClicked event', () => { - bridgeController.trackUnifiedSwapBridgeEvent( + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', UnifiedSwapBridgeEventName.ButtonClicked, { location: MetaMetricsSwapsEventSource.MainView, @@ -2663,7 +2735,8 @@ describe('BridgeController', function () { }); it('should track the PageViewed event', () => { - bridgeController.trackUnifiedSwapBridgeEvent( + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', UnifiedSwapBridgeEventName.PageViewed, { abc: 1 }, ); @@ -2673,7 +2746,8 @@ describe('BridgeController', function () { }); it('should track InputChanged with an enum quick amount preset label', () => { - bridgeController.trackUnifiedSwapBridgeEvent( + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', UnifiedSwapBridgeEventName.InputChanged, { input: 'token_amount_source', @@ -2695,7 +2769,8 @@ describe('BridgeController', function () { }); it('should track InputChanged with arbitrary quick amount preset labels', () => { - bridgeController.trackUnifiedSwapBridgeEvent( + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', UnifiedSwapBridgeEventName.InputChanged, { input: 'token_amount_source', @@ -2703,7 +2778,8 @@ describe('BridgeController', function () { input_amount_preset: '85%', }, ); - bridgeController.trackUnifiedSwapBridgeEvent( + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', UnifiedSwapBridgeEventName.InputChanged, { input: 'token_amount_source', @@ -2730,7 +2806,8 @@ describe('BridgeController', function () { }); it('should track the InputSourceDestinationFlipped event', () => { - bridgeController.trackUnifiedSwapBridgeEvent( + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', UnifiedSwapBridgeEventName.InputSourceDestinationSwitched, { token_symbol_destination: 'USDC', @@ -2748,7 +2825,8 @@ describe('BridgeController', function () { }); it('should track the AllQuotesOpened event', () => { - bridgeController.trackUnifiedSwapBridgeEvent( + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', UnifiedSwapBridgeEventName.AllQuotesOpened, { price_impact: 6, @@ -2765,7 +2843,8 @@ describe('BridgeController', function () { }); it('should track the AllQuotesSorted event', () => { - bridgeController.trackUnifiedSwapBridgeEvent( + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', UnifiedSwapBridgeEventName.AllQuotesSorted, { sort_order: SortOrder.COST_ASC, @@ -2784,7 +2863,8 @@ describe('BridgeController', function () { }); it('should track the QuoteSelected event', () => { - bridgeController.trackUnifiedSwapBridgeEvent( + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', UnifiedSwapBridgeEventName.QuoteSelected, { is_best_quote: true, @@ -2805,7 +2885,8 @@ describe('BridgeController', function () { }); it('should track the QuotesReceived event', () => { - bridgeController.trackUnifiedSwapBridgeEvent( + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', UnifiedSwapBridgeEventName.QuotesReceived, { warnings: ['insufficient_balance'], @@ -2821,14 +2902,15 @@ describe('BridgeController', function () { usd_balance_source: 0, }, ); - expect(messengerMock.call.mock.calls).toMatchSnapshot(); + expect(messengerCallMock.mock.calls).toMatchSnapshot(); expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); }); it('should track the AssetDetailTooltipClicked event', () => { - bridgeController.trackUnifiedSwapBridgeEvent( + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', UnifiedSwapBridgeEventName.AssetDetailTooltipClicked, { token_name: 'ETH', @@ -2849,7 +2931,7 @@ describe('BridgeController', function () { jest.clearAllMocks(); jest.restoreAllMocks(); - messengerMock.call.mockImplementation(() => { + messengerCallMock.mockImplementation(() => { return { provider: jest.fn() as never, rpcUrl: 'https://mainnet.infura.io/v3/123', @@ -2861,18 +2943,14 @@ describe('BridgeController', function () { }); it('should track the Submitted event', () => { - const controller = new BridgeController({ - messenger: messengerMock, - getLayer1GasFee: getLayer1GasFeeMock, - clientId: BridgeClientId.EXTENSION, + const { rootMessenger: localRootMessenger } = buildController({ clientVersion: '1.0.0', - fetchFn: mockFetchFn, - trackMetaMetricsFn, state: { ...EMPTY_INIT_STATE, }, }); - controller.trackUnifiedSwapBridgeEvent( + localRootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', UnifiedSwapBridgeEventName.Submitted, { action_type: MetricsActionType.SWAPBRIDGE_V1, @@ -2901,7 +2979,8 @@ describe('BridgeController', function () { }); it('should track the Completed event', () => { - bridgeController.trackUnifiedSwapBridgeEvent( + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', UnifiedSwapBridgeEventName.Completed, { action_type: MetricsActionType.SWAPBRIDGE_V1, @@ -2939,7 +3018,8 @@ describe('BridgeController', function () { }); it('should track the Failed event', () => { - bridgeController.trackUnifiedSwapBridgeEvent( + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', UnifiedSwapBridgeEventName.Failed, { allowance_reset_transaction: StatusTypes.PENDING, @@ -2970,20 +3050,15 @@ describe('BridgeController', function () { security_warnings: [], }, ); - expect(messengerMock.call).toHaveBeenCalledTimes(0); + expect(messengerCallMock).toHaveBeenCalledTimes(0); expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); }); it('should track the Failed event before tx is submitted', () => { - const controller = new BridgeController({ - messenger: messengerMock, - getLayer1GasFee: getLayer1GasFeeMock, - clientId: BridgeClientId.EXTENSION, + const { rootMessenger: localRootMessenger } = buildController({ clientVersion: '1.0.0', - fetchFn: mockFetchFn, - trackMetaMetricsFn, state: { quoteRequest: { srcChainId: SolScope.Mainnet, @@ -2997,7 +3072,8 @@ describe('BridgeController', function () { quotes: mockBridgeQuotesSolErc20 as never, }, }); - controller.trackUnifiedSwapBridgeEvent( + localRootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', UnifiedSwapBridgeEventName.Failed, { error_message: 'Failed to submit tx', @@ -3021,13 +3097,8 @@ describe('BridgeController', function () { }); it('should track the StatusValidationFailed event', () => { - const controller = new BridgeController({ - messenger: messengerMock, - getLayer1GasFee: getLayer1GasFeeMock, - clientId: BridgeClientId.EXTENSION, + const { rootMessenger: localRootMessenger } = buildController({ clientVersion: '1.0.0', - fetchFn: mockFetchFn, - trackMetaMetricsFn, state: { quoteRequest: { srcChainId: SolScope.Mainnet, @@ -3041,7 +3112,8 @@ describe('BridgeController', function () { quotes: mockBridgeQuotesSolErc20 as never, }, }); - controller.trackUnifiedSwapBridgeEvent( + localRootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', UnifiedSwapBridgeEventName.StatusValidationFailed, { failures: ['Failed to submit tx'], @@ -3057,7 +3129,7 @@ describe('BridgeController', function () { describe('trackUnifiedSwapBridgeEvent client-side call exceptions', () => { beforeEach(() => { jest.clearAllMocks(); - messengerMock.call.mockImplementation( + messengerCallMock.mockImplementation( ( ...args: Parameters ): ReturnType => { @@ -3092,14 +3164,18 @@ describe('BridgeController', function () { } as never; }, ); - bridgeController.setLocation(MetaMetricsSwapsEventSource.TrendingExplore); + rootMessenger.call( + 'BridgeController:setLocation', + MetaMetricsSwapsEventSource.TrendingExplore, + ); }); it('should not track the event if the account keyring type is not set', async () => { const errorSpy = jest .spyOn(console, 'error') .mockImplementationOnce(jest.fn()); - await bridgeController.updateBridgeQuoteRequestParams( + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', { walletAddress: '0x123', }, @@ -3111,7 +3187,8 @@ describe('BridgeController', function () { token_symbol_destination: 'USDC', }, ); - bridgeController.trackUnifiedSwapBridgeEvent( + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', UnifiedSwapBridgeEventName.QuotesReceived, { warnings: ['low_return'], @@ -3191,8 +3268,8 @@ describe('BridgeController', function () { }, }, }); - (messengerMock.call as jest.Mock).mockResolvedValueOnce('AUTH_TOKEN'); - (messengerMock.call as jest.Mock).mockReturnValueOnce(() => ({ + messengerCallMock.mockResolvedValueOnce('AUTH_TOKEN'); + messengerCallMock.mockReturnValueOnce(() => ({ address: '0x123', })); }); @@ -3206,7 +3283,8 @@ describe('BridgeController', function () { }); const expectedControllerState = bridgeController.state; - const quotes = await bridgeController.fetchQuotes( + const quotes = await rootMessenger.call( + 'BridgeController:fetchQuotes', { srcChainId: SolScope.Mainnet, destChainId: '1', @@ -3274,7 +3352,8 @@ describe('BridgeController', function () { const expectedControllerState = bridgeController.state; await expect( - bridgeController.fetchQuotes( + rootMessenger.call( + 'BridgeController:fetchQuotes', { srcChainId: SolScope.Mainnet, destChainId: '1', @@ -3306,7 +3385,8 @@ describe('BridgeController', function () { }); const expectedControllerState = bridgeController.state; - const quotes = await bridgeController.fetchQuotes( + const quotes = await rootMessenger.call( + 'BridgeController:fetchQuotes', { srcChainId: SolScope.Mainnet, destChainId: '1', @@ -3370,7 +3450,8 @@ describe('BridgeController', function () { }); const expectedControllerState = bridgeController.state; - const quotes = await bridgeController.fetchQuotes( + const quotes = await rootMessenger.call( + 'BridgeController:fetchQuotes', { srcChainId: SolScope.Mainnet, destChainId: '1', @@ -3434,7 +3515,10 @@ describe('BridgeController', function () { validationFailures: [], }); - const quotes = await bridgeController.fetchQuotes(makeQuoteRequest()); + const quotes = await rootMessenger.call( + 'BridgeController:fetchQuotes', + makeQuoteRequest(), + ); expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); expect(quotes).toHaveLength(2); diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index 07895677836..858f6046583 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -173,6 +173,16 @@ type BridgePollingInput = { >[UnifiedSwapBridgeEventName.QuotesRequested]; }; +const MESSENGER_EXPOSED_METHODS = [ + 'updateBridgeQuoteRequestParams', + 'fetchQuotes', + 'stopPollingForQuotes', + 'setLocation', + 'resetState', + 'setChainIntervalLength', + 'trackUnifiedSwapBridgeEvent', +] as const; + export class BridgeController extends StaticIntervalPollingController()< typeof BRIDGE_CONTROLLER_NAME, BridgeControllerState, @@ -278,30 +288,9 @@ export class BridgeController extends StaticIntervalPollingController false); - // Register action handlers - this.messenger.registerActionHandler( - `${BRIDGE_CONTROLLER_NAME}:setChainIntervalLength`, - this.setChainIntervalLength.bind(this), - ); - this.messenger.registerActionHandler( - `${BRIDGE_CONTROLLER_NAME}:updateBridgeQuoteRequestParams`, - this.updateBridgeQuoteRequestParams.bind(this), - ); - this.messenger.registerActionHandler( - `${BRIDGE_CONTROLLER_NAME}:resetState`, - this.resetState.bind(this), - ); - this.messenger.registerActionHandler( - `${BRIDGE_CONTROLLER_NAME}:trackUnifiedSwapBridgeEvent`, - this.trackUnifiedSwapBridgeEvent.bind(this), - ); - this.messenger.registerActionHandler( - `${BRIDGE_CONTROLLER_NAME}:stopPollingForQuotes`, - this.stopPollingForQuotes.bind(this), - ); - this.messenger.registerActionHandler( - `${BRIDGE_CONTROLLER_NAME}:fetchQuotes`, - this.fetchQuotes.bind(this), + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, ); } diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index d83ea093cc5..5794866620b 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -57,6 +57,16 @@ export type { FeatureFlagsPlatformConfig, } from './types'; +export type { + BridgeControllerUpdateBridgeQuoteRequestParamsAction, + BridgeControllerFetchQuotesAction, + BridgeControllerStopPollingForQuotesAction, + BridgeControllerSetLocationAction, + BridgeControllerResetStateAction, + BridgeControllerSetChainIntervalLengthAction, + BridgeControllerTrackUnifiedSwapBridgeEventAction, +} from './bridge-controller-method-action-types'; + export { AbortReason } from './utils/metrics/constants'; export { StatusTypes } from './types'; diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index 5e39e637f03..0edfcd2318b 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -28,6 +28,7 @@ import type { } from '@metamask/utils'; import type { BridgeController } from './bridge-controller'; +import type { BridgeControllerMethodActions } from './bridge-controller-method-action-types'; import type { BRIDGE_CONTROLLER_NAME } from './constants/bridge'; import type { BitcoinTradeDataSchema, @@ -412,15 +413,9 @@ export type BridgeControllerStateChangeEvent = ControllerStateChangeEvent< BridgeControllerState >; -// Maps to BridgeController function names export type BridgeControllerActions = | BridgeControllerGetStateAction - | BridgeControllerAction - | BridgeControllerAction - | BridgeControllerAction - | BridgeControllerAction - | BridgeControllerAction - | BridgeControllerAction; + | BridgeControllerMethodActions; export type BridgeControllerEvents = BridgeControllerStateChangeEvent; diff --git a/yarn.lock b/yarn.lock index b54595766ab..f7f5c78f589 100644 --- a/yarn.lock +++ b/yarn.lock @@ -470,6 +470,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/aix-ppc64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/aix-ppc64@npm:0.27.4" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/android-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/android-arm64@npm:0.25.9" @@ -477,6 +484,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/android-arm64@npm:0.27.4" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/android-arm@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/android-arm@npm:0.25.9" @@ -484,6 +498,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/android-arm@npm:0.27.4" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@esbuild/android-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/android-x64@npm:0.25.9" @@ -491,6 +512,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/android-x64@npm:0.27.4" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + "@esbuild/darwin-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/darwin-arm64@npm:0.25.9" @@ -498,6 +526,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/darwin-arm64@npm:0.27.4" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/darwin-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/darwin-x64@npm:0.25.9" @@ -505,6 +540,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/darwin-x64@npm:0.27.4" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@esbuild/freebsd-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/freebsd-arm64@npm:0.25.9" @@ -512,6 +554,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/freebsd-arm64@npm:0.27.4" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/freebsd-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/freebsd-x64@npm:0.25.9" @@ -519,6 +568,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/freebsd-x64@npm:0.27.4" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/linux-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-arm64@npm:0.25.9" @@ -526,6 +582,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-arm64@npm:0.27.4" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/linux-arm@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-arm@npm:0.25.9" @@ -533,6 +596,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-arm@npm:0.27.4" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@esbuild/linux-ia32@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-ia32@npm:0.25.9" @@ -540,6 +610,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ia32@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-ia32@npm:0.27.4" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/linux-loong64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-loong64@npm:0.25.9" @@ -547,6 +624,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-loong64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-loong64@npm:0.27.4" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + "@esbuild/linux-mips64el@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-mips64el@npm:0.25.9" @@ -554,6 +638,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-mips64el@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-mips64el@npm:0.27.4" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + "@esbuild/linux-ppc64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-ppc64@npm:0.25.9" @@ -561,6 +652,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ppc64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-ppc64@npm:0.27.4" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/linux-riscv64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-riscv64@npm:0.25.9" @@ -568,6 +666,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-riscv64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-riscv64@npm:0.27.4" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + "@esbuild/linux-s390x@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-s390x@npm:0.25.9" @@ -575,6 +680,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-s390x@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-s390x@npm:0.27.4" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + "@esbuild/linux-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-x64@npm:0.25.9" @@ -582,6 +694,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-x64@npm:0.27.4" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + "@esbuild/netbsd-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/netbsd-arm64@npm:0.25.9" @@ -589,6 +708,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/netbsd-arm64@npm:0.27.4" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/netbsd-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/netbsd-x64@npm:0.25.9" @@ -596,6 +722,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/netbsd-x64@npm:0.27.4" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openbsd-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/openbsd-arm64@npm:0.25.9" @@ -603,6 +736,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/openbsd-arm64@npm:0.27.4" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/openbsd-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/openbsd-x64@npm:0.25.9" @@ -610,6 +750,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/openbsd-x64@npm:0.27.4" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openharmony-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/openharmony-arm64@npm:0.25.9" @@ -617,6 +764,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openharmony-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/openharmony-arm64@npm:0.27.4" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/sunos-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/sunos-x64@npm:0.25.9" @@ -624,6 +778,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/sunos-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/sunos-x64@npm:0.27.4" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + "@esbuild/win32-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/win32-arm64@npm:0.25.9" @@ -631,6 +792,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/win32-arm64@npm:0.27.4" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/win32-ia32@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/win32-ia32@npm:0.25.9" @@ -638,6 +806,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-ia32@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/win32-ia32@npm:0.27.4" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/win32-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/win32-x64@npm:0.25.9" @@ -645,6 +820,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/win32-x64@npm:0.27.4" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.1.2, @eslint-community/eslint-utils@npm:^4.4.0, @eslint-community/eslint-utils@npm:^4.4.1, @eslint-community/eslint-utils@npm:^4.8.0, @eslint-community/eslint-utils@npm:^4.9.1": version: 4.9.1 resolution: "@eslint-community/eslint-utils@npm:4.9.1" @@ -3031,6 +3213,7 @@ __metadata: nock: "npm:^13.3.1" reselect: "npm:^5.1.1" ts-jest: "npm:^29.2.5" + tsx: "npm:^4.21.0" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" @@ -8682,6 +8865,95 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:~0.27.0": + version: 0.27.4 + resolution: "esbuild@npm:0.27.4" + dependencies: + "@esbuild/aix-ppc64": "npm:0.27.4" + "@esbuild/android-arm": "npm:0.27.4" + "@esbuild/android-arm64": "npm:0.27.4" + "@esbuild/android-x64": "npm:0.27.4" + "@esbuild/darwin-arm64": "npm:0.27.4" + "@esbuild/darwin-x64": "npm:0.27.4" + "@esbuild/freebsd-arm64": "npm:0.27.4" + "@esbuild/freebsd-x64": "npm:0.27.4" + "@esbuild/linux-arm": "npm:0.27.4" + "@esbuild/linux-arm64": "npm:0.27.4" + "@esbuild/linux-ia32": "npm:0.27.4" + "@esbuild/linux-loong64": "npm:0.27.4" + "@esbuild/linux-mips64el": "npm:0.27.4" + "@esbuild/linux-ppc64": "npm:0.27.4" + "@esbuild/linux-riscv64": "npm:0.27.4" + "@esbuild/linux-s390x": "npm:0.27.4" + "@esbuild/linux-x64": "npm:0.27.4" + "@esbuild/netbsd-arm64": "npm:0.27.4" + "@esbuild/netbsd-x64": "npm:0.27.4" + "@esbuild/openbsd-arm64": "npm:0.27.4" + "@esbuild/openbsd-x64": "npm:0.27.4" + "@esbuild/openharmony-arm64": "npm:0.27.4" + "@esbuild/sunos-x64": "npm:0.27.4" + "@esbuild/win32-arm64": "npm:0.27.4" + "@esbuild/win32-ia32": "npm:0.27.4" + "@esbuild/win32-x64": "npm:0.27.4" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10/32b46ec22ef78bae6cc141145022a4c0209852c07151f037fbefccc2033ca54e7f33705f8fca198eb7026f400142f64c2dbc9f0d0ce9c0a638ebc472a04abc4a + languageName: node + linkType: hard + "escalade@npm:^3.1.1, escalade@npm:^3.2.0": version: 3.2.0 resolution: "escalade@npm:3.2.0" @@ -14180,6 +14452,22 @@ __metadata: languageName: node linkType: hard +"tsx@npm:^4.21.0": + version: 4.21.0 + resolution: "tsx@npm:4.21.0" + dependencies: + esbuild: "npm:~0.27.0" + fsevents: "npm:~2.3.3" + get-tsconfig: "npm:^4.7.5" + dependenciesMeta: + fsevents: + optional: true + bin: + tsx: dist/cli.mjs + checksum: 10/7afedeff855ba98c47dc28b33d7e8e253c4dc1f791938db402d79c174bdf806b897c1a5f91e5b1259c112520c816f826b4c5d98f0bad7e95b02dec66fedb64d2 + languageName: node + linkType: hard + "tweetnacl@npm:^1.0.3": version: 1.0.3 resolution: "tweetnacl@npm:1.0.3" From edbef31d9149a710e7c0292b9746f7b09ed7b16c Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 18 Mar 2026 13:48:08 +0100 Subject: [PATCH 2/8] Update `BridgeStatusController` to expose all methods through messenger --- packages/bridge-controller/package.json | 2 +- .../bridge-controller-method-action-types.ts | 2 +- .../bridge-status-controller/CHANGELOG.md | 7 + .../bridge-status-controller/package.json | 2 + .../bridge-status-controller.test.ts.snap | 13 - ...e-status-controller-method-action-types.ts | 53 + .../src/bridge-status-controller.test.ts | 4547 +++++++++-------- .../src/bridge-status-controller.ts | 41 +- .../bridge-status-controller/src/index.ts | 14 +- .../bridge-status-controller/src/types.ts | 39 +- .../src/tests/messenger-mock.ts | 6 +- yarn.lock | 290 +- 12 files changed, 2493 insertions(+), 2523 deletions(-) create mode 100644 packages/bridge-status-controller/src/bridge-status-controller-method-action-types.ts diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index 227e7443147..89bf5dcbc61 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -36,11 +36,11 @@ ], "scripts": { "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/bridge-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/bridge-controller", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/bridge-controller/src/bridge-controller-method-action-types.ts b/packages/bridge-controller/src/bridge-controller-method-action-types.ts index e12b4350f09..0cc3d746a06 100644 --- a/packages/bridge-controller/src/bridge-controller-method-action-types.ts +++ b/packages/bridge-controller/src/bridge-controller-method-action-types.ts @@ -1,5 +1,5 @@ /** - * This file is auto generated by `scripts/generate-method-action-types.ts`. + * This file is auto generated. * Do not edit manually. */ diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index a408eaa5645..a22860a2595 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -75,6 +75,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Expose all public `BridgeStatusController` methods through its messenger + - The following actions are now available: + - `BridgeStatusController:submitTx` + - `BridgeStatusController:submitIntent` + - `BridgeStatusController:restartPollingForFailedAttempts` + - `BridgeStatusController:getBridgeHistoryItemByTxMetaId` + - Corresponding action types are now exported (e.g. `BridgeStatusControllerStartPollingForBridgeTxStatusAction`) - Added more unit test coverage for intents and EVM transactions. Also refactored some mocks and code blocks to improve testability ([#8186](https://github.com/MetaMask/core/pull/8186)) ### Changed diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index 98d1d602411..c7535c067e2 100644 --- a/packages/bridge-status-controller/package.json +++ b/packages/bridge-status-controller/package.json @@ -40,6 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/bridge-status-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/bridge-status-controller", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", @@ -74,6 +75,7 @@ "lodash": "^4.17.21", "nock": "^13.3.1", "ts-jest": "^29.2.5", + "tsx": "^4.21.0", "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" diff --git a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap index 3aa54d580f7..d7ab89a99d2 100644 --- a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap +++ b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap @@ -145,19 +145,6 @@ exports[`BridgeStatusController constructor rehydrates the tx history state 1`] } `; -exports[`BridgeStatusController constructor should setup correctly 1`] = ` -[ - [ - "TransactionController:transactionFailed", - [Function], - ], - [ - "TransactionController:transactionConfirmed", - [Function], - ], -] -`; - exports[`BridgeStatusController startPollingForBridgeTxStatus emits bridgeTransactionFailed event when the status response is failed 1`] = ` [ [ diff --git a/packages/bridge-status-controller/src/bridge-status-controller-method-action-types.ts b/packages/bridge-status-controller/src/bridge-status-controller-method-action-types.ts new file mode 100644 index 00000000000..1e885f0d419 --- /dev/null +++ b/packages/bridge-status-controller/src/bridge-status-controller-method-action-types.ts @@ -0,0 +1,53 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { BridgeStatusController } from './bridge-status-controller'; + +export type BridgeStatusControllerStartPollingForBridgeTxStatusAction = { + type: `BridgeStatusController:startPollingForBridgeTxStatus`; + handler: BridgeStatusController['startPollingForBridgeTxStatus']; +}; + +export type BridgeStatusControllerWipeBridgeStatusAction = { + type: `BridgeStatusController:wipeBridgeStatus`; + handler: BridgeStatusController['wipeBridgeStatus']; +}; + +export type BridgeStatusControllerResetStateAction = { + type: `BridgeStatusController:resetState`; + handler: BridgeStatusController['resetState']; +}; + +export type BridgeStatusControllerSubmitTxAction = { + type: `BridgeStatusController:submitTx`; + handler: BridgeStatusController['submitTx']; +}; + +export type BridgeStatusControllerSubmitIntentAction = { + type: `BridgeStatusController:submitIntent`; + handler: BridgeStatusController['submitIntent']; +}; + +export type BridgeStatusControllerRestartPollingForFailedAttemptsAction = { + type: `BridgeStatusController:restartPollingForFailedAttempts`; + handler: BridgeStatusController['restartPollingForFailedAttempts']; +}; + +export type BridgeStatusControllerGetBridgeHistoryItemByTxMetaIdAction = { + type: `BridgeStatusController:getBridgeHistoryItemByTxMetaId`; + handler: BridgeStatusController['getBridgeHistoryItemByTxMetaId']; +}; + +/** + * Union of all BridgeStatusController action types. + */ +export type BridgeStatusControllerMethodActions = + | BridgeStatusControllerStartPollingForBridgeTxStatusAction + | BridgeStatusControllerWipeBridgeStatusAction + | BridgeStatusControllerResetStateAction + | BridgeStatusControllerSubmitTxAction + | BridgeStatusControllerSubmitIntentAction + | BridgeStatusControllerRestartPollingForFailedAttemptsAction + | BridgeStatusControllerGetBridgeHistoryItemByTxMetaIdAction; diff --git a/packages/bridge-status-controller/src/bridge-status-controller.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.test.ts index 7bb2756372a..4ffc3396f6b 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -85,7 +85,6 @@ const EMPTY_INIT_STATE: BridgeStatusControllerState = { ...DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE, }; -const mockMessengerSubscribe = jest.fn(); const MockStatusResponse = { getPending: ({ srcTxHash = '0xsrcTxHash1', @@ -545,47 +544,144 @@ const MockTxHistory = { }), }; -const getMessengerMock = ({ - account = '0xaccount1', - srcChainId = 42161, - txHash = '0xsrcTxHash1', - txMetaId = 'bridgeTxMetaId1', -} = {}) => - ({ - call: jest.fn((method: string) => { - if (method === 'AccountsController:getSelectedMultichainAccount') { - return { - address: account, - metadata: { snap: { id: 'snapId' } }, - options: { scope: 'scope' }, - }; - } else if (method === 'NetworkController:findNetworkClientIdByChainId') { - return 'networkClientId'; - } else if (method === 'NetworkController:getState') { - return { selectedNetworkClientId: 'networkClientId' }; - } else if (method === 'NetworkController:getNetworkClientById') { - return { - configuration: { - chainId: numberToHex(srcChainId), - }, - }; - } else if (method === 'TransactionController:getState') { - return { - transactions: [ - { - id: txMetaId, - hash: txHash, - }, - ], - }; - } - return null; +const addTransactionBatchFn = jest.fn(); + +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +function getControllerMessenger( + rootMessenger: RootMessenger, +): BridgeStatusControllerMessenger { + const messenger = new Messenger({ + namespace: BRIDGE_STATUS_CONTROLLER_NAME, + parent: rootMessenger, + }) as unknown as BridgeStatusControllerMessenger; + rootMessenger.delegate({ + messenger, + actions: [ + 'AccountsController:getAccountByAddress', + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getState', + 'NetworkController:getNetworkClientById', + 'SnapController:handleRequest', + 'TransactionController:getState', + 'TransactionController:updateTransaction', + 'TransactionController:addTransaction', + 'TransactionController:estimateGasFee', + 'TransactionController:isAtomicBatchSupported', + 'BridgeController:trackUnifiedSwapBridgeEvent', + 'BridgeController:stopPollingForQuotes', + 'GasFeeController:getState', + 'AuthenticationController:getBearerToken', + 'KeyringController:signTypedMessage', + ], + events: [ + 'TransactionController:transactionFailed', + 'TransactionController:transactionConfirmed', + ], + }); + return messenger; +} + +function registerDefaultActionHandlers( + rootMessenger: RootMessenger, + { + account = '0xaccount1', + srcChainId = 42161, + txHash = '0xsrcTxHash1', + txMetaId = 'bridgeTxMetaId1', + }: { + account?: string; + srcChainId?: number; + txHash?: string; + txMetaId?: string; + } = {}, +) { + rootMessenger.registerActionHandler( + 'AccountsController:getAccountByAddress', + () => ({ + address: account, + // @ts-expect-error: Partial mock. + metadata: { keyring: { type: 'any' } }, }), - subscribe: mockMessengerSubscribe, - publish: jest.fn(), - registerActionHandler: jest.fn(), - registerInitialEventPayload: jest.fn(), - }) as unknown as jest.Mocked; + ); + + rootMessenger.registerActionHandler( + 'BridgeController:trackUnifiedSwapBridgeEvent', + jest.fn(), + ); + + rootMessenger.registerActionHandler( + 'NetworkController:findNetworkClientIdByChainId', + () => 'networkClientId', + ); + + // @ts-expect-error: Partial mock. + rootMessenger.registerActionHandler('NetworkController:getState', () => ({ + selectedNetworkClientId: 'networkClientId', + })); + + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + () => ({ + // @ts-expect-error: Partial mock. + configuration: { + chainId: numberToHex(srcChainId), + }, + }), + ); + + rootMessenger.registerActionHandler('TransactionController:getState', () => ({ + // @ts-expect-error: Partial mock. + transactions: [{ id: txMetaId, hash: txHash }], + })); +} + +type WithControllerCallback = (payload: { + controller: BridgeStatusController; + rootMessenger: RootMessenger; + messenger: BridgeStatusControllerMessenger; + startPollingForBridgeTxStatusSpy: jest.Mock; +}) => Promise | ReturnValue; + +type WithControllerOptions = { + options?: Partial[0]>; + mockMessengerCall?: jest.Mock; +}; + +async function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] +): Promise { + const [{ options = {}, mockMessengerCall = undefined }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + const rootMessenger = getRootMessenger(); + const messenger = getControllerMessenger(rootMessenger); + if (mockMessengerCall) { + jest.spyOn(messenger, 'call').mockImplementation(mockMessengerCall); + } + const controller = new BridgeStatusController({ + messenger, + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + addTransactionBatchFn, + ...options, + }); + const startPollingForBridgeTxStatusSpy = jest.fn(); + if (mockMessengerCall) { + jest + .spyOn(controller, 'startPolling') + .mockImplementation(startPollingForBridgeTxStatusSpy); + } + return await testFunction({ + controller, + rootMessenger, + messenger, + startPollingForBridgeTxStatusSpy, + }); +} const executePollingWithPendingStatus = async () => { // Setup @@ -596,8 +692,12 @@ const executePollingWithPendingStatus = async () => { status: MockStatusResponse.getPending(), validationFailures: [], }); + + const rootMessenger = getRootMessenger(); + registerDefaultActionHandlers(rootMessenger); + const messenger = getControllerMessenger(rootMessenger); const bridgeStatusController = new BridgeStatusController({ - messenger: getMessengerMock(), + messenger, clientId: BridgeClientId.EXTENSION, fetchFn: jest.fn(), addTransactionBatchFn: jest.fn(), @@ -606,7 +706,8 @@ const executePollingWithPendingStatus = async () => { const startPollingSpy = jest.spyOn(bridgeStatusController, 'startPolling'); // Execution - bridgeStatusController.startPollingForBridgeTxStatus( + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', getMockStartPollingForBridgeTxStatusArgs(), ); fetchBridgeTxStatusSpy.mockImplementationOnce(async () => { @@ -620,6 +721,7 @@ const executePollingWithPendingStatus = async () => { return { bridgeStatusController, + rootMessenger, startPollingSpy, fetchBridgeTxStatusSpy, }; @@ -636,35 +738,6 @@ const mockSelectedAccount = { }, }, }; -const addTransactionBatchFn = jest.fn(); - -const getController = ( - call: jest.Mock, - traceFn?: jest.Mock, - clientId: BridgeClientId = BridgeClientId.EXTENSION, - mockFetchFn = jest.fn(), -) => { - const controller = new BridgeStatusController({ - messenger: { - call, - subscribe: mockMessengerSubscribe, - publish: jest.fn(), - registerActionHandler: jest.fn(), - registerInitialEventPayload: jest.fn(), - } as never, - clientId, - fetchFn: mockFetchFn, - addTransactionBatchFn, - traceFn, - }); - - const startPollingSpy = jest.fn(); - jest.spyOn(controller, 'startPolling').mockImplementation(startPollingSpy); - return { - controller, - startPollingForBridgeTxStatusSpy: startPollingSpy, - }; -}; describe('BridgeStatusController', () => { beforeEach(() => { @@ -673,32 +746,21 @@ describe('BridgeStatusController', () => { }); describe('constructor', () => { - it('should setup correctly', () => { - const bridgeStatusController = new BridgeStatusController({ - messenger: getMessengerMock(), - clientId: BridgeClientId.EXTENSION, - fetchFn: jest.fn(), - addTransactionBatchFn: jest.fn(), + it('should setup correctly', async () => { + await withController(async ({ controller }) => { + expect(controller.state).toStrictEqual(EMPTY_INIT_STATE); }); - expect(bridgeStatusController.state).toStrictEqual(EMPTY_INIT_STATE); - expect(mockMessengerSubscribe.mock.calls).toMatchSnapshot(); }); it('rehydrates the tx history state', async () => { - // Setup - const bridgeStatusController = new BridgeStatusController({ - messenger: getMessengerMock(), - clientId: BridgeClientId.EXTENSION, - fetchFn: jest.fn(), - addTransactionBatchFn: jest.fn(), - state: { - txHistory: MockTxHistory.getPending(), + await withController( + { options: { state: { txHistory: MockTxHistory.getPending() } } }, + async ({ controller }) => { + // Assertion + expect(controller.state.txHistory).toMatchSnapshot(); + controller.stopAllPolling(); }, - }); - - // Assertion - expect(bridgeStatusController.state.txHistory).toMatchSnapshot(); - bridgeStatusController.stopAllPolling(); + ); }); it('restarts polling for history items that are not complete', async () => { @@ -709,29 +771,32 @@ describe('BridgeStatusController', () => { 'fetchBridgeTxStatus', ); - // Execution - const bridgeStatusController = new BridgeStatusController({ - messenger: getMessengerMock(), - state: { - txHistory: { - ...MockTxHistory.getPending(), - ...MockTxHistory.getUnknown(), - ...MockTxHistory.getPendingSwap(), + await withController( + { + options: { + state: { + txHistory: { + ...MockTxHistory.getPending(), + ...MockTxHistory.getUnknown(), + ...MockTxHistory.getPendingSwap(), + }, + }, + fetchFn: jest + .fn() + .mockResolvedValueOnce(MockStatusResponse.getPending()) + .mockResolvedValueOnce(MockStatusResponse.getComplete()), }, }, - clientId: BridgeClientId.EXTENSION, - fetchFn: jest - .fn() - .mockResolvedValueOnce(MockStatusResponse.getPending()) - .mockResolvedValueOnce(MockStatusResponse.getComplete()), - addTransactionBatchFn: jest.fn(), - }); - jest.advanceTimersByTime(10000); - await flushPromises(); - - // Assertions - expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); - bridgeStatusController.stopAllPolling(); + async ({ controller, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + jest.advanceTimersByTime(10000); + await flushPromises(); + + // Assertions + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); + controller.stopAllPolling(); + }, + ); }); }); @@ -758,54 +823,55 @@ describe('BridgeStatusController', () => { 'fetchBridgeTxStatus', ); - const mockFetchFn = jest - .fn() - .mockRejectedValueOnce(new Error('Network error')); - const bridgeStatusController = new BridgeStatusController({ - messenger: getMessengerMock(), - clientId: BridgeClientId.EXTENSION, - fetchFn: mockFetchFn, - addTransactionBatchFn: jest.fn(), - }); + await withController( + { + options: { + fetchFn: jest + .fn() + .mockRejectedValueOnce(new Error('Network error')), + }, + }, + async ({ controller, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); - // Execution - bridgeStatusController.startPollingForBridgeTxStatus( - getMockStartPollingForBridgeTxStatusArgs(), - ); + // Execution + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs(), + ); - // Trigger polling - jest.advanceTimersByTime(10000); - await flushPromises(); + // Trigger polling + jest.advanceTimersByTime(10000); + await flushPromises(); - // Assertions - expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); - // Transaction should still be in history but status should remain unchanged - expect(bridgeStatusController.state.txHistory).toHaveProperty( - 'bridgeTxMetaId1', - ); - expect( - bridgeStatusController.state.txHistory.bridgeTxMetaId1.status.status, - ).toBe('PENDING'); - - // Should increment attempts counter - expect( - bridgeStatusController.state.txHistory.bridgeTxMetaId1.attempts - ?.counter, - ).toBe(1); - expect( - bridgeStatusController.state.txHistory.bridgeTxMetaId1.attempts - ?.lastAttemptTime, - ).toBeDefined(); + // Assertions + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + // Transaction should still be in history but status should remain unchanged + expect(controller.state.txHistory).toHaveProperty('bridgeTxMetaId1'); + expect(controller.state.txHistory.bridgeTxMetaId1.status.status).toBe( + 'PENDING', + ); - bridgeStatusController.stopAllPolling(); - expect(consoleFnSpy.mock.calls).toMatchInlineSnapshot(` - [ - [ - "Failed to fetch bridge tx status", - [Error: Network error], - ], - ] - `); + // Should increment attempts counter + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts?.counter, + ).toBe(1); + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts + ?.lastAttemptTime, + ).toBeDefined(); + + controller.stopAllPolling(); + expect(consoleFnSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "Failed to fetch bridge tx status", + [Error: Network error], + ], + ] + `); + }, + ); }); it('should stop polling after max attempts are reached', async () => { @@ -816,74 +882,76 @@ describe('BridgeStatusController', () => { 'fetchBridgeTxStatus', ); - const failedFetch = jest - .fn() - .mockRejectedValue(new Error('Persistent error')); - const bridgeStatusController = new BridgeStatusController({ - messenger: getMessengerMock(), - clientId: BridgeClientId.EXTENSION, - fetchFn: failedFetch, - addTransactionBatchFn: jest.fn(), - }); + await withController( + { + options: { + fetchFn: jest.fn().mockRejectedValue(new Error('Persistent error')), + }, + }, + async ({ controller, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); - // Execution - bridgeStatusController.startPollingForBridgeTxStatus( - getMockStartPollingForBridgeTxStatusArgs(), - ); + // Execution + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs(), + ); - // Trigger polling with exponential backoff timing - for (let i = 0; i < MAX_ATTEMPTS * 2; i++) { - jest.advanceTimersByTime(10_000 * 2 ** i); - await flushPromises(); - } + // Trigger polling with exponential backoff timing + for (let i = 0; i < MAX_ATTEMPTS * 2; i++) { + jest.advanceTimersByTime(10_000 * 2 ** i); + await flushPromises(); + } - // Assertions - expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(MAX_ATTEMPTS); - expect( - bridgeStatusController.state.txHistory.bridgeTxMetaId1.attempts - ?.counter, - ).toBe(MAX_ATTEMPTS); - - // Verify polling stops after max attempts - even with a long wait, no more calls - const callCountBeforeExtraTime = fetchBridgeTxStatusSpy.mock.calls.length; - jest.advanceTimersByTime(1_000_000_000); - await flushPromises(); - expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes( - callCountBeforeExtraTime, + // Assertions + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(MAX_ATTEMPTS); + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts?.counter, + ).toBe(MAX_ATTEMPTS); + + // Verify polling stops after max attempts - even with a long wait, no more calls + const callCountBeforeExtraTime = + fetchBridgeTxStatusSpy.mock.calls.length; + jest.advanceTimersByTime(1_000_000_000); + await flushPromises(); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes( + callCountBeforeExtraTime, + ); + controller.stopAllPolling(); + expect(consoleFnSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "Failed to fetch bridge tx status", + [Error: Persistent error], + ], + [ + "Failed to fetch bridge tx status", + [Error: Persistent error], + ], + [ + "Failed to fetch bridge tx status", + [Error: Persistent error], + ], + [ + "Failed to fetch bridge tx status", + [Error: Persistent error], + ], + [ + "Failed to fetch bridge tx status", + [Error: Persistent error], + ], + [ + "Failed to fetch bridge tx status", + [Error: Persistent error], + ], + [ + "Failed to fetch bridge tx status", + [Error: Persistent error], + ], + ] + `); + }, ); - bridgeStatusController.stopAllPolling(); - expect(consoleFnSpy.mock.calls).toMatchInlineSnapshot(` - [ - [ - "Failed to fetch bridge tx status", - [Error: Persistent error], - ], - [ - "Failed to fetch bridge tx status", - [Error: Persistent error], - ], - [ - "Failed to fetch bridge tx status", - [Error: Persistent error], - ], - [ - "Failed to fetch bridge tx status", - [Error: Persistent error], - ], - [ - "Failed to fetch bridge tx status", - [Error: Persistent error], - ], - [ - "Failed to fetch bridge tx status", - [Error: Persistent error], - ], - [ - "Failed to fetch bridge tx status", - [Error: Persistent error], - ], - ] - `); }); }); @@ -892,67 +960,65 @@ describe('BridgeStatusController', () => { jest.clearAllMocks(); }); - it('throws error when bridgeTxMeta.id is not provided', () => { - const bridgeStatusController = new BridgeStatusController({ - messenger: getMessengerMock(), - clientId: BridgeClientId.EXTENSION, - fetchFn: jest.fn(), - addTransactionBatchFn: jest.fn(), - }); - - const argsWithoutId = getMockStartPollingForBridgeTxStatusArgs(); - // Remove the id from bridgeTxMeta - argsWithoutId.bridgeTxMeta = {} as never; + it('throws error when bridgeTxMeta.id is not provided', async () => { + await withController(async ({ controller, rootMessenger }) => { + const argsWithoutId = getMockStartPollingForBridgeTxStatusArgs(); + // Remove the id from bridgeTxMeta + argsWithoutId.bridgeTxMeta = {} as never; - expect(() => { - bridgeStatusController.startPollingForBridgeTxStatus(argsWithoutId); - }).toThrow( - 'Cannot start polling: bridgeTxMeta.id is required for polling', - ); - - bridgeStatusController.stopAllPolling(); - }); + expect(() => { + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + argsWithoutId, + ); + }).toThrow( + 'Cannot start polling: bridgeTxMeta.id is required for polling', + ); - it('throws error when bridgeTxMeta is undefined', () => { - const bridgeStatusController = new BridgeStatusController({ - messenger: getMessengerMock(), - clientId: BridgeClientId.EXTENSION, - fetchFn: jest.fn(), - addTransactionBatchFn: jest.fn(), + controller.stopAllPolling(); }); + }); - const argsWithoutMeta = getMockStartPollingForBridgeTxStatusArgs(); - // Remove bridgeTxMeta entirely - argsWithoutMeta.bridgeTxMeta = undefined as never; + it('throws error when bridgeTxMeta is undefined', async () => { + await withController(async ({ controller, rootMessenger }) => { + const argsWithoutMeta = getMockStartPollingForBridgeTxStatusArgs(); + // Remove bridgeTxMeta entirely + argsWithoutMeta.bridgeTxMeta = undefined; - expect(() => { - bridgeStatusController.startPollingForBridgeTxStatus(argsWithoutMeta); - }).toThrow( - 'Cannot start polling: bridgeTxMeta.id is required for polling', - ); + expect(() => { + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + argsWithoutMeta, + ); + }).toThrow( + 'Cannot start polling: bridgeTxMeta.id is required for polling', + ); - bridgeStatusController.stopAllPolling(); + controller.stopAllPolling(); + }); }); it('sets the inital tx history state', async () => { - // Setup - const bridgeStatusController = new BridgeStatusController({ - messenger: getMessengerMock(), - clientId: BridgeClientId.EXTENSION, - fetchFn: jest - .fn() - .mockResolvedValueOnce(MockStatusResponse.getPending()), - addTransactionBatchFn: jest.fn(), - }); + await withController( + { + options: { + fetchFn: jest + .fn() + .mockResolvedValueOnce(MockStatusResponse.getPending()), + }, + }, + async ({ controller, rootMessenger }) => { + // Execution + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs(), + ); - // Execution - bridgeStatusController.startPollingForBridgeTxStatus( - getMockStartPollingForBridgeTxStatusArgs(), + // Assertion + expect(controller.state.txHistory).toMatchSnapshot(); + controller.stopAllPolling(); + }, ); - - // Assertion - expect(bridgeStatusController.state.txHistory).toMatchSnapshot(); - bridgeStatusController.stopAllPolling(); }); it('starts polling and updates the tx history when the status response is received', async () => { @@ -977,120 +1043,92 @@ describe('BridgeStatusController', () => { jest.spyOn(Date, 'now').mockImplementation(() => { return MockTxHistory.getComplete().bridgeTxMetaId1.completionTime ?? 10; }); - const messengerMock = getMessengerMock(); - const bridgeStatusController = new BridgeStatusController({ - messenger: messengerMock, - clientId: BridgeClientId.EXTENSION, - fetchFn: jest.fn(), - addTransactionBatchFn: jest.fn(), - }); - const fetchBridgeTxStatusSpy = jest.spyOn( - bridgeStatusUtils, - 'fetchBridgeTxStatus', - ); - const stopPollingByNetworkClientIdSpy = jest.spyOn( - bridgeStatusController, - 'stopPollingByPollingToken', - ); - // Execution - bridgeStatusController.startPollingForBridgeTxStatus( - getMockStartPollingForBridgeTxStatusArgs({ - isStxEnabled: true, - }), - ); - fetchBridgeTxStatusSpy.mockImplementationOnce(async () => { - return { - status: MockStatusResponse.getComplete(), - validationFailures: [], - }; - }); - jest.advanceTimersByTime(10000); - await flushPromises(); + await withController(async ({ controller, messenger, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + const messengerCallSpy = jest.spyOn(messenger, 'call'); + const messengerPublishSpy = jest.spyOn(messenger, 'publish'); + const fetchBridgeTxStatusSpy = jest.spyOn( + bridgeStatusUtils, + 'fetchBridgeTxStatus', + ); + const stopPollingByNetworkClientIdSpy = jest.spyOn( + controller, + 'stopPollingByPollingToken', + ); - // Assertions - expect(stopPollingByNetworkClientIdSpy).toHaveBeenCalledTimes(1); - expect(bridgeStatusController.state.txHistory).toStrictEqual( - MockTxHistory.getComplete(), - ); + // Execution + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs({ isStxEnabled: true }), + ); + fetchBridgeTxStatusSpy.mockImplementationOnce(async () => { + return { + status: MockStatusResponse.getComplete(), + validationFailures: [], + }; + }); + jest.advanceTimersByTime(10000); + await flushPromises(); - expect(messengerMock.call.mock.calls).toMatchSnapshot(); - expect(messengerMock.publish.mock.calls.at(-1)).toMatchSnapshot(); - // Cleanup - jest.restoreAllMocks(); + // Assertions + expect(stopPollingByNetworkClientIdSpy).toHaveBeenCalledTimes(1); + expect(controller.state.txHistory).toStrictEqual( + MockTxHistory.getComplete(), + ); + + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); + expect(messengerPublishSpy.mock.calls.at(-1)).toMatchSnapshot(); + // Cleanup + jest.restoreAllMocks(); + }); }); it('does not poll if the srcTxHash is not available', async () => { // Setup jest.useFakeTimers(); - const messengerMock = { - call: jest.fn((method: string) => { - if (method === 'AccountsController:getSelectedMultichainAccount') { - return { address: '0xaccount1' }; - } else if ( - method === 'NetworkController:findNetworkClientIdByChainId' - ) { - return 'networkClientId'; - } else if (method === 'NetworkController:getState') { - return { selectedNetworkClientId: 'networkClientId' }; - } else if (method === 'NetworkController:getNetworkClientById') { - return { - configuration: { - chainId: numberToHex(42161), - }, - }; - } else if (method === 'TransactionController:getState') { - return { - transactions: [ - { - id: 'bridgeTxMetaId1', - hash: undefined, - }, - ], - }; - } - return null; - }), - subscribe: mockMessengerSubscribe, - publish: jest.fn(), - registerActionHandler: jest.fn(), - registerInitialEventPayload: jest.fn(), - } as unknown as jest.Mocked; + await withController(async ({ controller, rootMessenger }) => { + // Register handlers - but TransactionController:getState returns hash: undefined + registerDefaultActionHandlers(rootMessenger); - const fetchBridgeTxStatusSpy = jest.spyOn( - bridgeStatusUtils, - 'fetchBridgeTxStatus', - ); - const bridgeStatusController = new BridgeStatusController({ - messenger: messengerMock, - clientId: BridgeClientId.EXTENSION, - fetchFn: jest.fn(), - addTransactionBatchFn: jest.fn(), - }); + rootMessenger.unregisterActionHandler('TransactionController:getState'); + rootMessenger.registerActionHandler( + 'TransactionController:getState', + () => ({ + // @ts-expect-error: Partial mock. + transactions: [{ id: 'bridgeTxMetaId1', hash: undefined }], + }), + ); - // Start polling with args that have no srcTxHash - const startPollingArgs = getMockStartPollingForBridgeTxStatusArgs({ - srcTxHash: 'undefined', - }); - bridgeStatusController.startPollingForBridgeTxStatus(startPollingArgs); + const fetchBridgeTxStatusSpy = jest.spyOn( + bridgeStatusUtils, + 'fetchBridgeTxStatus', + ); - // Advance timer to trigger polling - jest.advanceTimersByTime(10000); - await flushPromises(); + // Start polling with args that have no srcTxHash + const startPollingArgs = getMockStartPollingForBridgeTxStatusArgs({ + srcTxHash: 'undefined', + }); + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + startPollingArgs, + ); - // Assertions - expect(fetchBridgeTxStatusSpy).not.toHaveBeenCalled(); - expect(bridgeStatusController.state.txHistory).toHaveProperty( - 'bridgeTxMetaId1', - ); - expect( - bridgeStatusController.state.txHistory.bridgeTxMetaId1.status.srcChain - .txHash, - ).toBeFalsy(); + // Advance timer to trigger polling + jest.advanceTimersByTime(10000); + await flushPromises(); + + // Assertions + expect(fetchBridgeTxStatusSpy).not.toHaveBeenCalled(); + expect(controller.state.txHistory).toHaveProperty('bridgeTxMetaId1'); + expect( + controller.state.txHistory.bridgeTxMetaId1.status.srcChain.txHash, + ).toBeFalsy(); - // Cleanup - jest.restoreAllMocks(); + // Cleanup + jest.restoreAllMocks(); + }); }); it('emits bridgeTransactionComplete event when the status response is complete', async () => { @@ -1100,35 +1138,31 @@ describe('BridgeStatusController', () => { return MockTxHistory.getComplete().bridgeTxMetaId1.completionTime ?? 10; }); - const messengerMock = getMessengerMock(); - const bridgeStatusController = new BridgeStatusController({ - messenger: messengerMock, - clientId: BridgeClientId.EXTENSION, - fetchFn: jest.fn(), - addTransactionBatchFn: jest.fn(), - }); - - const fetchBridgeTxStatusSpy = jest - .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') - .mockImplementationOnce(async () => { - return { - status: MockStatusResponse.getComplete(), - validationFailures: [], - }; - }); + await withController(async ({ rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + const fetchBridgeTxStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockImplementationOnce(async () => { + return { + status: MockStatusResponse.getComplete(), + validationFailures: [], + }; + }); - // Execution - bridgeStatusController.startPollingForBridgeTxStatus( - getMockStartPollingForBridgeTxStatusArgs(), - ); - jest.advanceTimersByTime(10000); - await flushPromises(); + // Execution + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs(), + ); + jest.advanceTimersByTime(10000); + await flushPromises(); - // Assertions - expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + // Assertions + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); - // Cleanup - jest.restoreAllMocks(); + // Cleanup + jest.restoreAllMocks(); + }); }); it('emits bridgeTransactionFailed event when the status response is failed', async () => { @@ -1138,38 +1172,37 @@ describe('BridgeStatusController', () => { return MockTxHistory.getComplete().bridgeTxMetaId1.completionTime ?? 10; }); - const messengerMock = getMessengerMock(); - const fetchBridgeTxStatusSpy = jest - .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') - .mockImplementationOnce(async () => { - return { - status: MockStatusResponse.getFailed(), - validationFailures: [], - }; - }); - const bridgeStatusController = new BridgeStatusController({ - messenger: messengerMock, - clientId: BridgeClientId.EXTENSION, - fetchFn: jest.fn(), - addTransactionBatchFn: jest.fn(), - }); + await withController(async ({ rootMessenger, messenger }) => { + registerDefaultActionHandlers(rootMessenger); + const messengerCallSpy = jest.spyOn(messenger, 'call'); + const messengerPublishSpy = jest.spyOn(messenger, 'publish'); + const fetchBridgeTxStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockImplementationOnce(async () => { + return { + status: MockStatusResponse.getFailed(), + validationFailures: [], + }; + }); - // Execution - bridgeStatusController.startPollingForBridgeTxStatus( - getMockStartPollingForBridgeTxStatusArgs(), - ); - jest.advanceTimersByTime(10000); - await flushPromises(); + // Execution + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs(), + ); + jest.advanceTimersByTime(10000); + await flushPromises(); - // Assertions - expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); - expect(messengerMock.call.mock.calls).toMatchSnapshot(); - expect(messengerMock.publish).not.toHaveBeenCalledWith( - 'BridgeStatusController:destinationTransactionCompleted', - ); + // Assertions + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); + expect(messengerPublishSpy).not.toHaveBeenCalledWith( + 'BridgeStatusController:destinationTransactionCompleted', + ); - // Cleanup - jest.restoreAllMocks(); + // Cleanup + jest.restoreAllMocks(); + }); }); it('updates the srcTxHash when one is available', async () => { @@ -1177,88 +1210,77 @@ describe('BridgeStatusController', () => { jest.useFakeTimers(); let getStateCallCount = 0; - const messengerMock = { - call: jest.fn((method: string) => { - if (method === 'AccountsController:getSelectedMultichainAccount') { - return { address: '0xaccount1' }; - } else if ( - method === 'NetworkController:findNetworkClientIdByChainId' - ) { - return 'networkClientId'; - } else if (method === 'NetworkController:getState') { - return { selectedNetworkClientId: 'networkClientId' }; - } else if (method === 'NetworkController:getNetworkClientById') { - return { - configuration: { - chainId: numberToHex(42161), - }, - }; - } else if (method === 'TransactionController:getState') { - getStateCallCount += 1; - return { - transactions: [ - { - id: 'bridgeTxMetaId1', - hash: getStateCallCount === 0 ? undefined : '0xnewTxHash', - }, - ], - }; - } - return null; - }), - subscribe: mockMessengerSubscribe, - publish: jest.fn(), - registerActionHandler: jest.fn(), - registerInitialEventPayload: jest.fn(), - } as unknown as jest.Mocked; - - const bridgeStatusController = new BridgeStatusController({ - messenger: messengerMock, - clientId: BridgeClientId.EXTENSION, - fetchFn: jest - .fn() - .mockResolvedValueOnce(MockStatusResponse.getPending()), - addTransactionBatchFn: jest.fn(), - traceFn: jest.fn(), - }); + await withController( + { + options: { + fetchFn: jest + .fn() + .mockResolvedValueOnce(MockStatusResponse.getPending()), + traceFn: jest.fn(), + }, + }, + async ({ controller, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); - // Start polling with no srcTxHash - const startPollingArgs = getMockStartPollingForBridgeTxStatusArgs({ - srcTxHash: 'undefined', - }); - bridgeStatusController.startPollingForBridgeTxStatus(startPollingArgs); + rootMessenger.unregisterActionHandler( + 'TransactionController:getState', + ); + rootMessenger.registerActionHandler( + 'TransactionController:getState', + // @ts-expect-error: Partial mock. + () => { + getStateCallCount += 1; + return { + transactions: [ + { + id: 'bridgeTxMetaId1', + hash: getStateCallCount === 0 ? undefined : '0xnewTxHash', + }, + ], + }; + }, + ); - // Verify initial state has no srcTxHash - expect( - bridgeStatusController.state.txHistory.bridgeTxMetaId1.status.srcChain - .txHash, - ).toBeUndefined(); + // Start polling with no srcTxHash + const startPollingArgs = getMockStartPollingForBridgeTxStatusArgs({ + srcTxHash: 'undefined', + }); + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + startPollingArgs, + ); - // Advance timer to trigger polling with new hash - jest.advanceTimersByTime(10000); - await flushPromises(); + // Verify initial state has no srcTxHash + expect( + controller.state.txHistory.bridgeTxMetaId1.status.srcChain.txHash, + ).toBeUndefined(); - // Verify the srcTxHash was updated - expect( - bridgeStatusController.state.txHistory.bridgeTxMetaId1.status.srcChain - .txHash, - ).toBe('0xsrcTxHash1'); + // Advance timer to trigger polling with new hash + jest.advanceTimersByTime(10000); + await flushPromises(); - // Cleanup - bridgeStatusController.stopAllPolling(); - jest.restoreAllMocks(); + // Verify the srcTxHash was updated + expect( + controller.state.txHistory.bridgeTxMetaId1.status.srcChain.txHash, + ).toBe('0xsrcTxHash1'); + + // Cleanup + controller.stopAllPolling(); + jest.restoreAllMocks(); + }, + ); }); }); describe('resetState', () => { it('resets the state', async () => { - const { bridgeStatusController } = + const { bridgeStatusController, rootMessenger } = await executePollingWithPendingStatus(); expect(bridgeStatusController.state.txHistory).toStrictEqual( MockTxHistory.getPending(), ); - bridgeStatusController.resetState(); + rootMessenger.call('BridgeStatusController:resetState'); expect(bridgeStatusController.state.txHistory).toStrictEqual( EMPTY_INIT_STATE.txHistory, ); @@ -1267,12 +1289,13 @@ describe('BridgeStatusController', () => { describe('getBridgeHistoryItemByTxMetaId', () => { it('returns the bridge history item when it exists', async () => { - const { bridgeStatusController } = - await executePollingWithPendingStatus(); + const { rootMessenger } = await executePollingWithPendingStatus(); const txMetaId = 'bridgeTxMetaId1'; - const bridgeHistoryItem = - bridgeStatusController.getBridgeHistoryItemByTxMetaId(txMetaId); + const bridgeHistoryItem = rootMessenger.call( + 'BridgeStatusController:getBridgeHistoryItemByTxMetaId', + txMetaId, + ); expect(bridgeHistoryItem).toBeDefined(); expect(bridgeHistoryItem?.quote.srcChainId).toBe(42161); @@ -1281,74 +1304,75 @@ describe('BridgeStatusController', () => { }); it('returns undefined when the transaction does not exist', async () => { - const { bridgeStatusController } = - await executePollingWithPendingStatus(); + const { rootMessenger } = await executePollingWithPendingStatus(); const txMetaId = 'nonExistentTxId'; - const bridgeHistoryItem = - bridgeStatusController.getBridgeHistoryItemByTxMetaId(txMetaId); + const bridgeHistoryItem = rootMessenger.call( + 'BridgeStatusController:getBridgeHistoryItemByTxMetaId', + txMetaId, + ); expect(bridgeHistoryItem).toBeUndefined(); }); - it('handles the case when txHistory is empty', () => { - const bridgeStatusController = new BridgeStatusController({ - messenger: getMessengerMock(), - clientId: BridgeClientId.EXTENSION, - fetchFn: jest.fn(), - addTransactionBatchFn: jest.fn(), - state: EMPTY_INIT_STATE, - }); - - const txMetaId = 'anyTxId'; - const bridgeHistoryItem = - bridgeStatusController.getBridgeHistoryItemByTxMetaId(txMetaId); - - expect(bridgeHistoryItem).toBeUndefined(); + it('handles the case when txHistory is empty', async () => { + await withController( + { options: { state: EMPTY_INIT_STATE } }, + async ({ rootMessenger }) => { + const bridgeHistoryItem = rootMessenger.call( + 'BridgeStatusController:getBridgeHistoryItemByTxMetaId', + 'anyTxId', + ); + expect(bridgeHistoryItem).toBeUndefined(); + }, + ); }); - it('returns the correct transaction when multiple transactions exist', () => { - const bridgeStatusController = new BridgeStatusController({ - messenger: getMessengerMock(), - clientId: BridgeClientId.EXTENSION, - fetchFn: jest.fn(), - addTransactionBatchFn: jest.fn(), - state: { - txHistory: { - bridgeTxMetaId1: { - ...MockTxHistory.getPending().bridgeTxMetaId1, - quote: { - ...MockTxHistory.getPending().bridgeTxMetaId1.quote, - srcChainId: 10, - destChainId: 137, - }, - }, - anotherTxId: { - ...MockTxHistory.getPending().bridgeTxMetaId1, - txMetaId: 'anotherTxId', - quote: { - ...MockTxHistory.getPending().bridgeTxMetaId1.quote, - srcChainId: 1, - destChainId: 42161, + it('returns the correct transaction when multiple transactions exist', async () => { + await withController( + { + options: { + state: { + txHistory: { + bridgeTxMetaId1: { + ...MockTxHistory.getPending().bridgeTxMetaId1, + quote: { + ...MockTxHistory.getPending().bridgeTxMetaId1.quote, + srcChainId: 10, + destChainId: 137, + }, + }, + anotherTxId: { + ...MockTxHistory.getPending().bridgeTxMetaId1, + txMetaId: 'anotherTxId', + quote: { + ...MockTxHistory.getPending().bridgeTxMetaId1.quote, + srcChainId: 1, + destChainId: 42161, + }, + }, }, }, }, }, - }); + async ({ rootMessenger }) => { + // Get the first transaction + const firstTransaction = rootMessenger.call( + 'BridgeStatusController:getBridgeHistoryItemByTxMetaId', + 'bridgeTxMetaId1', + ); + expect(firstTransaction?.quote.srcChainId).toBe(10); + expect(firstTransaction?.quote.destChainId).toBe(137); - // Get the first transaction - const firstTransaction = - bridgeStatusController.getBridgeHistoryItemByTxMetaId( - 'bridgeTxMetaId1', - ); - expect(firstTransaction?.quote.srcChainId).toBe(10); - expect(firstTransaction?.quote.destChainId).toBe(137); - - // Get the second transaction - const secondTransaction = - bridgeStatusController.getBridgeHistoryItemByTxMetaId('anotherTxId'); - expect(secondTransaction?.quote.srcChainId).toBe(1); - expect(secondTransaction?.quote.destChainId).toBe(42161); + // Get the second transaction + const secondTransaction = rootMessenger.call( + 'BridgeStatusController:getBridgeHistoryItemByTxMetaId', + 'anotherTxId', + ); + expect(secondTransaction?.quote.srcChainId).toBe(1); + expect(secondTransaction?.quote.destChainId).toBe(42161); + }, + ); }); }); @@ -1362,346 +1386,233 @@ describe('BridgeStatusController', () => { // Setup jest.useFakeTimers(); - let getSelectedMultichainAccountCalledTimes = 0; - const messengerMock = { - call: jest.fn((method: string) => { - if (method === 'AccountsController:getSelectedMultichainAccount') { - let account; + await withController(async ({ controller, rootMessenger, messenger }) => { + registerDefaultActionHandlers(rootMessenger, { + account: '0xaccount1', + }); + const messengerCallSpy = jest.spyOn(messenger, 'call'); - if (getSelectedMultichainAccountCalledTimes === 0) { - account = '0xaccount1'; - } else { - account = '0xaccount2'; - } - getSelectedMultichainAccountCalledTimes += 1; - return { address: account }; - } else if ( - method === 'NetworkController:findNetworkClientIdByChainId' - ) { - return 'networkClientId'; - } else if (method === 'NetworkController:getState') { - return { selectedNetworkClientId: 'networkClientId' }; - } else if (method === 'NetworkController:getNetworkClientById') { + const fetchBridgeTxStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockImplementationOnce(async () => { return { - configuration: { - chainId: numberToHex(42161), - }, + status: MockStatusResponse.getComplete(), + validationFailures: [], }; - } else if (method === 'TransactionController:getState') { + }) + .mockImplementationOnce(async () => { return { - transactions: [{ id: 'bridgeTxMetaId1', hash: '0xsrcTxHash1' }], + status: MockStatusResponse.getComplete({ + srcTxHash: '0xsrcTxHash2', + destTxHash: '0xdestTxHash2', + }), + validationFailures: [], }; - } else if (method === 'AuthenticationController:getBearerToken') { - return 'AUTH_TOKEN'; - } - return null; - }), - subscribe: mockMessengerSubscribe, - publish: jest.fn(), - registerActionHandler: jest.fn(), - registerInitialEventPayload: jest.fn(), - } as unknown as jest.Mocked; - const bridgeStatusController = new BridgeStatusController({ - messenger: messengerMock, - clientId: BridgeClientId.EXTENSION, - fetchFn: jest.fn(), - addTransactionBatchFn: jest.fn(), - }); - const fetchBridgeTxStatusSpy = jest - .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') - .mockImplementationOnce(async () => { - return { - status: MockStatusResponse.getComplete(), - validationFailures: [], - }; - }) - .mockImplementationOnce(async () => { - return { - status: MockStatusResponse.getComplete({ - srcTxHash: '0xsrcTxHash2', - destTxHash: '0xdestTxHash2', - }), - validationFailures: [], - }; - }); + }); - bridgeStatusController.startPollingForBridgeTxStatus( - getMockStartPollingForBridgeTxStatusArgs(), - ); - jest.advanceTimersToNextTimer(); - await flushPromises(); - jest.advanceTimersByTime(10_000); - expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); - - // Start polling for 0xaccount2 - bridgeStatusController.startPollingForBridgeTxStatus( - getMockStartPollingForBridgeTxStatusArgs({ - txMetaId: 'bridgeTxMetaId2', - srcTxHash: '0xsrcTxHash2', - account: '0xaccount2', - }), - ); - jest.advanceTimersByTime(10_000); - jest.advanceTimersToNextTimer(); - await flushPromises(); - expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); - - // Check that both accounts have a tx history entry - expect(bridgeStatusController.state.txHistory).toHaveProperty( - 'bridgeTxMetaId1', - ); - expect(bridgeStatusController.state.txHistory).toHaveProperty( - 'bridgeTxMetaId2', - ); + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs(), + ); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersByTime(10_000); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + + // Start polling for 0xaccount2 + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs({ + txMetaId: 'bridgeTxMetaId2', + srcTxHash: '0xsrcTxHash2', + account: '0xaccount2', + }), + ); + jest.advanceTimersByTime(10_000); + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); - // Wipe the status for 1 account only - bridgeStatusController.wipeBridgeStatus({ - address: '0xaccount1', - ignoreNetwork: false, - }); + // Check that both accounts have a tx history entry + expect(controller.state.txHistory).toHaveProperty('bridgeTxMetaId1'); + expect(controller.state.txHistory).toHaveProperty('bridgeTxMetaId2'); - // Assertions - const txHistoryItems = Object.values( - bridgeStatusController.state.txHistory, - ); - expect(txHistoryItems).toHaveLength(1); - expect(txHistoryItems[0].account).toBe('0xaccount2'); - const { calls } = messengerMock.call.mock; - expect(calls.map((call) => call.slice(0, 2))).toMatchSnapshot(); + // Wipe the status for 1 account only + rootMessenger.call('BridgeStatusController:wipeBridgeStatus', { + address: '0xaccount1', + ignoreNetwork: false, + }); + + // Assertions + const txHistoryItems = Object.values(controller.state.txHistory); + expect(txHistoryItems).toHaveLength(1); + expect(txHistoryItems[0].account).toBe('0xaccount2'); + expect( + messengerCallSpy.mock.calls.map((call) => call.slice(0, 2)), + ).toMatchSnapshot(); + }); }); it('wipes the bridge status for all networks if ignoreNetwork is true', async () => { // Setup jest.useFakeTimers(); - const messengerMock = { - call: jest.fn((method: string) => { - if (method === 'AuthenticationController:getBearerToken') { - return 'AUTH_TOKEN'; - } - if (method === 'AccountsController:getSelectedMultichainAccount') { - return { address: '0xaccount1' }; - } else if ( - method === 'NetworkController:findNetworkClientIdByChainId' - ) { - return 'networkClientId'; - } else if (method === 'NetworkController:getState') { - return { selectedNetworkClientId: 'networkClientId' }; - } else if (method === 'NetworkController:getNetworkClientById') { + await withController(async ({ controller, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + const fetchBridgeTxStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockImplementationOnce(async () => { return { - configuration: { - chainId: numberToHex(42161), - }, + status: MockStatusResponse.getComplete(), + validationFailures: [], }; - } else if (method === 'TransactionController:getState') { + }) + .mockImplementationOnce(async () => { return { - transactions: [{ id: 'bridgeTxMetaId1', hash: '0xsrcTxHash1' }], + status: MockStatusResponse.getComplete({ + srcTxHash: '0xsrcTxHash2', + }), + validationFailures: [], }; - } - return null; - }), - subscribe: mockMessengerSubscribe, - publish: jest.fn(), - registerActionHandler: jest.fn(), - registerInitialEventPayload: jest.fn(), - } as unknown as jest.Mocked; - const bridgeStatusController = new BridgeStatusController({ - messenger: messengerMock, - clientId: BridgeClientId.EXTENSION, - fetchFn: jest.fn(), - addTransactionBatchFn: jest.fn(), - }); - const fetchBridgeTxStatusSpy = jest - .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') - .mockImplementationOnce(async () => { - return { - status: MockStatusResponse.getComplete(), - validationFailures: [], - }; - }) - .mockImplementationOnce(async () => { - return { - status: MockStatusResponse.getComplete({ - srcTxHash: '0xsrcTxHash2', - }), - validationFailures: [], - }; - }); + }); - // Start polling for chainId 42161 to chainId 1 - bridgeStatusController.startPollingForBridgeTxStatus( - getMockStartPollingForBridgeTxStatusArgs({ - account: '0xaccount1', - srcTxHash: '0xsrcTxHash1', - txMetaId: 'bridgeTxMetaId1', - srcChainId: 42161, - destChainId: 1, - }), - ); - jest.advanceTimersToNextTimer(); - jest.advanceTimersToNextTimer(); - await flushPromises(); - expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); - - // Start polling for chainId 10 to chainId 123 - bridgeStatusController.startPollingForBridgeTxStatus( - getMockStartPollingForBridgeTxStatusArgs({ - account: '0xaccount1', - srcTxHash: '0xsrcTxHash2', - txMetaId: 'bridgeTxMetaId2', - srcChainId: 10, - destChainId: 123, - }), - ); - jest.advanceTimersToNextTimer(); - await flushPromises(); - expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); - - // Check we have a tx history entry for each chainId - expect( - bridgeStatusController.state.txHistory.bridgeTxMetaId1.quote.srcChainId, - ).toBe(42161); - expect( - bridgeStatusController.state.txHistory.bridgeTxMetaId1.quote - .destChainId, - ).toBe(1); - - expect( - bridgeStatusController.state.txHistory.bridgeTxMetaId2.quote.srcChainId, - ).toBe(10); - expect( - bridgeStatusController.state.txHistory.bridgeTxMetaId2.quote - .destChainId, - ).toBe(123); - - bridgeStatusController.wipeBridgeStatus({ - address: '0xaccount1', - ignoreNetwork: true, - }); + // Start polling for chainId 42161 to chainId 1 + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs({ + account: '0xaccount1', + srcTxHash: '0xsrcTxHash1', + txMetaId: 'bridgeTxMetaId1', + srcChainId: 42161, + destChainId: 1, + }), + ); + jest.advanceTimersToNextTimer(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + + // Start polling for chainId 10 to chainId 123 + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs({ + account: '0xaccount1', + srcTxHash: '0xsrcTxHash2', + txMetaId: 'bridgeTxMetaId2', + srcChainId: 10, + destChainId: 123, + }), + ); + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); + + // Check we have a tx history entry for each chainId + expect( + controller.state.txHistory.bridgeTxMetaId1.quote.srcChainId, + ).toBe(42161); + expect( + controller.state.txHistory.bridgeTxMetaId1.quote.destChainId, + ).toBe(1); + + expect( + controller.state.txHistory.bridgeTxMetaId2.quote.srcChainId, + ).toBe(10); + expect( + controller.state.txHistory.bridgeTxMetaId2.quote.destChainId, + ).toBe(123); - // Assertions - const txHistoryItems = Object.values( - bridgeStatusController.state.txHistory, - ); - expect(txHistoryItems).toHaveLength(0); + rootMessenger.call('BridgeStatusController:wipeBridgeStatus', { + address: '0xaccount1', + ignoreNetwork: true, + }); + + // Assertions + const txHistoryItems = Object.values(controller.state.txHistory); + expect(txHistoryItems).toHaveLength(0); + }); }); it('wipes the bridge status only for the current network if ignoreNetwork is false', async () => { // Setup jest.useFakeTimers(); - const messengerMock = { - call: jest.fn((method: string) => { - if (method === 'AccountsController:getSelectedMultichainAccount') { - return { address: '0xaccount1' }; - } else if ( - method === 'NetworkController:findNetworkClientIdByChainId' - ) { - return 'networkClientId'; - } else if (method === 'NetworkController:getState') { - return { selectedNetworkClientId: 'networkClientId' }; - } else if (method === 'NetworkController:getNetworkClientById') { + await withController(async ({ controller, rootMessenger }) => { + // This is what controls the selectedNetwork and what gets wiped in this test + registerDefaultActionHandlers(rootMessenger); + const fetchBridgeTxStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockImplementationOnce(async () => { return { - configuration: { - // This is what controls the selectedNetwork and what gets wiped in this test - chainId: numberToHex(42161), - }, + status: MockStatusResponse.getComplete(), + validationFailures: [], }; - } else if (method === 'TransactionController:getState') { + }) + .mockImplementationOnce(async () => { return { - transactions: [{ id: 'bridgeTxMetaId1', hash: '0xsrcTxHash1' }], + status: MockStatusResponse.getComplete({ + srcTxHash: '0xsrcTxHash2', + }), + validationFailures: [], }; - } - if (method === 'AuthenticationController:getBearerToken') { - return 'AUTH_TOKEN'; - } - return null; - }), - subscribe: mockMessengerSubscribe, - publish: jest.fn(), - registerActionHandler: jest.fn(), - registerInitialEventPayload: jest.fn(), - } as unknown as jest.Mocked; - const bridgeStatusController = new BridgeStatusController({ - messenger: messengerMock, - clientId: BridgeClientId.EXTENSION, - fetchFn: jest.fn(), - addTransactionBatchFn: jest.fn(), - }); - const fetchBridgeTxStatusSpy = jest - .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') - .mockImplementationOnce(async () => { - return { - status: MockStatusResponse.getComplete(), - validationFailures: [], - }; - }) - .mockImplementationOnce(async () => { - return { - status: MockStatusResponse.getComplete({ - srcTxHash: '0xsrcTxHash2', - }), - validationFailures: [], - }; + }); + + // Start polling for chainId 42161 to chainId 1 + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs({ + account: '0xaccount1', + srcTxHash: '0xsrcTxHash1', + txMetaId: 'bridgeTxMetaId1', + srcChainId: 42161, + destChainId: 1, + }), + ); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersByTime(10_000); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + + // Start polling for chainId 10 to chainId 123 + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs({ + account: '0xaccount1', + srcTxHash: '0xsrcTxHash2', + txMetaId: 'bridgeTxMetaId2', + srcChainId: 10, + destChainId: 123, + }), + ); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersByTime(10_000); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); + + // Check we have a tx history entry for each chainId + expect( + controller.state.txHistory.bridgeTxMetaId1.quote.srcChainId, + ).toBe(42161); + expect( + controller.state.txHistory.bridgeTxMetaId1.quote.destChainId, + ).toBe(1); + + expect( + controller.state.txHistory.bridgeTxMetaId2.quote.srcChainId, + ).toBe(10); + expect( + controller.state.txHistory.bridgeTxMetaId2.quote.destChainId, + ).toBe(123); + + rootMessenger.call('BridgeStatusController:wipeBridgeStatus', { + address: '0xaccount1', + ignoreNetwork: false, }); - // Start polling for chainId 42161 to chainId 1 - bridgeStatusController.startPollingForBridgeTxStatus( - getMockStartPollingForBridgeTxStatusArgs({ - account: '0xaccount1', - srcTxHash: '0xsrcTxHash1', - txMetaId: 'bridgeTxMetaId1', - srcChainId: 42161, - destChainId: 1, - }), - ); - jest.advanceTimersToNextTimer(); - await flushPromises(); - jest.advanceTimersByTime(10_000); - expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); - - // Start polling for chainId 10 to chainId 123 - bridgeStatusController.startPollingForBridgeTxStatus( - getMockStartPollingForBridgeTxStatusArgs({ - account: '0xaccount1', - srcTxHash: '0xsrcTxHash2', - txMetaId: 'bridgeTxMetaId2', - srcChainId: 10, - destChainId: 123, - }), - ); - jest.advanceTimersToNextTimer(); - await flushPromises(); - jest.advanceTimersByTime(10_000); - expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); - - // Check we have a tx history entry for each chainId - expect( - bridgeStatusController.state.txHistory.bridgeTxMetaId1.quote.srcChainId, - ).toBe(42161); - expect( - bridgeStatusController.state.txHistory.bridgeTxMetaId1.quote - .destChainId, - ).toBe(1); - - expect( - bridgeStatusController.state.txHistory.bridgeTxMetaId2.quote.srcChainId, - ).toBe(10); - expect( - bridgeStatusController.state.txHistory.bridgeTxMetaId2.quote - .destChainId, - ).toBe(123); - - bridgeStatusController.wipeBridgeStatus({ - address: '0xaccount1', - ignoreNetwork: false, + // Assertions + const txHistoryItems = Object.values(controller.state.txHistory); + expect(txHistoryItems).toHaveLength(1); + expect(txHistoryItems[0].quote.srcChainId).toBe(10); + expect(txHistoryItems[0].quote.destChainId).toBe(123); }); - - // Assertions - const txHistoryItems = Object.values( - bridgeStatusController.state.txHistory, - ); - expect(txHistoryItems).toHaveLength(1); - expect(txHistoryItems[0].quote.srcChainId).toBe(10); - expect(txHistoryItems[0].quote.destChainId).toBe(123); }); }); @@ -1853,22 +1764,30 @@ describe('BridgeStatusController', () => { transactions: [], }); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - const result = await controller.submitTx( - 'SOLaccountAddress', - mockQuoteResponse, - false, + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + 'SOLaccountAddress', + mockQuoteResponse, + false, + ); + controller.stopAllPolling(); + + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + expect( + startPollingForBridgeTxStatusSpy.mock.lastCall[0], + ).toMatchSnapshot(); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + }, ); - controller.stopAllPolling(); - - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); - expect(result).toMatchSnapshot(); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(1); - expect( - startPollingForBridgeTxStatusSpy.mock.lastCall[0], - ).toMatchSnapshot(); - expect(controller.state.txHistory[result.id]).toMatchSnapshot(); }); it('should throw error when snap ID is missing', async () => { @@ -1879,30 +1798,44 @@ describe('BridgeStatusController', () => { mockMessengerCall.mockReturnValueOnce(accountWithoutSnap); mockMessengerCall.mockImplementationOnce(jest.fn()); // track event - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - - await expect( - controller.submitTx('SOLaccountAddress', mockQuoteResponse, false), - ).rejects.toThrow( - 'Failed to submit cross-chain swap transaction: undefined snap id', + await withController( + { mockMessengerCall }, + async ({ rootMessenger, startPollingForBridgeTxStatusSpy }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + 'SOLaccountAddress', + mockQuoteResponse, + false, + ), + ).rejects.toThrow( + 'Failed to submit cross-chain swap transaction: undefined snap id', + ); + expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + }, ); - expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); }); it('should throw error when account is missing', async () => { mockMessengerCall.mockReturnValueOnce(undefined); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - - await expect( - controller.submitTx('SOLaccountAddress', mockQuoteResponse, false), - ).rejects.toThrow( - 'Failed to submit cross-chain swap transaction: undefined multichain account', + await withController( + { mockMessengerCall }, + async ({ rootMessenger, startPollingForBridgeTxStatusSpy }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + 'SOLaccountAddress', + mockQuoteResponse, + false, + ), + ).rejects.toThrow( + 'Failed to submit cross-chain swap transaction: undefined multichain account', + ); + expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); + }, ); - expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); }); it('should handle snap controller errors', async () => { @@ -1910,14 +1843,21 @@ describe('BridgeStatusController', () => { mockMessengerCall.mockImplementationOnce(jest.fn()); // track event mockMessengerCall.mockRejectedValueOnce(new Error('Snap error')); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - - await expect( - controller.submitTx('SOLaccountAddress', mockQuoteResponse, false), - ).rejects.toThrow('Snap error'); - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); - expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); + await withController( + { mockMessengerCall }, + async ({ rootMessenger, startPollingForBridgeTxStatusSpy }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + 'SOLaccountAddress', + mockQuoteResponse, + false, + ), + ).rejects.toThrow('Snap error'); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); + }, + ); }); }); @@ -2071,19 +2011,27 @@ describe('BridgeStatusController', () => { transactions: [], }); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - const result = await controller.submitTx( - 'SOLaccountAddress', - mockQuoteResponse, - false, - ); - controller.stopAllPolling(); + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + 'SOLaccountAddress', + mockQuoteResponse, + false, + ); + controller.stopAllPolling(); - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); - expect(result).toMatchSnapshot(); - expect(controller.state.txHistory[result.id]).toMatchSnapshot(); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(result).toMatchSnapshot(); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + }, + ); }); it('should throw error when snap ID is missing', async () => { @@ -2094,31 +2042,45 @@ describe('BridgeStatusController', () => { mockMessengerCall.mockReturnValueOnce(accountWithoutSnap); mockMessengerCall.mockImplementationOnce(jest.fn()); // track event - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - - await expect( - controller.submitTx('SOLaccountAddress', mockQuoteResponse, false), - ).rejects.toThrow( - 'Failed to submit cross-chain swap transaction: undefined snap id', + await withController( + { mockMessengerCall }, + async ({ rootMessenger, startPollingForBridgeTxStatusSpy }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + 'SOLaccountAddress', + mockQuoteResponse, + false, + ), + ).rejects.toThrow( + 'Failed to submit cross-chain swap transaction: undefined snap id', + ); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); + }, ); - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); - expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); }); it('should throw error when account is missing', async () => { mockMessengerCall.mockReturnValueOnce(undefined); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - - await expect( - controller.submitTx('SOLaccountAddress', mockQuoteResponse, false), - ).rejects.toThrow( - 'Failed to submit cross-chain swap transaction: undefined multichain account', + await withController( + { mockMessengerCall }, + async ({ rootMessenger, startPollingForBridgeTxStatusSpy }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + 'SOLaccountAddress', + mockQuoteResponse, + false, + ), + ).rejects.toThrow( + 'Failed to submit cross-chain swap transaction: undefined multichain account', + ); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); + }, ); - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); - expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); }); it('should handle snap controller errors', async () => { @@ -2126,14 +2088,21 @@ describe('BridgeStatusController', () => { mockMessengerCall.mockImplementationOnce(jest.fn()); // track event mockMessengerCall.mockRejectedValueOnce(new Error('Snap error')); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - - await expect( - controller.submitTx('SOLaccountAddress', mockQuoteResponse, false), - ).rejects.toThrow('Snap error'); - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); - expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); + await withController( + { mockMessengerCall }, + async ({ rootMessenger, startPollingForBridgeTxStatusSpy }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + 'SOLaccountAddress', + mockQuoteResponse, + false, + ), + ).rejects.toThrow('Snap error'); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); + }, + ); }); }); @@ -2294,20 +2263,28 @@ describe('BridgeStatusController', () => { mockMessengerCall.mockResolvedValueOnce('approval-signature'); // approval tx mockMessengerCall.mockResolvedValueOnce('swap-signature'); // swap tx - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - const result = await controller.submitTx( - 'TRXaccountAddress', - mockQuoteResponse, - false, - ); - controller.stopAllPolling(); + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + 'TRXaccountAddress', + mockQuoteResponse, + false, + ); + controller.stopAllPolling(); - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); - expect(result).toMatchSnapshot(); - // Tron swaps start polling for async settlement - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(1); - expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(result).toMatchSnapshot(); + // Tron swaps start polling for async settlement + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + }, + ); }); it('should handle approval transaction errors', async () => { @@ -2317,14 +2294,21 @@ describe('BridgeStatusController', () => { new Error('Approval transaction failed'), ); // approval tx error - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - - await expect( - controller.submitTx('TRXaccountAddress', mockQuoteResponse, false), - ).rejects.toThrow('Approval transaction failed'); - expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + await withController( + { mockMessengerCall }, + async ({ rootMessenger, startPollingForBridgeTxStatusSpy }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + 'TRXaccountAddress', + mockQuoteResponse, + false, + ), + ).rejects.toThrow('Approval transaction failed'); + expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + }, + ); }); it('should successfully submit a Tron bridge with approval transaction', async () => { @@ -2341,22 +2325,30 @@ describe('BridgeStatusController', () => { mockMessengerCall.mockResolvedValueOnce('approval-signature'); // approval tx mockMessengerCall.mockResolvedValueOnce('bridge-signature'); // bridge tx - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - const result = await controller.submitTx( - 'TRXaccountAddress', - mockTronBridgeQuote, - false, + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + 'TRXaccountAddress', + mockTronBridgeQuote, + false, + ); + controller.stopAllPolling(); + + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + expect( + startPollingForBridgeTxStatusSpy.mock.lastCall[0], + ).toMatchSnapshot(); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + }, ); - controller.stopAllPolling(); - - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); - expect(result).toMatchSnapshot(); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(1); - expect( - startPollingForBridgeTxStatusSpy.mock.lastCall[0], - ).toMatchSnapshot(); - expect(controller.state.txHistory[result.id]).toMatchSnapshot(); }); }); @@ -2549,54 +2541,70 @@ describe('BridgeStatusController', () => { setupApprovalMocks(mockMessengerCall); setupBridgeMocks(mockMessengerCall); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - const result = await controller.submitTx( - 'otherAccount', - mockEvmQuoteResponse, - false, - ); + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + 'otherAccount', + mockEvmQuoteResponse, + false, + ); - expect(result).toMatchSnapshot(); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - expect(controller.state.txHistory[result.id]).toMatchSnapshot(); - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); - controller.stopAllPolling(); + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + controller.stopAllPolling(); + }, + ); }); it('should successfully submit an EVM bridge transaction with no approval', async () => { setupEventTrackingMocks(mockMessengerCall); setupBridgeMocks(mockMessengerCall); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - const erc20Token = { - address: '0x0000000000000000000000000000000000000032', - assetId: `eip155:10/slip44:60` as CaipAssetType, - chainId: 10, - symbol: 'WETH', - decimals: 18, - name: 'WETH', - coinKey: 'WETH', - logoURI: - 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', - priceUSD: '2478.63', - icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', - }; - const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; - const result = await controller.submitTx( - (quoteWithoutApproval.trade as TxData).from, - { - ...quoteWithoutApproval, - quote: { ...quoteWithoutApproval.quote, destAsset: erc20Token }, + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const erc20Token = { + address: '0x0000000000000000000000000000000000000032', + assetId: `eip155:10/slip44:60` as CaipAssetType, + chainId: 10, + symbol: 'WETH', + decimals: 18, + name: 'WETH', + coinKey: 'WETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.63', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }; + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (quoteWithoutApproval.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { ...quoteWithoutApproval.quote, destAsset: erc20Token }, + }, + false, + ); + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); }, - false, ); - - expect(result).toMatchSnapshot(); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - expect(controller.state.txHistory[result.id]).toMatchSnapshot(); - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); }); it('should handle smart transactions and include quotesReceivedContext', async () => { @@ -2606,109 +2614,145 @@ describe('BridgeStatusController', () => { batchId: 'batchId1', }); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; - const result = await controller.submitTx( - (quoteWithoutApproval.trade as TxData).from, - quoteWithoutApproval, - true, - getQuotesReceivedProperties(quoteWithoutApproval, ['low_return'], true), - ); - controller.stopAllPolling(); + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (quoteWithoutApproval.trade as TxData).from, + quoteWithoutApproval, + true, + getQuotesReceivedProperties( + quoteWithoutApproval, + ['low_return'], + true, + ), + ); + controller.stopAllPolling(); - expect(result).toMatchSnapshot(); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - expect(controller.state.txHistory[result.id]).toMatchSnapshot(); - expect(addTransactionBatchFn.mock.calls).toMatchSnapshot(); - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(addTransactionBatchFn.mock.calls).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + }, + ); }); it('should throw an error if account is not found', async () => { setupEventTrackingMocks(mockMessengerCall); mockMessengerCall.mockReturnValueOnce(undefined); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; - - await expect( - controller.submitTx( - (quoteWithoutApproval.trade as TxData).from, - quoteWithoutApproval, - false, - ), - ).rejects.toThrow( - 'Failed to submit cross-chain swap transaction: unknown account in trade data', - ); - controller.stopAllPolling(); + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + (quoteWithoutApproval.trade as TxData).from, + quoteWithoutApproval, + false, + ), + ).rejects.toThrow( + 'Failed to submit cross-chain swap transaction: unknown account in trade data', + ); + controller.stopAllPolling(); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - const addTransactionCall = mockMessengerCall.mock.calls.find( - (call) => call[0] === 'TransactionController:addTransaction', + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + const addTransactionCall = mockMessengerCall.mock.calls.find( + (call) => call[0] === 'TransactionController:addTransaction', + ); + expect(addTransactionCall).toBeUndefined(); + }, ); - expect(addTransactionCall).toBeUndefined(); }); it('should throw an error if EVM trade data is not valid', async () => { setupEventTrackingMocks(mockMessengerCall); mockMessengerCall.mockReturnValueOnce(undefined); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; - - await expect( - controller.submitTx( - (quoteWithoutApproval.trade as TxData).from, - { - ...quoteWithoutApproval, - trade: (quoteWithoutApproval.trade as TxData).data, - }, - false, - ), - ).rejects.toThrow( - 'Failed to submit cross-chain swap transaction: trade is not an EVM transaction', - ); - controller.stopAllPolling(); + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + (quoteWithoutApproval.trade as TxData).from, + { + ...quoteWithoutApproval, + trade: (quoteWithoutApproval.trade as TxData).data, + }, + false, + ), + ).rejects.toThrow( + 'Failed to submit cross-chain swap transaction: trade is not an EVM transaction', + ); + controller.stopAllPolling(); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - const addTransactionCall = mockMessengerCall.mock.calls.find( - (call) => call[0] === 'TransactionController:addTransaction', + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + const addTransactionCall = mockMessengerCall.mock.calls.find( + (call) => call[0] === 'TransactionController:addTransaction', + ); + expect(addTransactionCall).toBeUndefined(); + }, ); - expect(addTransactionCall).toBeUndefined(); }); it('should throw an error if Solana trade data is not valid', async () => { setupEventTrackingMocks(mockMessengerCall); mockMessengerCall.mockReturnValueOnce(undefined); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; - - await expect( - controller.submitTx( - (quoteWithoutApproval.trade as TxData).from, - { - ...quoteWithoutApproval, - quote: { - ...quoteWithoutApproval.quote, - srcChainId: ChainId.SOLANA, - }, - }, - false, - ), - ).rejects.toThrow( - 'Failed to submit cross-chain swap transaction: trade is not a non-EVM transaction', - ); - controller.stopAllPolling(); + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + (quoteWithoutApproval.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { + ...quoteWithoutApproval.quote, + srcChainId: ChainId.SOLANA, + }, + }, + false, + ), + ).rejects.toThrow( + 'Failed to submit cross-chain swap transaction: trade is not a non-EVM transaction', + ); + controller.stopAllPolling(); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - const addTransactionCall = mockMessengerCall.mock.calls.find( - (call) => call[0] === 'TransactionController:addTransaction', + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + const addTransactionCall = mockMessengerCall.mock.calls.find( + (call) => call[0] === 'TransactionController:addTransaction', + ); + expect(addTransactionCall).toBeUndefined(); + }, ); - expect(addTransactionCall).toBeUndefined(); }); it('should reset USDT allowance', async () => { @@ -2724,29 +2768,37 @@ describe('BridgeStatusController', () => { // Bridge transaction setupBridgeMocks(mockMessengerCall); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - const result = await controller.submitTx( - (mockEvmQuoteResponse.trade as TxData).from, - { - ...mockEvmQuoteResponse, - resetApproval: { - chainId: 1, - data: '0x095ea7b3000000000000000000000000881d40237659c251811cec9c364ef91dc08d300c0000000000000000000000000000000000000000000000000000000000000000', - from: '0xaccount1', - gasLimit: 21000, - to: '0xtokenContract', - value: '0x0', - }, + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + { + ...mockEvmQuoteResponse, + resetApproval: { + chainId: 1, + data: '0x095ea7b3000000000000000000000000881d40237659c251811cec9c364ef91dc08d300c0000000000000000000000000000000000000000000000000000000000000000', + from: '0xaccount1', + gasLimit: 21000, + to: '0xtokenContract', + value: '0x0', + }, + }, + false, + ); + controller.stopAllPolling(); + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); }, - false, ); - controller.stopAllPolling(); - - expect(result).toMatchSnapshot(); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - expect(controller.state.txHistory[result.id]).toMatchSnapshot(); - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); }); it('should handle smart transactions with USDT reset', async () => { @@ -2773,49 +2825,58 @@ describe('BridgeStatusController', () => { }); mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - const result = await controller.submitTx( - (mockEvmQuoteResponse.trade as TxData).from, - { - ...mockEvmQuoteResponse, - resetApproval: { - chainId: 1, - data: '0x095ea7b3000000000000000000000000881d40237659c251811cec9c364ef91dc08d300c0000000000000000000000000000000000000000000000000000000000000000', - from: '0xaccount1', - gasLimit: 21000, - to: '0xtokenContract', - value: '0x0', - }, + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + { + ...mockEvmQuoteResponse, + resetApproval: { + chainId: 1, + data: '0x095ea7b3000000000000000000000000881d40237659c251811cec9c364ef91dc08d300c0000000000000000000000000000000000000000000000000000000000000000', + from: '0xaccount1', + gasLimit: 21000, + to: '0xtokenContract', + value: '0x0', + }, + }, + true, + ); + controller.stopAllPolling(); + + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + const { quote, txMetaId, batchId } = + controller.state.txHistory[result.id]; + expect(quote).toBeDefined(); + expect(txMetaId).toBe(result.id); + expect(batchId).toBe('batchId1'); + const mockCalls = mockMessengerCall.mock.calls; + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:estimateGasFee', + ), + ).toHaveLength(3); + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ), + ).toHaveLength(0); + expect(addTransactionBatchFn).toHaveBeenCalledTimes(1); + expect( + mockCalls.filter( + ([action]) => + action === 'TransactionController:updateTransaction', + ), + ).toHaveLength(1); + expect(mockMessengerCall).toHaveBeenCalledTimes(14); }, - true, ); - controller.stopAllPolling(); - - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - const { quote, txMetaId, batchId } = - controller.state.txHistory[result.id]; - expect(quote).toBeDefined(); - expect(txMetaId).toBe(result.id); - expect(batchId).toBe('batchId1'); - const mockCalls = mockMessengerCall.mock.calls; - expect( - mockCalls.filter( - ([action]) => action === 'TransactionController:estimateGasFee', - ), - ).toHaveLength(3); - expect( - mockCalls.filter( - ([action]) => action === 'TransactionController:addTransaction', - ), - ).toHaveLength(0); - expect(addTransactionBatchFn).toHaveBeenCalledTimes(1); - expect( - mockCalls.filter( - ([action]) => action === 'TransactionController:updateTransaction', - ), - ).toHaveLength(1); - expect(mockMessengerCall).toHaveBeenCalledTimes(14); }); it('should throw an error if approval tx fails', async () => { @@ -2828,19 +2889,22 @@ describe('BridgeStatusController', () => { mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); mockMessengerCall.mockRejectedValueOnce(new Error('Approval tx failed')); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - - await expect( - controller.submitTx( - (mockEvmQuoteResponse.trade as TxData).from, - mockEvmQuoteResponse, - false, - ), - ).rejects.toThrow('Approval tx failed'); - - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + await withController( + { mockMessengerCall }, + async ({ rootMessenger, startPollingForBridgeTxStatusSpy }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + mockEvmQuoteResponse, + false, + ), + ).rejects.toThrow('Approval tx failed'); + + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + }, + ); }); it('should throw an error if approval tx meta does not exist', async () => { @@ -2860,21 +2924,25 @@ describe('BridgeStatusController', () => { }); setupBridgeMocks(mockMessengerCall); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - - await expect( - controller.submitTx( - (mockEvmQuoteResponse.trade as TxData).from, - mockEvmQuoteResponse, - false, - ), - ).rejects.toThrow( - 'Failed to submit cross-chain swap tx: txMeta for txHash was not found', - ); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + await withController( + { mockMessengerCall }, + async ({ rootMessenger, startPollingForBridgeTxStatusSpy }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + mockEvmQuoteResponse, + false, + ), + ).rejects.toThrow( + 'Failed to submit cross-chain swap tx: txMeta for txHash was not found', + ); + + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + }, + ); }); it('should delay after submitting linea approval', async () => { @@ -2889,34 +2957,40 @@ describe('BridgeStatusController', () => { setupApprovalMocks(mockMessengerCall); setupBridgeMocks(mockMessengerCall); - const { controller, startPollingForBridgeTxStatusSpy } = getController( - mockMessengerCall, - mockTraceFn, - ); - - const lineaQuoteResponse = { - ...mockEvmQuoteResponse, - quote: { ...mockEvmQuoteResponse.quote, srcChainId: 59144 }, - trade: { - ...(mockEvmQuoteResponse.trade as TxData), - gasLimit: undefined, - } as never, - }; + await withController( + { mockMessengerCall, options: { traceFn: mockTraceFn } }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const lineaQuoteResponse = { + ...mockEvmQuoteResponse, + quote: { ...mockEvmQuoteResponse.quote, srcChainId: 59144 }, + trade: { + ...(mockEvmQuoteResponse.trade as TxData), + gasLimit: undefined, + }, + }; - const result = await controller.submitTx( - 'otherAccount', - lineaQuoteResponse, - false, + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + 'otherAccount', + // @ts-expect-error: Partial mock. + lineaQuoteResponse, + false, + ); + controller.stopAllPolling(); + + expect(mockTraceFn).toHaveBeenCalledTimes(2); + expect(handleLineaDelaySpy).toHaveBeenCalledTimes(1); + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(mockTraceFn.mock.calls).toMatchSnapshot(); + }, ); - controller.stopAllPolling(); - - expect(mockTraceFn).toHaveBeenCalledTimes(2); - expect(handleLineaDelaySpy).toHaveBeenCalledTimes(1); - expect(result).toMatchSnapshot(); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - expect(controller.state.txHistory[result.id]).toMatchSnapshot(); - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); - expect(mockTraceFn.mock.calls).toMatchSnapshot(); }); it('should delay after submitting base approval', async () => { @@ -2931,34 +3005,40 @@ describe('BridgeStatusController', () => { setupApprovalMocks(mockMessengerCall); setupBridgeMocks(mockMessengerCall); - const { controller, startPollingForBridgeTxStatusSpy } = getController( - mockMessengerCall, - mockTraceFn, - ); - - const baseQuoteResponse = { - ...mockEvmQuoteResponse, - quote: { ...mockEvmQuoteResponse.quote, srcChainId: 8453 }, - trade: { - ...(mockEvmQuoteResponse.trade as TxData), - gasLimit: undefined, - } as never, - }; + await withController( + { mockMessengerCall, options: { traceFn: mockTraceFn } }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const baseQuoteResponse = { + ...mockEvmQuoteResponse, + quote: { ...mockEvmQuoteResponse.quote, srcChainId: 8453 }, + trade: { + ...(mockEvmQuoteResponse.trade as TxData), + gasLimit: undefined, + }, + }; - const result = await controller.submitTx( - 'otherAccount', - baseQuoteResponse, - false, + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + 'otherAccount', + // @ts-expect-error: Partial mock. + baseQuoteResponse, + false, + ); + controller.stopAllPolling(); + + expect(mockTraceFn).toHaveBeenCalledTimes(2); + expect(handleBaseDelaySpy).toHaveBeenCalledTimes(1); + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(mockTraceFn.mock.calls).toMatchSnapshot(); + }, ); - controller.stopAllPolling(); - - expect(mockTraceFn).toHaveBeenCalledTimes(2); - expect(handleBaseDelaySpy).toHaveBeenCalledTimes(1); - expect(result).toMatchSnapshot(); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - expect(controller.state.txHistory[result.id]).toMatchSnapshot(); - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); - expect(mockTraceFn.mock.calls).toMatchSnapshot(); }); it('waits for approval tx confirmation before swap for hardware wallet on mobile', async () => { @@ -2991,34 +3071,41 @@ describe('BridgeStatusController', () => { setupApprovalMocks(mockMessengerCall); setupBridgeMocks(mockMessengerCall); - const { controller, startPollingForBridgeTxStatusSpy } = getController( - mockMessengerCall, - mockTraceFn, - BridgeClientId.MOBILE, - ); - - const result = await controller.submitTx( - (mockEvmQuoteResponse.trade as TxData).from, - mockEvmQuoteResponse, - false, - ); - controller.stopAllPolling(); - - expect(mockTraceFn).toHaveBeenCalledTimes(2); - expect(handleMobileHardwareWalletDelaySpy).toHaveBeenCalledTimes(1); - expect(handleMobileHardwareWalletDelaySpy).toHaveBeenCalledWith(true); - expect( - handleMobileHardwareWalletDelaySpy.mock.invocationCallOrder[0], - ).toBeLessThan(waitForTxConfirmationSpy.mock.invocationCallOrder[0]); - expect(waitForTxConfirmationSpy).toHaveBeenCalledWith( - expect.any(Object), - mockApprovalTxMeta.id, + await withController( + { + mockMessengerCall, + options: { traceFn: mockTraceFn, clientId: BridgeClientId.MOBILE }, + }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + mockEvmQuoteResponse, + false, + ); + controller.stopAllPolling(); + + expect(mockTraceFn).toHaveBeenCalledTimes(2); + expect(handleMobileHardwareWalletDelaySpy).toHaveBeenCalledTimes(1); + expect(handleMobileHardwareWalletDelaySpy).toHaveBeenCalledWith(true); + expect( + handleMobileHardwareWalletDelaySpy.mock.invocationCallOrder[0], + ).toBeLessThan(waitForTxConfirmationSpy.mock.invocationCallOrder[0]); + expect(waitForTxConfirmationSpy).toHaveBeenCalledWith( + expect.any(Object), + mockApprovalTxMeta.id, + ); + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(mockTraceFn.mock.calls).toMatchSnapshot(); + }, ); - expect(result).toMatchSnapshot(); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - expect(controller.state.txHistory[result.id]).toMatchSnapshot(); - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); - expect(mockTraceFn.mock.calls).toMatchSnapshot(); }); it('should not call handleMobileHardwareWalletDelay on extension', async () => { @@ -3033,28 +3120,40 @@ describe('BridgeStatusController', () => { setupApprovalMocks(mockMessengerCall); setupBridgeMocks(mockMessengerCall); - const { controller, startPollingForBridgeTxStatusSpy } = getController( - mockMessengerCall, - mockTraceFn, - BridgeClientId.EXTENSION, // Using EXTENSION client - ); + await withController( + { + mockMessengerCall, + options: { + traceFn: mockTraceFn, + clientId: BridgeClientId.EXTENSION, + }, + }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + 'otherAccount', + mockEvmQuoteResponse, + false, + ); + controller.stopAllPolling(); - const result = await controller.submitTx( - 'otherAccount', - mockEvmQuoteResponse, - false, + expect(mockTraceFn).toHaveBeenCalledTimes(2); + // Should call the function but with false since it's Extension + expect(handleMobileHardwareWalletDelaySpy).toHaveBeenCalledTimes(1); + expect(handleMobileHardwareWalletDelaySpy).toHaveBeenCalledWith( + false, + ); + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(mockTraceFn.mock.calls).toMatchSnapshot(); + }, ); - controller.stopAllPolling(); - - expect(mockTraceFn).toHaveBeenCalledTimes(2); - // Should call the function but with false since it's Extension - expect(handleMobileHardwareWalletDelaySpy).toHaveBeenCalledTimes(1); - expect(handleMobileHardwareWalletDelaySpy).toHaveBeenCalledWith(false); - expect(result).toMatchSnapshot(); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - expect(controller.state.txHistory[result.id]).toMatchSnapshot(); - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); - expect(mockTraceFn.mock.calls).toMatchSnapshot(); }); it('should not call handleMobileHardwareWalletDelay with true for non-hardware wallet on mobile', async () => { @@ -3081,28 +3180,37 @@ describe('BridgeStatusController', () => { setupApprovalMocks(mockMessengerCall); setupBridgeMocks(mockMessengerCall); - const { controller, startPollingForBridgeTxStatusSpy } = getController( - mockMessengerCall, - mockTraceFn, - BridgeClientId.MOBILE, // Using MOBILE client - ); + await withController( + { + mockMessengerCall, + options: { traceFn: mockTraceFn, clientId: BridgeClientId.MOBILE }, + }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + mockEvmQuoteResponse, + false, + ); + controller.stopAllPolling(); - const result = await controller.submitTx( - (mockEvmQuoteResponse.trade as TxData).from, - mockEvmQuoteResponse, - false, + expect(mockTraceFn).toHaveBeenCalledTimes(2); + // Should call the function but with false since it's not a hardware wallet + expect(handleMobileHardwareWalletDelaySpy).toHaveBeenCalledTimes(1); + expect(handleMobileHardwareWalletDelaySpy).toHaveBeenCalledWith( + false, + ); + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(mockTraceFn.mock.calls).toMatchSnapshot(); + }, ); - controller.stopAllPolling(); - - expect(mockTraceFn).toHaveBeenCalledTimes(2); - // Should call the function but with false since it's not a hardware wallet - expect(handleMobileHardwareWalletDelaySpy).toHaveBeenCalledTimes(1); - expect(handleMobileHardwareWalletDelaySpy).toHaveBeenCalledWith(false); - expect(result).toMatchSnapshot(); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - expect(controller.state.txHistory[result.id]).toMatchSnapshot(); - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); - expect(mockTraceFn.mock.calls).toMatchSnapshot(); }); describe('actionId tracking and rekeying', () => { @@ -3118,32 +3226,41 @@ describe('BridgeStatusController', () => { const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; setupBridgeMocks(mockMessengerCall); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - - const result = await controller.submitTx( - (quoteWithoutApproval.trade as TxData).from, - quoteWithoutApproval, - false, // STX disabled - uses non-batch path - ); - controller.stopAllPolling(); - - // Verify the final history is keyed by txMeta.id (not actionId) - expect(controller.state.txHistory[result.id]).toBeDefined(); - expect(controller.state.txHistory[result.id].txMetaId).toBe(result.id); - expect(controller.state.txHistory[result.id].actionId).toBe( - mockActionId, + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (quoteWithoutApproval.trade as TxData).from, + quoteWithoutApproval, + false, // STX disabled - uses non-batch path + ); + controller.stopAllPolling(); + + // Verify the final history is keyed by txMeta.id (not actionId) + expect(controller.state.txHistory[result.id]).toBeDefined(); + expect(controller.state.txHistory[result.id].txMetaId).toBe( + result.id, + ); + expect(controller.state.txHistory[result.id].actionId).toBe( + mockActionId, + ); + + // Verify the actionId key no longer exists (was rekeyed) + expect(controller.state.txHistory[mockActionId]).toBeUndefined(); + + // Verify srcTxHash was updated during rekey + expect( + controller.state.txHistory[result.id].status.srcChain.txHash, + ).toBe(result.hash); + + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + }, ); - - // Verify the actionId key no longer exists (was rekeyed) - expect(controller.state.txHistory[mockActionId]).toBeUndefined(); - - // Verify srcTxHash was updated during rekey - expect( - controller.state.txHistory[result.id].status.srcChain.txHash, - ).toBe(result.hash); - - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); }); it('should preserve pre-submission history for tracking when trade tx submission fails', async () => { @@ -3175,31 +3292,38 @@ describe('BridgeStatusController', () => { new Error('Trade tx submission failed'), ); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - - await expect( - controller.submitTx( - (quoteWithoutApproval.trade as TxData).from, - quoteWithoutApproval, - false, - ), - ).rejects.toThrow('Trade tx submission failed'); - - // Verify: Pre-submission history should still exist keyed by actionId - // This allows failed event tracking to find the quote data - expect(controller.state.txHistory[mockActionId]).toBeDefined(); - expect(controller.state.txHistory[mockActionId].actionId).toBe( - mockActionId, + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + (quoteWithoutApproval.trade as TxData).from, + quoteWithoutApproval, + false, + ), + ).rejects.toThrow('Trade tx submission failed'); + + // Verify: Pre-submission history should still exist keyed by actionId + // This allows failed event tracking to find the quote data + expect(controller.state.txHistory[mockActionId]).toBeDefined(); + expect(controller.state.txHistory[mockActionId].actionId).toBe( + mockActionId, + ); + expect( + controller.state.txHistory[mockActionId].txMetaId, + ).toBeUndefined(); + expect( + controller.state.txHistory[mockActionId].status.srcChain.txHash, + ).toBeUndefined(); // Empty since tx w submitted + + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + }, ); - expect( - controller.state.txHistory[mockActionId].txMetaId, - ).toBeUndefined(); - expect( - controller.state.txHistory[mockActionId].status.srcChain.txHash, - ).toBeUndefined(); // Empty since tx was never submitted - - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); }); it('should use provided actionId from addTransactionFn result', async () => { @@ -3212,21 +3336,28 @@ describe('BridgeStatusController', () => { setupApprovalMocks(mockMessengerCall); setupBridgeMocks(mockMessengerCall); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - - const result = await controller.submitTx( - (mockEvmQuoteResponse.trade as TxData).from, - mockEvmQuoteResponse, - false, // STX disabled - ); - controller.stopAllPolling(); - - // Verify actionId is stored in the history item - expect(controller.state.txHistory[result.id].actionId).toBe( - mockActionId, + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + mockEvmQuoteResponse, + false, // STX disabled + ); + controller.stopAllPolling(); + + // Verify actionId is stored in the history item + expect(controller.state.txHistory[result.id].actionId).toBe( + mockActionId, + ); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + }, ); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); }); }); }); @@ -3383,25 +3514,33 @@ describe('BridgeStatusController', () => { setupApprovalMocks(); setupBridgeMocks(); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - const result = await controller.submitTx( - (mockEvmQuoteResponse.trade as TxData).from, - mockEvmQuoteResponse, - false, + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + mockEvmQuoteResponse, + false, + ); + controller.stopAllPolling(); + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + const { approvalTxId } = controller.state.txHistory[result.id]; + expect(approvalTxId).toBe('test-approval-tx-id'); + expect( + mockMessengerCall.mock.calls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ), + ).toHaveLength(2); + expect(mockMessengerCall).toHaveBeenCalledTimes(16); + }, ); - controller.stopAllPolling(); - - expect(result).toMatchSnapshot(); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - const { approvalTxId } = controller.state.txHistory[result.id]; - expect(approvalTxId).toBe('test-approval-tx-id'); - expect( - mockMessengerCall.mock.calls.filter( - ([action]) => action === 'TransactionController:addTransaction', - ), - ).toHaveLength(2); - expect(mockMessengerCall).toHaveBeenCalledTimes(16); }); it('should successfully submit an EVM swap transaction with featureId', async () => { @@ -3410,26 +3549,34 @@ describe('BridgeStatusController', () => { setupApprovalMocks(); setupBridgeMocks(); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - const result = await controller.submitTx( - (mockEvmQuoteResponse.trade as TxData).from, - { - ...mockEvmQuoteResponse, - featureId: FeatureId.PERPS, + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + { + ...mockEvmQuoteResponse, + featureId: FeatureId.PERPS, + }, + false, + ); + controller.stopAllPolling(); + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + const { approvalTxId } = controller.state.txHistory[result.id]; + expect(approvalTxId).toBe('test-approval-tx-id'); + expect(controller.state.txHistory[result.id].featureId).toBe( + FeatureId.PERPS, + ); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); }, - false, - ); - controller.stopAllPolling(); - - expect(result).toMatchSnapshot(); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - const { approvalTxId } = controller.state.txHistory[result.id]; - expect(approvalTxId).toBe('test-approval-tx-id'); - expect(controller.state.txHistory[result.id].featureId).toBe( - FeatureId.PERPS, ); - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); }); it('should handle a gasless swap transaction with approval', async () => { @@ -3443,84 +3590,101 @@ describe('BridgeStatusController', () => { transactions: [{ ...mockEvmTxMeta, batchId: 'batchId1' }], }); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - const result = await controller.submitTx( - (mockEvmQuoteResponse.trade as TxData).from, - { - ...mockEvmQuoteResponse, - quote: { - ...mockEvmQuoteResponse.quote, - gasIncluded: true, - feeData: { - txFee: { - maxFeePerGas: '123', - maxPriorityFeePerGas: '123', - } as never, - } as never, - }, + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + { + ...mockEvmQuoteResponse, + quote: { + ...mockEvmQuoteResponse.quote, + gasIncluded: true, + feeData: { + // @ts-expect-error: Partial mock. + txFee: { + maxFeePerGas: '123', + maxPriorityFeePerGas: '123', + }, + }, + }, + }, + true, + ); + controller.stopAllPolling(); + + const { txParams, ...resultsToCheck } = result; + expect(resultsToCheck).toMatchInlineSnapshot(` + { + "batchId": "batchId1", + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "type": "swap", + } + `); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(addTransactionBatchFn).toHaveBeenCalledTimes(1); + expect( + mockMessengerCall.mock.calls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ), + ).toHaveLength(0); + expect(mockMessengerCall).toHaveBeenCalledTimes(8); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); }, - true, ); - controller.stopAllPolling(); - - const { txParams, ...resultsToCheck } = result; - expect(resultsToCheck).toMatchInlineSnapshot(` - { - "batchId": "batchId1", - "chainId": "0xa4b1", - "hash": "0xevmTxHash", - "id": "test-tx-id", - "status": "unapproved", - "time": 1234567890, - "type": "swap", - } - `); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - expect(addTransactionBatchFn).toHaveBeenCalledTimes(1); - expect( - mockMessengerCall.mock.calls.filter( - ([action]) => action === 'TransactionController:addTransaction', - ), - ).toHaveLength(0); - expect(mockMessengerCall).toHaveBeenCalledTimes(8); - expect(controller.state.txHistory[result.id]).toMatchSnapshot(); }); it('should successfully submit an EVM swap transaction with no approval', async () => { setupEventTrackingMocks(mockMessengerCall); setupBridgeMocks(); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - const erc20Token = { - address: '0x0000000000000000000000000000000000000032', - assetId: `eip155:10/slip44:60` as CaipAssetType, - chainId: 10, - symbol: 'WETH', - decimals: 18, - name: 'WETH', - coinKey: 'WETH', - logoURI: - 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', - priceUSD: '2478.63', - icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', - }; - const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; - const result = await controller.submitTx( - (mockEvmQuoteResponse.trade as TxData).from, - { - ...quoteWithoutApproval, - quote: { ...quoteWithoutApproval.quote, destAsset: erc20Token }, - gasFee: undefined as never, + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const erc20Token = { + address: '0x0000000000000000000000000000000000000032', + assetId: `eip155:10/slip44:60` as CaipAssetType, + chainId: 10, + symbol: 'WETH', + decimals: 18, + name: 'WETH', + coinKey: 'WETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.63', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }; + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { ...quoteWithoutApproval.quote, destAsset: erc20Token }, + gasFee: undefined as never, + }, + false, + ); + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); }, - false, ); - - expect(result).toMatchSnapshot(); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - expect(controller.state.txHistory[result.id]).toMatchSnapshot(); - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); }); it('should use quote txFee when gasIncluded is true and STX is off (Max native token swap)', async () => { @@ -3538,53 +3702,61 @@ describe('BridgeStatusController', () => { }); mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - - const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; - const result = await controller.submitTx( - (mockEvmQuoteResponse.trade as TxData).from, - { - ...quoteWithoutApproval, - quote: { - ...quoteWithoutApproval.quote, - gasIncluded: true, - gasIncluded7702: false, - feeData: { - ...quoteWithoutApproval.quote.feeData, - txFee: { - maxFeePerGas: '1395348', // Decimal string from quote - maxPriorityFeePerGas: '1000001', + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { + ...quoteWithoutApproval.quote, + gasIncluded: true, + gasIncluded7702: false, + feeData: { + ...quoteWithoutApproval.quote.feeData, + // @ts-expect-error: Partial mock. + txFee: { + maxFeePerGas: '1395348', // Decimal string from quote + maxPriorityFeePerGas: '1000001', + }, + }, }, }, - }, - } as never, - false, // isStxEnabledOnClient = FALSE (key for this test) - ); - controller.stopAllPolling(); + false, // isStxEnabledOnClient = FALSE (key for this test) + ); + controller.stopAllPolling(); - const mockCalls = mockMessengerCall.mock.calls; + const mockCalls = mockMessengerCall.mock.calls; - // Should use single tx path (addTransactionFn), NOT batch path - const addTransactionCalls = mockCalls.filter( - ([action]) => action === 'TransactionController:addTransaction', - ); - expect(addTransactionCalls).toHaveLength(1); - // Should NOT estimate gas (uses quote's txFee instead) - const estimateGasFeeCalls = mockCalls.filter( - ([action]) => action === 'TransactionController:estimateGasFee', - ); - expect(estimateGasFeeCalls).toHaveLength(0); + // Should use single tx path (addTransactionFn), NOT batch path + const addTransactionCalls = mockCalls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ); + expect(addTransactionCalls).toHaveLength(1); + // Should NOT estimate gas (uses quote's txFee instead) + const estimateGasFeeCalls = mockCalls.filter( + ([action]) => action === 'TransactionController:estimateGasFee', + ); + expect(estimateGasFeeCalls).toHaveLength(0); - // Verify the tx params have hex-converted gas fees from quote - const txParams = addTransactionCalls[0]?.[1]; - expect(txParams.maxFeePerGas).toBe('0x154a94'); // toHex(1395348) - expect(txParams.maxPriorityFeePerGas).toBe('0xf4241'); // toHex(1000001) - expect(txParams.gas).toBe('0x5208'); + // Verify the tx params have hex-converted gas fees from quote + const txParams = addTransactionCalls[0]?.[1]; + expect(txParams.maxFeePerGas).toBe('0x154a94'); // toHex(1395348) + expect(txParams.maxPriorityFeePerGas).toBe('0xf4241'); // toHex(1000001) + expect(txParams.gas).toBe('0x5208'); - expect(result).toMatchSnapshot(); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + }, + ); }); it('should use quote txFee when gasIncluded is true and STX is off (undefined gasLimit)', async () => { @@ -3602,107 +3774,128 @@ describe('BridgeStatusController', () => { }); mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - - const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; - const result = await controller.submitTx( - (mockEvmQuoteResponse.trade as TxData).from, - { - ...quoteWithoutApproval, - quote: { - ...quoteWithoutApproval.quote, - gasIncluded: true, - gasIncluded7702: false, - feeData: { - ...quoteWithoutApproval.quote.feeData, - txFee: { - maxFeePerGas: '1395348', // Decimal string from quote - maxPriorityFeePerGas: '1000001', + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { + ...quoteWithoutApproval.quote, + gasIncluded: true, + gasIncluded7702: false, + feeData: { + ...quoteWithoutApproval.quote.feeData, + // @ts-expect-error: Partial mock. + txFee: { + maxFeePerGas: '1395348', // Decimal string from quote + maxPriorityFeePerGas: '1000001', + }, + }, + }, + trade: { + ...(quoteWithoutApproval.trade as TxData), + gasLimit: undefined as never, + }, + sentAmount: { + amount: null as never, + valueInCurrency: null, + usd: null, }, }, - }, - trade: { - ...(quoteWithoutApproval.trade as TxData), - gasLimit: undefined, - }, - sentAmount: { amount: null, valueInCurrency: null, usd: null }, - } as never, - false, // isStxEnabledOnClient = FALSE (key for this test) - ); - controller.stopAllPolling(); - - const mockCalls = mockMessengerCall.mock.calls; - - // Should NOT estimate gas (uses quote's txFee instead) - expect( - mockCalls.filter( - ([action]) => action === 'TransactionController:estimateGasFee', - ), - ).toHaveLength(0); - expect( - mockCalls.filter( - ([action]) => action === 'TransactionController:addTransactionBatch', - ), - ).toHaveLength(0); - - // Should use single tx path (addTransactionFn), NOT batch path - const addTransactionCalls = mockCalls.filter( - ([action]) => action === 'TransactionController:addTransaction', + false, // isStxEnabledOnClient = FALSE (key for this test) + ); + controller.stopAllPolling(); + + const mockCalls = mockMessengerCall.mock.calls; + + // Should NOT estimate gas (uses quote's txFee instead) + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:estimateGasFee', + ), + ).toHaveLength(0); + expect( + mockCalls.filter( + ([action]) => + action === 'TransactionController:addTransactionBatch', + ), + ).toHaveLength(0); + + // Should use single tx path (addTransactionFn), NOT batch path + const addTransactionCalls = mockCalls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ); + expect(addTransactionCalls).toHaveLength(1); + // Verify the tx params have hex-converted gas fees from quote + const txParams = addTransactionCalls[0]?.[1]; + expect(txParams.maxFeePerGas).toBe('0x154a94'); // toHex(1395348) + expect(txParams.maxPriorityFeePerGas).toBe('0xf4241'); // toHex(1000001) + expect(txParams.gas).toBeUndefined(); + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + }, ); - expect(addTransactionCalls).toHaveLength(1); - // Verify the tx params have hex-converted gas fees from quote - const txParams = addTransactionCalls[0]?.[1]; - expect(txParams.maxFeePerGas).toBe('0x154a94'); // toHex(1395348) - expect(txParams.maxPriorityFeePerGas).toBe('0xf4241'); // toHex(1000001) - expect(txParams.gas).toBeUndefined(); - - expect(result).toMatchSnapshot(); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - expect(controller.state.txHistory[result.id]).toMatchSnapshot(); }); it('should estimate gas when gasIncluded is false and STX is off', async () => { setupEventTrackingMocks(mockMessengerCall); setupBridgeMocks(); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - - const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; - const result = await controller.submitTx( - (mockEvmQuoteResponse.trade as TxData).from, - { - ...quoteWithoutApproval, - quote: { - ...quoteWithoutApproval.quote, - gasIncluded: false, - gasIncluded7702: false, - }, + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { + ...quoteWithoutApproval.quote, + gasIncluded: false, + gasIncluded7702: false, + }, + }, + false, // STX off + ); + controller.stopAllPolling(); + + // Should estimate gas since gasIncluded is false + const mockCalls = mockMessengerCall.mock.calls; + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:estimateGasFee', + ), + ).toHaveLength(1); + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ), + ).toHaveLength(1); + expect( + mockCalls.filter( + ([action]) => + action === 'TransactionController:addTransactionBatch', + ), + ).toHaveLength(0); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(result).toMatchSnapshot(); }, - false, // STX off ); - controller.stopAllPolling(); - - // Should estimate gas since gasIncluded is false - const mockCalls = mockMessengerCall.mock.calls; - expect( - mockCalls.filter( - ([action]) => action === 'TransactionController:estimateGasFee', - ), - ).toHaveLength(1); - expect( - mockCalls.filter( - ([action]) => action === 'TransactionController:addTransaction', - ), - ).toHaveLength(1); - expect( - mockCalls.filter( - ([action]) => action === 'TransactionController:addTransactionBatch', - ), - ).toHaveLength(0); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - expect(result).toMatchSnapshot(); }); it('should use batch path when gasIncluded7702 is true regardless of STX setting', async () => { @@ -3716,41 +3909,49 @@ describe('BridgeStatusController', () => { transactions: [{ ...mockEvmTxMeta, batchId: 'batchId1' }], }); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - - const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; - const result = await controller.submitTx( - (mockEvmQuoteResponse.trade as TxData).from, - { - ...quoteWithoutApproval, - quote: { - ...quoteWithoutApproval.quote, - gasIncluded: true, - gasIncluded7702: true, // 7702 takes precedence → batch path - feeData: { - ...quoteWithoutApproval.quote.feeData, - txFee: { - maxFeePerGas: '1395348', - maxPriorityFeePerGas: '1000001', + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { + ...quoteWithoutApproval.quote, + gasIncluded: true, + gasIncluded7702: true, // 7702 takes precedence → batch path + feeData: { + ...quoteWithoutApproval.quote.feeData, + // @ts-expect-error: Partial mock. + txFee: { + maxFeePerGas: '1395348', + maxPriorityFeePerGas: '1000001', + }, + }, }, }, - }, - } as never, - false, // STX off, but gasIncluded7702 = true forces batch path + false, // STX off, but gasIncluded7702 = true forces batch path + ); + controller.stopAllPolling(); + + // Should use batch path because gasIncluded7702 = true + expect(addTransactionBatchFn).toHaveBeenCalledTimes(1); + const mockCalls = mockMessengerCall.mock.calls; + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ), + ).toHaveLength(0); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(result).toMatchSnapshot(); + }, ); - controller.stopAllPolling(); - - // Should use batch path because gasIncluded7702 = true - expect(addTransactionBatchFn).toHaveBeenCalledTimes(1); - const mockCalls = mockMessengerCall.mock.calls; - expect( - mockCalls.filter( - ([action]) => action === 'TransactionController:addTransaction', - ), - ).toHaveLength(0); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - expect(result).toMatchSnapshot(); }); it('should use batch path when gasIncluded7702 is true regardless of STX setting (with approval)', async () => { @@ -3767,30 +3968,35 @@ describe('BridgeStatusController', () => { ], }); - const { controller } = getController(mockMessengerCall); - - const result = await controller.submitTx( - (mockEvmQuoteResponse.trade as TxData).from, - { - ...mockEvmQuoteResponse, - quote: { - ...mockEvmQuoteResponse.quote, - gasIncluded: true, - gasIncluded7702: true, // 7702 takes precedence → batch path - feeData: { - ...mockEvmQuoteResponse.quote.feeData, - txFee: { - maxFeePerGas: '1395348', - maxPriorityFeePerGas: '1000001', + await withController( + { mockMessengerCall }, + async ({ controller, rootMessenger }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + { + ...mockEvmQuoteResponse, + quote: { + ...mockEvmQuoteResponse.quote, + gasIncluded: true, + gasIncluded7702: true, // 7702 takes precedence → batch path + feeData: { + ...mockEvmQuoteResponse.quote.feeData, + // @ts-expect-error: Partial mock. + txFee: { + maxFeePerGas: '1395348', + maxPriorityFeePerGas: '1000001', + }, + }, }, }, - }, - } as never, - false, // STX off, but gasIncluded7702 = true forces batch path - ); - controller.stopAllPolling(); + false, // STX off, but gasIncluded7702 = true forces batch path + ); + controller.stopAllPolling(); - expect(result).toMatchSnapshot(); + expect(result).toMatchSnapshot(); + }, + ); }); it('should handle smart transactions', async () => { @@ -3812,90 +4018,107 @@ describe('BridgeStatusController', () => { transactions: [{ ...mockEvmTxMeta, batchId: 'batchId1' }], }); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - const result = await controller.submitTx( - (mockEvmQuoteResponse.trade as TxData).from, - mockEvmQuoteResponse, - true, - ); - controller.stopAllPolling(); + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + mockEvmQuoteResponse, + true, + ); + controller.stopAllPolling(); - expect(result).toMatchSnapshot(); - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - expect(controller.state.txHistory[result.id]).toMatchSnapshot(); - expect(addTransactionBatchFn.mock.calls).toMatchSnapshot(); - expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(addTransactionBatchFn.mock.calls).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + }, + ); }); it('should throw error if account is not found', async () => { setupEventTrackingMocks(mockMessengerCall); mockMessengerCall.mockReturnValueOnce(undefined); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - await expect( - controller.submitTx( - (mockEvmQuoteResponse.trade as TxData).from, - mockEvmQuoteResponse, - true, - ), - ).rejects.toThrow( - 'Failed to submit cross-chain swap batch transaction: unknown account in trade data', + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + mockEvmQuoteResponse, + true, + ), + ).rejects.toThrow( + 'Failed to submit cross-chain swap batch transaction: unknown account in trade data', + ); + controller.stopAllPolling(); + + expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); + const mockCalls = mockMessengerCall.mock.calls; + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:estimateGasFee', + ), + ).toHaveLength(0); + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ), + ).toHaveLength(0); + expect( + mockCalls.filter( + ([action]) => + action === 'TransactionController:addTransactionBatch', + ), + ).toHaveLength(0); + expect(mockMessengerCall).toHaveBeenCalledTimes(6); + expect( + mockCalls.find( + ([action, eventName]) => + action === 'BridgeController:trackUnifiedSwapBridgeEvent' && + eventName === UnifiedSwapBridgeEventName.Failed, + ), + ).toMatchInlineSnapshot(` + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + { + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:42161", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "error_message": "Failed to submit cross-chain swap batch transaction: unknown account in trade data", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Main View", + "price_impact": 0, + "provider": "lifi_across", + "quoted_time_minutes": 0, + "stx_enabled": true, + "swap_type": "single_chain", + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_amount_source": 1.01, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ] + `); + }, ); - controller.stopAllPolling(); - - expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); - const mockCalls = mockMessengerCall.mock.calls; - expect( - mockCalls.filter( - ([action]) => action === 'TransactionController:estimateGasFee', - ), - ).toHaveLength(0); - expect( - mockCalls.filter( - ([action]) => action === 'TransactionController:addTransaction', - ), - ).toHaveLength(0); - expect( - mockCalls.filter( - ([action]) => action === 'TransactionController:addTransactionBatch', - ), - ).toHaveLength(0); - expect(mockMessengerCall).toHaveBeenCalledTimes(6); - expect( - mockCalls.find( - ([action, eventName]) => - action === 'BridgeController:trackUnifiedSwapBridgeEvent' && - eventName === UnifiedSwapBridgeEventName.Failed, - ), - ).toMatchInlineSnapshot(` - [ - "BridgeController:trackUnifiedSwapBridgeEvent", - "Unified SwapBridge Failed", - { - "action_type": "swapbridge-v1", - "chain_id_destination": "eip155:42161", - "chain_id_source": "eip155:42161", - "custom_slippage": false, - "error_message": "Failed to submit cross-chain swap batch transaction: unknown account in trade data", - "gas_included": false, - "gas_included_7702": false, - "is_hardware_wallet": false, - "location": "Main View", - "price_impact": 0, - "provider": "lifi_across", - "quoted_time_minutes": 0, - "stx_enabled": true, - "swap_type": "single_chain", - "token_symbol_destination": "ETH", - "token_symbol_source": "ETH", - "usd_amount_source": 1.01, - "usd_quoted_gas": 2.5778, - "usd_quoted_return": 0, - }, - ] - `); }); it('should throw error if batched tx is not found', async () => { @@ -3917,66 +4140,74 @@ describe('BridgeStatusController', () => { transactions: [{ ...mockEvmTxMeta, batchId: 'batchIdUnknown' }], }); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); - await expect( - controller.submitTx( - (mockEvmQuoteResponse.trade as TxData).from, - mockEvmQuoteResponse, - true, - ), - ).rejects.toThrow( - 'Failed to update cross-chain swap transaction batch: tradeMeta not found', + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + mockEvmQuoteResponse, + true, + ), + ).rejects.toThrow( + 'Failed to update cross-chain swap transaction batch: tradeMeta not found', + ); + controller.stopAllPolling(); + + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + const mockCalls = mockMessengerCall.mock.calls; + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:estimateGasFee', + ), + ).toHaveLength(2); + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ), + ).toHaveLength(0); + expect(addTransactionBatchFn).toHaveBeenCalledTimes(1); + expect(mockMessengerCall).toHaveBeenCalledTimes(12); + expect( + mockCalls.find( + ([action, eventName]) => + action === 'BridgeController:trackUnifiedSwapBridgeEvent' && + eventName === UnifiedSwapBridgeEventName.Failed, + ), + ).toMatchInlineSnapshot(` + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + { + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:42161", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "error_message": "Failed to update cross-chain swap transaction batch: tradeMeta not found", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Main View", + "price_impact": 0, + "provider": "lifi_across", + "quoted_time_minutes": 0, + "stx_enabled": true, + "swap_type": "single_chain", + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_amount_source": 1.01, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ] + `); + }, ); - controller.stopAllPolling(); - - expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - const mockCalls = mockMessengerCall.mock.calls; - expect( - mockCalls.filter( - ([action]) => action === 'TransactionController:estimateGasFee', - ), - ).toHaveLength(2); - expect( - mockCalls.filter( - ([action]) => action === 'TransactionController:addTransaction', - ), - ).toHaveLength(0); - expect(addTransactionBatchFn).toHaveBeenCalledTimes(1); - expect(mockMessengerCall).toHaveBeenCalledTimes(12); - expect( - mockCalls.find( - ([action, eventName]) => - action === 'BridgeController:trackUnifiedSwapBridgeEvent' && - eventName === UnifiedSwapBridgeEventName.Failed, - ), - ).toMatchInlineSnapshot(` - [ - "BridgeController:trackUnifiedSwapBridgeEvent", - "Unified SwapBridge Failed", - { - "action_type": "swapbridge-v1", - "chain_id_destination": "eip155:42161", - "chain_id_source": "eip155:42161", - "custom_slippage": false, - "error_message": "Failed to update cross-chain swap transaction batch: tradeMeta not found", - "gas_included": false, - "gas_included_7702": false, - "is_hardware_wallet": false, - "location": "Main View", - "price_impact": 0, - "provider": "lifi_across", - "quoted_time_minutes": 0, - "stx_enabled": true, - "swap_type": "single_chain", - "token_symbol_destination": "ETH", - "token_symbol_source": "ETH", - "usd_amount_source": 1.01, - "usd_quoted_gas": 2.5778, - "usd_quoted_return": 0, - }, - ] - `); }); it('should gracefully handle isAtomicBatchSupported failure', async () => { @@ -3990,366 +4221,374 @@ describe('BridgeStatusController', () => { setupApprovalMocks(); setupBridgeMocks(); - const { controller } = getController(mockMessengerCall); - const result = await controller.submitTx( - (mockEvmQuoteResponse.trade as TxData).from, - mockEvmQuoteResponse, - false, // STX disabled - uses non-batch path + await withController( + { mockMessengerCall }, + async ({ controller, rootMessenger }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + mockEvmQuoteResponse, + false, // STX disabled - uses non-batch path + ); + controller.stopAllPolling(); + + // Should fall back to non-batch path when isAtomicBatchSupported throws + const mockCalls = mockMessengerCall.mock.calls; + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:estimateGasFee', + ), + ).toHaveLength(2); + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ), + ).toHaveLength(2); + expect(mockMessengerCall).toHaveBeenCalledTimes(16); + expect(addTransactionBatchFn).not.toHaveBeenCalled(); + expect(mockCalls).toMatchSnapshot(); + expect(result).toMatchInlineSnapshot(` + { + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", + } + `); + }, ); - controller.stopAllPolling(); - - // Should fall back to non-batch path when isAtomicBatchSupported throws - const mockCalls = mockMessengerCall.mock.calls; - expect( - mockCalls.filter( - ([action]) => action === 'TransactionController:estimateGasFee', - ), - ).toHaveLength(2); - expect( - mockCalls.filter( - ([action]) => action === 'TransactionController:addTransaction', - ), - ).toHaveLength(2); - expect(mockMessengerCall).toHaveBeenCalledTimes(16); - expect(addTransactionBatchFn).not.toHaveBeenCalled(); - expect(mockCalls).toMatchSnapshot(); - expect(result).toMatchInlineSnapshot(` - { - "chainId": "0xa4b1", - "hash": "0xevmTxHash", - "id": "test-tx-id", - "status": "unapproved", - "time": 1234567890, - "txParams": { - "chainId": "0xa4b1", - "data": "0xdata", - "from": "0xaccount1", - "gasLimit": "0x5208", - "to": "0xbridgeContract", - "value": "0x0", - }, - "type": "swap", - } - `); }); }); describe('resetAttempts', () => { - let bridgeStatusController: BridgeStatusController; - let mockMessenger: jest.Mocked; - - beforeEach(() => { - mockMessenger = getMessengerMock(); - bridgeStatusController = new BridgeStatusController({ - messenger: mockMessenger, - clientId: BridgeClientId.EXTENSION, - fetchFn: jest.fn(), - addTransactionBatchFn: jest.fn(), - state: { - txHistory: { - ...MockTxHistory.getPending({ - txMetaId: 'bridgeTxMetaId1', - srcTxHash: '0xsrcTxHash1', - }), - ...MockTxHistory.getPendingSwap({ - txMetaId: 'swapTxMetaId1', - srcTxHash: '0xswapTxHash1', - }), - }, - }, - }); - }); + const defaultState = { + txHistory: { + ...MockTxHistory.getPending({ + txMetaId: 'bridgeTxMetaId1', + srcTxHash: '0xsrcTxHash1', + }), + ...MockTxHistory.getPendingSwap({ + txMetaId: 'swapTxMetaId1', + srcTxHash: '0xswapTxHash1', + }), + }, + }; describe('success cases', () => { - it('should reset attempts by txMetaId for bridge transaction', () => { - // Setup - add attempts to the history item using controller state initialization - const controllerWithAttempts = new BridgeStatusController({ - messenger: mockMessenger, - clientId: BridgeClientId.EXTENSION, - fetchFn: jest.fn(), - addTransactionBatchFn: jest.fn(), - state: { - txHistory: { - bridgeTxMetaId1: { - ...MockTxHistory.getPending({ txMetaId: 'bridgeTxMetaId1' }) - .bridgeTxMetaId1, - attempts: { - counter: 5, - lastAttemptTime: Date.now(), + it('should reset attempts by txMetaId for bridge transaction', async () => { + await withController( + { + options: { + state: { + txHistory: { + bridgeTxMetaId1: { + ...MockTxHistory.getPending({ txMetaId: 'bridgeTxMetaId1' }) + .bridgeTxMetaId1, + attempts: { counter: 5, lastAttemptTime: Date.now() }, + }, }, }, }, }, - }); - - expect( - controllerWithAttempts.state.txHistory.bridgeTxMetaId1.attempts - ?.counter, - ).toBe(5); - - // Execute - controllerWithAttempts.restartPollingForFailedAttempts({ - txMetaId: 'bridgeTxMetaId1', - }); - - // Assert - expect( - controllerWithAttempts.state.txHistory.bridgeTxMetaId1.attempts, - ).toBeUndefined(); + async ({ controller, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts?.counter, + ).toBe(5); + + rootMessenger.call( + 'BridgeStatusController:restartPollingForFailedAttempts', + { txMetaId: 'bridgeTxMetaId1' }, + ); + + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts, + ).toBeUndefined(); + }, + ); }); - it('should reset attempts by txHash for bridge transaction', () => { - // Setup - add attempts to the history item using controller state initialization - const controllerWithAttempts = new BridgeStatusController({ - messenger: mockMessenger, - clientId: BridgeClientId.EXTENSION, - fetchFn: jest.fn(), - addTransactionBatchFn: jest.fn(), - state: { - txHistory: { - bridgeTxMetaId1: { - ...MockTxHistory.getPending({ txMetaId: 'bridgeTxMetaId1' }) - .bridgeTxMetaId1, - attempts: { - counter: 3, - lastAttemptTime: Date.now(), + it('should reset attempts by txHash for bridge transaction', async () => { + await withController( + { + options: { + state: { + txHistory: { + bridgeTxMetaId1: { + ...MockTxHistory.getPending({ txMetaId: 'bridgeTxMetaId1' }) + .bridgeTxMetaId1, + attempts: { counter: 3, lastAttemptTime: Date.now() }, + }, }, }, }, }, - }); - - expect( - controllerWithAttempts.state.txHistory.bridgeTxMetaId1.attempts - ?.counter, - ).toBe(3); - - // Execute - controllerWithAttempts.restartPollingForFailedAttempts({ - txHash: '0xsrcTxHash1', - }); - - // Assert - expect( - controllerWithAttempts.state.txHistory.bridgeTxMetaId1.attempts, - ).toBeUndefined(); + async ({ controller, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts?.counter, + ).toBe(3); + + rootMessenger.call( + 'BridgeStatusController:restartPollingForFailedAttempts', + { txHash: '0xsrcTxHash1' }, + ); + + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts, + ).toBeUndefined(); + }, + ); }); - it('should prioritize txMetaId when both txMetaId and txHash are provided', () => { - // Setup - create controller with attempts on both transactions - const controllerWithAttempts = new BridgeStatusController({ - messenger: mockMessenger, - clientId: BridgeClientId.EXTENSION, - fetchFn: jest.fn(), - addTransactionBatchFn: jest.fn(), - state: { - txHistory: { - bridgeTxMetaId1: { - ...MockTxHistory.getPending({ txMetaId: 'bridgeTxMetaId1' }) - .bridgeTxMetaId1, - attempts: { - counter: 3, - lastAttemptTime: Date.now(), - }, - }, - swapTxMetaId1: { - ...MockTxHistory.getPendingSwap({ txMetaId: 'swapTxMetaId1' }) - .swapTxMetaId1, - attempts: { - counter: 5, - lastAttemptTime: Date.now(), + it('should prioritize txMetaId when both txMetaId and txHash are provided', async () => { + await withController( + { + options: { + state: { + txHistory: { + bridgeTxMetaId1: { + ...MockTxHistory.getPending({ txMetaId: 'bridgeTxMetaId1' }) + .bridgeTxMetaId1, + attempts: { counter: 3, lastAttemptTime: Date.now() }, + }, + swapTxMetaId1: { + ...MockTxHistory.getPendingSwap({ + txMetaId: 'swapTxMetaId1', + }).swapTxMetaId1, + attempts: { counter: 5, lastAttemptTime: Date.now() }, + }, }, }, }, }, - }); - - // Execute with both identifiers - should use txMetaId (bridgeTxMetaId1) - controllerWithAttempts.restartPollingForFailedAttempts({ - txMetaId: 'bridgeTxMetaId1', - txHash: '0xswapTxHash1', - }); - - // Assert - only bridgeTxMetaId1 should have attempts reset - expect( - controllerWithAttempts.state.txHistory.bridgeTxMetaId1.attempts, - ).toBeUndefined(); - expect( - controllerWithAttempts.state.txHistory.swapTxMetaId1.attempts - ?.counter, - ).toBe(5); + async ({ controller, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + // Execute with both identifiers - should use txMetaId (bridgeTxMetaId1) + rootMessenger.call( + 'BridgeStatusController:restartPollingForFailedAttempts', + { txMetaId: 'bridgeTxMetaId1', txHash: '0xswapTxHash1' }, + ); + + // Assert - only bridgeTxMetaId1 should have attempts reset + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts, + ).toBeUndefined(); + expect( + controller.state.txHistory.swapTxMetaId1.attempts?.counter, + ).toBe(5); + }, + ); }); it('should restart polling for bridge transaction when attempts are reset', async () => { - // Setup - use the same pattern as "restarts polling for history items that are not complete" jest.useFakeTimers(); const fetchBridgeTxStatusSpy = jest.spyOn( bridgeStatusUtils, 'fetchBridgeTxStatus', ); fetchBridgeTxStatusSpy - .mockImplementationOnce(async () => { - return { - status: MockStatusResponse.getPending(), - validationFailures: [], - }; - }) - .mockImplementationOnce(async () => { - return { - status: MockStatusResponse.getPending(), - validationFailures: [], - }; - }); + .mockImplementationOnce(async () => ({ + status: MockStatusResponse.getPending(), + validationFailures: [], + })) + .mockImplementationOnce(async () => ({ + status: MockStatusResponse.getPending(), + validationFailures: [], + })); - // Create controller with a bridge transaction that has failed attempts - const controllerWithFailedAttempts = new BridgeStatusController({ - messenger: getMessengerMock(), - clientId: BridgeClientId.EXTENSION, - fetchFn: jest.fn(), - addTransactionBatchFn: jest.fn(), - state: { - txHistory: { - bridgeTxMetaId1: { - ...MockTxHistory.getPending({ txMetaId: 'bridgeTxMetaId1' }) - .bridgeTxMetaId1, - attempts: { - counter: MAX_ATTEMPTS + 1, // High number to simulate failed attempts - lastAttemptTime: Date.now() - 60000, // 1 minute ago + await withController( + { + options: { + state: { + txHistory: { + bridgeTxMetaId1: { + ...MockTxHistory.getPending({ txMetaId: 'bridgeTxMetaId1' }) + .bridgeTxMetaId1, + attempts: { + counter: MAX_ATTEMPTS + 1, + lastAttemptTime: Date.now() - 60000, + }, + }, }, }, }, }, - }); - - // Verify initial state has attempts - expect( - controllerWithFailedAttempts.state.txHistory.bridgeTxMetaId1.attempts - ?.counter, - ).toBe(MAX_ATTEMPTS + 1); - - // Execute resetAttempts - this should reset attempts and restart polling - controllerWithFailedAttempts.restartPollingForFailedAttempts({ - txMetaId: 'bridgeTxMetaId1', - }); - - // Verify attempts were reset - expect( - controllerWithFailedAttempts.state.txHistory.bridgeTxMetaId1.attempts, - ).toBeUndefined(); - expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(0); - - // Now advance timer again - polling should work since attempts are reset - // Advance in steps to allow recursive setTimeout to be set up properly with Jest 28 - jest.advanceTimersByTime(0); - await flushPromises(); - jest.advanceTimersByTime(10000); - await flushPromises(); - - // Assertions - polling should now happen since attempts were reset - expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); - expect( - controllerWithFailedAttempts.state.txHistory.bridgeTxMetaId1.attempts - ?.counter, - ).toBeUndefined(); // Should be undefined since we've reset attempts and fetchBridgeTxStatus did not error + async ({ controller, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts?.counter, + ).toBe(MAX_ATTEMPTS + 1); + + rootMessenger.call( + 'BridgeStatusController:restartPollingForFailedAttempts', + { txMetaId: 'bridgeTxMetaId1' }, + ); + + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts, + ).toBeUndefined(); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + + // Advance in steps to allow recursive setTimeout to be set up properly with Jest 28 + jest.advanceTimersByTime(0); + await flushPromises(); + jest.advanceTimersByTime(10000); + await flushPromises(); + + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts?.counter, + ).toBeUndefined(); + }, + ); }); }); describe('error cases', () => { - it('should throw error when no identifier is provided', () => { - expect(() => { - bridgeStatusController.restartPollingForFailedAttempts({}); - }).toThrow('Either txMetaId or txHash must be provided'); + it('should throw error when no identifier is provided', async () => { + await withController( + { options: { state: defaultState } }, + async ({ rootMessenger }) => { + expect(() => { + rootMessenger.call( + 'BridgeStatusController:restartPollingForFailedAttempts', + {}, + ); + }).toThrow('Either txMetaId or txHash must be provided'); + }, + ); }); - it('should throw error when txMetaId is not found', () => { - expect(() => { - bridgeStatusController.restartPollingForFailedAttempts({ - txMetaId: 'nonexistentTxMetaId', - }); - }).toThrow( - 'No bridge transaction history found for txMetaId: nonexistentTxMetaId', + it('should throw error when txMetaId is not found', async () => { + await withController( + { options: { state: defaultState } }, + async ({ rootMessenger }) => { + expect(() => { + rootMessenger.call( + 'BridgeStatusController:restartPollingForFailedAttempts', + { txMetaId: 'nonexistentTxMetaId' }, + ); + }).toThrow( + 'No bridge transaction history found for txMetaId: nonexistentTxMetaId', + ); + }, ); }); - it('should throw error when txHash is not found', () => { - expect(() => { - bridgeStatusController.restartPollingForFailedAttempts({ - txHash: '0xnonexistentTxHash', - }); - }).toThrow( - 'No bridge transaction history found for txHash: 0xnonexistentTxHash', + it('should throw error when txHash is not found', async () => { + await withController( + { options: { state: defaultState } }, + async ({ rootMessenger }) => { + expect(() => { + rootMessenger.call( + 'BridgeStatusController:restartPollingForFailedAttempts', + { txHash: '0xnonexistentTxHash' }, + ); + }).toThrow( + 'No bridge transaction history found for txHash: 0xnonexistentTxHash', + ); + }, ); }); - it('should throw error when txMetaId is empty string', () => { - expect(() => { - bridgeStatusController.restartPollingForFailedAttempts({ - txMetaId: '', - }); - }).toThrow('Either txMetaId or txHash must be provided'); + it('should throw error when txMetaId is empty string', async () => { + await withController( + { options: { state: defaultState } }, + async ({ rootMessenger }) => { + expect(() => { + rootMessenger.call( + 'BridgeStatusController:restartPollingForFailedAttempts', + { txMetaId: '' }, + ); + }).toThrow('Either txMetaId or txHash must be provided'); + }, + ); }); - it('should throw error when txHash is empty string', () => { - expect(() => { - bridgeStatusController.restartPollingForFailedAttempts({ - txHash: '', - }); - }).toThrow('Either txMetaId or txHash must be provided'); + it('should throw error when txHash is empty string', async () => { + await withController( + { options: { state: defaultState } }, + async ({ rootMessenger }) => { + expect(() => { + rootMessenger.call( + 'BridgeStatusController:restartPollingForFailedAttempts', + { txHash: '' }, + ); + }).toThrow('Either txMetaId or txHash must be provided'); + }, + ); }); }); describe('edge cases', () => { - it('should handle transaction with no srcChain.txHash when searching by txHash', () => { - // Setup - create a controller with a transaction without srcChain.txHash - const controllerWithNoHash = new BridgeStatusController({ - messenger: mockMessenger, - clientId: BridgeClientId.EXTENSION, - fetchFn: jest.fn(), - addTransactionBatchFn: jest.fn(), - state: { - txHistory: { - noHashTx: { - ...MockTxHistory.getPending({ txMetaId: 'noHashTx' }).noHashTx, - status: { - ...MockTxHistory.getPending({ txMetaId: 'noHashTx' }).noHashTx - .status, - srcChain: { + it('should handle transaction with no srcChain.txHash when searching by txHash', async () => { + await withController( + { + options: { + state: { + txHistory: { + noHashTx: { ...MockTxHistory.getPending({ txMetaId: 'noHashTx' }) - .noHashTx.status.srcChain, - txHash: undefined as never, + .noHashTx, + status: { + ...MockTxHistory.getPending({ txMetaId: 'noHashTx' }) + .noHashTx.status, + srcChain: { + ...MockTxHistory.getPending({ txMetaId: 'noHashTx' }) + .noHashTx.status.srcChain, + txHash: undefined, + }, + }, }, }, }, }, }, - }); - - expect(() => { - controllerWithNoHash.restartPollingForFailedAttempts({ - txHash: '0xsomeHash', - }); - }).toThrow( - 'No bridge transaction history found for txHash: 0xsomeHash', + async ({ rootMessenger }) => { + expect(() => { + rootMessenger.call( + 'BridgeStatusController:restartPollingForFailedAttempts', + { txHash: '0xsomeHash' }, + ); + }).toThrow( + 'No bridge transaction history found for txHash: 0xsomeHash', + ); + }, ); }); - it('should handle transaction that exists but has no attempts to reset', () => { - // Ensure transaction has no attempts initially - expect( - bridgeStatusController.state.txHistory.bridgeTxMetaId1.attempts, - ).toBeUndefined(); - - // Execute - should not throw error - expect(() => { - bridgeStatusController.restartPollingForFailedAttempts({ - txMetaId: 'bridgeTxMetaId1', - }); - }).not.toThrow(); - - // Assert - attempts should still be undefined - expect( - bridgeStatusController.state.txHistory.bridgeTxMetaId1.attempts, - ).toBeUndefined(); + it('should handle transaction that exists but has no attempts to reset', async () => { + await withController( + { options: { state: defaultState } }, + async ({ controller, rootMessenger }) => { + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts, + ).toBeUndefined(); + + expect(() => { + rootMessenger.call( + 'BridgeStatusController:restartPollingForFailedAttempts', + { txMetaId: 'bridgeTxMetaId1' }, + ); + }).not.toThrow(); + + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts, + ).toBeUndefined(); + }, + ); }); }); }); @@ -4478,14 +4717,19 @@ describe('BridgeStatusController', () => { it('should include ab_tests and active_ab_tests from history in tracked event properties', () => { const abTestsTxMetaId = 'bridgeTxMetaIdAbTests'; - bridgeStatusController.startPollingForBridgeTxStatus({ - ...getMockStartPollingForBridgeTxStatusArgs({ - txMetaId: abTestsTxMetaId, - srcTxHash: '0xsrcTxHashAbTests', - }), - abTests: { token_details_layout: 'treatment' }, - activeAbTests: [{ key: 'bridge_quote_sorting', value: 'variant_b' }], - }); + mockMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + { + ...getMockStartPollingForBridgeTxStatusArgs({ + txMetaId: abTestsTxMetaId, + srcTxHash: '0xsrcTxHashAbTests', + }), + abTests: { token_details_layout: 'treatment' }, + activeAbTests: [ + { key: 'bridge_quote_sorting', value: 'variant_b' }, + ], + }, + ); const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); mockMessenger.publish('TransactionController:transactionFailed', { @@ -4773,10 +5017,10 @@ describe('BridgeStatusController', () => { ...MockStatusResponse.getComplete(), status: 'INVALID', }); - const oldHistoryItem = - bridgeStatusController.getBridgeHistoryItemByTxMetaId( - 'bridgeTxMetaId1', - ); + const oldHistoryItem = mockMessenger.call( + 'BridgeStatusController:getBridgeHistoryItemByTxMetaId', + 'bridgeTxMetaId1', + ); mockMessenger.publish('TransactionController:transactionConfirmed', { chainId: CHAIN_IDS.ARBITRUM, networkClientId: 'eth-id', @@ -4800,7 +5044,8 @@ describe('BridgeStatusController', () => { }, ); expect( - bridgeStatusController.getBridgeHistoryItemByTxMetaId( + mockMessenger.call( + 'BridgeStatusController:getBridgeHistoryItemByTxMetaId', 'bridgeTxMetaId1', ), ).toStrictEqual({ @@ -4850,7 +5095,8 @@ describe('BridgeStatusController', () => { }, ); expect( - bridgeStatusController.getBridgeHistoryItemByTxMetaId( + mockMessenger.call( + 'BridgeStatusController:getBridgeHistoryItemByTxMetaId', 'perpsBridgeTxMetaId1', )?.status, ).toMatchSnapshot(); @@ -4895,7 +5141,8 @@ describe('BridgeStatusController', () => { }, ); expect( - bridgeStatusController.getBridgeHistoryItemByTxMetaId( + mockMessenger.call( + 'BridgeStatusController:getBridgeHistoryItemByTxMetaId', 'perpsBridgeTxMetaId1', )?.status, ).toMatchSnapshot(); @@ -5025,64 +5272,64 @@ describe('BridgeStatusController', () => { }); describe('metadata', () => { - it('includes expected state in debug snapshots', () => { - const { controller } = getController(jest.fn()); - - expect( - deriveStateFromMetadata( - controller.state, - controller.metadata, - 'includeInDebugSnapshot', - ), - ).toMatchInlineSnapshot(`{}`); + it('includes expected state in debug snapshots', async () => { + await withController(async ({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); }); - it('includes expected state in state logs', () => { - const { controller } = getController(jest.fn()); - - expect( - deriveStateFromMetadata( - controller.state, - controller.metadata, - 'includeInStateLogs', - ), - ).toMatchInlineSnapshot(` - { - "txHistory": {}, - } - `); + it('includes expected state in state logs', async () => { + await withController(async ({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "txHistory": {}, + } + `); + }); }); - it('persists expected state', () => { - const { controller } = getController(jest.fn()); - - expect( - deriveStateFromMetadata( - controller.state, - controller.metadata, - 'persist', - ), - ).toMatchInlineSnapshot(` - { - "txHistory": {}, - } - `); + it('persists expected state', async () => { + await withController(async ({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "txHistory": {}, + } + `); + }); }); - it('exposes expected state to UI', () => { - const { controller } = getController(jest.fn()); - - expect( - deriveStateFromMetadata( - controller.state, - controller.metadata, - 'usedInUi', - ), - ).toMatchInlineSnapshot(` - { - "txHistory": {}, - } - `); + it('exposes expected state to UI', async () => { + await withController(async ({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "txHistory": {}, + } + `); + }); }); }); }); diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 4f0a2496264..208cfe7f98e 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -118,6 +118,17 @@ type SrcTxMetaId = string; export type FetchBridgeTxStatusArgs = { bridgeTxMetaId: string; }; + +const MESSENGER_EXPOSED_METHODS = [ + 'startPollingForBridgeTxStatus', + 'wipeBridgeStatus', + 'resetState', + 'submitTx', + 'submitIntent', + 'restartPollingForFailedAttempts', + 'getBridgeHistoryItemByTxMetaId', +] as const; + export class BridgeStatusController extends StaticIntervalPollingController()< typeof BRIDGE_STATUS_CONTROLLER_NAME, BridgeStatusControllerState, @@ -184,33 +195,9 @@ export class BridgeStatusController extends StaticIntervalPollingController = { - type: `${typeof BRIDGE_STATUS_CONTROLLER_NAME}:${FunctionName}`; - handler: BridgeStatusController[FunctionName]; -}; - export type BridgeStatusControllerGetStateAction = ControllerGetStateAction< typeof BRIDGE_STATUS_CONTROLLER_NAME, BridgeStatusControllerState >; -// Maps to BridgeController function names -export type BridgeStatusControllerStartPollingForBridgeTxStatusAction = - BridgeStatusControllerAction<'startPollingForBridgeTxStatus'>; - -export type BridgeStatusControllerWipeBridgeStatusAction = - BridgeStatusControllerAction<'wipeBridgeStatus'>; - -export type BridgeStatusControllerResetStateAction = - BridgeStatusControllerAction<'resetState'>; - -export type BridgeStatusControllerSubmitTxAction = - BridgeStatusControllerAction<'submitTx'>; - -export type BridgeStatusControllerSubmitIntentAction = - BridgeStatusControllerAction<'submitIntent'>; - -export type BridgeStatusControllerRestartPollingForFailedAttemptsAction = - BridgeStatusControllerAction<'restartPollingForFailedAttempts'>; - -export type BridgeStatusControllerGetBridgeHistoryItemByTxMetaIdAction = - BridgeStatusControllerAction<'getBridgeHistoryItemByTxMetaId'>; - export type BridgeStatusControllerActions = - | BridgeStatusControllerStartPollingForBridgeTxStatusAction - | BridgeStatusControllerWipeBridgeStatusAction - | BridgeStatusControllerResetStateAction | BridgeStatusControllerGetStateAction - | BridgeStatusControllerSubmitTxAction - | BridgeStatusControllerSubmitIntentAction - | BridgeStatusControllerRestartPollingForFailedAttemptsAction - | BridgeStatusControllerGetBridgeHistoryItemByTxMetaIdAction; + | BridgeStatusControllerMethodActions; // Events export type BridgeStatusControllerStateChangeEvent = ControllerStateChangeEvent< diff --git a/packages/transaction-pay-controller/src/tests/messenger-mock.ts b/packages/transaction-pay-controller/src/tests/messenger-mock.ts index c164eb45e30..7ebda275cde 100644 --- a/packages/transaction-pay-controller/src/tests/messenger-mock.ts +++ b/packages/transaction-pay-controller/src/tests/messenger-mock.ts @@ -2,7 +2,10 @@ import type { TokensControllerGetStateAction } from '@metamask/assets-controller import type { TokenBalancesControllerGetStateAction } from '@metamask/assets-controllers'; import type { TokenRatesControllerGetStateAction } from '@metamask/assets-controllers'; import type { AccountTrackerControllerGetStateAction } from '@metamask/assets-controllers'; -import type { BridgeStatusControllerGetStateAction } from '@metamask/bridge-status-controller'; +import type { + BridgeStatusControllerGetStateAction, + BridgeStatusControllerSubmitTxAction, +} from '@metamask/bridge-status-controller'; import type { MessengerActions, MessengerEvents, @@ -23,7 +26,6 @@ import type { import type { TransactionControllerUpdateTransactionAction } from '@metamask/transaction-controller'; import type { TransactionPayControllerMessenger } from '..'; -import type { BridgeStatusControllerSubmitTxAction } from '../../../bridge-status-controller/src/types'; import type { TransactionPayControllerGetDelegationTransactionAction, TransactionPayControllerGetStrategyAction, diff --git a/yarn.lock b/yarn.lock index f7f5c78f589..c8d8d4e5ead 100644 --- a/yarn.lock +++ b/yarn.lock @@ -463,13 +463,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/aix-ppc64@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/aix-ppc64@npm:0.25.9" - conditions: os=aix & cpu=ppc64 - languageName: node - linkType: hard - "@esbuild/aix-ppc64@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/aix-ppc64@npm:0.27.4" @@ -477,13 +470,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-arm64@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/android-arm64@npm:0.25.9" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/android-arm64@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/android-arm64@npm:0.27.4" @@ -491,13 +477,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-arm@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/android-arm@npm:0.25.9" - conditions: os=android & cpu=arm - languageName: node - linkType: hard - "@esbuild/android-arm@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/android-arm@npm:0.27.4" @@ -505,13 +484,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-x64@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/android-x64@npm:0.25.9" - conditions: os=android & cpu=x64 - languageName: node - linkType: hard - "@esbuild/android-x64@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/android-x64@npm:0.27.4" @@ -519,13 +491,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/darwin-arm64@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/darwin-arm64@npm:0.25.9" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/darwin-arm64@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/darwin-arm64@npm:0.27.4" @@ -533,13 +498,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/darwin-x64@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/darwin-x64@npm:0.25.9" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - "@esbuild/darwin-x64@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/darwin-x64@npm:0.27.4" @@ -547,13 +505,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/freebsd-arm64@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/freebsd-arm64@npm:0.25.9" - conditions: os=freebsd & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/freebsd-arm64@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/freebsd-arm64@npm:0.27.4" @@ -561,13 +512,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/freebsd-x64@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/freebsd-x64@npm:0.25.9" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - "@esbuild/freebsd-x64@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/freebsd-x64@npm:0.27.4" @@ -575,13 +519,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-arm64@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/linux-arm64@npm:0.25.9" - conditions: os=linux & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/linux-arm64@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/linux-arm64@npm:0.27.4" @@ -589,13 +526,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-arm@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/linux-arm@npm:0.25.9" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - "@esbuild/linux-arm@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/linux-arm@npm:0.27.4" @@ -603,13 +533,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-ia32@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/linux-ia32@npm:0.25.9" - conditions: os=linux & cpu=ia32 - languageName: node - linkType: hard - "@esbuild/linux-ia32@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/linux-ia32@npm:0.27.4" @@ -617,13 +540,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-loong64@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/linux-loong64@npm:0.25.9" - conditions: os=linux & cpu=loong64 - languageName: node - linkType: hard - "@esbuild/linux-loong64@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/linux-loong64@npm:0.27.4" @@ -631,13 +547,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-mips64el@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/linux-mips64el@npm:0.25.9" - conditions: os=linux & cpu=mips64el - languageName: node - linkType: hard - "@esbuild/linux-mips64el@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/linux-mips64el@npm:0.27.4" @@ -645,13 +554,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-ppc64@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/linux-ppc64@npm:0.25.9" - conditions: os=linux & cpu=ppc64 - languageName: node - linkType: hard - "@esbuild/linux-ppc64@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/linux-ppc64@npm:0.27.4" @@ -659,13 +561,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-riscv64@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/linux-riscv64@npm:0.25.9" - conditions: os=linux & cpu=riscv64 - languageName: node - linkType: hard - "@esbuild/linux-riscv64@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/linux-riscv64@npm:0.27.4" @@ -673,13 +568,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-s390x@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/linux-s390x@npm:0.25.9" - conditions: os=linux & cpu=s390x - languageName: node - linkType: hard - "@esbuild/linux-s390x@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/linux-s390x@npm:0.27.4" @@ -687,13 +575,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-x64@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/linux-x64@npm:0.25.9" - conditions: os=linux & cpu=x64 - languageName: node - linkType: hard - "@esbuild/linux-x64@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/linux-x64@npm:0.27.4" @@ -701,13 +582,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/netbsd-arm64@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/netbsd-arm64@npm:0.25.9" - conditions: os=netbsd & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/netbsd-arm64@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/netbsd-arm64@npm:0.27.4" @@ -715,13 +589,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/netbsd-x64@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/netbsd-x64@npm:0.25.9" - conditions: os=netbsd & cpu=x64 - languageName: node - linkType: hard - "@esbuild/netbsd-x64@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/netbsd-x64@npm:0.27.4" @@ -729,13 +596,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/openbsd-arm64@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/openbsd-arm64@npm:0.25.9" - conditions: os=openbsd & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/openbsd-arm64@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/openbsd-arm64@npm:0.27.4" @@ -743,13 +603,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/openbsd-x64@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/openbsd-x64@npm:0.25.9" - conditions: os=openbsd & cpu=x64 - languageName: node - linkType: hard - "@esbuild/openbsd-x64@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/openbsd-x64@npm:0.27.4" @@ -757,13 +610,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/openharmony-arm64@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/openharmony-arm64@npm:0.25.9" - conditions: os=openharmony & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/openharmony-arm64@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/openharmony-arm64@npm:0.27.4" @@ -771,13 +617,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/sunos-x64@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/sunos-x64@npm:0.25.9" - conditions: os=sunos & cpu=x64 - languageName: node - linkType: hard - "@esbuild/sunos-x64@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/sunos-x64@npm:0.27.4" @@ -785,13 +624,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-arm64@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/win32-arm64@npm:0.25.9" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/win32-arm64@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/win32-arm64@npm:0.27.4" @@ -799,13 +631,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-ia32@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/win32-ia32@npm:0.25.9" - conditions: os=win32 & cpu=ia32 - languageName: node - linkType: hard - "@esbuild/win32-ia32@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/win32-ia32@npm:0.27.4" @@ -813,13 +638,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-x64@npm:0.25.9": - version: 0.25.9 - resolution: "@esbuild/win32-x64@npm:0.25.9" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - "@esbuild/win32-x64@npm:0.27.4": version: 0.27.4 resolution: "@esbuild/win32-x64@npm:0.27.4" @@ -3249,6 +3067,7 @@ __metadata: lodash: "npm:^4.17.21" nock: "npm:^13.3.1" ts-jest: "npm:^29.2.5" + tsx: "npm:^4.21.0" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" @@ -8776,95 +8595,6 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:~0.25.0": - version: 0.25.9 - resolution: "esbuild@npm:0.25.9" - dependencies: - "@esbuild/aix-ppc64": "npm:0.25.9" - "@esbuild/android-arm": "npm:0.25.9" - "@esbuild/android-arm64": "npm:0.25.9" - "@esbuild/android-x64": "npm:0.25.9" - "@esbuild/darwin-arm64": "npm:0.25.9" - "@esbuild/darwin-x64": "npm:0.25.9" - "@esbuild/freebsd-arm64": "npm:0.25.9" - "@esbuild/freebsd-x64": "npm:0.25.9" - "@esbuild/linux-arm": "npm:0.25.9" - "@esbuild/linux-arm64": "npm:0.25.9" - "@esbuild/linux-ia32": "npm:0.25.9" - "@esbuild/linux-loong64": "npm:0.25.9" - "@esbuild/linux-mips64el": "npm:0.25.9" - "@esbuild/linux-ppc64": "npm:0.25.9" - "@esbuild/linux-riscv64": "npm:0.25.9" - "@esbuild/linux-s390x": "npm:0.25.9" - "@esbuild/linux-x64": "npm:0.25.9" - "@esbuild/netbsd-arm64": "npm:0.25.9" - "@esbuild/netbsd-x64": "npm:0.25.9" - "@esbuild/openbsd-arm64": "npm:0.25.9" - "@esbuild/openbsd-x64": "npm:0.25.9" - "@esbuild/openharmony-arm64": "npm:0.25.9" - "@esbuild/sunos-x64": "npm:0.25.9" - "@esbuild/win32-arm64": "npm:0.25.9" - "@esbuild/win32-ia32": "npm:0.25.9" - "@esbuild/win32-x64": "npm:0.25.9" - dependenciesMeta: - "@esbuild/aix-ppc64": - optional: true - "@esbuild/android-arm": - optional: true - "@esbuild/android-arm64": - optional: true - "@esbuild/android-x64": - optional: true - "@esbuild/darwin-arm64": - optional: true - "@esbuild/darwin-x64": - optional: true - "@esbuild/freebsd-arm64": - optional: true - "@esbuild/freebsd-x64": - optional: true - "@esbuild/linux-arm": - optional: true - "@esbuild/linux-arm64": - optional: true - "@esbuild/linux-ia32": - optional: true - "@esbuild/linux-loong64": - optional: true - "@esbuild/linux-mips64el": - optional: true - "@esbuild/linux-ppc64": - optional: true - "@esbuild/linux-riscv64": - optional: true - "@esbuild/linux-s390x": - optional: true - "@esbuild/linux-x64": - optional: true - "@esbuild/netbsd-arm64": - optional: true - "@esbuild/netbsd-x64": - optional: true - "@esbuild/openbsd-arm64": - optional: true - "@esbuild/openbsd-x64": - optional: true - "@esbuild/openharmony-arm64": - optional: true - "@esbuild/sunos-x64": - optional: true - "@esbuild/win32-arm64": - optional: true - "@esbuild/win32-ia32": - optional: true - "@esbuild/win32-x64": - optional: true - bin: - esbuild: bin/esbuild - checksum: 10/fc174ae7f646ad413adb641c7e46f16be575e462ed209866b55d5954d382e5da839e3f3f89a8e42e2b71d48895cc636ba43523011249fe5ff9c63d8d39d3a364 - languageName: node - linkType: hard - "esbuild@npm:~0.27.0": version: 0.27.4 resolution: "esbuild@npm:0.27.4" @@ -14436,23 +14166,7 @@ __metadata: languageName: node linkType: hard -"tsx@npm:^4.20.5": - version: 4.20.5 - resolution: "tsx@npm:4.20.5" - dependencies: - esbuild: "npm:~0.25.0" - fsevents: "npm:~2.3.3" - get-tsconfig: "npm:^4.7.5" - dependenciesMeta: - fsevents: - optional: true - bin: - tsx: dist/cli.mjs - checksum: 10/161420678027c43d07b60b7b6b512cc67ff86ae3cca0641a19b0d3e742c5e262bca57034c4bff6d9346f9269e9ada24b6030e1d2bc890df5e1a9754865d3c08a - languageName: node - linkType: hard - -"tsx@npm:^4.21.0": +"tsx@npm:^4.20.5, tsx@npm:^4.21.0": version: 4.21.0 resolution: "tsx@npm:4.21.0" dependencies: From d9fb55dfe5557f5c73e9196f0ec723304f7bb54c Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 2 Apr 2026 11:33:42 +0200 Subject: [PATCH 3/8] Refactor bridge controller tests to `withController` pattern --- .../src/bridge-controller.sse.test.ts | 2681 ++++---- .../src/bridge-controller.test.ts | 5739 +++++++++-------- 2 files changed, 4458 insertions(+), 3962 deletions(-) diff --git a/packages/bridge-controller/src/bridge-controller.sse.test.ts b/packages/bridge-controller/src/bridge-controller.sse.test.ts index e3bf9ffeea6..d7096405607 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.test.ts @@ -60,41 +60,9 @@ const BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS = [ ] as const; const messengerCallMock = jest.fn(); - -function buildController( - options: Partial[0]> = {}, -): { controller: BridgeController; rootMessenger: RootMessenger } { - const newRootMessenger: RootMessenger = new Messenger({ - namespace: MOCK_ANY_NAMESPACE, - }); - const messenger: BridgeControllerMessenger = new Messenger({ - namespace: 'BridgeController', - parent: newRootMessenger, - }); - - newRootMessenger.delegate({ - messenger, - actions: [...BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS], - }); - - for (const action of BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS) { - newRootMessenger.registerActionHandler(action, (...args) => - messengerCallMock(action, ...args), - ); - } - - const controller = new BridgeController({ - messenger, - getLayer1GasFee: jest.fn().mockResolvedValue('0x1'), - clientId: BridgeClientId.EXTENSION, - fetchFn: jest.fn(), - trackMetaMetricsFn: jest.fn(), - clientVersion: '13.8.0', - ...options, - }); - - return { controller, rootMessenger: newRootMessenger }; -} +const getLayer1GasFeeMock = jest.fn(); +const mockFetchFn = jest.fn(); +const trackMetaMetricsFn = jest.fn(); const FIRST_FETCH_DELAY = 4000; const SECOND_FETCH_DELAY = 9000; @@ -128,175 +96,239 @@ const assetExchangeRates = { }, }; -describe('BridgeController SSE', function () { - let bridgeController: BridgeController, - fetchAssetPricesSpy: jest.SpyInstance, - stopAllPollingSpy: jest.SpyInstance, - startPollingSpy: jest.SpyInstance, - hasSufficientBalanceSpy: jest.SpyInstance, - fetchBridgeQuotesSpy: jest.SpyInstance, - consoleLogSpy: jest.SpyInstance; - - let rootMessenger: RootMessenger; - const getLayer1GasFeeMock = jest.fn(); - const mockFetchFn = jest.fn(); - const trackMetaMetricsFn = jest.fn(); - - beforeEach(async () => { - jest.clearAllMocks(); - jest.clearAllTimers(); - jest.resetAllMocks(); +type WithControllerCallback = (payload: { + controller: BridgeController; + rootMessenger: RootMessenger; + stopAllPollingSpy: jest.SpyInstance; + startPollingSpy: jest.SpyInstance; + hasSufficientBalanceSpy: jest.SpyInstance; + fetchBridgeQuotesSpy: jest.SpyInstance; + fetchAssetPricesSpy: jest.SpyInstance; + consoleLogSpy: jest.SpyInstance; +}) => Promise | ReturnValue; + +type WithControllerOptions = { + options?: Partial[0]>; +}; - fetchAssetPricesSpy = jest - .spyOn(fetchUtils, 'fetchAssetPrices') - .mockResolvedValue({ - 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': { - usd: '100', - }, - }); - getLayer1GasFeeMock.mockResolvedValue('0x1'); - messengerCallMock.mockImplementation( - (...args: Parameters) => { - switch (args[0]) { - case 'AuthenticationController:getBearerToken': - return 'AUTH_TOKEN'; - default: - return { - address: '0x123', - provider: jest.fn(), - currencyRates: {}, - marketData: {}, - conversionRates: {}, - }; - } - }, +async function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] +): Promise { + const [{ options = {} }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + const messenger: BridgeControllerMessenger = new Messenger({ + namespace: 'BridgeController', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger, + actions: [...BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS], + }); + + for (const action of BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS) { + rootMessenger.registerActionHandler(action, (...actionArgs) => + messengerCallMock(action, ...actionArgs), ); - jest.spyOn(featureFlagUtils, 'getBridgeFeatureFlags').mockReturnValue({ - minimumVersion: '0.0.0', - maxRefreshCount: 5, - refreshRate: 30000, - support: true, - sse: { - enabled: true, - minimumVersion: '13.8.0', + } + + jest.useFakeTimers(); + + getLayer1GasFeeMock.mockResolvedValue('0x1'); + + messengerCallMock.mockImplementation( + (...messengerArgs: Parameters) => { + switch (messengerArgs[0]) { + case 'AuthenticationController:getBearerToken': + return 'AUTH_TOKEN'; + default: + return { + address: '0x123', + provider: jest.fn(), + currencyRates: {}, + marketData: {}, + conversionRates: {}, + }; + } + }, + ); + + jest.spyOn(featureFlagUtils, 'getBridgeFeatureFlags').mockReturnValue({ + minimumVersion: '0.0.0', + maxRefreshCount: 5, + refreshRate: 30000, + support: true, + sse: { + enabled: true, + minimumVersion: '13.8.0', + }, + chains: { + '10': { isActiveSrc: true, isActiveDest: false }, + '534352': { isActiveSrc: true, isActiveDest: false }, + '137': { isActiveSrc: false, isActiveDest: true }, + '42161': { isActiveSrc: false, isActiveDest: true }, + [ChainId.SOLANA]: { + isActiveSrc: true, + isActiveDest: true, }, - chains: { - '10': { isActiveSrc: true, isActiveDest: false }, - '534352': { isActiveSrc: true, isActiveDest: false }, - '137': { isActiveSrc: false, isActiveDest: true }, - '42161': { isActiveSrc: false, isActiveDest: true }, - [ChainId.SOLANA]: { - isActiveSrc: true, - isActiveDest: true, - }, + }, + chainRanking: [{ chainId: 'eip155:1' as const, name: 'Ethereum' }], + }); + + const controller = new BridgeController({ + messenger, + getLayer1GasFee: getLayer1GasFeeMock, + clientId: BridgeClientId.EXTENSION, + fetchFn: mockFetchFn, + trackMetaMetricsFn, + clientVersion: '13.8.0', + ...options, + }); + + const stopAllPollingSpy = jest.spyOn(controller, 'stopAllPolling'); + const startPollingSpy = jest.spyOn(controller, 'startPolling'); + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(true); + const fetchBridgeQuotesSpy = jest.spyOn(fetchUtils, 'fetchBridgeQuoteStream'); + const fetchAssetPricesSpy = jest + .spyOn(fetchUtils, 'fetchAssetPrices') + .mockResolvedValue({ + 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': { + usd: '100', }, - chainRanking: [{ chainId: 'eip155:1' as const, name: 'Ethereum' }], }); - ({ controller: bridgeController, rootMessenger } = buildController({ - getLayer1GasFee: getLayer1GasFeeMock, - fetchFn: mockFetchFn, - trackMetaMetricsFn, - clientVersion: '13.8.0', - })); - - jest.useFakeTimers(); - stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); - startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); - hasSufficientBalanceSpy = jest - .spyOn(balanceUtils, 'hasSufficientBalance') - .mockResolvedValue(true); - fetchBridgeQuotesSpy = jest.spyOn(fetchUtils, 'fetchBridgeQuoteStream'); - consoleLogSpy = jest.spyOn(console, 'log'); + const consoleLogSpy = jest.spyOn(console, 'log'); + + return await testFunction({ + controller, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + hasSufficientBalanceSpy, + fetchBridgeQuotesSpy, + fetchAssetPricesSpy, + consoleLogSpy, + }); +} + +describe('BridgeController SSE', function () { + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + jest.resetAllMocks(); }); it('should trigger quote polling if request is valid', async function () { - mockFetchFn.mockImplementationOnce(async () => { - return mockSseEventSource(mockBridgeQuotesNativeErc20 as QuoteResponse[]); - }); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteRequest, - metricsContext, - ); + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + hasSufficientBalanceSpy, + fetchBridgeQuotesSpy, + fetchAssetPricesSpy, + consoleLogSpy, + }) => { + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSource( + mockBridgeQuotesNativeErc20 as QuoteResponse[], + ); + }); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest, + metricsContext, + ); + + // Before polling starts + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledWith({ + updatedQuoteRequest: { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + context: metricsContext, + }); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); + const expectedState = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quoteRequest, + quotesLoadingStatus: RequestStatus.LOADING, + }; + expect(bridgeController.state).toStrictEqual(expectedState); - // Before polling starts - expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).toHaveBeenCalledTimes(1); - expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).toHaveBeenCalledWith({ - updatedQuoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, - context: metricsContext, - }); - expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); - const expectedState = { - ...DEFAULT_BRIDGE_CONTROLLER_STATE, - quoteRequest, - quotesLoadingStatus: RequestStatus.LOADING, - }; - expect(bridgeController.state).toStrictEqual(expectedState); - - // Loading state - jest.advanceTimersByTime(1000); - await advanceToNthTimerThenFlush(); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( - mockFetchFn, - { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, - expect.any(AbortSignal), - BridgeClientId.EXTENSION, - 'AUTH_TOKEN', - BRIDGE_PROD_API_BASE_URL, - { - onQuoteValidationFailure: expect.any(Function), - onValidQuoteReceived: expect.any(Function), - onTokenWarning: expect.any(Function), - onComplete: expect.any(Function), - onClose: expect.any(Function), + // Loading state + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + mockFetchFn, + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + expect.any(AbortSignal), + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + BRIDGE_PROD_API_BASE_URL, + { + onQuoteValidationFailure: expect.any(Function), + onValidQuoteReceived: expect.any(Function), + onTokenWarning: expect.any(Function), + onComplete: expect.any(Function), + onClose: expect.any(Function), + }, + '13.8.0', + ); + const { quotesLastFetched: t1, ...stateWithoutTimestamp } = + bridgeController.state; + // eslint-disable-next-line jest/no-restricted-matchers + expect(stateWithoutTimestamp).toMatchSnapshot(); + expect(t1).toBeCloseTo(Date.now() - 1000); + + // After first fetch + jest.advanceTimersByTime(5000); + await flushPromises(); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + quotesInitialLoadTime: 6000, + quoteRequest: { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + quotes: mockBridgeQuotesNativeErc20.map((quote) => ({ + ...quote, + l1GasFeesInHexWei: '0x1', + resetApproval: undefined, + })), + quotesRefreshCount: 1, + quotesLoadingStatus: 1, + quotesLastFetched: t1, + assetExchangeRates, + }); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).toHaveBeenCalledTimes(0); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(2); + // eslint-disable-next-line jest/no-restricted-matchers + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); }, - '13.8.0', ); - const { quotesLastFetched: t1, ...stateWithoutTimestamp } = - bridgeController.state; - // eslint-disable-next-line jest/no-restricted-matchers - expect(stateWithoutTimestamp).toMatchSnapshot(); - expect(t1).toBeCloseTo(Date.now() - 1000); - - // After first fetch - jest.advanceTimersByTime(5000); - await flushPromises(); - expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); - expect(bridgeController.state).toStrictEqual({ - ...expectedState, - quotesInitialLoadTime: 6000, - quoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, - quotes: mockBridgeQuotesNativeErc20.map((quote) => ({ - ...quote, - l1GasFeesInHexWei: '0x1', - resetApproval: undefined, - })), - quotesRefreshCount: 1, - quotesLoadingStatus: 1, - quotesLastFetched: t1, - assetExchangeRates, - }); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); - expect(consoleLogSpy).toHaveBeenCalledTimes(0); - expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); - expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(2); - // eslint-disable-next-line jest/no-restricted-matchers - expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); }); it.each([ @@ -324,1186 +356,1293 @@ describe('BridgeController SSE', function () { mockContractCalls: number = 3, srcTokenAddress: string = ETH_USDT_ADDRESS, ) { - const mockUSDTQuoteResponse = mockBridgeQuotesErc20Erc20.map((quote) => ({ - ...quote, - quote: { - ...quote.quote, - srcTokenAddress, - srcChainId: 1, - destChainId: formatChainIdToDec(destChainId), - }, - })); - mockFetchFn.mockImplementationOnce(async () => { - return mockSseEventSource(mockUSDTQuoteResponse as QuoteResponse[]); - }); - - const contractMock = new ethersContractUtils.Contract( - ETH_USDT_ADDRESS, - abiERC20, - ); - const contractMockSpy = jest - .spyOn(ethersContractUtils, 'Contract') - .mockImplementation(() => { - return { - ...jest.requireActual('@ethersproject/contracts').Contract, - interface: contractMock.interface, - allowance: jest.fn().mockResolvedValue(BigNumber.from(allowance)), + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + fetchBridgeQuotesSpy, + consoleLogSpy, + }) => { + const mockUSDTQuoteResponse = mockBridgeQuotesErc20Erc20.map( + (quote) => ({ + ...quote, + quote: { + ...quote.quote, + srcTokenAddress, + srcChainId: 1, + destChainId: formatChainIdToDec(destChainId), + }, + }), + ); + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSource(mockUSDTQuoteResponse as QuoteResponse[]); + }); + + const contractMock = new ethersContractUtils.Contract( + ETH_USDT_ADDRESS, + abiERC20, + ); + const contractMockSpy = jest + .spyOn(ethersContractUtils, 'Contract') + .mockImplementation(() => { + return { + ...jest.requireActual('@ethersproject/contracts').Contract, + interface: contractMock.interface, + allowance: jest + .fn() + .mockResolvedValue(BigNumber.from(allowance)), + }; + }); + + const usdtQuoteRequest = { + ...quoteRequest, + srcTokenAddress, + srcChainId: '0x1', + destChainId, }; - }); - const usdtQuoteRequest = { - ...quoteRequest, - srcTokenAddress, - srcChainId: '0x1', - destChainId, - }; - - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - usdtQuoteRequest, - metricsContext, - ); - - // Before polling starts - expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).toHaveBeenCalledWith({ - updatedQuoteRequest: { - ...usdtQuoteRequest, - insufficientBal: false, - resetApproval, - }, - context: metricsContext, - }); - const expectedState = { - ...DEFAULT_BRIDGE_CONTROLLER_STATE, - quoteRequest: usdtQuoteRequest, - quotesLoadingStatus: RequestStatus.LOADING, - }; - expect(bridgeController.state).toStrictEqual(expectedState); - - // Loading state - jest.advanceTimersByTime(1000); - await advanceToNthTimerThenFlush(); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( - mockFetchFn, - { - ...usdtQuoteRequest, - insufficientBal: false, - resetApproval, - }, - expect.any(AbortSignal), - BridgeClientId.EXTENSION, - 'AUTH_TOKEN', - BRIDGE_PROD_API_BASE_URL, - { - onQuoteValidationFailure: expect.any(Function), - onValidQuoteReceived: expect.any(Function), - onTokenWarning: expect.any(Function), - onComplete: expect.any(Function), - onClose: expect.any(Function), + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + usdtQuoteRequest, + metricsContext, + ); + + // Before polling starts + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledWith({ + updatedQuoteRequest: { + ...usdtQuoteRequest, + insufficientBal: false, + resetApproval, + }, + context: metricsContext, + }); + const expectedState = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quoteRequest: usdtQuoteRequest, + quotesLoadingStatus: RequestStatus.LOADING, + }; + expect(bridgeController.state).toStrictEqual(expectedState); + + // Loading state + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + mockFetchFn, + { + ...usdtQuoteRequest, + insufficientBal: false, + resetApproval, + }, + expect.any(AbortSignal), + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + BRIDGE_PROD_API_BASE_URL, + { + onQuoteValidationFailure: expect.any(Function), + onValidQuoteReceived: expect.any(Function), + onTokenWarning: expect.any(Function), + onComplete: expect.any(Function), + onClose: expect.any(Function), + }, + '13.8.0', + ); + const { quotesLastFetched: t1, quoteRequest: stateQuoteRequest } = + bridgeController.state; + expect(stateQuoteRequest).toStrictEqual({ + ...usdtQuoteRequest, + insufficientBal: false, + resetApproval, + }); + expect(t1).toBeCloseTo(Date.now() - 1000); + + // After first fetch + jest.advanceTimersByTime(5000); + await flushPromises(); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + quotesInitialLoadTime: 6000, + quoteRequest: { + ...usdtQuoteRequest, + insufficientBal: false, + resetApproval, + }, + quotes: mockUSDTQuoteResponse.map((quote) => ({ + ...quote, + resetApproval: tradeData + ? { + ...quote.approval, + data: tradeData, + } + : undefined, + })), + quotesRefreshCount: 1, + quotesLoadingStatus: 1, + quotesLastFetched: t1, + assetExchangeRates, + }); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).toHaveBeenCalledTimes(0); + expect(getLayer1GasFeeMock).not.toHaveBeenCalled(); + expect(contractMockSpy.mock.calls).toHaveLength(mockContractCalls); }, - '13.8.0', ); - const { quotesLastFetched: t1, quoteRequest: stateQuoteRequest } = - bridgeController.state; - expect(stateQuoteRequest).toStrictEqual({ - ...usdtQuoteRequest, - insufficientBal: false, - resetApproval, - }); - expect(t1).toBeCloseTo(Date.now() - 1000); - - // After first fetch - jest.advanceTimersByTime(5000); - await flushPromises(); - expect(bridgeController.state).toStrictEqual({ - ...expectedState, - quotesInitialLoadTime: 6000, - quoteRequest: { - ...usdtQuoteRequest, - insufficientBal: false, - resetApproval, - }, - quotes: mockUSDTQuoteResponse.map((quote) => ({ - ...quote, - resetApproval: tradeData - ? { - ...quote.approval, - data: tradeData, - } - : undefined, - })), - quotesRefreshCount: 1, - quotesLoadingStatus: 1, - quotesLastFetched: t1, - assetExchangeRates, - }); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); - expect(consoleLogSpy).toHaveBeenCalledTimes(0); - expect(getLayer1GasFeeMock).not.toHaveBeenCalled(); - expect(contractMockSpy.mock.calls).toHaveLength(mockContractCalls); }, ); it('should use resetApproval and insufficientBal fallback values if provider is not found', async function () { - messengerCallMock.mockImplementation( - (...args: Parameters) => { - if (args[0] === 'AuthenticationController:getBearerToken') { - return 'AUTH_TOKEN'; - } - return { - address: '0x123', - provider: undefined, - currencyRates: {}, - marketData: {}, - conversionRates: {}, - } as never; - }, - ); - const mockUSDTQuoteResponse = mockBridgeQuotesErc20Erc20.map((quote) => ({ - ...quote, - quote: { - ...quote.quote, - srcTokenAddress: ETH_USDT_ADDRESS, - srcChainId: 1, - }, - })); - mockFetchFn.mockImplementationOnce(async () => { - return mockSseEventSource(mockUSDTQuoteResponse as QuoteResponse[]); - }); - - const contractMock = new ethersContractUtils.Contract( - ETH_USDT_ADDRESS, - abiERC20, - ); - const contractMockSpy = jest - .spyOn(ethersContractUtils, 'Contract') - .mockImplementation(() => { - return { - ...jest.requireActual('@ethersproject/contracts').Contract, - interface: contractMock.interface, - allowance: jest.fn().mockResolvedValue(BigNumber.from('1')), - }; - }); + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + fetchBridgeQuotesSpy, + consoleLogSpy, + }) => { + messengerCallMock.mockImplementation( + (...args: Parameters) => { + if (args[0] === 'AuthenticationController:getBearerToken') { + return 'AUTH_TOKEN'; + } + return { + address: '0x123', + provider: undefined, + currencyRates: {}, + marketData: {}, + conversionRates: {}, + } as never; + }, + ); + const mockUSDTQuoteResponse = mockBridgeQuotesErc20Erc20.map( + (quote) => ({ + ...quote, + quote: { + ...quote.quote, + srcTokenAddress: ETH_USDT_ADDRESS, + srcChainId: 1, + }, + }), + ); + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSource(mockUSDTQuoteResponse as QuoteResponse[]); + }); - const usdtQuoteRequest = { - ...quoteRequest, - srcTokenAddress: ETH_USDT_ADDRESS, - srcChainId: '0x1', - }; + const contractMock = new ethersContractUtils.Contract( + ETH_USDT_ADDRESS, + abiERC20, + ); + const contractMockSpy = jest + .spyOn(ethersContractUtils, 'Contract') + .mockImplementation(() => { + return { + ...jest.requireActual('@ethersproject/contracts').Contract, + interface: contractMock.interface, + allowance: jest.fn().mockResolvedValue(BigNumber.from('1')), + }; + }); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - usdtQuoteRequest, - metricsContext, - ); + const usdtQuoteRequest = { + ...quoteRequest, + srcTokenAddress: ETH_USDT_ADDRESS, + srcChainId: '0x1', + }; - // Before polling starts - expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).toHaveBeenCalledWith({ - updatedQuoteRequest: { - ...usdtQuoteRequest, - insufficientBal: true, - resetApproval: true, - }, - context: metricsContext, - }); - const expectedState = { - ...DEFAULT_BRIDGE_CONTROLLER_STATE, - quoteRequest: usdtQuoteRequest, - quotesLoadingStatus: RequestStatus.LOADING, - }; - expect(bridgeController.state).toStrictEqual(expectedState); - - // Loading state - jest.advanceTimersByTime(1000); - // Wait for JWT token retrieval - await advanceToNthTimerThenFlush(); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( - mockFetchFn, - { - ...usdtQuoteRequest, - insufficientBal: true, - resetApproval: true, - }, - expect.any(AbortSignal), - BridgeClientId.EXTENSION, - 'AUTH_TOKEN', - BRIDGE_PROD_API_BASE_URL, - { - onQuoteValidationFailure: expect.any(Function), - onValidQuoteReceived: expect.any(Function), - onTokenWarning: expect.any(Function), - onComplete: expect.any(Function), - onClose: expect.any(Function), + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + usdtQuoteRequest, + metricsContext, + ); + + // Before polling starts + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledWith({ + updatedQuoteRequest: { + ...usdtQuoteRequest, + insufficientBal: true, + resetApproval: true, + }, + context: metricsContext, + }); + const expectedState = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quoteRequest: usdtQuoteRequest, + quotesLoadingStatus: RequestStatus.LOADING, + }; + expect(bridgeController.state).toStrictEqual(expectedState); + + // Loading state + jest.advanceTimersByTime(1000); + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + mockFetchFn, + { + ...usdtQuoteRequest, + insufficientBal: true, + resetApproval: true, + }, + expect.any(AbortSignal), + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + BRIDGE_PROD_API_BASE_URL, + { + onQuoteValidationFailure: expect.any(Function), + onValidQuoteReceived: expect.any(Function), + onTokenWarning: expect.any(Function), + onComplete: expect.any(Function), + onClose: expect.any(Function), + }, + '13.8.0', + ); + const { quotesLastFetched: t1, quoteRequest: stateQuoteRequest } = + bridgeController.state; + expect(stateQuoteRequest).toStrictEqual({ + ...usdtQuoteRequest, + insufficientBal: true, + resetApproval: true, + }); + expect(t1).toBeCloseTo(Date.now() - 1000); + + // After first fetch + jest.advanceTimersByTime(5000); + await flushPromises(); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + quotesInitialLoadTime: 6000, + quoteRequest: { + ...usdtQuoteRequest, + insufficientBal: true, + resetApproval: true, + }, + quotes: mockUSDTQuoteResponse.map((quote) => ({ + ...quote, + resetApproval: { + ...quote.approval, + data: '0x095ea7b30000000000000000000000000439e60f02a8900a951603950d8d4527f400c3f10000000000000000000000000000000000000000000000000000000000000000', + }, + })), + quotesRefreshCount: 1, + quotesLoadingStatus: 1, + quotesLastFetched: t1, + assetExchangeRates, + }); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).toHaveBeenCalledTimes(0); + expect(getLayer1GasFeeMock).not.toHaveBeenCalled(); + expect(contractMockSpy.mock.calls).toHaveLength(2); }, - '13.8.0', ); - const { quotesLastFetched: t1, quoteRequest: stateQuoteRequest } = - bridgeController.state; - expect(stateQuoteRequest).toStrictEqual({ - ...usdtQuoteRequest, - insufficientBal: true, - resetApproval: true, - }); - expect(t1).toBeCloseTo(Date.now() - 1000); - - // After first fetch - jest.advanceTimersByTime(5000); - await flushPromises(); - expect(bridgeController.state).toStrictEqual({ - ...expectedState, - quotesInitialLoadTime: 6000, - quoteRequest: { - ...usdtQuoteRequest, - insufficientBal: true, - resetApproval: true, - }, - quotes: mockUSDTQuoteResponse.map((quote) => ({ - ...quote, - resetApproval: { - ...quote.approval, - data: '0x095ea7b30000000000000000000000000439e60f02a8900a951603950d8d4527f400c3f10000000000000000000000000000000000000000000000000000000000000000', - }, - })), - quotesRefreshCount: 1, - quotesLoadingStatus: 1, - quotesLastFetched: t1, - assetExchangeRates, - }); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); - expect(consoleLogSpy).toHaveBeenCalledTimes(0); - expect(getLayer1GasFeeMock).not.toHaveBeenCalled(); - expect(contractMockSpy.mock.calls).toHaveLength(2); }); it('should replace all stale quotes after a refresh and first quote is received', async function () { - mockFetchFn.mockImplementationOnce(async () => { - return mockSseEventSource( - mockBridgeQuotesNativeErc20 as QuoteResponse[], - FIRST_FETCH_DELAY, - ); - }); - mockFetchFn.mockImplementationOnce(async () => { - return mockSseEventSourceWithMultipleDelays( - mockBridgeQuotesNativeErc20Eth as never, - SECOND_FETCH_DELAY, - ); - }); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteRequest, - metricsContext, - ); - // Wait for JWT token retrieval - await advanceToNthTimerThenFlush(); - // 1st fetch - jest.advanceTimersByTime(FIRST_FETCH_DELAY); - await flushPromises(); - expect(bridgeController.state.quotes).toStrictEqual( - mockBridgeQuotesNativeErc20.map((quote) => ({ - ...quote, - l1GasFeesInHexWei: '0x1', - resetApproval: undefined, - })), - ); - const t1 = bridgeController.state.quotesLastFetched; - expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).toHaveBeenCalledTimes(1); - - // Wait for next polling interval - jest.advanceTimersToNextTimer(); - await flushPromises(); - - const expectedState = { - ...DEFAULT_BRIDGE_CONTROLLER_STATE, - quotesInitialLoadTime: FIRST_FETCH_DELAY, - quoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, - quotes: [mockBridgeQuotesNativeErc20Eth[0]].map((quote) => ({ - ...quote, - resetApproval: undefined, - })), - quotesLoadingStatus: RequestStatus.LOADING, - quotesRefreshCount: 1, - assetExchangeRates, - }; - - // 2nd fetch request's first server event - jest.advanceTimersToNextTimer(); - jest.advanceTimersByTime(SECOND_FETCH_DELAY - 1000); - await flushPromises(); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(2); - expect(bridgeController.state).toStrictEqual({ - ...expectedState, - quotesLastFetched: expect.any(Number), - }); - const t2 = bridgeController.state.quotesLastFetched; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - expect(t2).toBeGreaterThan(t1!); - - // After 2nd server event - await advanceToNthTimerThenFlush(); - expect(bridgeController.state).toStrictEqual({ - ...expectedState, - quotes: mockBridgeQuotesNativeErc20Eth.map((quote) => ({ - ...quote, - resetApproval: undefined, - })), - quotesLastFetched: t2, - quotesRefreshCount: 2, - quotesLoadingStatus: RequestStatus.FETCHED, - }); - - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(2); - expect(consoleLogSpy).toHaveBeenCalledTimes(0); - expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); - expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(2); - // eslint-disable-next-line jest/no-restricted-matchers - expect(trackMetaMetricsFn.mock.calls.at(-1)).toMatchSnapshot(); - }); + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + hasSufficientBalanceSpy, + fetchBridgeQuotesSpy, + consoleLogSpy, + }) => { + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSource( + mockBridgeQuotesNativeErc20 as QuoteResponse[], + FIRST_FETCH_DELAY, + ); + }); + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithMultipleDelays( + mockBridgeQuotesNativeErc20Eth as never, + SECOND_FETCH_DELAY, + ); + }); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest, + metricsContext, + ); + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); + // 1st fetch + jest.advanceTimersByTime(FIRST_FETCH_DELAY); + await flushPromises(); + expect(bridgeController.state.quotes).toStrictEqual( + mockBridgeQuotesNativeErc20.map((quote) => ({ + ...quote, + l1GasFeesInHexWei: '0x1', + resetApproval: undefined, + })), + ); + const t1 = bridgeController.state.quotesLastFetched; + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + + // Wait for next polling interval + jest.advanceTimersToNextTimer(); + await flushPromises(); + + const expectedState = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quotesInitialLoadTime: FIRST_FETCH_DELAY, + quoteRequest: { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + quotes: [mockBridgeQuotesNativeErc20Eth[0]].map((quote) => ({ + ...quote, + resetApproval: undefined, + })), + quotesLoadingStatus: RequestStatus.LOADING, + quotesRefreshCount: 1, + assetExchangeRates, + }; - it('should reset quotes list if quote refresh fails', async function () { - mockFetchFn.mockImplementationOnce(async () => { - return mockSseEventSource( - mockBridgeQuotesNativeErc20 as never, - FIRST_FETCH_DELAY, - ); - }); - mockFetchFn.mockImplementationOnce(async () => { - return mockSseEventSourceWithMultipleDelays( - mockBridgeQuotesNativeErc20Eth as never, - SECOND_FETCH_DELAY, - ); - }); - mockFetchFn.mockRejectedValueOnce('Network error'); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteRequest, - metricsContext, - ); + // 2nd fetch request's first server event + jest.advanceTimersToNextTimer(); + jest.advanceTimersByTime(SECOND_FETCH_DELAY - 1000); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(2); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + quotesLastFetched: expect.any(Number), + }); + const t2 = bridgeController.state.quotesLastFetched; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(t2).toBeGreaterThan(t1!); + + // After 2nd server event + await advanceToNthTimerThenFlush(); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + quotes: mockBridgeQuotesNativeErc20Eth.map((quote) => ({ + ...quote, + resetApproval: undefined, + })), + quotesLastFetched: t2, + quotesRefreshCount: 2, + quotesLoadingStatus: RequestStatus.FETCHED, + }); - consoleLogSpy.mockImplementationOnce(jest.fn()); - // Wait for JWT token retrieval - await advanceToNthTimerThenFlush(); - // 1st fetch - jest.advanceTimersByTime(FIRST_FETCH_DELAY); - await flushPromises(); - expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).toHaveBeenCalledTimes(1); - expect(bridgeController.state.quotesInitialLoadTime).toBe( - FIRST_FETCH_DELAY, + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(2); + expect(consoleLogSpy).toHaveBeenCalledTimes(0); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(2); + // eslint-disable-next-line jest/no-restricted-matchers + expect(trackMetaMetricsFn.mock.calls.at(-1)).toMatchSnapshot(); + }, ); + }); - // 2nd fetch - await advanceToNthTimerThenFlush(); - await advanceToNthTimerThenFlush(2); - expect(bridgeController.state.quotesRefreshCount).toBe(2); - expect(bridgeController.state.quotesInitialLoadTime).toBe( - FIRST_FETCH_DELAY, - ); - expect(bridgeController.state.quotes).toStrictEqual( - mockBridgeQuotesNativeErc20Eth.map((quote) => ({ - ...quote, - resetApproval: undefined, - })), - ); - const t2 = bridgeController.state.quotesLastFetched; - - // 3nd fetch throws an error - await advanceToNthTimerThenFlush(); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(3); - expect(bridgeController.state).toStrictEqual({ - ...DEFAULT_BRIDGE_CONTROLLER_STATE, - quotesInitialLoadTime: FIRST_FETCH_DELAY, - quoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, - quotes: [], - quotesLoadingStatus: 2, - quoteFetchError: 'Network error', - quotesRefreshCount: 3, - quotesLastFetched: Date.now(), - assetExchangeRates, - }); - expect( - bridgeController.state.quotesLastFetched, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - ).toBeGreaterThan(t2!); - expect(consoleLogSpy.mock.calls).toMatchInlineSnapshot(` - [ + it('should reset quotes list if quote refresh fails', async function () { + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + hasSufficientBalanceSpy, + fetchBridgeQuotesSpy, + consoleLogSpy, + }) => { + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSource( + mockBridgeQuotesNativeErc20 as never, + FIRST_FETCH_DELAY, + ); + }); + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithMultipleDelays( + mockBridgeQuotesNativeErc20Eth as never, + SECOND_FETCH_DELAY, + ); + }); + mockFetchFn.mockRejectedValueOnce('Network error'); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest, + metricsContext, + ); + + consoleLogSpy.mockImplementationOnce(jest.fn()); + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); + // 1st fetch + jest.advanceTimersByTime(FIRST_FETCH_DELAY); + await flushPromises(); + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(bridgeController.state.quotesInitialLoadTime).toBe( + FIRST_FETCH_DELAY, + ); + + // 2nd fetch + await advanceToNthTimerThenFlush(); + await advanceToNthTimerThenFlush(2); + expect(bridgeController.state.quotesRefreshCount).toBe(2); + expect(bridgeController.state.quotesInitialLoadTime).toBe( + FIRST_FETCH_DELAY, + ); + expect(bridgeController.state.quotes).toStrictEqual( + mockBridgeQuotesNativeErc20Eth.map((quote) => ({ + ...quote, + resetApproval: undefined, + })), + ); + const t2 = bridgeController.state.quotesLastFetched; + + // 3nd fetch throws an error + await advanceToNthTimerThenFlush(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(3); + expect(bridgeController.state).toStrictEqual({ + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quotesInitialLoadTime: FIRST_FETCH_DELAY, + quoteRequest: { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + quotes: [], + quotesLoadingStatus: 2, + quoteFetchError: 'Network error', + quotesRefreshCount: 3, + quotesLastFetched: Date.now(), + assetExchangeRates, + }); + expect( + bridgeController.state.quotesLastFetched, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ).toBeGreaterThan(t2!); + expect(consoleLogSpy.mock.calls).toMatchInlineSnapshot(` [ - "Failed to stream bridge quotes", - "Network error", - ], - ] - `); - expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); - expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(2); - expect(trackMetaMetricsFn).toHaveBeenCalledTimes(8); - // eslint-disable-next-line jest/no-restricted-matchers - expect(trackMetaMetricsFn.mock.calls.slice(6, 8)).toMatchSnapshot(); + [ + "Failed to stream bridge quotes", + "Network error", + ], + ] + `); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(2); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(8); + // eslint-disable-next-line jest/no-restricted-matchers + expect(trackMetaMetricsFn.mock.calls.slice(6, 8)).toMatchSnapshot(); + }, + ); }); it('should reset and refetch quotes after quote request is changed', async function () { - mockFetchFn.mockImplementationOnce(async () => { - return mockSseEventSource( - mockBridgeQuotesNativeErc20 as never, - FIRST_FETCH_DELAY, - ); - }); - mockFetchFn.mockImplementationOnce(async () => { - return mockSseEventSourceWithMultipleDelays( - mockBridgeQuotesNativeErc20Eth as never, - SECOND_FETCH_DELAY, - ); - }); - mockFetchFn.mockRejectedValueOnce('Network error'); - mockFetchFn.mockImplementationOnce(async () => { - return mockSseEventSourceWithMultipleDelays( - [ - ...(mockBridgeQuotesNativeErc20 as never[]), - ...(mockBridgeQuotesNativeErc20 as never), - ] as never, - THIRD_FETCH_DELAY, - ); - }); - - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteRequest, - metricsContext, - ); + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + hasSufficientBalanceSpy, + fetchBridgeQuotesSpy, + consoleLogSpy, + }) => { + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSource( + mockBridgeQuotesNativeErc20 as never, + FIRST_FETCH_DELAY, + ); + }); + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithMultipleDelays( + mockBridgeQuotesNativeErc20Eth as never, + SECOND_FETCH_DELAY, + ); + }); + mockFetchFn.mockRejectedValueOnce('Network error'); + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithMultipleDelays( + [ + ...(mockBridgeQuotesNativeErc20 as never[]), + ...(mockBridgeQuotesNativeErc20 as never), + ] as never, + THIRD_FETCH_DELAY, + ); + }); - consoleLogSpy.mockImplementationOnce(jest.fn()); - hasSufficientBalanceSpy.mockRejectedValue(new Error('Balance error')); - - // Wait for JWT token retrieval - await advanceToNthTimerThenFlush(); - - // 1st fetch - jest.advanceTimersByTime(FIRST_FETCH_DELAY); - await flushPromises(); - expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).toHaveBeenCalledTimes(1); - - // Wait for next polling interval - jest.advanceTimersToNextTimer(); - await flushPromises(); - - // 2nd fetch - jest.advanceTimersToNextTimer(); - jest.advanceTimersToNextTimer(); - await flushPromises(); - expect(bridgeController.state.quotesRefreshCount).toBe(2); - const t2 = bridgeController.state.quotesLastFetched; - - // 3nd fetch throws an error - await advanceToNthTimerThenFlush(); - const t5 = bridgeController.state.quotesLastFetched; - expect(bridgeController.state.quotesRefreshCount).toBe(3); - expect(bridgeController.state.quotes).toStrictEqual([]); - expect(consoleLogSpy).toHaveBeenCalledTimes(1); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - expect(t5).toBeGreaterThan(t2!); - const expectedState = { - ...DEFAULT_BRIDGE_CONTROLLER_STATE, - quotesLoadingStatus: RequestStatus.LOADING, - quoteRequest: { - ...quoteRequest, - srcTokenAmount: '10', - }, - assetExchangeRates: {}, - }; - // Start new quote request - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - { ...quoteRequest, srcTokenAmount: '10' }, - { - stx_enabled: true, - token_symbol_source: 'ETH', - token_symbol_destination: 'USDC', - security_warnings: [], - usd_amount_source: 100, - }, - ); - // Right after state update, before fetch has started - expect(bridgeController.state).toStrictEqual(expectedState); - advanceToNthTimer(); - expect(bridgeController.state).toStrictEqual({ - ...expectedState, - quoteRequest: { - ...quoteRequest, - srcTokenAmount: '10', - insufficientBal: true, - resetApproval: false, - }, - quotesLastFetched: Date.now(), - quotesLoadingStatus: RequestStatus.LOADING, - }); - const t1 = bridgeController.state.quotesLastFetched; - // Wait for JWT token retrieval - await advanceToNthTimerThenFlush(); - // 1st quote is received - await advanceToNthTimerThenFlush(); - const expectedStateAfterFirstQuote = { - ...expectedState, - quotesInitialLoadTime: THIRD_FETCH_DELAY, - quotes: [ - { - ...mockBridgeQuotesNativeErc20[0], - l1GasFeesInHexWei: '0x1', - resetApproval: undefined, - }, - ], - quotesRefreshCount: 0, - quotesLoadingStatus: RequestStatus.LOADING, - quoteRequest: { - ...quoteRequest, - srcTokenAmount: '10', - insufficientBal: true, - resetApproval: false, + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest, + metricsContext, + ); + + consoleLogSpy.mockImplementationOnce(jest.fn()); + hasSufficientBalanceSpy.mockRejectedValue(new Error('Balance error')); + + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); + + // 1st fetch + jest.advanceTimersByTime(FIRST_FETCH_DELAY); + await flushPromises(); + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + + // Wait for next polling interval + jest.advanceTimersToNextTimer(); + await flushPromises(); + + // 2nd fetch + jest.advanceTimersToNextTimer(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(bridgeController.state.quotesRefreshCount).toBe(2); + const t2 = bridgeController.state.quotesLastFetched; + + // 3nd fetch throws an error + await advanceToNthTimerThenFlush(); + const t5 = bridgeController.state.quotesLastFetched; + expect(bridgeController.state.quotesRefreshCount).toBe(3); + expect(bridgeController.state.quotes).toStrictEqual([]); + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(t5).toBeGreaterThan(t2!); + const expectedState = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quotesLoadingStatus: RequestStatus.LOADING, + quoteRequest: { + ...quoteRequest, + srcTokenAmount: '10', + }, + assetExchangeRates: {}, + }; + // Start new quote request + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { ...quoteRequest, srcTokenAmount: '10' }, + { + stx_enabled: true, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + security_warnings: [], + usd_amount_source: 100, + }, + ); + // Right after state update, before fetch has started + expect(bridgeController.state).toStrictEqual(expectedState); + advanceToNthTimer(); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + quoteRequest: { + ...quoteRequest, + srcTokenAmount: '10', + insufficientBal: true, + resetApproval: false, + }, + quotesLastFetched: Date.now(), + quotesLoadingStatus: RequestStatus.LOADING, + }); + const t1 = bridgeController.state.quotesLastFetched; + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); + // 1st quote is received + await advanceToNthTimerThenFlush(); + const expectedStateAfterFirstQuote = { + ...expectedState, + quotesInitialLoadTime: THIRD_FETCH_DELAY, + quotes: [ + { + ...mockBridgeQuotesNativeErc20[0], + l1GasFeesInHexWei: '0x1', + resetApproval: undefined, + }, + ], + quotesRefreshCount: 0, + quotesLoadingStatus: RequestStatus.LOADING, + quoteRequest: { + ...quoteRequest, + srcTokenAmount: '10', + insufficientBal: true, + resetApproval: false, + }, + quotesLastFetched: t1, + assetExchangeRates, + }; + expect(bridgeController.state.quotes).toHaveLength(1); + expect(bridgeController.state).toStrictEqual({ + ...expectedStateAfterFirstQuote, + }); + const t4 = bridgeController.state.quotesLastFetched; + expect(t4).toBe( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + t5!, + ); + // All other quotes are received + await advanceToNthTimerThenFlush(3); + expect(bridgeController.state).toStrictEqual({ + ...expectedStateAfterFirstQuote, + quotesRefreshCount: 1, + quotesLoadingStatus: RequestStatus.FETCHED, + quotes: [ + ...mockBridgeQuotesNativeErc20, + ...mockBridgeQuotesNativeErc20, + ].map((quote) => ({ + ...quote, + l1GasFeesInHexWei: '0x1', + resetApproval: undefined, + })), + assetExchangeRates, + }); + expect( + bridgeController.state.quotesLastFetched, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ).toBe(t4!); + + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(4); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(2); + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(6); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(9); + // eslint-disable-next-line jest/no-restricted-matchers + expect(trackMetaMetricsFn.mock.calls.slice(8, 9)).toMatchSnapshot(); }, - quotesLastFetched: t1, - assetExchangeRates, - }; - expect(bridgeController.state.quotes).toHaveLength(1); - expect(bridgeController.state).toStrictEqual({ - ...expectedStateAfterFirstQuote, - }); - const t4 = bridgeController.state.quotesLastFetched; - expect(t4).toBe( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - t5!, ); - // All other quotes are received - await advanceToNthTimerThenFlush(3); - expect(bridgeController.state).toStrictEqual({ - ...expectedStateAfterFirstQuote, - quotesRefreshCount: 1, - quotesLoadingStatus: RequestStatus.FETCHED, - quotes: [ - ...mockBridgeQuotesNativeErc20, - ...mockBridgeQuotesNativeErc20, - ].map((quote) => ({ - ...quote, - l1GasFeesInHexWei: '0x1', - resetApproval: undefined, - })), - assetExchangeRates, - }); - expect( - bridgeController.state.quotesLastFetched, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - ).toBe(t4!); - - expect(consoleLogSpy).toHaveBeenCalledTimes(1); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(4); - expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(2); - expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(6); - expect(trackMetaMetricsFn).toHaveBeenCalledTimes(9); - // eslint-disable-next-line jest/no-restricted-matchers - expect(trackMetaMetricsFn.mock.calls.slice(8, 9)).toMatchSnapshot(); }); it('should publish validation failures', async function () { - mockFetchFn.mockImplementationOnce(async () => { - return mockSseEventSource( - mockBridgeQuotesNativeErc20 as QuoteResponse[], - FIRST_FETCH_DELAY, - ); - }); - mockFetchFn.mockImplementationOnce(async () => { - return mockSseEventSourceWithMultipleDelays( - mockBridgeQuotesNativeErc20Eth as never[], - SECOND_FETCH_DELAY, - ); - }); - mockFetchFn.mockRejectedValueOnce('Network error'); - mockFetchFn.mockImplementationOnce(async () => { - return mockSseEventSourceWithMultipleDelays( - [ - ...(mockBridgeQuotesNativeErc20 as never[]), - ...(mockBridgeQuotesNativeErc20 as never[]), - ] as never[], - THIRD_FETCH_DELAY, - ); - }); - mockFetchFn.mockImplementationOnce(async () => { - const { quote, ...rest } = mockBridgeQuotesNativeErc20[0]; - return mockSseEventSourceWithMultipleDelays( - [ + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + hasSufficientBalanceSpy, + fetchBridgeQuotesSpy, + consoleLogSpy, + }) => { + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSource( + mockBridgeQuotesNativeErc20 as QuoteResponse[], + FIRST_FETCH_DELAY, + ); + }); + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithMultipleDelays( + mockBridgeQuotesNativeErc20Eth as never[], + SECOND_FETCH_DELAY, + ); + }); + mockFetchFn.mockRejectedValueOnce('Network error'); + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithMultipleDelays( + [ + ...(mockBridgeQuotesNativeErc20 as never[]), + ...(mockBridgeQuotesNativeErc20 as never[]), + ] as never[], + THIRD_FETCH_DELAY, + ); + }); + mockFetchFn.mockImplementationOnce(async () => { + const { quote, ...rest } = mockBridgeQuotesNativeErc20[0]; + return mockSseEventSourceWithMultipleDelays( + [ + { + ...mockBridgeQuotesNativeErc20Eth[1], + trade: { abc: '123' } as unknown as TxData, + } as never, + '' as unknown as never, + mockBridgeQuotesNativeErc20Eth[0] as unknown as never, + rest as unknown as never, + ], + FOURTH_FETCH_DELAY, + ); + }); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest, + metricsContext, + ); + + consoleLogSpy.mockImplementationOnce(jest.fn()); + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementationOnce(jest.fn()) + .mockImplementationOnce(jest.fn()); + + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); + + // 1st fetch + jest.advanceTimersByTime(FIRST_FETCH_DELAY); + await flushPromises(); + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + + // Wait for next polling interval + await advanceToNthTimerThenFlush(); + + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); + + // 2nd fetch + await advanceToNthTimerThenFlush(1); + expect(bridgeController.state.quotesRefreshCount).toBe(2); + + // 3nd fetch throws an error + await advanceToNthTimerThenFlush(); + const t5 = bridgeController.state.quotesLastFetched; + expect(bridgeController.state.quotesRefreshCount).toBe(3); + expect(bridgeController.state.quotes).toStrictEqual([]); + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + + // Start new quote request + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { ...quoteRequest, srcTokenAmount: '10' }, { - ...mockBridgeQuotesNativeErc20Eth[1], - trade: { abc: '123' } as unknown as TxData, - } as never, - '' as unknown as never, - mockBridgeQuotesNativeErc20Eth[0] as unknown as never, - rest as unknown as never, - ], - FOURTH_FETCH_DELAY, - ); - }); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteRequest, - metricsContext, - ); - - consoleLogSpy.mockImplementationOnce(jest.fn()); - const consoleWarnSpy = jest - .spyOn(console, 'warn') - .mockImplementationOnce(jest.fn()) - .mockImplementationOnce(jest.fn()); - - // Wait for JWT token retrieval - await advanceToNthTimerThenFlush(); - - // 1st fetch - jest.advanceTimersByTime(FIRST_FETCH_DELAY); - await flushPromises(); - expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).toHaveBeenCalledTimes(1); - - // Wait for next polling interval - await advanceToNthTimerThenFlush(); - - // Wait for JWT token retrieval - await advanceToNthTimerThenFlush(); - - // 2nd fetch - await advanceToNthTimerThenFlush(1); - expect(bridgeController.state.quotesRefreshCount).toBe(2); - - // 3nd fetch throws an error - await advanceToNthTimerThenFlush(); - const t5 = bridgeController.state.quotesLastFetched; - expect(bridgeController.state.quotesRefreshCount).toBe(3); - expect(bridgeController.state.quotes).toStrictEqual([]); - expect(consoleLogSpy).toHaveBeenCalledTimes(1); - - // Start new quote request - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - { ...quoteRequest, srcTokenAmount: '10' }, - { - stx_enabled: true, - token_symbol_source: 'ETH', - token_symbol_destination: 'USDC', - security_warnings: [], - usd_amount_source: 100, - }, - ); - - // Wait for JWT token retrieval - await advanceToNthTimerThenFlush(); - - // 1st quote is received - jest.advanceTimersByTime(FOURTH_FETCH_DELAY - 1000); - await flushPromises(); - - const t4 = bridgeController.state.quotesLastFetched; - expect(t4).toBe( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - t5!, - ); - expect(bridgeController.state.quotesRefreshCount).toBe(0); - expect(bridgeController.state.quotesLoadingStatus).toBe( - RequestStatus.LOADING, - ); - - // 2nd quote is received - await advanceToNthTimerThenFlush(3); - expect(bridgeController.state.quotes).toStrictEqual( - [...mockBridgeQuotesNativeErc20, ...mockBridgeQuotesNativeErc20].map( - (quote) => ({ - ...quote, - l1GasFeesInHexWei: '0x1', - resetApproval: undefined, - }), - ), - ); - - // Wait for next polling interval - jest.advanceTimersToNextTimer(); - await flushPromises(); - - // 2nd fetch after request is updated - // Iterate through a list of received valid and invalid quotes - // Invalid quotes received - // Invalid quote - jest.advanceTimersByTime(FOURTH_FETCH_DELAY - 1000); - await flushPromises(); - const expectedState = { - ...DEFAULT_BRIDGE_CONTROLLER_STATE, - quotesInitialLoadTime: 2000, - quoteRequest: { - ...quoteRequest, - srcTokenAmount: '10', - insufficientBal: false, - resetApproval: false, - }, - quotes: [mockBridgeQuotesNativeErc20Eth[0]].map((quote) => ({ - ...quote, - resetApproval: undefined, - })), - quotesRefreshCount: 1, - quoteFetchError: null, - quotesLoadingStatus: RequestStatus.LOADING, - assetExchangeRates, - quotesLastFetched: expect.any(Number), - }; - const t6 = bridgeController.state.quotesLastFetched; - expect(t6).toBeCloseTo(Date.now() - 2000); - // Empty event.data - await advanceToNthTimerThenFlush(); - // Valid quote - await advanceToNthTimerThenFlush(); - await advanceToNthTimerThenFlush(); - expect(bridgeController.state).toStrictEqual(expectedState); - const t7 = bridgeController.state.quotesLastFetched; - expect(t7).toBe( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - t6!, - ); - expect(consoleWarnSpy.mock.calls[0]).toMatchInlineSnapshot(` - [ - "Quote validation failed", + stx_enabled: true, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + security_warnings: [], + usd_amount_source: 100, + }, + ); + + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); + + // 1st quote is received + jest.advanceTimersByTime(FOURTH_FETCH_DELAY - 1000); + await flushPromises(); + + const t4 = bridgeController.state.quotesLastFetched; + expect(t4).toBe( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + t5!, + ); + expect(bridgeController.state.quotesRefreshCount).toBe(0); + expect(bridgeController.state.quotesLoadingStatus).toBe( + RequestStatus.LOADING, + ); + + // 2nd quote is received + await advanceToNthTimerThenFlush(3); + expect(bridgeController.state.quotes).toStrictEqual( + [...mockBridgeQuotesNativeErc20, ...mockBridgeQuotesNativeErc20].map( + (quote) => ({ + ...quote, + l1GasFeesInHexWei: '0x1', + resetApproval: undefined, + }), + ), + ); + + // Wait for next polling interval + jest.advanceTimersToNextTimer(); + await flushPromises(); + + // 2nd fetch after request is updated + // Iterate through a list of received valid and invalid quotes + // Invalid quotes received + // Invalid quote + jest.advanceTimersByTime(FOURTH_FETCH_DELAY - 1000); + await flushPromises(); + const expectedState = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quotesInitialLoadTime: 2000, + quoteRequest: { + ...quoteRequest, + srcTokenAmount: '10', + insufficientBal: false, + resetApproval: false, + }, + quotes: [mockBridgeQuotesNativeErc20Eth[0]].map((quote) => ({ + ...quote, + resetApproval: undefined, + })), + quotesRefreshCount: 1, + quoteFetchError: null, + quotesLoadingStatus: RequestStatus.LOADING, + assetExchangeRates, + quotesLastFetched: expect.any(Number), + }; + const t6 = bridgeController.state.quotesLastFetched; + expect(t6).toBeCloseTo(Date.now() - 2000); + // Empty event.data + await advanceToNthTimerThenFlush(); + // Valid quote + await advanceToNthTimerThenFlush(); + await advanceToNthTimerThenFlush(); + expect(bridgeController.state).toStrictEqual(expectedState); + const t7 = bridgeController.state.quotesLastFetched; + expect(t7).toBe( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + t6!, + ); + expect(consoleWarnSpy.mock.calls[0]).toMatchInlineSnapshot(` [ - "lifi|trade", - "lifi|trade.chainId", - "lifi|trade.to", - "lifi|trade.from", - "lifi|trade.value", - "lifi|trade.data", - "lifi|trade.gasLimit", - "lifi|trade.unsignedPsbtBase64", - "lifi|trade.inputsToSign", - "lifi|trade.raw_data_hex", - ], - ] - `); - // Invalid quote - jest.advanceTimersByTime(FOURTH_FETCH_DELAY * 3 - 1000); - await flushPromises(); - expect(bridgeController.state).toStrictEqual({ - ...expectedState, - quotesRefreshCount: 2, - quotesLoadingStatus: RequestStatus.FETCHED, - }); - expect(bridgeController.state.quotesLastFetched).toBe( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - t7!, - ); - expect(consoleWarnSpy.mock.calls).toHaveLength(3); - expect(consoleWarnSpy.mock.calls[1]).toMatchInlineSnapshot(` - [ - "Quote validation failed", + "Quote validation failed", + [ + "lifi|trade", + "lifi|trade.chainId", + "lifi|trade.to", + "lifi|trade.from", + "lifi|trade.value", + "lifi|trade.data", + "lifi|trade.gasLimit", + "lifi|trade.unsignedPsbtBase64", + "lifi|trade.inputsToSign", + "lifi|trade.raw_data_hex", + ], + ] + `); + // Invalid quote + jest.advanceTimersByTime(FOURTH_FETCH_DELAY * 3 - 1000); + await flushPromises(); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + quotesRefreshCount: 2, + quotesLoadingStatus: RequestStatus.FETCHED, + }); + expect(bridgeController.state.quotesLastFetched).toBe( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + t7!, + ); + expect(consoleWarnSpy.mock.calls).toHaveLength(3); + expect(consoleWarnSpy.mock.calls[1]).toMatchInlineSnapshot(` [ - "unknown|unknown", - ], - ] - `); - expect(consoleWarnSpy.mock.calls[2]).toMatchInlineSnapshot(` - [ - "Quote validation failed", + "Quote validation failed", + [ + "unknown|unknown", + ], + ] + `); + expect(consoleWarnSpy.mock.calls[2]).toMatchInlineSnapshot(` [ - "unknown|quote", - ], - ] - `); - - expect(consoleLogSpy).toHaveBeenCalledTimes(1); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(5); - expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(2); - expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(6); - expect(trackMetaMetricsFn).toHaveBeenCalledTimes(13); - // eslint-disable-next-line jest/no-restricted-matchers - expect(trackMetaMetricsFn.mock.calls.slice(10, 13)).toMatchSnapshot(); + "Quote validation failed", + [ + "unknown|quote", + ], + ] + `); + + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(5); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(2); + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(6); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(13); + // eslint-disable-next-line jest/no-restricted-matchers + expect(trackMetaMetricsFn.mock.calls.slice(10, 13)).toMatchSnapshot(); + }, + ); }); it('should rethrow error from server', async function () { - mockFetchFn.mockImplementationOnce(async () => { - return mockSseServerError('timeout from server'); - }); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteRequest, - metricsContext, - ); - - // Before polling starts - expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).toHaveBeenCalledTimes(1); - expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).toHaveBeenCalledWith({ - updatedQuoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, - context: metricsContext, - }); - const expectedState = { - ...DEFAULT_BRIDGE_CONTROLLER_STATE, - quoteRequest, - assetExchangeRates: {}, - quotesLoadingStatus: RequestStatus.LOADING, - }; - expect(bridgeController.state).toStrictEqual(expectedState); - - // Loading state - jest.advanceTimersByTime(1000); - // Wait for JWT token retrieval - await advanceToNthTimerThenFlush(); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( - mockFetchFn, - { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, - expect.any(AbortSignal), - BridgeClientId.EXTENSION, - 'AUTH_TOKEN', - BRIDGE_PROD_API_BASE_URL, - { - onQuoteValidationFailure: expect.any(Function), - onValidQuoteReceived: expect.any(Function), - onTokenWarning: expect.any(Function), - onComplete: expect.any(Function), - onClose: expect.any(Function), + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + hasSufficientBalanceSpy, + fetchBridgeQuotesSpy, + fetchAssetPricesSpy, + consoleLogSpy, + }) => { + mockFetchFn.mockImplementationOnce(async () => { + return mockSseServerError('timeout from server'); + }); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest, + metricsContext, + ); + + // Before polling starts + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledWith({ + updatedQuoteRequest: { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + context: metricsContext, + }); + const expectedState = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quoteRequest, + assetExchangeRates: {}, + quotesLoadingStatus: RequestStatus.LOADING, + }; + expect(bridgeController.state).toStrictEqual(expectedState); + + // Loading state + jest.advanceTimersByTime(1000); + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + mockFetchFn, + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + expect.any(AbortSignal), + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + BRIDGE_PROD_API_BASE_URL, + { + onQuoteValidationFailure: expect.any(Function), + onValidQuoteReceived: expect.any(Function), + onTokenWarning: expect.any(Function), + onComplete: expect.any(Function), + onClose: expect.any(Function), + }, + '13.8.0', + ); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); + const { quotesLastFetched: t1, ...stateWithoutTimestamp } = + bridgeController.state; + // eslint-disable-next-line jest/no-restricted-matchers + expect(stateWithoutTimestamp).toMatchSnapshot(); + expect(t1).toBeCloseTo(Date.now() - 1000); + + // After first fetch + jest.advanceTimersByTime(5000); + await flushPromises(); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + assetExchangeRates, + quoteRequest: { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + quotesRefreshCount: 1, + quotesLoadingStatus: 2, + quoteFetchError: 'Bridge-api error: timeout from server', + quotesLastFetched: t1, + }); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy.mock.calls[0]).toMatchInlineSnapshot(` + [ + "Failed to stream bridge quotes", + [Error: Bridge-api error: timeout from server], + ] + `); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(0); + // eslint-disable-next-line jest/no-restricted-matchers + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); }, - '13.8.0', ); - expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); - const { quotesLastFetched: t1, ...stateWithoutTimestamp } = - bridgeController.state; - // eslint-disable-next-line jest/no-restricted-matchers - expect(stateWithoutTimestamp).toMatchSnapshot(); - expect(t1).toBeCloseTo(Date.now() - 1000); - - // After first fetch - jest.advanceTimersByTime(5000); - await flushPromises(); - expect(bridgeController.state).toStrictEqual({ - ...expectedState, - assetExchangeRates, - quoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, - quotesRefreshCount: 1, - quotesLoadingStatus: 2, - quoteFetchError: 'Bridge-api error: timeout from server', - quotesLastFetched: t1, - }); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); - expect(consoleLogSpy).toHaveBeenCalledTimes(1); - expect(consoleLogSpy.mock.calls[0]).toMatchInlineSnapshot(` - [ - "Failed to stream bridge quotes", - [Error: Bridge-api error: timeout from server], - ] - `); - expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); - expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(0); - // eslint-disable-next-line jest/no-restricted-matchers - expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); }); it('should populate tokenWarnings from token_warning SSE events', async function () { - const mockWarning = { - feature_id: 'HONEYPOT', - type: TokenFeatureType.MALICIOUS, - description: 'Token is a honeypot', - }; - mockFetchFn.mockImplementationOnce(async () => { - return mockSseEventSourceWithWarnings( - mockBridgeQuotesNativeErc20 as QuoteResponse[], - [mockWarning], - ); - }); + await withController(async ({ controller: bridgeController }) => { + const mockWarning = { + feature_id: 'HONEYPOT', + type: TokenFeatureType.MALICIOUS, + description: 'Token is a honeypot', + }; + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithWarnings( + mockBridgeQuotesNativeErc20 as QuoteResponse[], + [mockWarning], + ); + }); - await bridgeController.updateBridgeQuoteRequestParams( - quoteRequest, - metricsContext, - ); + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); - expect(bridgeController.state.tokenWarnings).toStrictEqual([]); + expect(bridgeController.state.tokenWarnings).toStrictEqual([]); - jest.advanceTimersByTime(1000); - await advanceToNthTimerThenFlush(); + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); - // After stream completes - jest.advanceTimersByTime(5000); - await flushPromises(); + // After stream completes + jest.advanceTimersByTime(5000); + await flushPromises(); - expect(bridgeController.state.tokenWarnings).toStrictEqual([mockWarning]); - expect(bridgeController.state.quotes.length).toBeGreaterThan(0); + expect(bridgeController.state.tokenWarnings).toStrictEqual([mockWarning]); + expect(bridgeController.state.quotes.length).toBeGreaterThan(0); + }); }); it('should clear tokenWarnings on resetState', async function () { - const mockWarning = { - feature_id: 'HONEYPOT', - type: TokenFeatureType.MALICIOUS, - description: 'Token is a honeypot', - }; - mockFetchFn.mockImplementationOnce(async () => { - return mockSseEventSourceWithWarnings( - mockBridgeQuotesNativeErc20 as QuoteResponse[], - [mockWarning], - ); - }); + await withController(async ({ controller: bridgeController }) => { + const mockWarning = { + feature_id: 'HONEYPOT', + type: TokenFeatureType.MALICIOUS, + description: 'Token is a honeypot', + }; + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithWarnings( + mockBridgeQuotesNativeErc20 as QuoteResponse[], + [mockWarning], + ); + }); - await bridgeController.updateBridgeQuoteRequestParams( - quoteRequest, - metricsContext, - ); + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); - jest.advanceTimersByTime(1000); - await advanceToNthTimerThenFlush(); - jest.advanceTimersByTime(5000); - await flushPromises(); + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + jest.advanceTimersByTime(5000); + await flushPromises(); - expect(bridgeController.state.tokenWarnings).toStrictEqual([mockWarning]); + expect(bridgeController.state.tokenWarnings).toStrictEqual([mockWarning]); - bridgeController.resetState(); - expect(bridgeController.state.tokenWarnings).toStrictEqual([]); + bridgeController.resetState(); + expect(bridgeController.state.tokenWarnings).toStrictEqual([]); + }); }); it('should deduplicate tokenWarnings with the same feature_id', async function () { - const mockWarning = { - feature_id: 'HONEYPOT', - type: TokenFeatureType.MALICIOUS, - description: 'Token is a honeypot', - }; - const duplicateWarning = { - feature_id: 'HONEYPOT', - type: TokenFeatureType.MALICIOUS, - description: 'Duplicate warning', - }; - mockFetchFn.mockImplementationOnce(async () => { - return mockSseEventSourceWithWarnings( - mockBridgeQuotesNativeErc20 as QuoteResponse[], - [mockWarning, duplicateWarning], - ); - }); + await withController(async ({ controller: bridgeController }) => { + const mockWarning = { + feature_id: 'HONEYPOT', + type: TokenFeatureType.MALICIOUS, + description: 'Token is a honeypot', + }; + const duplicateWarning = { + feature_id: 'HONEYPOT', + type: TokenFeatureType.MALICIOUS, + description: 'Duplicate warning', + }; + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithWarnings( + mockBridgeQuotesNativeErc20 as QuoteResponse[], + [mockWarning, duplicateWarning], + ); + }); - await bridgeController.updateBridgeQuoteRequestParams( - quoteRequest, - metricsContext, - ); + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); - jest.advanceTimersByTime(1000); - await advanceToNthTimerThenFlush(); - jest.advanceTimersByTime(5000); - await flushPromises(); + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + jest.advanceTimersByTime(5000); + await flushPromises(); - expect(bridgeController.state.tokenWarnings).toStrictEqual([mockWarning]); + expect(bridgeController.state.tokenWarnings).toStrictEqual([mockWarning]); + }); }); it('should deduplicate tokenWarnings with the same feature_id but different type', async function () { - const maliciousWarning = { - feature_id: 'HONEYPOT', - type: TokenFeatureType.MALICIOUS, - description: 'Token is a honeypot', - }; - const infoWarning = { - feature_id: 'HONEYPOT', - type: TokenFeatureType.INFO, - description: 'Informational notice', - }; - mockFetchFn.mockImplementationOnce(async () => { - return mockSseEventSourceWithWarnings( - mockBridgeQuotesNativeErc20 as QuoteResponse[], - [maliciousWarning, infoWarning], - ); - }); + await withController(async ({ controller: bridgeController }) => { + const maliciousWarning = { + feature_id: 'HONEYPOT', + type: TokenFeatureType.MALICIOUS, + description: 'Token is a honeypot', + }; + const infoWarning = { + feature_id: 'HONEYPOT', + type: TokenFeatureType.INFO, + description: 'Informational notice', + }; + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithWarnings( + mockBridgeQuotesNativeErc20 as QuoteResponse[], + [maliciousWarning, infoWarning], + ); + }); - await bridgeController.updateBridgeQuoteRequestParams( - quoteRequest, - metricsContext, - ); + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); - jest.advanceTimersByTime(1000); - await advanceToNthTimerThenFlush(); - jest.advanceTimersByTime(5000); - await flushPromises(); + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + jest.advanceTimersByTime(5000); + await flushPromises(); - expect(bridgeController.state.tokenWarnings).toStrictEqual([ - maliciousWarning, - ]); + expect(bridgeController.state.tokenWarnings).toStrictEqual([ + maliciousWarning, + ]); + }); }); it('should keep tokenWarnings with the same type but different feature_id', async function () { - const honeypotWarning = { - feature_id: 'HONEYPOT', - type: TokenFeatureType.MALICIOUS, - description: 'Token is a honeypot', - }; - const fakeTokenWarning = { - feature_id: 'FAKE_TOKEN', - type: TokenFeatureType.MALICIOUS, - description: 'Possible fake token', - }; - mockFetchFn.mockImplementationOnce(async () => { - return mockSseEventSourceWithWarnings( - mockBridgeQuotesNativeErc20 as QuoteResponse[], - [honeypotWarning, fakeTokenWarning], - ); - }); + await withController(async ({ controller: bridgeController }) => { + const honeypotWarning = { + feature_id: 'HONEYPOT', + type: TokenFeatureType.MALICIOUS, + description: 'Token is a honeypot', + }; + const fakeTokenWarning = { + feature_id: 'FAKE_TOKEN', + type: TokenFeatureType.MALICIOUS, + description: 'Possible fake token', + }; + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithWarnings( + mockBridgeQuotesNativeErc20 as QuoteResponse[], + [honeypotWarning, fakeTokenWarning], + ); + }); - await bridgeController.updateBridgeQuoteRequestParams( - quoteRequest, - metricsContext, - ); + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); - jest.advanceTimersByTime(1000); - await advanceToNthTimerThenFlush(); - jest.advanceTimersByTime(5000); - await flushPromises(); + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + jest.advanceTimersByTime(5000); + await flushPromises(); - expect(bridgeController.state.tokenWarnings).toStrictEqual([ - honeypotWarning, - fakeTokenWarning, - ]); + expect(bridgeController.state.tokenWarnings).toStrictEqual([ + honeypotWarning, + fakeTokenWarning, + ]); + }); }); it('should populate quoteStreamComplete from complete SSE event', async function () { - const mockComplete = { - quoteCount: 2, - hasQuotes: true, - reason: QuoteStreamCompleteReason.RETRY, - context: { source: 'bridge-api' }, - }; - mockFetchFn.mockImplementationOnce(async () => { - return mockSseEventSourceWithComplete( - mockBridgeQuotesNativeErc20 as QuoteResponse[], - [], - mockComplete, - ); - }); + await withController(async ({ controller: bridgeController }) => { + const mockComplete = { + quoteCount: 2, + hasQuotes: true, + reason: QuoteStreamCompleteReason.RETRY, + context: { source: 'bridge-api' }, + }; + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithComplete( + mockBridgeQuotesNativeErc20 as QuoteResponse[], + [], + mockComplete, + ); + }); - await bridgeController.updateBridgeQuoteRequestParams( - quoteRequest, - metricsContext, - ); + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); - expect(bridgeController.state.quoteStreamComplete).toBeNull(); + expect(bridgeController.state.quoteStreamComplete).toBeNull(); - jest.advanceTimersByTime(1000); - await advanceToNthTimerThenFlush(); - jest.advanceTimersByTime(5000); - await flushPromises(); + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + jest.advanceTimersByTime(5000); + await flushPromises(); - expect(bridgeController.state.quoteStreamComplete).toStrictEqual( - mockComplete, - ); - expect(bridgeController.state.quotes.length).toBeGreaterThan(0); + expect(bridgeController.state.quoteStreamComplete).toStrictEqual( + mockComplete, + ); + expect(bridgeController.state.quotes.length).toBeGreaterThan(0); + }); }); it('should populate quoteStreamComplete with optional fields omitted', async function () { - const mockComplete = { - quoteCount: 0, - hasQuotes: false, - }; - mockFetchFn.mockImplementationOnce(async () => { - return mockSseEventSourceWithComplete([], [], mockComplete); - }); - - await bridgeController.updateBridgeQuoteRequestParams( - quoteRequest, - metricsContext, - ); + await withController(async ({ controller: bridgeController }) => { + const mockComplete = { + quoteCount: 0, + hasQuotes: false, + }; + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithComplete([], [], mockComplete); + }); - jest.advanceTimersByTime(1000); - await advanceToNthTimerThenFlush(); - jest.advanceTimersByTime(5000); - await flushPromises(); + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); - expect(bridgeController.state.quoteStreamComplete).toStrictEqual( - mockComplete, - ); - }); + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + jest.advanceTimersByTime(5000); + await flushPromises(); - it('should clear quoteStreamComplete on resetState', async function () { - const mockComplete = { - quoteCount: 2, - hasQuotes: true, - }; - mockFetchFn.mockImplementationOnce(async () => { - return mockSseEventSourceWithComplete( - mockBridgeQuotesNativeErc20 as QuoteResponse[], - [], + expect(bridgeController.state.quoteStreamComplete).toStrictEqual( mockComplete, ); }); + }); - await bridgeController.updateBridgeQuoteRequestParams( - quoteRequest, - metricsContext, - ); - - jest.advanceTimersByTime(1000); - await advanceToNthTimerThenFlush(); - jest.advanceTimersByTime(5000); - await flushPromises(); + it('should clear quoteStreamComplete on resetState', async function () { + await withController(async ({ controller: bridgeController }) => { + const mockComplete = { + quoteCount: 2, + hasQuotes: true, + }; + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithComplete( + mockBridgeQuotesNativeErc20 as QuoteResponse[], + [], + mockComplete, + ); + }); - expect(bridgeController.state.quoteStreamComplete).toStrictEqual( - mockComplete, - ); + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); - bridgeController.resetState(); - expect(bridgeController.state.quoteStreamComplete).toBeNull(); - }); + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + jest.advanceTimersByTime(5000); + await flushPromises(); - it('should clear quoteStreamComplete at the start of each fetch', async function () { - const mockComplete = { - quoteCount: 2, - hasQuotes: true, - }; - mockFetchFn.mockImplementation(async () => { - return mockSseEventSourceWithComplete( - mockBridgeQuotesNativeErc20 as QuoteResponse[], - [], + expect(bridgeController.state.quoteStreamComplete).toStrictEqual( mockComplete, ); + + bridgeController.resetState(); + expect(bridgeController.state.quoteStreamComplete).toBeNull(); }); + }); - await bridgeController.updateBridgeQuoteRequestParams( - quoteRequest, - metricsContext, - ); + it('should clear quoteStreamComplete at the start of each fetch', async function () { + await withController(async ({ controller: bridgeController }) => { + const mockComplete = { + quoteCount: 2, + hasQuotes: true, + }; + mockFetchFn.mockImplementation(async () => { + return mockSseEventSourceWithComplete( + mockBridgeQuotesNativeErc20 as QuoteResponse[], + [], + mockComplete, + ); + }); - jest.advanceTimersByTime(1000); - await advanceToNthTimerThenFlush(); - jest.advanceTimersByTime(5000); - await flushPromises(); + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); - expect(bridgeController.state.quoteStreamComplete).toStrictEqual( - mockComplete, - ); + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + jest.advanceTimersByTime(5000); + await flushPromises(); - // Trigger a second fetch — quoteStreamComplete should be cleared before the stream completes - jest.advanceTimersByTime(1000); - await advanceToNthTimerThenFlush(); + expect(bridgeController.state.quoteStreamComplete).toStrictEqual( + mockComplete, + ); - expect(bridgeController.state.quoteStreamComplete).toBeNull(); + // Trigger a second fetch — quoteStreamComplete should be cleared before the stream completes + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); - jest.advanceTimersByTime(5000); - await flushPromises(); + expect(bridgeController.state.quoteStreamComplete).toBeNull(); - expect(bridgeController.state.quoteStreamComplete).toStrictEqual( - mockComplete, - ); + jest.advanceTimersByTime(5000); + await flushPromises(); + + expect(bridgeController.state.quoteStreamComplete).toStrictEqual( + mockComplete, + ); + }); }); }); diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index 35a18eafae9..f442bab2f4e 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -120,9 +120,22 @@ const BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS = [ const messengerCallMock = jest.fn(); -function buildController( - options: Partial[0]> = {}, -): { controller: BridgeController; rootMessenger: RootMessenger } { +type WithControllerCallback = (payload: { + controller: BridgeController; + rootMessenger: RootMessenger; +}) => Promise | ReturnValue; + +type WithControllerOptions = { + options?: Partial[0]>; +}; + +async function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] +): Promise { + const [{ options = {} }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; const newRootMessenger: RootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE, }); @@ -135,8 +148,8 @@ function buildController( actions: [...BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS], }); for (const action of BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS) { - newRootMessenger.registerActionHandler(action, (...args) => - messengerCallMock(action, ...args), + newRootMessenger.registerActionHandler(action, (...actionArgs) => + messengerCallMock(action, ...actionArgs), ); } const controller = new BridgeController({ @@ -148,17 +161,16 @@ function buildController( trackMetaMetricsFn, ...options, }); - return { controller, rootMessenger: newRootMessenger }; + if (!options.state) { + newRootMessenger.call('BridgeController:resetState'); + } + return await testFunction({ controller, rootMessenger: newRootMessenger }); } describe('BridgeController', function () { - let bridgeController: BridgeController; - let rootMessenger: RootMessenger; - beforeEach(function () { jest.clearAllMocks(); jest.clearAllTimers(); - ({ controller: bridgeController, rootMessenger } = buildController()); nock(BRIDGE_PROD_API_BASE_URL) .get('/getTokens?chainId=10') @@ -191,1606 +203,1690 @@ describe('BridgeController', function () { usd: '100', }, }); - rootMessenger.call('BridgeController:resetState'); }); - it('constructor should setup correctly', function () { - expect(bridgeController.state).toStrictEqual(EMPTY_INIT_STATE); + it('constructor should setup correctly', async function () { + await withController(async ({ controller: bridgeController }) => { + expect(bridgeController.state).toStrictEqual(EMPTY_INIT_STATE); + }); }); describe('getExchangeRateSources and fetchAssetExchangeRates', function () { it('calls MultichainAssetsRatesController, CurrencyRateController, and TokenRatesController when useAssetsControllerForRates is false', async function () { jest.useFakeTimers(); - const hasSufficientBalanceSpy = jest - .spyOn(balanceUtils, 'hasSufficientBalance') - .mockResolvedValue(true); - - const getStateReturn = { - conversionRates: {}, - currencyRates: {}, - marketData: {}, - currentCurrency: 'USD', - }; - messengerCallMock.mockImplementation( - (actionType: string): ReturnType => { - if (actionType === 'AuthenticationController:getBearerToken') { - return 'AUTH_TOKEN'; - } - if ( - actionType === 'MultichainAssetsRatesController:getState' || - actionType === 'CurrencyRateController:getState' || - actionType === 'TokenRatesController:getState' - ) { - return getStateReturn as never; - } - return { - remoteFeatureFlags: { bridgeConfig: { ...bridgeConfig } }, - address: '0x123', - provider: jest.fn(), - } as never; - }, - ); + await withController(async ({ rootMessenger }) => { + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(true); + + const getStateReturn = { + conversionRates: {}, + currencyRates: {}, + marketData: {}, + currentCurrency: 'USD', + }; + messengerCallMock.mockImplementation( + ( + actionType: string, + ): ReturnType => { + if (actionType === 'AuthenticationController:getBearerToken') { + return 'AUTH_TOKEN'; + } + if ( + actionType === 'MultichainAssetsRatesController:getState' || + actionType === 'CurrencyRateController:getState' || + actionType === 'TokenRatesController:getState' + ) { + return getStateReturn as never; + } + return { + remoteFeatureFlags: { bridgeConfig: { ...bridgeConfig } }, + address: '0x123', + provider: jest.fn(), + } as never; + }, + ); - const selectIsAssetExchangeRateInStateSpy = jest.spyOn( - selectors, - 'selectIsAssetExchangeRateInState', - ); + const selectIsAssetExchangeRateInStateSpy = jest.spyOn( + selectors, + 'selectIsAssetExchangeRateInState', + ); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - { - srcChainId: '0x1', - destChainId: '0xa', - srcTokenAddress: '0x0000000000000000000000000000000000000000', - destTokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', - srcTokenAmount: '1000000000000000000', - walletAddress: '0x123', - slippage: 0.5, - }, - metricsContext, - ); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + srcChainId: '0x1', + destChainId: '0xa', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + srcTokenAmount: '1000000000000000000', + walletAddress: '0x123', + slippage: 0.5, + }, + metricsContext, + ); - jest.advanceTimersToNextTimer(); - await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); - expect(messengerCallMock).toHaveBeenCalledWith( - 'MultichainAssetsRatesController:getState', - ); - expect(messengerCallMock).toHaveBeenCalledWith( - 'CurrencyRateController:getState', - ); - expect(messengerCallMock).toHaveBeenCalledWith( - 'TokenRatesController:getState', - ); - expect(messengerCallMock).not.toHaveBeenCalledWith( - 'AssetsController:getExchangeRatesForBridge', - ); + expect(messengerCallMock).toHaveBeenCalledWith( + 'MultichainAssetsRatesController:getState', + ); + expect(messengerCallMock).toHaveBeenCalledWith( + 'CurrencyRateController:getState', + ); + expect(messengerCallMock).toHaveBeenCalledWith( + 'TokenRatesController:getState', + ); + expect(messengerCallMock).not.toHaveBeenCalledWith( + 'AssetsController:getExchangeRatesForBridge', + ); - expect(selectIsAssetExchangeRateInStateSpy).toHaveBeenCalled(); - const [firstCallSources] = - selectIsAssetExchangeRateInStateSpy.mock.calls[0]; - expect(firstCallSources).toHaveProperty('assetExchangeRates'); - expect(firstCallSources).toHaveProperty('conversionRates'); - expect(firstCallSources).toHaveProperty('currencyRates'); - expect(firstCallSources).toHaveProperty('marketData'); + expect(selectIsAssetExchangeRateInStateSpy).toHaveBeenCalled(); + const [firstCallSources] = + selectIsAssetExchangeRateInStateSpy.mock.calls[0]; + expect(firstCallSources).toHaveProperty('assetExchangeRates'); + expect(firstCallSources).toHaveProperty('conversionRates'); + expect(firstCallSources).toHaveProperty('currencyRates'); + expect(firstCallSources).toHaveProperty('marketData'); - hasSufficientBalanceSpy.mockRestore(); - selectIsAssetExchangeRateInStateSpy.mockRestore(); + hasSufficientBalanceSpy.mockRestore(); + selectIsAssetExchangeRateInStateSpy.mockRestore(); + }); }); it('calls AssetsController:getExchangeRatesForBridge when getUseAssetsControllerForRates returns true', async function () { jest.useFakeTimers(); - const { rootMessenger: assetsRatesRootMessenger } = buildController({ - getUseAssetsControllerForRates: (): boolean => true, - }); - assetsRatesRootMessenger.call('BridgeController:resetState'); - - const hasSufficientBalanceSpy = jest - .spyOn(balanceUtils, 'hasSufficientBalance') - .mockResolvedValue(true); - - const bridgeRatesReturn = { - conversionRates: {}, - currencyRates: {}, - marketData: {}, - currentCurrency: 'EUR', - }; - messengerCallMock.mockImplementation( - (actionType: string): ReturnType => { - if (actionType === 'AuthenticationController:getBearerToken') { - return 'AUTH_TOKEN'; - } - if (actionType === 'AssetsController:getExchangeRatesForBridge') { - return bridgeRatesReturn as never; - } - return { - remoteFeatureFlags: { bridgeConfig: { ...bridgeConfig } }, - address: '0x123', - provider: jest.fn(), - } as never; - }, - ); + await withController( + { options: { getUseAssetsControllerForRates: (): boolean => true } }, + async ({ rootMessenger: assetsRatesRootMessenger }) => { + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(true); + + const bridgeRatesReturn = { + conversionRates: {}, + currencyRates: {}, + marketData: {}, + currentCurrency: 'EUR', + }; + messengerCallMock.mockImplementation( + ( + actionType: string, + ): ReturnType => { + if (actionType === 'AuthenticationController:getBearerToken') { + return 'AUTH_TOKEN'; + } + if (actionType === 'AssetsController:getExchangeRatesForBridge') { + return bridgeRatesReturn as never; + } + return { + remoteFeatureFlags: { bridgeConfig: { ...bridgeConfig } }, + address: '0x123', + provider: jest.fn(), + } as never; + }, + ); - const selectIsAssetExchangeRateInStateSpy = jest.spyOn( - selectors, - 'selectIsAssetExchangeRateInState', - ); + const selectIsAssetExchangeRateInStateSpy = jest.spyOn( + selectors, + 'selectIsAssetExchangeRateInState', + ); - await assetsRatesRootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - { - srcChainId: '0x1', - destChainId: '0xa', - srcTokenAddress: '0x0000000000000000000000000000000000000000', - destTokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', - srcTokenAmount: '1000000000000000000', - walletAddress: '0x123', - slippage: 0.5, - }, - metricsContext, - ); + await assetsRatesRootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + srcChainId: '0x1', + destChainId: '0xa', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + srcTokenAmount: '1000000000000000000', + walletAddress: '0x123', + slippage: 0.5, + }, + metricsContext, + ); - jest.advanceTimersToNextTimer(); - await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); - expect(messengerCallMock).toHaveBeenCalledWith( - 'AssetsController:getExchangeRatesForBridge', - ); - expect(messengerCallMock).not.toHaveBeenCalledWith( - 'MultichainAssetsRatesController:getState', - ); + expect(messengerCallMock).toHaveBeenCalledWith( + 'AssetsController:getExchangeRatesForBridge', + ); + expect(messengerCallMock).not.toHaveBeenCalledWith( + 'MultichainAssetsRatesController:getState', + ); - expect(selectIsAssetExchangeRateInStateSpy).toHaveBeenCalled(); - const [firstCallSources] = - selectIsAssetExchangeRateInStateSpy.mock.calls[0]; - expect(firstCallSources).toHaveProperty('assetExchangeRates'); - expect(firstCallSources).toHaveProperty('currentCurrency', 'EUR'); + expect(selectIsAssetExchangeRateInStateSpy).toHaveBeenCalled(); + const [firstCallSources] = + selectIsAssetExchangeRateInStateSpy.mock.calls[0]; + expect(firstCallSources).toHaveProperty('assetExchangeRates'); + expect(firstCallSources).toHaveProperty('currentCurrency', 'EUR'); - hasSufficientBalanceSpy.mockRestore(); - selectIsAssetExchangeRateInStateSpy.mockRestore(); + hasSufficientBalanceSpy.mockRestore(); + selectIsAssetExchangeRateInStateSpy.mockRestore(); + }, + ); }); it('calls selectIsAssetExchangeRateInState with exchange rate sources, src chain/address, and dest chain/address', async function () { jest.useFakeTimers(); - const hasSufficientBalanceSpy = jest - .spyOn(balanceUtils, 'hasSufficientBalance') - .mockResolvedValue(true); - - messengerCallMock.mockImplementation( - (actionType: string): ReturnType => { - if (actionType === 'AuthenticationController:getBearerToken') { - return 'AUTH_TOKEN'; - } - if ( - actionType === 'MultichainAssetsRatesController:getState' || - actionType === 'CurrencyRateController:getState' || - actionType === 'TokenRatesController:getState' - ) { + await withController(async ({ rootMessenger }) => { + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(true); + + messengerCallMock.mockImplementation( + ( + actionType: string, + ): ReturnType => { + if (actionType === 'AuthenticationController:getBearerToken') { + return 'AUTH_TOKEN'; + } + if ( + actionType === 'MultichainAssetsRatesController:getState' || + actionType === 'CurrencyRateController:getState' || + actionType === 'TokenRatesController:getState' + ) { + return { + conversionRates: {}, + currencyRates: {}, + marketData: {}, + currentCurrency: 'USD', + } as never; + } return { - conversionRates: {}, - currencyRates: {}, - marketData: {}, - currentCurrency: 'USD', + remoteFeatureFlags: { bridgeConfig: { ...bridgeConfig } }, + address: '0x123', + provider: jest.fn(), } as never; - } - return { - remoteFeatureFlags: { bridgeConfig: { ...bridgeConfig } }, - address: '0x123', - provider: jest.fn(), - } as never; - }, - ); + }, + ); - const selectIsAssetExchangeRateInStateSpy = jest.spyOn( - selectors, - 'selectIsAssetExchangeRateInState', - ); + const selectIsAssetExchangeRateInStateSpy = jest.spyOn( + selectors, + 'selectIsAssetExchangeRateInState', + ); - const quoteParams = { - srcChainId: '0x1', - destChainId: '0xa', - srcTokenAddress: '0x0000000000000000000000000000000000000000', - destTokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', - srcTokenAmount: '1000000000000000000', - walletAddress: '0x123', - slippage: 0.5, - }; - - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteParams, - metricsContext, - ); + const quoteParams = { + srcChainId: '0x1', + destChainId: '0xa', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + srcTokenAmount: '1000000000000000000', + walletAddress: '0x123', + slippage: 0.5, + }; - jest.advanceTimersToNextTimer(); - await flushPromises(); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); - expect(selectIsAssetExchangeRateInStateSpy).toHaveBeenCalledWith( - expect.objectContaining({ - assetExchangeRates: expect.any(Object), - }), - quoteParams.srcChainId, - quoteParams.srcTokenAddress, - ); - expect(selectIsAssetExchangeRateInStateSpy).toHaveBeenCalledWith( - expect.objectContaining({ - assetExchangeRates: expect.any(Object), - }), - quoteParams.destChainId, - quoteParams.destTokenAddress, - ); + jest.advanceTimersToNextTimer(); + await flushPromises(); + + expect(selectIsAssetExchangeRateInStateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + assetExchangeRates: expect.any(Object), + }), + quoteParams.srcChainId, + quoteParams.srcTokenAddress, + ); + expect(selectIsAssetExchangeRateInStateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + assetExchangeRates: expect.any(Object), + }), + quoteParams.destChainId, + quoteParams.destTokenAddress, + ); - hasSufficientBalanceSpy.mockRestore(); - selectIsAssetExchangeRateInStateSpy.mockRestore(); + hasSufficientBalanceSpy.mockRestore(); + selectIsAssetExchangeRateInStateSpy.mockRestore(); + }); }); }); it('setBridgeFeatureFlags should fetch and set the bridge feature flags', async function () { - const remoteFeatureFlagControllerState = { - cacheTimestamp: 1745515389440, - remoteFeatureFlags: { - bridgeConfig, - assetsNotificationsEnabled: false, - confirmation_redesign: { - contract_interaction: false, - signatures: false, - staking_confirmations: false, - }, - confirmations_eip_7702: {}, - earnFeatureFlagTemplate: { - enabled: false, - minimumVersion: '0.0.0', - }, - earnPooledStakingEnabled: { - enabled: false, - minimumVersion: '0.0.0', - }, - earnPooledStakingServiceInterruptionBannerEnabled: { - enabled: false, - minimumVersion: '0.0.0', - }, - earnStablecoinLendingEnabled: { - enabled: false, - minimumVersion: '0.0.0', - }, - earnStablecoinLendingServiceInterruptionBannerEnabled: { - enabled: false, - minimumVersion: '0.0.0', - }, - mobileMinimumVersions: { - androidMinimumAPIVersion: 0, - appMinimumBuild: 0, - appleMinimumOS: 0, - }, - productSafetyDappScanning: false, - testFlagForThreshold: {}, - tokenSearchDiscoveryEnabled: false, - transactionsPrivacyPolicyUpdate: 'no_update', - transactionsTxHashInAnalytics: false, - walletFrameworkRpcFailoverEnabled: false, - }, - }; - - expect(bridgeController.state).toStrictEqual(EMPTY_INIT_STATE); - - const setIntervalLengthSpy = jest.spyOn( - bridgeController, - 'setIntervalLength', - ); - messengerCallMock.mockImplementation(() => { - return remoteFeatureFlagControllerState; - }); - - rootMessenger.call('BridgeController:setChainIntervalLength'); + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const remoteFeatureFlagControllerState = { + cacheTimestamp: 1745515389440, + remoteFeatureFlags: { + bridgeConfig, + assetsNotificationsEnabled: false, + confirmation_redesign: { + contract_interaction: false, + signatures: false, + staking_confirmations: false, + }, + confirmations_eip_7702: {}, + earnFeatureFlagTemplate: { + enabled: false, + minimumVersion: '0.0.0', + }, + earnPooledStakingEnabled: { + enabled: false, + minimumVersion: '0.0.0', + }, + earnPooledStakingServiceInterruptionBannerEnabled: { + enabled: false, + minimumVersion: '0.0.0', + }, + earnStablecoinLendingEnabled: { + enabled: false, + minimumVersion: '0.0.0', + }, + earnStablecoinLendingServiceInterruptionBannerEnabled: { + enabled: false, + minimumVersion: '0.0.0', + }, + mobileMinimumVersions: { + androidMinimumAPIVersion: 0, + appMinimumBuild: 0, + appleMinimumOS: 0, + }, + productSafetyDappScanning: false, + testFlagForThreshold: {}, + tokenSearchDiscoveryEnabled: false, + transactionsPrivacyPolicyUpdate: 'no_update', + transactionsTxHashInAnalytics: false, + walletFrameworkRpcFailoverEnabled: false, + }, + }; - expect(setIntervalLengthSpy).toHaveBeenCalledTimes(1); - expect(setIntervalLengthSpy).toHaveBeenCalledWith(3); - }); + expect(bridgeController.state).toStrictEqual(EMPTY_INIT_STATE); - it('updateBridgeQuoteRequestParams should update the quoteRequest state', async function () { - messengerCallMock.mockReturnValue({ - currentCurrency: 'usd', - } as never); - - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - { srcChainId: 1, walletAddress: '0x123' }, - metricsContext, - ); - expect(bridgeController.state.quoteRequest).toStrictEqual({ - walletAddress: '0x123', - srcChainId: 1, - srcTokenAddress: '0x0000000000000000000000000000000000000000', - }); - expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + const setIntervalLengthSpy = jest.spyOn( + bridgeController, + 'setIntervalLength', + ); + messengerCallMock.mockImplementation(() => { + return remoteFeatureFlagControllerState; + }); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - { destChainId: 10, walletAddress: '0x123' }, - metricsContext, - ); - expect(bridgeController.state.quoteRequest).toStrictEqual({ - walletAddress: '0x123', - destChainId: 10, - srcTokenAddress: '0x0000000000000000000000000000000000000000', - }); + rootMessenger.call('BridgeController:setChainIntervalLength'); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - { - destChainId: undefined, - walletAddress: '0x123abc', + expect(setIntervalLengthSpy).toHaveBeenCalledTimes(1); + expect(setIntervalLengthSpy).toHaveBeenCalledWith(3); }, - metricsContext, ); - expect(bridgeController.state.quoteRequest).toStrictEqual({ - walletAddress: '0x123abc', - destChainId: undefined, - srcTokenAddress: '0x0000000000000000000000000000000000000000', - }); + }); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - { - srcTokenAddress: undefined, - walletAddress: '0x123', - }, - metricsContext, - ); - expect(bridgeController.state.quoteRequest).toStrictEqual({ - walletAddress: '0x123', - srcTokenAddress: undefined, - }); + it('updateBridgeQuoteRequestParams should update the quoteRequest state', async function () { + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + messengerCallMock.mockReturnValue({ + currentCurrency: 'usd', + } as never); + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { srcChainId: 1, walletAddress: '0x123' }, + metricsContext, + ); + expect(bridgeController.state.quoteRequest).toStrictEqual({ + walletAddress: '0x123', + srcChainId: 1, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + }); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - { - srcTokenAmount: '100000', - destTokenAddress: '0x123', - slippage: 0.5, - srcTokenAddress: '0x0000000000000000000000000000000000000000', - walletAddress: '0x123', - }, - metricsContext, - ); - expect(bridgeController.state.quoteRequest).toStrictEqual({ - walletAddress: '0x123', - srcTokenAmount: '100000', - destTokenAddress: '0x123', - slippage: 0.5, - srcTokenAddress: '0x0000000000000000000000000000000000000000', - }); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { destChainId: 10, walletAddress: '0x123' }, + metricsContext, + ); + expect(bridgeController.state.quoteRequest).toStrictEqual({ + walletAddress: '0x123', + destChainId: 10, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + }); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - { - srcTokenAddress: '0x2ABC', - walletAddress: '0x123', - }, - metricsContext, - ); - expect(bridgeController.state.quoteRequest).toStrictEqual({ - walletAddress: '0x123', - srcTokenAddress: '0x2ABC', - }); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + destChainId: undefined, + walletAddress: '0x123abc', + }, + metricsContext, + ); + expect(bridgeController.state.quoteRequest).toStrictEqual({ + walletAddress: '0x123abc', + destChainId: undefined, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + }); - rootMessenger.call('BridgeController:resetState'); - expect(bridgeController.state.quoteRequest).toStrictEqual({ - srcTokenAddress: '0x0000000000000000000000000000000000000000', - }); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + srcTokenAddress: undefined, + walletAddress: '0x123', + }, + metricsContext, + ); + expect(bridgeController.state.quoteRequest).toStrictEqual({ + walletAddress: '0x123', + srcTokenAddress: undefined, + }); - expect(trackMetaMetricsFn).toHaveBeenCalledTimes(3); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + srcTokenAmount: '100000', + destTokenAddress: '0x123', + slippage: 0.5, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + walletAddress: '0x123', + }, + metricsContext, + ); + expect(bridgeController.state.quoteRequest).toStrictEqual({ + walletAddress: '0x123', + srcTokenAmount: '100000', + destTokenAddress: '0x123', + slippage: 0.5, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + }); - expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); - }); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + srcTokenAddress: '0x2ABC', + walletAddress: '0x123', + }, + metricsContext, + ); + expect(bridgeController.state.quoteRequest).toStrictEqual({ + walletAddress: '0x123', + srcTokenAddress: '0x2ABC', + }); - it('updateBridgeQuoteRequestParams should not call fetchBridgeQuotes if SSE is enabled', async function () { - jest.useFakeTimers(); - const stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); - const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); - const hasSufficientBalanceSpy = jest - .spyOn(balanceUtils, 'hasSufficientBalance') - .mockResolvedValue(true); - - messengerCallMock.mockReturnValue({ - address: '0x123', - provider: jest.fn(), - currencyRates: {}, - marketData: {}, - conversionRates: {}, - remoteFeatureFlags: { - bridgeConfig: { - ...bridgeConfig, - sse: { enabled: true, minimumVersion: '13.1.0' }, - }, - }, - } as never); - - const fetchQuotesStreamSpy = jest - .spyOn(fetchUtils, 'fetchBridgeQuoteStream') - .mockImplementationOnce(async () => { - return await new Promise((resolve) => { - return setTimeout(() => { - resolve(); - }, 1000); + rootMessenger.call('BridgeController:resetState'); + expect(bridgeController.state.quoteRequest).toStrictEqual({ + srcTokenAddress: '0x0000000000000000000000000000000000000000', }); - }); - const fetchBridgeQuotesSpy = jest.spyOn(fetchUtils, 'fetchBridgeQuotes'); - const quoteParams = { - srcChainId: '0x1', - destChainId: SolScope.Mainnet, - srcTokenAddress: '0x0000000000000000000000000000000000000000', - destTokenAddress: '123d1', - srcTokenAmount: '1000000000000000000', - slippage: 0.5, - walletAddress: '0x123', - destWalletAddress: 'SolanaWalletAddres1234', - }; - const quoteRequest = { - ...quoteParams, - }; - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteParams, - metricsContext, - ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(3); - expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).toHaveBeenCalledTimes(1); - expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).toHaveBeenCalledWith({ - updatedQuoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); }, - context: metricsContext, - }); - expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); - - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - quoteRequest: { ...quoteRequest, walletAddress: '0x123' }, - quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, - quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, - quotesLoadingStatus: RequestStatus.LOADING, - }), ); - - jest.advanceTimersToNextTimer(); - await flushPromises(); - expect(fetchBridgeQuotesSpy).not.toHaveBeenCalled(); - expect(fetchQuotesStreamSpy).toHaveBeenCalledTimes(1); - expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); }); - it('updateBridgeQuoteRequestParams should trigger quote polling if request is valid', async function () { + it('updateBridgeQuoteRequestParams should not call fetchBridgeQuotes if SSE is enabled', async function () { jest.useFakeTimers(); - const stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); - const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); - const hasSufficientBalanceSpy = jest - .spyOn(balanceUtils, 'hasSufficientBalance') - .mockResolvedValue(true); - messengerCallMock.mockImplementation( - (...args: Parameters) => { - switch (args[0]) { - case 'AuthenticationController:getBearerToken': - return 'AUTH_TOKEN'; - default: - return { - address: '0x123', - provider: jest.fn(), - currencyRates: {}, - marketData: {}, - conversionRates: {}, - remoteFeatureFlags: { - bridgeConfig: { - ...bridgeConfig, - sse: { enabled: true, minimumVersion: '13.9.0' }, - }, - }, - } as never; - } - }, - ); - - const fetchBridgeQuotesSpy = jest - .spyOn(fetchUtils, 'fetchBridgeQuotes') - .mockImplementationOnce(async () => { - return await new Promise((resolve) => { - return setTimeout(() => { - resolve({ - quotes: mockBridgeQuotesNativeErc20Eth as never, - validationFailures: [], + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const stopAllPollingSpy = jest.spyOn( + bridgeController, + 'stopAllPolling', + ); + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(true); + + messengerCallMock.mockReturnValue({ + address: '0x123', + provider: jest.fn(), + currencyRates: {}, + marketData: {}, + conversionRates: {}, + remoteFeatureFlags: { + bridgeConfig: { + ...bridgeConfig, + sse: { enabled: true, minimumVersion: '13.1.0' }, + }, + }, + } as never); + + const fetchQuotesStreamSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuoteStream') + .mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve(); + }, 1000); }); - }, 5000); + }); + const fetchBridgeQuotesSpy = jest.spyOn( + fetchUtils, + 'fetchBridgeQuotes', + ); + + const quoteParams = { + srcChainId: '0x1', + destChainId: SolScope.Mainnet, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '123d1', + srcTokenAmount: '1000000000000000000', + slippage: 0.5, + walletAddress: '0x123', + destWalletAddress: 'SolanaWalletAddres1234', + }; + const quoteRequest = { + ...quoteParams, + }; + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledWith({ + updatedQuoteRequest: { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + context: metricsContext, }); - }); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); + + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, walletAddress: '0x123' }, + quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, + quotesLastFetched: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesLoadingStatus: RequestStatus.LOADING, + }), + ); - fetchBridgeQuotesSpy.mockImplementationOnce(async () => { - return await new Promise((resolve) => { - return setTimeout(() => { - resolve({ - quotes: [ - ...mockBridgeQuotesNativeErc20Eth, - ...mockBridgeQuotesNativeErc20Eth, - ] as never, - validationFailures: [], + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(fetchBridgeQuotesSpy).not.toHaveBeenCalled(); + expect(fetchQuotesStreamSpy).toHaveBeenCalledTimes(1); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('updateBridgeQuoteRequestParams should trigger quote polling if request is valid', async function () { + jest.useFakeTimers(); + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const stopAllPollingSpy = jest.spyOn( + bridgeController, + 'stopAllPolling', + ); + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(true); + messengerCallMock.mockImplementation( + (...args: Parameters) => { + switch (args[0]) { + case 'AuthenticationController:getBearerToken': + return 'AUTH_TOKEN'; + default: + return { + address: '0x123', + provider: jest.fn(), + currencyRates: {}, + marketData: {}, + conversionRates: {}, + remoteFeatureFlags: { + bridgeConfig: { + ...bridgeConfig, + sse: { enabled: true, minimumVersion: '13.9.0' }, + }, + }, + } as never; + } + }, + ); + + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: mockBridgeQuotesNativeErc20Eth as never, + validationFailures: [], + }); + }, 5000); + }); }); - }, 10000); - }); - }); - fetchBridgeQuotesSpy.mockImplementationOnce(async () => { - return await new Promise((_resolve, reject) => { - return setTimeout(() => { - reject(new Error('Network error')); - }, 10000); - }); - }); + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: [ + ...mockBridgeQuotesNativeErc20Eth, + ...mockBridgeQuotesNativeErc20Eth, + ] as never, + validationFailures: [], + }); + }, 10000); + }); + }); + + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((_resolve, reject) => { + return setTimeout(() => { + reject(new Error('Network error')); + }, 10000); + }); + }); + + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: [ + ...mockBridgeQuotesNativeErc20Eth, + ...mockBridgeQuotesNativeErc20Eth, + ] as never, + validationFailures: [], + }); + }, 10000); + }); + }); + + const consoleLogSpy = jest + .spyOn(console, 'log') + .mockImplementationOnce(jest.fn()); - fetchBridgeQuotesSpy.mockImplementationOnce(async () => { - return await new Promise((resolve) => { - return setTimeout(() => { - resolve({ + const quoteParams = { + srcChainId: '0x1', + destChainId: SolScope.Mainnet, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '123d1', + srcTokenAmount: '1000000000000000000', + slippage: 0.5, + walletAddress: '0x123', + destWalletAddress: 'SolanaWalletAddres1234', + }; + const quoteRequest = { + ...quoteParams, + }; + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledWith({ + updatedQuoteRequest: { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + context: metricsContext, + }); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); + + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, walletAddress: '0x123' }, + quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, + quotesLastFetched: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesLoadingStatus: RequestStatus.LOADING, + }), + ); + + // Loading state + jest.advanceTimersByTime(1000); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + expect.any(AbortSignal), + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + mockFetchFn, + BRIDGE_PROD_API_BASE_URL, + null, + '13.7.0', + ); + expect(bridgeController.state.quotesLastFetched).toBeCloseTo( + Date.now() - 1000, + ); + + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + quotes: [], + quotesLoadingStatus: 0, + }), + ); + + // After first fetch + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + quotes: mockBridgeQuotesNativeErc20Eth, + quotesLoadingStatus: 1, + }), + ); + const firstFetchTime = bridgeController.state.quotesLastFetched; + expect(firstFetchTime).toBeGreaterThan(0); + + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + // After 2nd fetch + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, quotes: [ ...mockBridgeQuotesNativeErc20Eth, ...mockBridgeQuotesNativeErc20Eth, - ] as never, - validationFailures: [], - }); - }, 10000); - }); - }); - - const consoleLogSpy = jest - .spyOn(console, 'log') - .mockImplementationOnce(jest.fn()); + ], + quotesLoadingStatus: 1, + quoteFetchError: null, + quotesRefreshCount: 2, + }), + ); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(2); + const secondFetchTime = bridgeController.state.quotesLastFetched; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(secondFetchTime).toBeGreaterThan(firstFetchTime!); - const quoteParams = { - srcChainId: '0x1', - destChainId: SolScope.Mainnet, - srcTokenAddress: '0x0000000000000000000000000000000000000000', - destTokenAddress: '123d1', - srcTokenAmount: '1000000000000000000', - slippage: 0.5, - walletAddress: '0x123', - destWalletAddress: 'SolanaWalletAddres1234', - }; - const quoteRequest = { - ...quoteParams, - }; - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteParams, - metricsContext, - ); + // After 3nd fetch throws an error + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(3); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + quotes: [], + quotesLoadingStatus: 2, + quoteFetchError: 'Network error', + quotesRefreshCount: 3, + }), + ); + expect( + bridgeController.state.quotesLastFetched, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ).toBeGreaterThan(secondFetchTime!); + const thirdFetchTime = bridgeController.state.quotesLastFetched; - expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).toHaveBeenCalledTimes(1); - expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).toHaveBeenCalledWith({ - updatedQuoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, - context: metricsContext, - }); - expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); - - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - quoteRequest: { ...quoteRequest, walletAddress: '0x123' }, - quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, - quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, - quotesLoadingStatus: RequestStatus.LOADING, - }), - ); + // Incoming request update aborts current polling + jest.advanceTimersToNextTimer(); + await flushPromises(); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { ...quoteRequest, srcTokenAmount: '10', insufficientBal: false }, + { + stx_enabled: true, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + security_warnings: [], + usd_amount_source: 100, + }, + ); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(3); - // Loading state - jest.advanceTimersByTime(1000); - await flushPromises(); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); - expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( - { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, - expect.any(AbortSignal), - BridgeClientId.EXTENSION, - 'AUTH_TOKEN', - mockFetchFn, - BRIDGE_PROD_API_BASE_URL, - null, - '13.7.0', - ); - expect(bridgeController.state.quotesLastFetched).toBeCloseTo( - Date.now() - 1000, - ); + expect(bridgeController.state).toMatchSnapshot(); + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).toHaveBeenCalledWith( + 'Failed to fetch bridge quotes', + new Error('Network error'), + ); - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - quoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, - quotes: [], - quotesLoadingStatus: 0, - }), - ); + // Next fetch succeeds + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(4); + const { quotesLastFetched, quotes, ...stateWithoutTimestamp } = + bridgeController.state; - // After first fetch - jest.advanceTimersToNextTimer(); - await flushPromises(); - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - quoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, - quotes: mockBridgeQuotesNativeErc20Eth, - quotesLoadingStatus: 1, - }), - ); - const firstFetchTime = bridgeController.state.quotesLastFetched; - expect(firstFetchTime).toBeGreaterThan(0); - - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); - // After 2nd fetch - jest.advanceTimersToNextTimer(); - await flushPromises(); - jest.advanceTimersToNextTimer(); - await flushPromises(); - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - quoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, - quotes: [ + expect(stateWithoutTimestamp).toMatchSnapshot(); + expect(quotes).toStrictEqual([ ...mockBridgeQuotesNativeErc20Eth, ...mockBridgeQuotesNativeErc20Eth, - ], - quotesLoadingStatus: 1, - quoteFetchError: null, - quotesRefreshCount: 2, - }), - ); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(2); - const secondFetchTime = bridgeController.state.quotesLastFetched; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - expect(secondFetchTime).toBeGreaterThan(firstFetchTime!); - - // After 3nd fetch throws an error - jest.advanceTimersToNextTimer(); - await flushPromises(); - jest.advanceTimersToNextTimer(); - await flushPromises(); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(3); - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - quoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, - quotes: [], - quotesLoadingStatus: 2, - quoteFetchError: 'Network error', - quotesRefreshCount: 3, - }), - ); - expect( - bridgeController.state.quotesLastFetched, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - ).toBeGreaterThan(secondFetchTime!); - const thirdFetchTime = bridgeController.state.quotesLastFetched; - - // Incoming request update aborts current polling - jest.advanceTimersToNextTimer(); - await flushPromises(); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - { ...quoteRequest, srcTokenAmount: '10', insufficientBal: false }, - { - stx_enabled: true, - token_symbol_source: 'ETH', - token_symbol_destination: 'USDC', - security_warnings: [], - usd_amount_source: 100, + ]); + expect( + quotesLastFetched, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ).toBeGreaterThan(thirdFetchTime!); + + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(getLayer1GasFeeMock).not.toHaveBeenCalled(); + + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(9); + + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); }, ); - await flushPromises(); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(3); - - expect(bridgeController.state).toMatchSnapshot(); - expect(consoleLogSpy).toHaveBeenCalledTimes(1); - expect(consoleLogSpy).toHaveBeenCalledWith( - 'Failed to fetch bridge quotes', - new Error('Network error'), - ); - - // Next fetch succeeds - jest.advanceTimersToNextTimer(); - await flushPromises(); - jest.advanceTimersToNextTimer(); - await flushPromises(); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(4); - const { quotesLastFetched, quotes, ...stateWithoutTimestamp } = - bridgeController.state; - - expect(stateWithoutTimestamp).toMatchSnapshot(); - expect(quotes).toStrictEqual([ - ...mockBridgeQuotesNativeErc20Eth, - ...mockBridgeQuotesNativeErc20Eth, - ]); - expect( - quotesLastFetched, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - ).toBeGreaterThan(thirdFetchTime!); - - expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); - expect(getLayer1GasFeeMock).not.toHaveBeenCalled(); - - expect(trackMetaMetricsFn).toHaveBeenCalledTimes(9); - - expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); }); it('updateBridgeQuoteRequestParams should reset minimumBalanceForRentExemptionInLamports if getMinimumBalanceForRentExemption call fails', async function () { jest.useFakeTimers(); jest.clearAllMocks(); - jest.spyOn(balanceUtils, 'hasSufficientBalance').mockResolvedValue(false); - const consoleErrorSpy = jest - .spyOn(console, 'error') - .mockImplementation(jest.fn()); - const consoleWarnSpy = jest - .spyOn(console, 'warn') - .mockImplementation(jest.fn()); - - const setupMessengerMock = (shouldMinBalanceFail = false): void => { - messengerCallMock.mockImplementation( - ( - ...args: Parameters - ): ReturnType => { - const [actionType, params] = args; - - if (actionType === 'CurrencyRateController:getState') { - throw new Error('Currency rate error'); - } + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(false); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(jest.fn()); + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(jest.fn()); + + const setupMessengerMock = (shouldMinBalanceFail = false): void => { + messengerCallMock.mockImplementation( + ( + ...args: Parameters + ): ReturnType => { + const [actionType, params] = args; + + if (actionType === 'CurrencyRateController:getState') { + throw new Error('Currency rate error'); + } - if (actionType === 'AccountsController:getAccountByAddress') { - return { - type: SolAccountType.DataAccount, - id: 'account1', - scopes: [SolScope.Mainnet], - methods: [], - address: '0x123', - metadata: { - name: 'Account 1', - importTime: 1717334400, - keyring: { - type: 'Keyring', - }, - snap: { - id: 'npm:@metamask/solana-snap', - name: 'Solana Snap', - enabled: true, - }, - }, - options: { - scope: SolScope.Mainnet, - }, - }; - } + if (actionType === 'AccountsController:getAccountByAddress') { + return { + type: SolAccountType.DataAccount, + id: 'account1', + scopes: [SolScope.Mainnet], + methods: [], + address: '0x123', + metadata: { + name: 'Account 1', + importTime: 1717334400, + keyring: { + type: 'Keyring', + }, + snap: { + id: 'npm:@metamask/solana-snap', + name: 'Solana Snap', + enabled: true, + }, + }, + options: { + scope: SolScope.Mainnet, + }, + }; + } - if (actionType === 'SnapController:handleRequest') { - return new Promise((resolve, reject) => { - if ( - (params as { handler: string })?.handler === 'onProtocolRequest' - ) { - if (shouldMinBalanceFail) { + if (actionType === 'SnapController:handleRequest') { + return new Promise((resolve, reject) => { + if ( + (params as { handler: string })?.handler === + 'onProtocolRequest' + ) { + if (shouldMinBalanceFail) { + return setTimeout(() => { + reject(new Error('Min balance error')); + }, 200); + } + return setTimeout(() => { + resolve('5000'); + }, 200); + } + if ( + (params as { handler: string })?.handler === + 'onClientRequest' && + (params as { request?: { method: string } })?.request + ?.method === 'computeFee' + ) { + return setTimeout(() => { + resolve([ + { + type: 'base', + asset: { + unit: 'SOL', + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:11111111111111111111111111111111', + amount: '0.000000014', // 14 lamports in SOL + fungible: true, + }, + }, + ]); + }, 100); + } return setTimeout(() => { - reject(new Error('Min balance error')); - }, 200); - } - return setTimeout(() => { - resolve('5000'); - }, 200); - } - if ( - (params as { handler: string })?.handler === - 'onClientRequest' && - (params as { request?: { method: string } })?.request - ?.method === 'computeFee' - ) { - return setTimeout(() => { - resolve([ - { - type: 'base', - asset: { - unit: 'SOL', - type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:11111111111111111111111111111111', - amount: '0.000000014', // 14 lamports in SOL - fungible: true, - }, - }, - ]); - }, 100); + resolve({ value: '14' }); + }, 100); + }); } + return { + provider: jest.fn() as never, + } as never; + }, + ); + }; + jest + .spyOn(selectors, 'selectIsAssetExchangeRateInState') + .mockReturnValue(true); + + setupMessengerMock(); + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockImplementation(async () => { + return await new Promise((resolve) => { return setTimeout(() => { - resolve({ value: '14' }); - }, 100); - }); - } - return { - provider: jest.fn() as never, - } as never; - }, - ); - }; - jest - .spyOn(selectors, 'selectIsAssetExchangeRateInState') - .mockReturnValue(true); - - setupMessengerMock(); - const fetchBridgeQuotesSpy = jest - .spyOn(fetchUtils, 'fetchBridgeQuotes') - .mockImplementation(async () => { - return await new Promise((resolve) => { - return setTimeout(() => { - resolve({ - quotes: mockBridgeQuotesSolErc20 as never, - validationFailures: [], + resolve({ + quotes: mockBridgeQuotesSolErc20 as never, + validationFailures: [], + }); + }, 2000); }); - }, 2000); - }); - }); + }); - const quoteParams = { - srcChainId: SolScope.Mainnet, - destChainId: SolScope.Mainnet, - srcTokenAddress: '0x0000000000000000000000000000000000000000', - destTokenAddress: '0x123', - srcTokenAmount: '1000000000000000000', - walletAddress: '0x123', - slippage: 0.5, - }; + const quoteParams = { + srcChainId: SolScope.Mainnet, + destChainId: SolScope.Mainnet, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + srcTokenAmount: '1000000000000000000', + walletAddress: '0x123', + slippage: 0.5, + }; - /* + /* Set quote request with Solana srcChainId */ - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteParams, - metricsContext, - ); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); - // Initial state check - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - quoteRequest: { ...quoteParams }, - minimumBalanceForRentExemptionInLamports: '0', - quotesLoadingStatus: - DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, - }), - ); + // Initial state check + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: { ...quoteParams }, + minimumBalanceForRentExemptionInLamports: '0', + quotesLoadingStatus: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, + }), + ); - // Advance timers and check loading state - jest.advanceTimersToNextTimer(); - await flushPromises(); - jest.advanceTimersToNextTimer(); - await flushPromises(); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - minimumBalanceForRentExemptionInLamports: '5000', - quotes: [], - quotesLoadingStatus: RequestStatus.LOADING, - }), - ); + // Advance timers and check loading state + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + minimumBalanceForRentExemptionInLamports: '5000', + quotes: [], + quotesLoadingStatus: RequestStatus.LOADING, + }), + ); - // Advance timers and check final state - jest.advanceTimersToNextTimer(); - await flushPromises(); - jest.advanceTimersToNextTimer(); - await flushPromises(); - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - minimumBalanceForRentExemptionInLamports: '5000', - quotes: mockBridgeQuotesSolErc20.map((quote) => ({ - ...quote, - nonEvmFeesInNative: '0.000000014', - })), - quotesLoadingStatus: RequestStatus.FETCHED, - quoteRequest: { - ...quoteParams, - resetApproval: false, - insufficientBal: undefined, - }, - quoteFetchError: null, - assetExchangeRates: {}, - quotesRefreshCount: 1, - quotesInitialLoadTime: 2100, - quotesLastFetched: expect.any(Number), - }), - ); - expect(consoleErrorSpy).not.toHaveBeenCalled(); - expect( - messengerCallMock.mock.calls.filter(([action]) => - action.includes('SnapController'), - ), - ).toHaveLength(3); - - /* + // Advance timers and check final state + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + minimumBalanceForRentExemptionInLamports: '5000', + quotes: mockBridgeQuotesSolErc20.map((quote) => ({ + ...quote, + nonEvmFeesInNative: '0.000000014', + })), + quotesLoadingStatus: RequestStatus.FETCHED, + quoteRequest: { + ...quoteParams, + resetApproval: false, + insufficientBal: undefined, + }, + quoteFetchError: null, + assetExchangeRates: {}, + quotesRefreshCount: 1, + quotesInitialLoadTime: 2100, + quotesLastFetched: expect.any(Number), + }), + ); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect( + messengerCallMock.mock.calls.filter(([action]) => + action.includes('SnapController'), + ), + ).toHaveLength(3); + + /* Update quote request params to EVM and back to Solana */ - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - { ...quoteParams, srcChainId: '0x1' }, - metricsContext, - ); - jest.advanceTimersByTime(2000); - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - minimumBalanceForRentExemptionInLamports: '0', - quotes: [], - quotesLoadingStatus: null, - }), - ); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { ...quoteParams, srcChainId: '0x1' }, + metricsContext, + ); + jest.advanceTimersByTime(2000); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + minimumBalanceForRentExemptionInLamports: '0', + quotes: [], + quotesLoadingStatus: null, + }), + ); - /* + /* Add destWalletAddress */ - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - { ...quoteParams, destWalletAddress: 'SolanaWalletAddres1234' }, - metricsContext, - ); - jest.advanceTimersByTime(2000); - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - minimumBalanceForRentExemptionInLamports: '0', - quotes: [], - quotesLoadingStatus: RequestStatus.LOADING, - }), - ); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { ...quoteParams, destWalletAddress: 'SolanaWalletAddres1234' }, + metricsContext, + ); + jest.advanceTimersByTime(2000); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + minimumBalanceForRentExemptionInLamports: '0', + quotes: [], + quotesLoadingStatus: RequestStatus.LOADING, + }), + ); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteParams, - metricsContext, - ); - jest.advanceTimersToNextTimer(); - await flushPromises(); - jest.advanceTimersToNextTimer(); - await flushPromises(); - jest.advanceTimersToNextTimer(); - await flushPromises(); - jest.advanceTimersToNextTimer(); - await flushPromises(); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(3); - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - minimumBalanceForRentExemptionInLamports: '5000', - quotes: mockBridgeQuotesSolErc20.map((quote) => ({ - ...quote, - nonEvmFeesInNative: '0.000000014', - })), - quotesLoadingStatus: RequestStatus.FETCHED, - quoteRequest: { - ...quoteParams, - resetApproval: false, - insufficientBal: undefined, - }, - quoteFetchError: null, - assetExchangeRates: {}, - quotesRefreshCount: expect.any(Number), - quotesInitialLoadTime: expect.any(Number), - quotesLastFetched: expect.any(Number), - }), - ); - expect(consoleErrorSpy).not.toHaveBeenCalled(); - expect( - messengerCallMock.mock.calls.filter(([action]) => - action.includes('SnapController'), - ), - ).toHaveLength(9); - - /* + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(3); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + minimumBalanceForRentExemptionInLamports: '5000', + quotes: mockBridgeQuotesSolErc20.map((quote) => ({ + ...quote, + nonEvmFeesInNative: '0.000000014', + })), + quotesLoadingStatus: RequestStatus.FETCHED, + quoteRequest: { + ...quoteParams, + resetApproval: false, + insufficientBal: undefined, + }, + quoteFetchError: null, + assetExchangeRates: {}, + quotesRefreshCount: expect.any(Number), + quotesInitialLoadTime: expect.any(Number), + quotesLastFetched: expect.any(Number), + }), + ); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect( + messengerCallMock.mock.calls.filter(([action]) => + action.includes('SnapController'), + ), + ).toHaveLength(9); + + /* Test min balance fetch failure */ - setupMessengerMock(true); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - { ...quoteParams, srcTokenAmount: '11111' }, - metricsContext, - ); + setupMessengerMock(true); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { ...quoteParams, srcTokenAmount: '11111' }, + metricsContext, + ); - // Check states during failure scenario - jest.advanceTimersToNextTimer(); - await flushPromises(); - jest.advanceTimersToNextTimer(); - await flushPromises(); - jest.advanceTimersToNextTimer(); - await flushPromises(); - jest.advanceTimersToNextTimer(); - await flushPromises(); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(4); - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - minimumBalanceForRentExemptionInLamports: '0', - quotes: mockBridgeQuotesSolErc20.map((quote) => ({ - ...quote, - nonEvmFeesInNative: '0.000000014', - })), - quotesLoadingStatus: RequestStatus.FETCHED, - quoteRequest: { - ...quoteParams, - srcTokenAmount: '11111', - insufficientBal: undefined, - resetApproval: false, - }, - quoteFetchError: null, - assetExchangeRates: {}, - quotesRefreshCount: 1, - quotesInitialLoadTime: 2100, - quotesLastFetched: expect.any(Number), - }), - ); + // Check states during failure scenario + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(4); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + minimumBalanceForRentExemptionInLamports: '0', + quotes: mockBridgeQuotesSolErc20.map((quote) => ({ + ...quote, + nonEvmFeesInNative: '0.000000014', + })), + quotesLoadingStatus: RequestStatus.FETCHED, + quoteRequest: { + ...quoteParams, + srcTokenAmount: '11111', + insufficientBal: undefined, + resetApproval: false, + }, + quoteFetchError: null, + assetExchangeRates: {}, + quotesRefreshCount: 1, + quotesInitialLoadTime: 2100, + quotesLastFetched: expect.any(Number), + }), + ); - // Verify error handling - expect(consoleErrorSpy.mock.calls).toMatchSnapshot(); - expect( - messengerCallMock.mock.calls.filter(([action]) => - action.includes('SnapController'), - ), - ).toHaveLength(12); - expect( - messengerCallMock.mock.calls.filter(([action]) => - action.includes('SnapController'), - ), - ).toMatchSnapshot(); - expect(consoleWarnSpy).toHaveBeenCalledTimes(4); - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Failed to fetch asset exchange rates', - new Error('Currency rate error'), + // Verify error handling + expect(consoleErrorSpy.mock.calls).toMatchSnapshot(); + expect( + messengerCallMock.mock.calls.filter(([action]) => + action.includes('SnapController'), + ), + ).toHaveLength(12); + expect( + messengerCallMock.mock.calls.filter(([action]) => + action.includes('SnapController'), + ), + ).toMatchSnapshot(); + expect(consoleWarnSpy).toHaveBeenCalledTimes(4); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Failed to fetch asset exchange rates', + new Error('Currency rate error'), + ); + }, ); }); it('updateBridgeQuoteRequestParams should only poll once if insufficientBal=true', async function () { jest.useFakeTimers(); - const stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); - const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); - const hasSufficientBalanceSpy = jest - .spyOn(balanceUtils, 'hasSufficientBalance') - .mockResolvedValue(false); - messengerCallMock.mockImplementation( - (...args: Parameters) => { - switch (args[0]) { - case 'AuthenticationController:getBearerToken': - return 'AUTH_TOKEN'; - default: - return { - address: '0x123', - provider: jest.fn(), - currentCurrency: 'usd', - currencyRates: {}, - marketData: {}, - conversionRates: {}, - } as never; - } - }, - ); - jest - .spyOn(selectors, 'selectIsAssetExchangeRateInState') - .mockReturnValue(true); - - const fetchBridgeQuotesSpy = jest - .spyOn(fetchUtils, 'fetchBridgeQuotes') - .mockImplementationOnce(async () => { - return await new Promise((resolve) => { - return setTimeout(() => { - resolve({ - quotes: mockBridgeQuotesNativeErc20Eth as never, - validationFailures: [], + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const stopAllPollingSpy = jest.spyOn( + bridgeController, + 'stopAllPolling', + ); + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(false); + messengerCallMock.mockImplementation( + (...args: Parameters) => { + switch (args[0]) { + case 'AuthenticationController:getBearerToken': + return 'AUTH_TOKEN'; + default: + return { + address: '0x123', + provider: jest.fn(), + currentCurrency: 'usd', + currencyRates: {}, + marketData: {}, + conversionRates: {}, + } as never; + } + }, + ); + jest + .spyOn(selectors, 'selectIsAssetExchangeRateInState') + .mockReturnValue(true); + + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: mockBridgeQuotesNativeErc20Eth as never, + validationFailures: [], + }); + }, 5000); }); - }, 5000); - }); - }); + }); - fetchBridgeQuotesSpy.mockImplementationOnce(async () => { - return await new Promise((resolve) => { - return setTimeout(() => { - resolve({ - quotes: [ - ...mockBridgeQuotesNativeErc20Eth, - ...mockBridgeQuotesNativeErc20Eth, - ] as never, - validationFailures: [], + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: [ + ...mockBridgeQuotesNativeErc20Eth, + ...mockBridgeQuotesNativeErc20Eth, + ] as never, + validationFailures: [], + }); + }, 10000); }); - }, 10000); - }); - }); + }); - const quoteParams = { - srcChainId: '0x1', - destChainId: '0xa', - srcTokenAddress: '0x0000000000000000000000000000000000000000', - destTokenAddress: '0x123', - srcTokenAmount: '1000000000000000000', - walletAddress: '0x123', - slippage: 0.5, - }; - const quoteRequest = { - ...quoteParams, - }; - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteParams, - metricsContext, - ); + const quoteParams = { + srcChainId: '0x1', + destChainId: '0xa', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + srcTokenAmount: '1000000000000000000', + walletAddress: '0x123', + slippage: 0.5, + }; + const quoteRequest = { + ...quoteParams, + }; + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); - expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).toHaveBeenCalledTimes(1); - expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).toHaveBeenCalledWith({ - updatedQuoteRequest: { - ...quoteRequest, - insufficientBal: true, - resetApproval: false, - }, - context: metricsContext, - }); - expect(fetchAssetPricesSpy).not.toHaveBeenCalled(); - - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - quoteRequest, - quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, - quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, - quotesInitialLoadTime: null, - quotesLoadingStatus: RequestStatus.LOADING, - }), - ); + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledWith({ + updatedQuoteRequest: { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + context: metricsContext, + }); + expect(fetchAssetPricesSpy).not.toHaveBeenCalled(); + + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest, + quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, + quotesLastFetched: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesInitialLoadTime: null, + quotesLoadingStatus: RequestStatus.LOADING, + }), + ); - // Loading state - jest.advanceTimersByTime(1000); - await flushPromises(); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( - { - ...quoteRequest, - insufficientBal: true, - resetApproval: false, - }, - expect.any(AbortSignal), - BridgeClientId.EXTENSION, - 'AUTH_TOKEN', - mockFetchFn, - BRIDGE_PROD_API_BASE_URL, - null, - '13.7.0', - ); - expect(bridgeController.state.quotesLastFetched).toBeCloseTo( - Date.now() - 1000, - ); - const t1 = bridgeController.state.quotesLastFetched; - - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - quoteRequest: { - ...quoteRequest, - insufficientBal: true, - resetApproval: false, - }, - quotes: [], - quotesLoadingStatus: 0, - quotesLastFetched: t1, - }), - ); + // Loading state + jest.advanceTimersByTime(1000); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + expect.any(AbortSignal), + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + mockFetchFn, + BRIDGE_PROD_API_BASE_URL, + null, + '13.7.0', + ); + expect(bridgeController.state.quotesLastFetched).toBeCloseTo( + Date.now() - 1000, + ); + const t1 = bridgeController.state.quotesLastFetched; + + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + quotes: [], + quotesLoadingStatus: 0, + quotesLastFetched: t1, + }), + ); - // After first fetch - jest.advanceTimersByTime(10000); - await flushPromises(); - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - quoteRequest: { - ...quoteRequest, - insufficientBal: true, - resetApproval: false, - }, - quotes: mockBridgeQuotesNativeErc20Eth, - quotesLoadingStatus: 1, - quotesRefreshCount: 1, - quotesInitialLoadTime: 11000, - }), - ); - const firstFetchTime = bridgeController.state.quotesLastFetched; - expect(firstFetchTime).toBeGreaterThan(0); - rootMessenger.call( - 'BridgeController:trackUnifiedSwapBridgeEvent', - UnifiedSwapBridgeEventName.QuotesReceived, - { - warnings: ['low_return'], - usd_quoted_gas: 0, - gas_included: false, - gas_included_7702: false, - quoted_time_minutes: 10, - usd_quoted_return: 100, - price_impact: 0, - provider: 'provider_bridge', - best_quote_provider: 'provider_bridge2', - can_submit: true, - usd_balance_source: 0, - }, - ); + // After first fetch + jest.advanceTimersByTime(10000); + await flushPromises(); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + quotes: mockBridgeQuotesNativeErc20Eth, + quotesLoadingStatus: 1, + quotesRefreshCount: 1, + quotesInitialLoadTime: 11000, + }), + ); + const firstFetchTime = bridgeController.state.quotesLastFetched; + expect(firstFetchTime).toBeGreaterThan(0); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.QuotesReceived, + { + warnings: ['low_return'], + usd_quoted_gas: 0, + gas_included: false, + gas_included_7702: false, + quoted_time_minutes: 10, + usd_quoted_return: 100, + price_impact: 0, + provider: 'provider_bridge', + best_quote_provider: 'provider_bridge2', + can_submit: true, + usd_balance_source: 0, + }, + ); - expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); - - // After 2nd fetch - jest.advanceTimersByTime(50000); - await flushPromises(); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - quoteRequest: { - ...quoteRequest, - insufficientBal: true, - resetApproval: false, - }, - quotes: mockBridgeQuotesNativeErc20Eth, - quotesLoadingStatus: 1, - quotesRefreshCount: 1, - quotesInitialLoadTime: 11000, - }), + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + + // After 2nd fetch + jest.advanceTimersByTime(50000); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + quotes: mockBridgeQuotesNativeErc20Eth, + quotesLoadingStatus: 1, + quotesRefreshCount: 1, + quotesInitialLoadTime: 11000, + }), + ); + const secondFetchTime = bridgeController.state.quotesLastFetched; + expect(secondFetchTime).toStrictEqual(t1); + expect(secondFetchTime).toStrictEqual(firstFetchTime); + expect(getLayer1GasFeeMock).not.toHaveBeenCalled(); + }, ); - const secondFetchTime = bridgeController.state.quotesLastFetched; - expect(secondFetchTime).toStrictEqual(t1); - expect(secondFetchTime).toStrictEqual(firstFetchTime); - expect(getLayer1GasFeeMock).not.toHaveBeenCalled(); }); it('updateBridgeQuoteRequestParams should set insufficientBal=true if RPC provider is tenderly', async function () { jest.useFakeTimers(); - const stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); - const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); - const hasSufficientBalanceSpy = jest - .spyOn(balanceUtils, 'hasSufficientBalance') - .mockResolvedValue(false); - - messengerCallMock.mockImplementation( - ( - ...args: Parameters - ): ReturnType => { - const actionType = args[0]; - - if (actionType === 'AccountsController:getAccountByAddress') { - return { - type: SolAccountType.DataAccount, - id: 'account1', - scopes: [SolScope.Mainnet], - methods: [], - address: '0x123', - metadata: { - snap: { - id: 'npm:@metamask/solana-snap', - name: 'Solana Snap', - enabled: true, - }, - name: 'Account 1', - importTime: 1717334400, - keyring: { - type: 'Keyring', - }, - }, - options: { - scope: 'mainnet', - }, - }; - } + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const stopAllPollingSpy = jest.spyOn( + bridgeController, + 'stopAllPolling', + ); + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(false); + + messengerCallMock.mockImplementation( + ( + ...args: Parameters + ): ReturnType => { + const actionType = args[0]; + + if (actionType === 'AccountsController:getAccountByAddress') { + return { + type: SolAccountType.DataAccount, + id: 'account1', + scopes: [SolScope.Mainnet], + methods: [], + address: '0x123', + metadata: { + snap: { + id: 'npm:@metamask/solana-snap', + name: 'Solana Snap', + enabled: true, + }, + name: 'Account 1', + importTime: 1717334400, + keyring: { + type: 'Keyring', + }, + }, + options: { + scope: 'mainnet', + }, + }; + } - if (actionType === 'NetworkController:getNetworkClientById') { - return { - configuration: { rpcUrl: 'https://rpc.tenderly.co' }, - } as never; - } - return { - provider: jest.fn() as never, - } as never; - }, - ); + if (actionType === 'NetworkController:getNetworkClientById') { + return { + configuration: { rpcUrl: 'https://rpc.tenderly.co' }, + } as never; + } + return { + provider: jest.fn() as never, + } as never; + }, + ); - const fetchBridgeQuotesSpy = jest - .spyOn(fetchUtils, 'fetchBridgeQuotes') - .mockImplementationOnce(async () => { - return await new Promise((resolve) => { - return setTimeout(() => { - resolve({ - quotes: mockBridgeQuotesNativeErc20Eth as never, - validationFailures: [], + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: mockBridgeQuotesNativeErc20Eth as never, + validationFailures: [], + }); + }, 5000); }); - }, 5000); - }); - }); + }); - fetchBridgeQuotesSpy.mockImplementationOnce(async () => { - return await new Promise((resolve) => { - return setTimeout(() => { - resolve({ - quotes: [ - ...mockBridgeQuotesNativeErc20Eth, - ...mockBridgeQuotesNativeErc20Eth, - ] as never, - validationFailures: [], + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: [ + ...mockBridgeQuotesNativeErc20Eth, + ...mockBridgeQuotesNativeErc20Eth, + ] as never, + validationFailures: [], + }); + }, 10000); }); - }, 10000); - }); - }); + }); - const quoteParams = { - srcChainId: '0x1', - destChainId: '0xa', - srcTokenAddress: '0x0000000000000000000000000000000000000000', - destTokenAddress: '0x123', - srcTokenAmount: '1000000000000000000', - walletAddress: '0x123', - slippage: 0.5, - }; - const quoteRequest = { - ...quoteParams, - }; - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteParams, - metricsContext, - ); + const quoteParams = { + srcChainId: '0x1', + destChainId: '0xa', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + srcTokenAmount: '1000000000000000000', + walletAddress: '0x123', + slippage: 0.5, + }; + const quoteRequest = { + ...quoteParams, + }; + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); - expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).toHaveBeenCalledTimes(1); - expect(hasSufficientBalanceSpy).not.toHaveBeenCalled(); - expect(startPollingSpy).toHaveBeenCalledWith({ - updatedQuoteRequest: { - ...quoteRequest, - insufficientBal: true, - resetApproval: false, - }, - context: metricsContext, - }); + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).not.toHaveBeenCalled(); + expect(startPollingSpy).toHaveBeenCalledWith({ + updatedQuoteRequest: { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + context: metricsContext, + }); - // Loading state - jest.advanceTimersByTime(1000); - await flushPromises(); - - // After first fetch - jest.advanceTimersByTime(10000); - await flushPromises(); - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - quoteRequest: { - ...quoteRequest, - insufficientBal: true, - resetApproval: false, - }, - quotes: mockBridgeQuotesNativeErc20Eth, - quotesLoadingStatus: 1, - quotesRefreshCount: 1, - quotesInitialLoadTime: 11000, - }), - ); - const firstFetchTime = bridgeController.state.quotesLastFetched; - expect(firstFetchTime).toBeGreaterThan(0); - }); + // Loading state + jest.advanceTimersByTime(1000); + await flushPromises(); - it('updateBridgeQuoteRequestParams should not trigger quote polling if request is invalid', async function () { - const stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); - const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); - messengerCallMock.mockReturnValue({ - address: '0x123WalletAddress', - provider: jest.fn(), - } as never); - - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - { - walletAddress: '0x123WalletAddress', - srcChainId: 1, - destChainId: 10, - srcTokenAddress: '0x0000000000000000000000000000000000000000', - destTokenAddress: '0x123', - slippage: 0.5, + // After first fetch + jest.advanceTimersByTime(10000); + await flushPromises(); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + quotes: mockBridgeQuotesNativeErc20Eth, + quotesLoadingStatus: 1, + quotesRefreshCount: 1, + quotesInitialLoadTime: 11000, + }), + ); + const firstFetchTime = bridgeController.state.quotesLastFetched; + expect(firstFetchTime).toBeGreaterThan(0); }, - metricsContext, ); + }); - expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).not.toHaveBeenCalled(); + it('updateBridgeQuoteRequestParams should not trigger quote polling if request is invalid', async function () { + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const stopAllPollingSpy = jest.spyOn( + bridgeController, + 'stopAllPolling', + ); + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + messengerCallMock.mockReturnValue({ + address: '0x123WalletAddress', + provider: jest.fn(), + } as never); + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123WalletAddress', + srcChainId: 1, + destChainId: 10, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + slippage: 0.5, + }, + metricsContext, + ); - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - quoteRequest: { - srcChainId: 1, - slippage: 0.5, - srcTokenAddress: '0x0000000000000000000000000000000000000000', - walletAddress: '0x123WalletAddress', - destChainId: 10, - destTokenAddress: '0x123', - }, - quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, - quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, - quotesLoadingStatus: - DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, - }), + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).not.toHaveBeenCalled(); + + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: { + srcChainId: 1, + slippage: 0.5, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + walletAddress: '0x123WalletAddress', + destChainId: 10, + destTokenAddress: '0x123', + }, + quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, + quotesLastFetched: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesLoadingStatus: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, + }), + ); + }, ); }); it('updateBridgeQuoteRequestParams should not trigger quote polling if bridging to or from solana and destWalletAddress is undefined', async function () { - const stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); - const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); - messengerCallMock.mockReturnValue({ - address: '0xabcWalletAddress', - provider: jest.fn(), - } as never); - - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - { - walletAddress: '0xabcWalletAddress', - srcChainId: 1, - destChainId: ChainId.SOLANA, - srcTokenAddress: '0x0000000000000000000000000000000000000000', - destTokenAddress: '0x123', - slippage: 0.5, - }, - metricsContext, - ); - - expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).not.toHaveBeenCalled(); - - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - quoteRequest: { - srcChainId: 1, - slippage: 0.5, - srcTokenAddress: '0x0000000000000000000000000000000000000000', - walletAddress: '0xabcWalletAddress', - destChainId: ChainId.SOLANA, - destTokenAddress: '0x123', - }, - quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, - quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, - quotesLoadingStatus: - DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, - }), - ); - }); + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const stopAllPollingSpy = jest.spyOn( + bridgeController, + 'stopAllPolling', + ); + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + messengerCallMock.mockReturnValue({ + address: '0xabcWalletAddress', + provider: jest.fn(), + } as never); + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0xabcWalletAddress', + srcChainId: 1, + destChainId: ChainId.SOLANA, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + slippage: 0.5, + }, + metricsContext, + ); - it('updateBridgeQuoteRequestParams should include undefined Authentication header if getBearerToken throws an error', async function () { - jest.useFakeTimers(); - const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); - messengerCallMock.mockImplementation( - (...args: Parameters) => { - switch (args[0]) { - case 'AuthenticationController:getBearerToken': - throw new Error( - 'AuthenticationController:getBearerToken not implemented', - ); - default: - return { - address: '0x123', - provider: jest.fn(), - currentCurrency: 'usd', - currencyRates: {}, - marketData: {}, - conversionRates: {}, - } as never; - } + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).not.toHaveBeenCalled(); + + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: { + srcChainId: 1, + slippage: 0.5, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + walletAddress: '0xabcWalletAddress', + destChainId: ChainId.SOLANA, + destTokenAddress: '0x123', + }, + quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, + quotesLastFetched: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesLoadingStatus: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, + }), + ); }, ); - jest - .spyOn(selectors, 'selectIsAssetExchangeRateInState') - .mockReturnValue(true); - - const fetchBridgeQuotesSpy = jest - .spyOn(fetchUtils, 'fetchBridgeQuotes') - .mockImplementationOnce(async () => { - return await new Promise((resolve) => { - return setTimeout(() => { - resolve({ - quotes: mockBridgeQuotesNativeErc20Eth as never, - validationFailures: [], + }); + + it('updateBridgeQuoteRequestParams should include undefined Authentication header if getBearerToken throws an error', async function () { + jest.useFakeTimers(); + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + messengerCallMock.mockImplementation( + (...args: Parameters) => { + switch (args[0]) { + case 'AuthenticationController:getBearerToken': + throw new Error( + 'AuthenticationController:getBearerToken not implemented', + ); + default: + return { + address: '0x123', + provider: jest.fn(), + currentCurrency: 'usd', + currencyRates: {}, + marketData: {}, + conversionRates: {}, + } as never; + } + }, + ); + jest + .spyOn(selectors, 'selectIsAssetExchangeRateInState') + .mockReturnValue(true); + + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: mockBridgeQuotesNativeErc20Eth as never, + validationFailures: [], + }); + }, 5000); }); - }, 5000); - }); - }); + }); - const quoteParams = { - srcChainId: '0x1', - destChainId: '0xa', - srcTokenAddress: '0x0000000000000000000000000000000000000000', - destTokenAddress: '0x123', - srcTokenAmount: '1000000000000000000', - walletAddress: '0x123', - slippage: 0.5, - }; + const quoteParams = { + srcChainId: '0x1', + destChainId: '0xa', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + srcTokenAmount: '1000000000000000000', + walletAddress: '0x123', + slippage: 0.5, + }; - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteParams, - metricsContext, - ); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); - await advanceToNthTimerThenFlush(); + await advanceToNthTimerThenFlush(); - expect(startPollingSpy).toHaveBeenCalledTimes(1); - expect(fetchBridgeQuotesSpy.mock.calls[0][3]).toBeUndefined(); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy.mock.calls[0][3]).toBeUndefined(); + }, + ); }); it('updateBridgeQuoteRequestParams should include auth token as Authentication header', async function () { jest.useFakeTimers(); - const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); - messengerCallMock.mockImplementation( - (...args: Parameters) => { - switch (args[0]) { - case 'AuthenticationController:getBearerToken': - return 'AUTH_TOKEN'; - default: - return { - address: '0x123', - provider: jest.fn(), - currentCurrency: 'usd', - currencyRates: {}, - marketData: {}, - conversionRates: {}, - } as never; - } - }, - ); - jest - .spyOn(selectors, 'selectIsAssetExchangeRateInState') - .mockReturnValue(true); - - const fetchBridgeQuotesSpy = jest - .spyOn(fetchUtils, 'fetchBridgeQuotes') - .mockImplementationOnce(async () => { - return await new Promise((resolve) => { - return setTimeout(() => { - resolve({ - quotes: mockBridgeQuotesNativeErc20Eth as never, - validationFailures: [], + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + messengerCallMock.mockImplementation( + (...args: Parameters) => { + switch (args[0]) { + case 'AuthenticationController:getBearerToken': + return 'AUTH_TOKEN'; + default: + return { + address: '0x123', + provider: jest.fn(), + currentCurrency: 'usd', + currencyRates: {}, + marketData: {}, + conversionRates: {}, + } as never; + } + }, + ); + jest + .spyOn(selectors, 'selectIsAssetExchangeRateInState') + .mockReturnValue(true); + + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: mockBridgeQuotesNativeErc20Eth as never, + validationFailures: [], + }); + }, 5000); }); - }, 5000); - }); - }); + }); - const quoteParams = { - srcChainId: '0x1', - destChainId: '0xa', - srcTokenAddress: '0x0000000000000000000000000000000000000000', - destTokenAddress: '0x123', - srcTokenAmount: '1000000000000000000', - walletAddress: '0x123', - slippage: 0.5, - }; + const quoteParams = { + srcChainId: '0x1', + destChainId: '0xa', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + srcTokenAmount: '1000000000000000000', + walletAddress: '0x123', + slippage: 0.5, + }; - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteParams, - metricsContext, - ); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); - await advanceToNthTimerThenFlush(); + await advanceToNthTimerThenFlush(); - expect(startPollingSpy).toHaveBeenCalledTimes(1); - expect(fetchBridgeQuotesSpy.mock.calls[0][3]).toBe('AUTH_TOKEN'); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy.mock.calls[0][3]).toBe('AUTH_TOKEN'); + }, + ); }); it.each([ @@ -1839,343 +1935,365 @@ describe('BridgeController', function () { .spyOn(console, 'error') .mockImplementation(jest.fn()); jest.useFakeTimers(); - const stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); - const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); - const hasSufficientBalanceSpy = jest - .spyOn(balanceUtils, 'hasSufficientBalance') - .mockResolvedValue(false); - messengerCallMock.mockImplementation( - (...args: Parameters) => { - switch (args[0]) { - case 'AuthenticationController:getBearerToken': - return 'AUTH_TOKEN'; - default: - return { - address: '0x123', - provider: jest.fn(), - } as never; - } - }, - ); - - for (const [index, quote] of quoteResponse.entries()) { - if (tradeL1GasFeeError && index === 0) { - getLayer1GasFeeMock.mockRejectedValueOnce( - new Error(tradeL1GasFeeError), + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const stopAllPollingSpy = jest.spyOn( + bridgeController, + 'stopAllPolling', + ); + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(false); + messengerCallMock.mockImplementation( + (...args: Parameters) => { + switch (args[0]) { + case 'AuthenticationController:getBearerToken': + return 'AUTH_TOKEN'; + default: + return { + address: '0x123', + provider: jest.fn(), + } as never; + } + }, ); - continue; - } - if (quote.approval) { - getLayer1GasFeeMock.mockResolvedValueOnce('0x1'); - } + for (const [index, quote] of quoteResponse.entries()) { + if (tradeL1GasFeeError && index === 0) { + getLayer1GasFeeMock.mockRejectedValueOnce( + new Error(tradeL1GasFeeError), + ); + continue; + } - if (tradeL1GasFeesInHexWei === undefined && index === 0) { - getLayer1GasFeeMock.mockResolvedValueOnce(undefined); - continue; - } - getLayer1GasFeeMock.mockResolvedValueOnce( - tradeL1GasFeesInHexWei ?? '0x1', - ); - } + if (quote.approval) { + getLayer1GasFeeMock.mockResolvedValueOnce('0x1'); + } - const fetchBridgeQuotesSpy = jest - .spyOn(fetchUtils, 'fetchBridgeQuotes') - .mockImplementationOnce(async () => { - return await new Promise((resolve) => { - return setTimeout(() => { - resolve({ - quotes: quoteResponse as never, - validationFailures: [], + if (tradeL1GasFeesInHexWei === undefined && index === 0) { + getLayer1GasFeeMock.mockResolvedValueOnce(undefined); + continue; + } + getLayer1GasFeeMock.mockResolvedValueOnce( + tradeL1GasFeesInHexWei ?? '0x1', + ); + } + + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: quoteResponse as never, + validationFailures: [], + }); + }, 1000); }); - }, 1000); - }); - }); + }); - const quoteParams = { - srcChainId: '0xa', - destChainId: '0x1', - srcTokenAddress: '0x4200000000000000000000000000000000000006', - destTokenAddress: '0x0000000000000000000000000000000000000000', - srcTokenAmount: '991250000000000000', - walletAddress: 'eip:id/id:id/0x123', - slippage: 0.5, - }; - const quoteRequest = { - ...quoteParams, - }; - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteParams, - metricsContext, - ); + const quoteParams = { + srcChainId: '0xa', + destChainId: '0x1', + srcTokenAddress: '0x4200000000000000000000000000000000000006', + destTokenAddress: '0x0000000000000000000000000000000000000000', + srcTokenAmount: '991250000000000000', + walletAddress: 'eip:id/id:id/0x123', + slippage: 0.5, + }; + const quoteRequest = { + ...quoteParams, + }; + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); - expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).toHaveBeenCalledTimes(1); - expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).toHaveBeenCalledWith({ - updatedQuoteRequest: { - ...quoteRequest, - insufficientBal: true, - resetApproval: false, - }, - context: metricsContext, - }); + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledWith({ + updatedQuoteRequest: { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + context: metricsContext, + }); - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - quoteRequest, - quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, - quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, - quotesLoadingStatus: RequestStatus.LOADING, - }), - ); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest, + quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, + quotesLastFetched: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesLoadingStatus: RequestStatus.LOADING, + }), + ); - // Loading state - jest.advanceTimersByTime(500); - await flushPromises(); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( - { - ...quoteRequest, - insufficientBal: true, - resetApproval: false, - }, - expect.any(AbortSignal), - BridgeClientId.EXTENSION, - 'AUTH_TOKEN', - mockFetchFn, - BRIDGE_PROD_API_BASE_URL, - null, - '13.7.0', - ); - expect(bridgeController.state.quotesLastFetched).toBeCloseTo( - Date.now() - 500, - ); + // Loading state + jest.advanceTimersByTime(500); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + expect.any(AbortSignal), + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + mockFetchFn, + BRIDGE_PROD_API_BASE_URL, + null, + '13.7.0', + ); + expect(bridgeController.state.quotesLastFetched).toBeCloseTo( + Date.now() - 500, + ); - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - quoteRequest: { - ...quoteRequest, - insufficientBal: true, - resetApproval: false, - }, - quotes: [], - quotesLoadingStatus: 0, - }), - ); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + quotes: [], + quotesLoadingStatus: 0, + }), + ); - // After first fetch - jest.advanceTimersByTime(1500); - await flushPromises(); - const { quotes } = bridgeController.state; - expect(quotes).toHaveLength(expectedQuotesLength); - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - quoteRequest: { - ...quoteRequest, - insufficientBal: true, - resetApproval: false, - }, - quotesLoadingStatus: 1, - quotesRefreshCount: 1, - }), - ); - quotes.forEach((quote) => { - const expectedQuote = { - ...quote, - l1GasFeesInHexWei: totalL1GasFeesInHexWei, - }; - // eslint-disable-next-line jest/prefer-strict-equal - expect(quote).toEqual(expectedQuote); - }); + // After first fetch + jest.advanceTimersByTime(1500); + await flushPromises(); + const { quotes } = bridgeController.state; + expect(quotes).toHaveLength(expectedQuotesLength); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + quotesLoadingStatus: 1, + quotesRefreshCount: 1, + }), + ); + quotes.forEach((quote) => { + const expectedQuote = { + ...quote, + l1GasFeesInHexWei: totalL1GasFeesInHexWei, + }; + // eslint-disable-next-line jest/prefer-strict-equal + expect(quote).toEqual(expectedQuote); + }); - const firstFetchTime = bridgeController.state.quotesLastFetched; - expect(firstFetchTime).toBeGreaterThan(0); + const firstFetchTime = bridgeController.state.quotesLastFetched; + expect(firstFetchTime).toBeGreaterThan(0); - expect(getLayer1GasFeeMock).toHaveBeenCalledTimes( - expectedGetLayer1GasFeeMockCallCount, - ); + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes( + expectedGetLayer1GasFeeMockCallCount, + ); - expect(errorSpy).toHaveBeenCalledTimes(tradeL1GasFeeError ? 1 : 0); + expect(errorSpy).toHaveBeenCalledTimes(tradeL1GasFeeError ? 1 : 0); + }, + ); }, ); it('should handle errors from fetchBridgeQuotes', async () => { jest.useFakeTimers(); - const fetchBridgeQuotesSpy = jest.spyOn(fetchUtils, 'fetchBridgeQuotes'); - messengerCallMock.mockReturnValue({ - address: '0x123', - provider: jest.fn(), - } as never); - - jest.spyOn(balanceUtils, 'hasSufficientBalance').mockResolvedValue(true); - - const consoleLogSpy = jest - .spyOn(console, 'log') - .mockImplementationOnce(jest.fn()); - - // Fetch throws unknown Error - fetchBridgeQuotesSpy.mockImplementationOnce(async () => { - return await new Promise((_resolve, reject) => { - return setTimeout(() => { - reject(new Error('Other error')); - }, 1000); - }); - }); + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const fetchBridgeQuotesSpy = jest.spyOn( + fetchUtils, + 'fetchBridgeQuotes', + ); + messengerCallMock.mockReturnValue({ + address: '0x123', + provider: jest.fn(), + } as never); + + jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(true); + + const consoleLogSpy = jest + .spyOn(console, 'log') + .mockImplementationOnce(jest.fn()); + + // Fetch throws unknown Error + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((_resolve, reject) => { + return setTimeout(() => { + reject(new Error('Other error')); + }, 1000); + }); + }); - // Fetch succeeds - fetchBridgeQuotesSpy.mockImplementationOnce(async () => { - return await new Promise((resolve) => { - return setTimeout(() => { - resolve({ - quotes: mockBridgeQuotesNativeErc20Eth, - validationFailures: [], - } as never); - }, 1000); - }); - }); + // Fetch succeeds + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: mockBridgeQuotesNativeErc20Eth, + validationFailures: [], + } as never); + }, 1000); + }); + }); - // Fetch throws string error - fetchBridgeQuotesSpy.mockImplementationOnce(async () => { - return await new Promise((_resolve, reject) => { - return setTimeout(() => { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - reject('Test error'); - }, 1000); - }); - }); + // Fetch throws string error + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((_resolve, reject) => { + return setTimeout(() => { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject('Test error'); + }, 1000); + }); + }); - const quoteParams = { - srcChainId: '0xa', - destChainId: '0x1', - srcTokenAddress: '0x4200000000000000000000000000000000000006', - destTokenAddress: '0x0000000000000000000000000000000000000000', - srcTokenAmount: '991250000000000000', - walletAddress: 'eip:id/id:id/0x123', - }; + const quoteParams = { + srcChainId: '0xa', + destChainId: '0x1', + srcTokenAddress: '0x4200000000000000000000000000000000000006', + destTokenAddress: '0x0000000000000000000000000000000000000000', + srcTokenAmount: '991250000000000000', + walletAddress: 'eip:id/id:id/0x123', + }; - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteParams, - metricsContext, - ); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); - // Advance timers to trigger fetch - jest.advanceTimersToNextTimer(); - await flushPromises(); - jest.advanceTimersToNextTimer(); - await flushPromises(); - // Verify state wasn't updated due to abort - expect(bridgeController.state.quoteFetchError).toBe('Other error'); - expect(bridgeController.state.quotesLoadingStatus).toBe( - RequestStatus.ERROR, - ); - expect(bridgeController.state.quotes).toStrictEqual([]); - - // Verify state is reset - rootMessenger.call('BridgeController:resetState'); - expect(bridgeController.state.quoteFetchError).toBeNull(); - expect(bridgeController.state.quotesLoadingStatus).toBeNull(); - expect(bridgeController.state.quotes).toStrictEqual([]); - - // Verify quotes are fetched - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteParams, - metricsContext, - ); + // Advance timers to trigger fetch + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + // Verify state wasn't updated due to abort + expect(bridgeController.state.quoteFetchError).toBe('Other error'); + expect(bridgeController.state.quotesLoadingStatus).toBe( + RequestStatus.ERROR, + ); + expect(bridgeController.state.quotes).toStrictEqual([]); + + // Verify state is reset + rootMessenger.call('BridgeController:resetState'); + expect(bridgeController.state.quoteFetchError).toBeNull(); + expect(bridgeController.state.quotesLoadingStatus).toBeNull(); + expect(bridgeController.state.quotes).toStrictEqual([]); + + // Verify quotes are fetched + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); + + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersByTime(10000); + await flushPromises(); + const { quotes, quotesLastFetched, ...stateWithoutQuotes } = + bridgeController.state; - jest.advanceTimersToNextTimer(); - await flushPromises(); - jest.advanceTimersByTime(10000); - await flushPromises(); - const { quotes, quotesLastFetched, ...stateWithoutQuotes } = - bridgeController.state; - - expect(stateWithoutQuotes).toMatchSnapshot(); - expect(quotes).toStrictEqual(mockBridgeQuotesNativeErc20Eth); - expect(quotesLastFetched).toBeCloseTo(Date.now() - 10000); - - jest.advanceTimersByTime(10000); - await flushPromises(); - const { - quotes: quotes2, - quotesLastFetched: quotesLastFetched2, - ...stateWithoutQuotes2 - } = bridgeController.state; - - expect(stateWithoutQuotes2).toMatchSnapshot(); - expect(quotes2).toStrictEqual(mockBridgeQuotesNativeErc20Eth); - - expect(quotesLastFetched2).toBe(quotesLastFetched); - expect(consoleLogSpy).toHaveBeenCalledTimes(1); - expect(consoleLogSpy).toHaveBeenCalledWith( - 'Failed to fetch bridge quotes', - new Error('Other error'), + expect(stateWithoutQuotes).toMatchSnapshot(); + expect(quotes).toStrictEqual(mockBridgeQuotesNativeErc20Eth); + expect(quotesLastFetched).toBeCloseTo(Date.now() - 10000); + + jest.advanceTimersByTime(10000); + await flushPromises(); + const { + quotes: quotes2, + quotesLastFetched: quotesLastFetched2, + ...stateWithoutQuotes2 + } = bridgeController.state; + + expect(stateWithoutQuotes2).toMatchSnapshot(); + expect(quotes2).toStrictEqual(mockBridgeQuotesNativeErc20Eth); + + expect(quotesLastFetched2).toBe(quotesLastFetched); + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).toHaveBeenCalledWith( + 'Failed to fetch bridge quotes', + new Error('Other error'), + ); + }, ); }); it('returns early on AbortError without updating post-fetch state', async () => { jest.useFakeTimers(); + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const abortError = new Error('Aborted'); + // Make it look like an AbortError to hit the early return + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + abortError.name = 'AbortError'; + + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockImplementationOnce( + async () => + await new Promise((_resolve, reject) => { + setTimeout(() => reject(abortError), 1000); + }), + ); - const abortError = new Error('Aborted'); - // Make it look like an AbortError to hit the early return - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - abortError.name = 'AbortError'; - - const fetchBridgeQuotesSpy = jest - .spyOn(fetchUtils, 'fetchBridgeQuotes') - .mockImplementationOnce( - async () => - await new Promise((_resolve, reject) => { - setTimeout(() => reject(abortError), 1000); - }), - ); - - // Minimal messenger/env setup to allow polling to start - messengerCallMock.mockReturnValue({ - address: '0x123', - provider: jest.fn(), - currencyRates: {}, - marketData: {}, - conversionRates: {}, - } as never); + // Minimal messenger/env setup to allow polling to start + messengerCallMock.mockReturnValue({ + address: '0x123', + provider: jest.fn(), + currencyRates: {}, + marketData: {}, + conversionRates: {}, + } as never); - jest.spyOn(balanceUtils, 'hasSufficientBalance').mockResolvedValue(true); + jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(true); - const quoteParams = { - srcChainId: '0x1', - destChainId: '0xa', - srcTokenAddress: '0x0000000000000000000000000000000000000000', - destTokenAddress: '0x123', - srcTokenAmount: '1000000000000000000', - walletAddress: '0x123', - slippage: 0.5, - }; + const quoteParams = { + srcChainId: '0x1', + destChainId: '0xa', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + srcTokenAmount: '1000000000000000000', + walletAddress: '0x123', + slippage: 0.5, + }; - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteParams, - metricsContext, - ); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); - // Trigger the fetch + abort rejection - jest.advanceTimersByTime(1000); - await flushPromises(); + // Trigger the fetch + abort rejection + jest.advanceTimersByTime(1000); + await flushPromises(); - // Early return path: no post-fetch updates - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); - expect(bridgeController.state.quoteFetchError).toBeNull(); - expect(bridgeController.state.quotesLoadingStatus).toBe( - RequestStatus.LOADING, - ); - expect(bridgeController.state.quotesLastFetched).toBeCloseTo( - Date.now() - 1000, + // Early return path: no post-fetch updates + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(bridgeController.state.quoteFetchError).toBeNull(); + expect(bridgeController.state.quotesLoadingStatus).toBe( + RequestStatus.LOADING, + ); + expect(bridgeController.state.quotesLastFetched).toBeCloseTo( + Date.now() - 1000, + ); + expect(bridgeController.state.quotesRefreshCount).toBe(0); + expect(bridgeController.state.quotes).toStrictEqual([]); + }, ); - expect(bridgeController.state.quotesRefreshCount).toBe(0); - expect(bridgeController.state.quotes).toStrictEqual([]); }); it.each([ @@ -2234,445 +2352,447 @@ describe('BridgeController', function () { isEvmAccount = false, ) => { jest.useFakeTimers(); - const stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); - const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); - const hasSufficientBalanceSpy = jest - .spyOn(balanceUtils, 'hasSufficientBalance') - .mockResolvedValue(false); - - messengerCallMock.mockImplementation( - ( - ...args: Parameters - ): ReturnType => { - const [actionType, params] = args; - - if (actionType === 'AuthenticationController:getBearerToken') { - return 'AUTH_TOKEN'; - } - - if (actionType === 'AccountsController:getAccountByAddress') { - if (isEvmAccount) { - return { - type: EthAccountType.Eoa, - id: 'account1', - scopes: [EthScope.Eoa], - methods: [], - address: '0x123', - metadata: { - name: 'Account 1', - importTime: 1717334400, - keyring: { - type: 'Keyring', - }, - }, - options: { - scope: 'mainnet', - }, - }; - } - return { - type: SolAccountType.DataAccount, - id: 'account1', - scopes: [SolScope.Mainnet], - methods: [], - address: '0x123', - metadata: { - name: 'Account 1', - importTime: 1717334400, - keyring: { - type: 'Keyring', - }, - snap: { - id: 'npm:@metamask/solana-snap', - name: 'Solana Snap', - enabled: true, - }, - }, - options: { - scope: SolScope.Mainnet, - }, - }; - } - - if (actionType === 'SnapController:handleRequest') { - return new Promise((resolve) => { - if ( - (params as { handler: string })?.handler === 'onProtocolRequest' - ) { - return setTimeout(() => { - resolve(expectedMinBalance); - }, 200); + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const stopAllPollingSpy = jest.spyOn( + bridgeController, + 'stopAllPolling', + ); + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(false); + + messengerCallMock.mockImplementation( + ( + ...args: Parameters + ): ReturnType => { + const [actionType, params] = args; + + if (actionType === 'AuthenticationController:getBearerToken') { + return 'AUTH_TOKEN'; } - if ( - (params as { handler: string })?.handler === - 'onClientRequest' && - (params as { request?: { method: string } })?.request - ?.method === 'computeFee' - ) { - return setTimeout(() => { - resolve([ - { - type: 'base', - asset: { - unit: 'SOL', - type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:11111111111111111111111111111111', - amount: expectedFees ?? '0', - fungible: true, + + if (actionType === 'AccountsController:getAccountByAddress') { + if (isEvmAccount) { + return { + type: EthAccountType.Eoa, + id: 'account1', + scopes: [EthScope.Eoa], + methods: [], + address: '0x123', + metadata: { + name: 'Account 1', + importTime: 1717334400, + keyring: { + type: 'Keyring', }, }, - ]); - }, 100); + options: { + scope: 'mainnet', + }, + }; + } + return { + type: SolAccountType.DataAccount, + id: 'account1', + scopes: [SolScope.Mainnet], + methods: [], + address: '0x123', + metadata: { + name: 'Account 1', + importTime: 1717334400, + keyring: { + type: 'Keyring', + }, + snap: { + id: 'npm:@metamask/solana-snap', + name: 'Solana Snap', + enabled: true, + }, + }, + options: { + scope: SolScope.Mainnet, + }, + }; } - return setTimeout(() => { - resolve({ value: expectedFees }); - }, 100); - }); - } - return { - provider: jest.fn() as never, - } as never; - }, - ); - const fetchBridgeQuotesSpy = jest - .spyOn(fetchUtils, 'fetchBridgeQuotes') - .mockImplementation(async () => { - return await new Promise((resolve) => { - return setTimeout(() => { - resolve({ - quotes: quoteResponse, - validationFailures, + if (actionType === 'SnapController:handleRequest') { + return new Promise((resolve) => { + if ( + (params as { handler: string })?.handler === + 'onProtocolRequest' + ) { + return setTimeout(() => { + resolve(expectedMinBalance); + }, 200); + } + if ( + (params as { handler: string })?.handler === + 'onClientRequest' && + (params as { request?: { method: string } })?.request + ?.method === 'computeFee' + ) { + return setTimeout(() => { + resolve([ + { + type: 'base', + asset: { + unit: 'SOL', + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:11111111111111111111111111111111', + amount: expectedFees ?? '0', + fungible: true, + }, + }, + ]); + }, 100); + } + return setTimeout(() => { + resolve({ value: expectedFees }); + }, 100); + }); + } + return { + provider: jest.fn() as never, + } as never; + }, + ); + + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockImplementation(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: quoteResponse, + validationFailures, + }); + }, 1000); }); - }, 1000); - }); - }); + }); - const quoteParams = { - srcChainId: SolScope.Mainnet, - destChainId: '1', - srcTokenAddress: 'NATIVE', - destTokenAddress: '0x0000000000000000000000000000000000000000', - srcTokenAmount: '1000000', - walletAddress: '0x123', - destWalletAddress: '0x5342', - slippage: 0.5, - }; - - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteParams, - metricsContext, - ); + const quoteParams = { + srcChainId: SolScope.Mainnet, + destChainId: '1', + srcTokenAddress: 'NATIVE', + destTokenAddress: '0x0000000000000000000000000000000000000000', + srcTokenAmount: '1000000', + walletAddress: '0x123', + destWalletAddress: '0x5342', + slippage: 0.5, + }; - expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).toHaveBeenCalledTimes(1); - expect(hasSufficientBalanceSpy).not.toHaveBeenCalled(); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); - // Loading state - jest.advanceTimersByTime(201); - await flushPromises(); + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).not.toHaveBeenCalled(); - // Wait for JWT token retrieval - if (!isEvmAccount) { - jest.advanceTimersToNextTimer(); - await flushPromises(); - } - - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - quotesLoadingStatus: RequestStatus.LOADING, - quotes: [], - minimumBalanceForRentExemptionInLamports: expectedMinBalance, - }), - ); - jest.advanceTimersByTime(295); - await flushPromises(); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); - - // After fetch completes - jest.advanceTimersByTime(2601); - await flushPromises(); - - jest.advanceTimersByTime(100); - await flushPromises(); - const { quotes } = bridgeController.state; - expect(bridgeController.state).toStrictEqual( - expect.objectContaining({ - quotesLoadingStatus: RequestStatus.FETCHED, - quotesRefreshCount: 1, - }), - ); + // Loading state + jest.advanceTimersByTime(201); + await flushPromises(); - // Verify non-EVM fees - quotes.forEach((quote) => { - expect(quote.nonEvmFeesInNative).toBe( - isSolanaChainId(quote.quote.srcChainId) ? expectedFees : undefined, - ); - }); + // Wait for JWT token retrieval + if (!isEvmAccount) { + jest.advanceTimersToNextTimer(); + await flushPromises(); + } - // Verify snap interaction - const snapCalls = messengerCallMock.mock.calls.filter( - ([methodName]) => methodName === 'SnapController:handleRequest', - ); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quotesLoadingStatus: RequestStatus.LOADING, + quotes: [], + minimumBalanceForRentExemptionInLamports: expectedMinBalance, + }), + ); + jest.advanceTimersByTime(295); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + + // After fetch completes + jest.advanceTimersByTime(2601); + await flushPromises(); + + jest.advanceTimersByTime(100); + await flushPromises(); + const { quotes } = bridgeController.state; + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quotesLoadingStatus: RequestStatus.FETCHED, + quotesRefreshCount: 1, + }), + ); - expect(snapCalls).toMatchSnapshot(); + // Verify non-EVM fees + quotes.forEach((quote) => { + expect(quote.nonEvmFeesInNative).toBe( + isSolanaChainId(quote.quote.srcChainId) + ? expectedFees + : undefined, + ); + }); + + // Verify snap interaction + const snapCalls = messengerCallMock.mock.calls.filter( + ([methodName]) => methodName === 'SnapController:handleRequest', + ); - expect(quotes).toHaveLength(expectedQuotesLength); + expect(snapCalls).toMatchSnapshot(); - // Verify validation failure tracking - expect(trackMetaMetricsFn).toHaveBeenCalledTimes( - 6 + (validationFailures.length ? 1 : 0), + expect(quotes).toHaveLength(expectedQuotesLength); + + // Verify validation failure tracking + expect(trackMetaMetricsFn).toHaveBeenCalledTimes( + 6 + (validationFailures.length ? 1 : 0), + ); + expect( + trackMetaMetricsFn.mock.calls.filter( + ([eventName]) => + eventName === UnifiedSwapBridgeEventName.QuotesValidationFailed, + ), + ).toMatchSnapshot(); + }, ); - expect( - trackMetaMetricsFn.mock.calls.filter( - ([eventName]) => - eventName === UnifiedSwapBridgeEventName.QuotesValidationFailed, - ), - ).toMatchSnapshot(); }, ); it('should handle BTC chain fees correctly', async () => { jest.useFakeTimers(); - // Use the actual Solana mock which already has string trade type - const btcQuoteResponse = mockBridgeQuotesSolErc20.map((quote) => ({ - ...quote, - quote: { - ...quote.quote, - srcChainId: ChainId.BTC, - }, - })) as unknown as QuoteResponse[]; - - messengerCallMock.mockImplementation( - ( - ...args: Parameters - ): ReturnType => { - const [actionType, params] = args; + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + // Use the actual Solana mock which already has string trade type + const btcQuoteResponse = mockBridgeQuotesSolErc20.map((quote) => ({ + ...quote, + quote: { + ...quote.quote, + srcChainId: ChainId.BTC, + }, + })) as unknown as QuoteResponse[]; - if (actionType === 'AccountsController:getAccountByAddress') { - return { - type: 'btc:p2wpkh', - id: 'btc-account-1', - scopes: [BtcScope.Mainnet], - methods: [], - address: 'bc1q...', - metadata: { - name: 'BTC Account 1', - importTime: 1717334400, - keyring: { - type: 'Snap Keyring', - }, - snap: { - id: 'btc-snap-id', - name: 'BTC Snap', - }, - }, - } as never; - } + messengerCallMock.mockImplementation( + ( + ...args: Parameters + ): ReturnType => { + const [actionType, params] = args; - if (actionType === 'SnapController:handleRequest') { - return new Promise((resolve) => { - if ( - (params as { handler: string })?.handler === 'onClientRequest' && - (params as { request?: { method: string } })?.request?.method === - 'computeFee' - ) { - return setTimeout(() => { - resolve([ - { - type: 'priority', - asset: { - unit: 'BTC', - type: 'bip122:000000000019d6689c085ae165831e93/slip44:0', - amount: '0.00005', // BTC fee - fungible: true, - }, + if (actionType === 'AccountsController:getAccountByAddress') { + return { + type: 'btc:p2wpkh', + id: 'btc-account-1', + scopes: [BtcScope.Mainnet], + methods: [], + address: 'bc1q...', + metadata: { + name: 'BTC Account 1', + importTime: 1717334400, + keyring: { + type: 'Snap Keyring', }, - ]); - }, 100); + snap: { + id: 'btc-snap-id', + name: 'BTC Snap', + }, + }, + } as never; } - return setTimeout(() => { - resolve('5000'); - }, 200); - }); - } - return { - provider: jest.fn() as never, - } as never; - }, - ); + if (actionType === 'SnapController:handleRequest') { + return new Promise((resolve) => { + if ( + (params as { handler: string })?.handler === + 'onClientRequest' && + (params as { request?: { method: string } })?.request + ?.method === 'computeFee' + ) { + return setTimeout(() => { + resolve([ + { + type: 'priority', + asset: { + unit: 'BTC', + type: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + amount: '0.00005', // BTC fee + fungible: true, + }, + }, + ]); + }, 100); + } + return setTimeout(() => { + resolve('5000'); + }, 200); + }); + } - jest.spyOn(fetchUtils, 'fetchBridgeQuotes').mockResolvedValue({ - quotes: btcQuoteResponse, - validationFailures: [], - }); + return { + provider: jest.fn() as never, + } as never; + }, + ); - const quoteParams = { - srcChainId: ChainId.BTC.toString(), - destChainId: '1', - srcTokenAddress: 'NATIVE', - destTokenAddress: '0x0000000000000000000000000000000000000000', - srcTokenAmount: '100000', // satoshis - walletAddress: 'bc1q...', - destWalletAddress: '0x5342', - slippage: 0.5, - }; + jest.spyOn(fetchUtils, 'fetchBridgeQuotes').mockResolvedValue({ + quotes: btcQuoteResponse, + validationFailures: [], + }); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteParams, - metricsContext, - ); + const quoteParams = { + srcChainId: ChainId.BTC.toString(), + destChainId: '1', + srcTokenAddress: 'NATIVE', + destTokenAddress: '0x0000000000000000000000000000000000000000', + srcTokenAmount: '100000', // satoshis + walletAddress: 'bc1q...', + destWalletAddress: '0x5342', + slippage: 0.5, + }; - // Wait for polling to start - jest.advanceTimersByTime(201); - await flushPromises(); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); + + // Wait for polling to start + jest.advanceTimersByTime(201); + await flushPromises(); - // Wait for fetch to trigger - jest.advanceTimersByTime(295); - await flushPromises(); + // Wait for fetch to trigger + jest.advanceTimersByTime(295); + await flushPromises(); - // Wait for fetch to complete - jest.advanceTimersByTime(2601); - await flushPromises(); + // Wait for fetch to complete + jest.advanceTimersByTime(2601); + await flushPromises(); - // Final wait for fee calculation - jest.advanceTimersByTime(100); - await flushPromises(); + // Final wait for fee calculation + jest.advanceTimersByTime(100); + await flushPromises(); - const { quotes } = bridgeController.state; - expect(quotes).toHaveLength(2); // mockBridgeQuotesSolErc20 has 2 quotes - expect(quotes[0].nonEvmFeesInNative).toBe('0.00005'); // BTC fee as-is - expect(quotes[1].nonEvmFeesInNative).toBe('0.00005'); // BTC fee as-is + const { quotes } = bridgeController.state; + expect(quotes).toHaveLength(2); // mockBridgeQuotesSolErc20 has 2 quotes + expect(quotes[0].nonEvmFeesInNative).toBe('0.00005'); // BTC fee as-is + expect(quotes[1].nonEvmFeesInNative).toBe('0.00005'); // BTC fee as-is + }, + ); }); it('should catch BTC chain fees errors and return undefined fees', async () => { jest.useFakeTimers(); - // Use the actual Solana mock which already has string trade type - const btcQuoteResponse = mockBridgeQuotesSolErc20.map((quote) => ({ - ...quote, - quote: { - ...quote.quote, - srcChainId: ChainId.BTC, - }, - })) as unknown as QuoteResponse[]; + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + // Use the actual Solana mock which already has string trade type + const btcQuoteResponse = mockBridgeQuotesSolErc20.map((quote) => ({ + ...quote, + quote: { + ...quote.quote, + srcChainId: ChainId.BTC, + }, + })) as unknown as QuoteResponse[]; - const consoleErrorSpy = jest - .spyOn(console, 'error') - .mockImplementation(jest.fn()); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(jest.fn()); - messengerCallMock.mockImplementation( - ( - ...args: Parameters - ): ReturnType => { - const [actionType] = args; + messengerCallMock.mockImplementation( + ( + ...args: Parameters + ): ReturnType => { + const [actionType] = args; - if (actionType === 'AccountsController:getAccountByAddress') { - return { - type: 'btc:p2wpkh', - id: 'btc-account-1', - scopes: [BtcScope.Mainnet], - methods: [], - address: 'bc1q...', - metadata: { - name: 'BTC Account 1', - importTime: 1717334400, - keyring: { - type: 'Snap Keyring', - }, - snap: { - id: 'btc-snap-id', - name: 'BTC Snap', - }, - }, - } as never; - } + if (actionType === 'AccountsController:getAccountByAddress') { + return { + type: 'btc:p2wpkh', + id: 'btc-account-1', + scopes: [BtcScope.Mainnet], + methods: [], + address: 'bc1q...', + metadata: { + name: 'BTC Account 1', + importTime: 1717334400, + keyring: { + type: 'Snap Keyring', + }, + snap: { + id: 'btc-snap-id', + name: 'BTC Snap', + }, + }, + } as never; + } - if (actionType === 'SnapController:handleRequest') { - return new Promise((_resolve, reject) => { - reject(new Error('Failed to compute fees')); - }); - } + if (actionType === 'SnapController:handleRequest') { + return new Promise((_resolve, reject) => { + reject(new Error('Failed to compute fees')); + }); + } - return { - provider: jest.fn() as never, - } as never; - }, - ); + return { + provider: jest.fn() as never, + } as never; + }, + ); - jest.spyOn(fetchUtils, 'fetchBridgeQuotes').mockResolvedValue({ - quotes: btcQuoteResponse, - validationFailures: [], - }); + jest.spyOn(fetchUtils, 'fetchBridgeQuotes').mockResolvedValue({ + quotes: btcQuoteResponse, + validationFailures: [], + }); - const quoteParams = { - srcChainId: ChainId.BTC.toString(), - destChainId: '1', - srcTokenAddress: 'NATIVE', - destTokenAddress: '0x0000000000000000000000000000000000000000', - srcTokenAmount: '100000', // satoshis - walletAddress: 'bc1q...', - destWalletAddress: '0x5342', - slippage: 0.5, - }; + const quoteParams = { + srcChainId: ChainId.BTC.toString(), + destChainId: '1', + srcTokenAddress: 'NATIVE', + destTokenAddress: '0x0000000000000000000000000000000000000000', + srcTokenAmount: '100000', // satoshis + walletAddress: 'bc1q...', + destWalletAddress: '0x5342', + slippage: 0.5, + }; - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteParams, - metricsContext, - ); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); - // Wait for polling to start - jest.advanceTimersByTime(201); - await flushPromises(); - - // Wait for fetch to trigger - jest.advanceTimersByTime(295); - await flushPromises(); - - // Wait for fetch to complete - jest.advanceTimersByTime(2601); - await flushPromises(); - - // Final wait for fee calculation - jest.advanceTimersByTime(100); - await flushPromises(); - - const { quotes } = bridgeController.state; - expect(quotes).toHaveLength(2); // mockBridgeQuotesSolErc20 has 2 quotes - expect(quotes[0].nonEvmFeesInNative).toBeUndefined(); - expect(quotes[1].nonEvmFeesInNative).toBeUndefined(); - expect(consoleErrorSpy).toHaveBeenCalledTimes(2); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Failed to compute non-EVM fees for quote 5cb5a527-d4e4-4b5e-b753-136afc3986d3:', - new Error('Failed to compute fees'), - ); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Failed to compute non-EVM fees for quote 12c94d29-4b5c-4aee-92de-76eee4172d3d:', - new Error('Failed to compute fees'), + // Wait for polling to start + jest.advanceTimersByTime(201); + await flushPromises(); + + // Wait for fetch to trigger + jest.advanceTimersByTime(295); + await flushPromises(); + + // Wait for fetch to complete + jest.advanceTimersByTime(2601); + await flushPromises(); + + // Final wait for fee calculation + jest.advanceTimersByTime(100); + await flushPromises(); + + const { quotes } = bridgeController.state; + expect(quotes).toHaveLength(2); // mockBridgeQuotesSolErc20 has 2 quotes + expect(quotes[0].nonEvmFeesInNative).toBeUndefined(); + expect(quotes[1].nonEvmFeesInNative).toBeUndefined(); + expect(consoleErrorSpy).toHaveBeenCalledTimes(2); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to compute non-EVM fees for quote 5cb5a527-d4e4-4b5e-b753-136afc3986d3:', + new Error('Failed to compute fees'), + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to compute non-EVM fees for quote 12c94d29-4b5c-4aee-92de-76eee4172d3d:', + new Error('Failed to compute fees'), + ); + }, ); }); describe('trackUnifiedSwapBridgeEvent client-side calls', () => { - beforeEach(async () => { - jest.clearAllMocks(); - // Ignore console.warn for this test bc there will be expected asset rate fetching warnings - jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); - // Add walletAddress to the quoteRequest because it's required for some events - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - { - walletAddress: '0x123', - }, - { - stx_enabled: false, - security_warnings: [], - token_symbol_source: 'ETH', - token_symbol_destination: 'USDC', - usd_amount_source: 100, - }, - ); + beforeEach(() => { jest.clearAllMocks(); messengerCallMock.mockImplementationOnce( (): ReturnType => { @@ -2719,210 +2839,401 @@ describe('BridgeController', function () { ); }); - it('should track the ButtonClicked event', () => { - rootMessenger.call( - 'BridgeController:trackUnifiedSwapBridgeEvent', - UnifiedSwapBridgeEventName.ButtonClicked, - { - location: MetaMetricsSwapsEventSource.MainView, - token_symbol_source: 'ETH', - token_symbol_destination: null, - }, - ); - expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + it('should track the ButtonClicked event', async () => { + await withController(async ({ rootMessenger }) => { + // Ignore console.warn for this test bc there will be expected asset rate fetching warnings + jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); + // Add walletAddress to the quoteRequest because it's required for some events + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + }, + ); + jest.clearAllMocks(); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.ButtonClicked, + { + location: MetaMetricsSwapsEventSource.MainView, + token_symbol_source: 'ETH', + token_symbol_destination: null, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); - expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }); }); - it('should track the PageViewed event', () => { - rootMessenger.call( - 'BridgeController:trackUnifiedSwapBridgeEvent', - UnifiedSwapBridgeEventName.PageViewed, - { abc: 1 }, - ); - expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + it('should track the PageViewed event', async () => { + await withController(async ({ rootMessenger }) => { + // Ignore console.warn for this test bc there will be expected asset rate fetching warnings + jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); + // Add walletAddress to the quoteRequest because it's required for some events + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + }, + ); + jest.clearAllMocks(); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.PageViewed, + // @ts-expect-error Partial mock. + { abc: 1 }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); - expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }); }); - it('should track InputChanged with an enum quick amount preset label', () => { - rootMessenger.call( - 'BridgeController:trackUnifiedSwapBridgeEvent', - UnifiedSwapBridgeEventName.InputChanged, - { - input: 'token_amount_source', - input_value: '1', - input_amount_preset: InputAmountPreset.PERCENT_90, - }, - ); + it('should track InputChanged with an enum quick amount preset label', async () => { + await withController(async ({ rootMessenger }) => { + // Ignore console.warn for this test bc there will be expected asset rate fetching warnings + jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); + // Add walletAddress to the quoteRequest because it's required for some events + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + }, + ); + jest.clearAllMocks(); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.InputChanged, + { + input: 'token_amount_source', + input_value: '1', + input_amount_preset: InputAmountPreset.PERCENT_90, + }, + ); - expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); - expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( - 1, - UnifiedSwapBridgeEventName.InputChanged, - expect.objectContaining({ - input: 'token_amount_source', - input_value: '1', - input_amount_preset: InputAmountPreset.PERCENT_90, - }), - ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 1, + UnifiedSwapBridgeEventName.InputChanged, + expect.objectContaining({ + input: 'token_amount_source', + input_value: '1', + input_amount_preset: InputAmountPreset.PERCENT_90, + }), + ); + }); }); - it('should track InputChanged with arbitrary quick amount preset labels', () => { - rootMessenger.call( - 'BridgeController:trackUnifiedSwapBridgeEvent', - UnifiedSwapBridgeEventName.InputChanged, - { - input: 'token_amount_source', - input_value: '1', - input_amount_preset: '85%', - }, - ); - rootMessenger.call( - 'BridgeController:trackUnifiedSwapBridgeEvent', - UnifiedSwapBridgeEventName.InputChanged, - { - input: 'token_amount_source', - input_value: '1', - input_amount_preset: '95%', - }, - ); + it('should track InputChanged with arbitrary quick amount preset labels', async () => { + await withController(async ({ rootMessenger }) => { + // Ignore console.warn for this test bc there will be expected asset rate fetching warnings + jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); + // Add walletAddress to the quoteRequest because it's required for some events + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + }, + ); + jest.clearAllMocks(); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.InputChanged, + { + input: 'token_amount_source', + input_value: '1', + input_amount_preset: '85%', + }, + ); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.InputChanged, + { + input: 'token_amount_source', + input_value: '1', + input_amount_preset: '95%', + }, + ); - expect(trackMetaMetricsFn).toHaveBeenCalledTimes(2); - expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( - 1, - UnifiedSwapBridgeEventName.InputChanged, - expect.objectContaining({ - input_amount_preset: '85%', - }), - ); - expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( - 2, - UnifiedSwapBridgeEventName.InputChanged, - expect.objectContaining({ - input_amount_preset: '95%', - }), - ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(2); + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 1, + UnifiedSwapBridgeEventName.InputChanged, + expect.objectContaining({ + input_amount_preset: '85%', + }), + ); + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 2, + UnifiedSwapBridgeEventName.InputChanged, + expect.objectContaining({ + input_amount_preset: '95%', + }), + ); + }); }); - it('should track the InputSourceDestinationFlipped event', () => { - rootMessenger.call( - 'BridgeController:trackUnifiedSwapBridgeEvent', - UnifiedSwapBridgeEventName.InputSourceDestinationSwitched, - { - token_symbol_destination: 'USDC', - token_symbol_source: 'ETH', - security_warnings: ['warning1'], - chain_id_source: formatChainIdToCaip(1), - token_address_source: getNativeAssetForChainId(1).assetId, - chain_id_destination: formatChainIdToCaip(10), - token_address_destination: getNativeAssetForChainId(10).assetId, - }, - ); - expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + it('should track the InputSourceDestinationFlipped event', async () => { + await withController(async ({ rootMessenger }) => { + // Ignore console.warn for this test bc there will be expected asset rate fetching warnings + jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); + // Add walletAddress to the quoteRequest because it's required for some events + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + }, + ); + jest.clearAllMocks(); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.InputSourceDestinationSwitched, + { + token_symbol_destination: 'USDC', + token_symbol_source: 'ETH', + security_warnings: ['warning1'], + chain_id_source: formatChainIdToCaip(1), + token_address_source: getNativeAssetForChainId(1).assetId, + chain_id_destination: formatChainIdToCaip(10), + token_address_destination: getNativeAssetForChainId(10).assetId, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); - expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }); }); - it('should track the AllQuotesOpened event', () => { - rootMessenger.call( - 'BridgeController:trackUnifiedSwapBridgeEvent', - UnifiedSwapBridgeEventName.AllQuotesOpened, - { - price_impact: 6, - token_symbol_source: 'ETH', - token_symbol_destination: 'USDC', - gas_included: false, - stx_enabled: false, - can_submit: true, - }, - ); - expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + it('should track the AllQuotesOpened event', async () => { + await withController(async ({ rootMessenger }) => { + // Ignore console.warn for this test bc there will be expected asset rate fetching warnings + jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); + // Add walletAddress to the quoteRequest because it's required for some events + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + }, + ); + jest.clearAllMocks(); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.AllQuotesOpened, + { + price_impact: 6, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + gas_included: false, + stx_enabled: false, + can_submit: true, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); - expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }); }); - it('should track the AllQuotesSorted event', () => { - rootMessenger.call( - 'BridgeController:trackUnifiedSwapBridgeEvent', - UnifiedSwapBridgeEventName.AllQuotesSorted, - { - sort_order: SortOrder.COST_ASC, - price_impact: 6, - gas_included: false, - stx_enabled: false, - token_symbol_source: 'ETH', - best_quote_provider: 'provider_bridge2', - token_symbol_destination: 'USDC', - can_submit: true, - }, - ); - expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + it('should track the AllQuotesSorted event', async () => { + await withController(async ({ rootMessenger }) => { + // Ignore console.warn for this test bc there will be expected asset rate fetching warnings + jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); + // Add walletAddress to the quoteRequest because it's required for some events + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + }, + ); + jest.clearAllMocks(); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.AllQuotesSorted, + { + sort_order: SortOrder.COST_ASC, + price_impact: 6, + gas_included: false, + stx_enabled: false, + token_symbol_source: 'ETH', + best_quote_provider: 'provider_bridge2', + token_symbol_destination: 'USDC', + can_submit: true, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); - expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }); }); - it('should track the QuoteSelected event', () => { - rootMessenger.call( - 'BridgeController:trackUnifiedSwapBridgeEvent', - UnifiedSwapBridgeEventName.QuoteSelected, - { - is_best_quote: true, - usd_quoted_gas: 0, - gas_included: false, - gas_included_7702: false, - quoted_time_minutes: 10, - usd_quoted_return: 100, - price_impact: 0, - provider: 'provider_bridge', - best_quote_provider: 'provider_bridge2', - can_submit: false, - }, - ); - expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + it('should track the QuoteSelected event', async () => { + await withController(async ({ rootMessenger }) => { + // Ignore console.warn for this test bc there will be expected asset rate fetching warnings + jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); + // Add walletAddress to the quoteRequest because it's required for some events + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + }, + ); + jest.clearAllMocks(); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.QuoteSelected, + { + is_best_quote: true, + usd_quoted_gas: 0, + gas_included: false, + gas_included_7702: false, + quoted_time_minutes: 10, + usd_quoted_return: 100, + price_impact: 0, + provider: 'provider_bridge', + best_quote_provider: 'provider_bridge2', + can_submit: false, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); - expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }); }); - it('should track the QuotesReceived event', () => { - rootMessenger.call( - 'BridgeController:trackUnifiedSwapBridgeEvent', - UnifiedSwapBridgeEventName.QuotesReceived, - { - warnings: ['insufficient_balance'], - usd_quoted_gas: 0, - gas_included: false, - gas_included_7702: false, - quoted_time_minutes: 10, - usd_quoted_return: 100, - price_impact: 0, - provider: 'provider_bridge', - best_quote_provider: 'provider_bridge2', - can_submit: true, - usd_balance_source: 0, - }, - ); - expect(messengerCallMock.mock.calls).toMatchSnapshot(); - expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + it('should track the QuotesReceived event', async () => { + await withController(async ({ rootMessenger }) => { + // Ignore console.warn for this test bc there will be expected asset rate fetching warnings + jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); + // Add walletAddress to the quoteRequest because it's required for some events + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + }, + ); + jest.clearAllMocks(); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.QuotesReceived, + { + warnings: ['insufficient_balance'], + usd_quoted_gas: 0, + gas_included: false, + gas_included_7702: false, + quoted_time_minutes: 10, + usd_quoted_return: 100, + price_impact: 0, + provider: 'provider_bridge', + best_quote_provider: 'provider_bridge2', + can_submit: true, + usd_balance_source: 0, + }, + ); + expect(messengerCallMock.mock.calls).toMatchSnapshot(); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); - expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }); }); - it('should track the AssetDetailTooltipClicked event', () => { - rootMessenger.call( - 'BridgeController:trackUnifiedSwapBridgeEvent', - UnifiedSwapBridgeEventName.AssetDetailTooltipClicked, - { - token_name: 'ETH', - token_symbol: 'ETH', - token_contract: '0x123', - chain_name: 'Ethereum', - chain_id: '1', - }, - ); - expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + it('should track the AssetDetailTooltipClicked event', async () => { + await withController(async ({ rootMessenger }) => { + // Ignore console.warn for this test bc there will be expected asset rate fetching warnings + jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); + // Add walletAddress to the quoteRequest because it's required for some events + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + }, + ); + jest.clearAllMocks(); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.AssetDetailTooltipClicked, + { + token_name: 'ETH', + token_symbol: 'ETH', + token_contract: '0x123', + chain_name: 'Ethereum', + chain_id: '1', + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); - expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }); }); }); @@ -2942,187 +3253,202 @@ describe('BridgeController', function () { }); }); - it('should track the Submitted event', () => { - const { rootMessenger: localRootMessenger } = buildController({ - clientVersion: '1.0.0', - state: { - ...EMPTY_INIT_STATE, - }, - }); - localRootMessenger.call( - 'BridgeController:trackUnifiedSwapBridgeEvent', - UnifiedSwapBridgeEventName.Submitted, - { - action_type: MetricsActionType.SWAPBRIDGE_V1, - swap_type: MetricsSwapType.CROSSCHAIN, - chain_id_source: formatChainIdToCaip(ChainId.SOLANA), - chain_id_destination: formatChainIdToCaip(1), - custom_slippage: false, - is_hardware_wallet: false, - slippage_limit: 0.5, - usd_quoted_gas: 1, - gas_included: false, - gas_included_7702: false, - quoted_time_minutes: 2, - usd_quoted_return: 113, - provider: 'provider_bridge', - price_impact: 12, - token_symbol_source: 'ETH', - token_symbol_destination: 'USDC', - stx_enabled: false, - usd_amount_source: 100, + it('should track the Submitted event', async () => { + await withController( + { options: { clientVersion: '1.0.0', state: { ...EMPTY_INIT_STATE } } }, + async ({ rootMessenger: localRootMessenger }) => { + localRootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.Submitted, + { + action_type: MetricsActionType.SWAPBRIDGE_V1, + swap_type: MetricsSwapType.CROSSCHAIN, + chain_id_source: formatChainIdToCaip(ChainId.SOLANA), + chain_id_destination: formatChainIdToCaip(1), + custom_slippage: false, + is_hardware_wallet: false, + slippage_limit: 0.5, + usd_quoted_gas: 1, + gas_included: false, + gas_included_7702: false, + quoted_time_minutes: 2, + usd_quoted_return: 113, + provider: 'provider_bridge', + price_impact: 12, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + stx_enabled: false, + usd_amount_source: 100, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); }, ); - expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); - - expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); }); - it('should track the Completed event', () => { - rootMessenger.call( - 'BridgeController:trackUnifiedSwapBridgeEvent', - UnifiedSwapBridgeEventName.Completed, - { - action_type: MetricsActionType.SWAPBRIDGE_V1, - approval_transaction: StatusTypes.PENDING, - source_transaction: StatusTypes.PENDING, - destination_transaction: StatusTypes.PENDING, - actual_time_minutes: 10, - usd_actual_return: 100, - usd_actual_gas: 10, - quote_vs_execution_ratio: 1, - quoted_vs_used_gas_ratio: 1, - chain_id_source: formatChainIdToCaip(1), - token_symbol_source: 'ETH', - token_address_source: getNativeAssetForChainId(1).assetId, - custom_slippage: true, - usd_amount_source: 100, - stx_enabled: false, - is_hardware_wallet: false, - swap_type: MetricsSwapType.CROSSCHAIN, - provider: 'provider_bridge', - price_impact: 6, - gas_included: false, - gas_included_7702: false, - usd_quoted_gas: 0, - quoted_time_minutes: 0, - usd_quoted_return: 0, - chain_id_destination: formatChainIdToCaip(10), - token_symbol_destination: 'USDC', - token_address_destination: getNativeAssetForChainId(10).assetId, - }, - ); - expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + it('should track the Completed event', async () => { + await withController(async ({ rootMessenger }) => { + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.Completed, + { + action_type: MetricsActionType.SWAPBRIDGE_V1, + approval_transaction: StatusTypes.PENDING, + source_transaction: StatusTypes.PENDING, + destination_transaction: StatusTypes.PENDING, + actual_time_minutes: 10, + usd_actual_return: 100, + usd_actual_gas: 10, + quote_vs_execution_ratio: 1, + quoted_vs_used_gas_ratio: 1, + chain_id_source: formatChainIdToCaip(1), + token_symbol_source: 'ETH', + token_address_source: getNativeAssetForChainId(1).assetId, + custom_slippage: true, + usd_amount_source: 100, + stx_enabled: false, + is_hardware_wallet: false, + swap_type: MetricsSwapType.CROSSCHAIN, + provider: 'provider_bridge', + price_impact: 6, + gas_included: false, + gas_included_7702: false, + usd_quoted_gas: 0, + quoted_time_minutes: 0, + usd_quoted_return: 0, + chain_id_destination: formatChainIdToCaip(10), + token_symbol_destination: 'USDC', + token_address_destination: getNativeAssetForChainId(10).assetId, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); - expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }); }); - it('should track the Failed event', () => { - rootMessenger.call( - 'BridgeController:trackUnifiedSwapBridgeEvent', - UnifiedSwapBridgeEventName.Failed, - { - allowance_reset_transaction: StatusTypes.PENDING, - approval_transaction: StatusTypes.PENDING, - source_transaction: StatusTypes.PENDING, - destination_transaction: StatusTypes.PENDING, - usd_quoted_gas: 0, - gas_included: false, - gas_included_7702: false, - quoted_time_minutes: 0, - usd_quoted_return: 0, - price_impact: 0, - provider: 'provider_bridge', - actual_time_minutes: 10, - error_message: 'error_message', - chain_id_source: formatChainIdToCaip(1), - token_symbol_source: 'ETH', - token_address_source: getNativeAssetForChainId(1).assetId, - custom_slippage: true, - usd_amount_source: 100, - stx_enabled: false, - is_hardware_wallet: false, - swap_type: MetricsSwapType.CROSSCHAIN, - chain_id_destination: formatChainIdToCaip(ChainId.SOLANA), - token_symbol_destination: 'USDC', - token_address_destination: getNativeAssetForChainId(ChainId.SOLANA) - .assetId, - security_warnings: [], - }, - ); - expect(messengerCallMock).toHaveBeenCalledTimes(0); - expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + it('should track the Failed event', async () => { + await withController(async ({ rootMessenger }) => { + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.Failed, + { + allowance_reset_transaction: StatusTypes.PENDING, + approval_transaction: StatusTypes.PENDING, + source_transaction: StatusTypes.PENDING, + destination_transaction: StatusTypes.PENDING, + usd_quoted_gas: 0, + gas_included: false, + gas_included_7702: false, + quoted_time_minutes: 0, + usd_quoted_return: 0, + price_impact: 0, + provider: 'provider_bridge', + actual_time_minutes: 10, + error_message: 'error_message', + chain_id_source: formatChainIdToCaip(1), + token_symbol_source: 'ETH', + token_address_source: getNativeAssetForChainId(1).assetId, + custom_slippage: true, + usd_amount_source: 100, + stx_enabled: false, + is_hardware_wallet: false, + swap_type: MetricsSwapType.CROSSCHAIN, + chain_id_destination: formatChainIdToCaip(ChainId.SOLANA), + token_symbol_destination: 'USDC', + token_address_destination: getNativeAssetForChainId(ChainId.SOLANA) + .assetId, + security_warnings: [], + }, + ); + expect(messengerCallMock).toHaveBeenCalledTimes(0); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); - expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }); }); - it('should track the Failed event before tx is submitted', () => { - const { rootMessenger: localRootMessenger } = buildController({ - clientVersion: '1.0.0', - state: { - quoteRequest: { - srcChainId: SolScope.Mainnet, - destChainId: '1', - srcTokenAddress: 'NATIVE', - destTokenAddress: '0x1234', - srcTokenAmount: '1000000', - walletAddress: '0x123', - slippage: 0.5, + it('should track the Failed event before tx is submitted', async () => { + await withController( + { + options: { + clientVersion: '1.0.0', + state: { + quoteRequest: { + srcChainId: SolScope.Mainnet, + destChainId: '1', + srcTokenAddress: 'NATIVE', + destTokenAddress: '0x1234', + srcTokenAmount: '1000000', + walletAddress: '0x123', + slippage: 0.5, + }, + quotes: mockBridgeQuotesSolErc20 as never, + }, }, - quotes: mockBridgeQuotesSolErc20 as never, }, - }); - localRootMessenger.call( - 'BridgeController:trackUnifiedSwapBridgeEvent', - UnifiedSwapBridgeEventName.Failed, - { - error_message: 'Failed to submit tx', - is_hardware_wallet: false, - usd_quoted_gas: 1, - gas_included: false, - gas_included_7702: false, - quoted_time_minutes: 2, - usd_quoted_return: 113, - provider: 'provider_bridge', - price_impact: 12, - token_symbol_source: 'ETH', - token_symbol_destination: 'USDC', - stx_enabled: false, - usd_amount_source: 100, + async ({ rootMessenger: localRootMessenger }) => { + localRootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.Failed, + { + error_message: 'Failed to submit tx', + is_hardware_wallet: false, + usd_quoted_gas: 1, + gas_included: false, + gas_included_7702: false, + quoted_time_minutes: 2, + usd_quoted_return: 113, + provider: 'provider_bridge', + price_impact: 12, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + stx_enabled: false, + usd_amount_source: 100, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); }, ); - expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); - - expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); }); - it('should track the StatusValidationFailed event', () => { - const { rootMessenger: localRootMessenger } = buildController({ - clientVersion: '1.0.0', - state: { - quoteRequest: { - srcChainId: SolScope.Mainnet, - destChainId: '1', - srcTokenAddress: 'NATIVE', - destTokenAddress: '0x1234', - srcTokenAmount: '1000000', - walletAddress: '0x123', - slippage: 0.5, + it('should track the StatusValidationFailed event', async () => { + await withController( + { + options: { + clientVersion: '1.0.0', + state: { + quoteRequest: { + srcChainId: SolScope.Mainnet, + destChainId: '1', + srcTokenAddress: 'NATIVE', + destTokenAddress: '0x1234', + srcTokenAmount: '1000000', + walletAddress: '0x123', + slippage: 0.5, + }, + quotes: mockBridgeQuotesSolErc20 as never, + }, }, - quotes: mockBridgeQuotesSolErc20 as never, }, - }); - localRootMessenger.call( - 'BridgeController:trackUnifiedSwapBridgeEvent', - UnifiedSwapBridgeEventName.StatusValidationFailed, - { - failures: ['Failed to submit tx'], - refresh_count: 0, + async ({ rootMessenger: localRootMessenger }) => { + localRootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.StatusValidationFailed, + { + failures: ['Failed to submit tx'], + refresh_count: 0, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); }, ); - expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); - - expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); }); }); @@ -3164,52 +3490,54 @@ describe('BridgeController', function () { } as never; }, ); - rootMessenger.call( - 'BridgeController:setLocation', - MetaMetricsSwapsEventSource.TrendingExplore, - ); }); it('should not track the event if the account keyring type is not set', async () => { - const errorSpy = jest - .spyOn(console, 'error') - .mockImplementationOnce(jest.fn()); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - { - walletAddress: '0x123', - }, - { - stx_enabled: false, - security_warnings: [], - token_symbol_source: 'ETH', - usd_amount_source: 100, - token_symbol_destination: 'USDC', - }, - ); - rootMessenger.call( - 'BridgeController:trackUnifiedSwapBridgeEvent', - UnifiedSwapBridgeEventName.QuotesReceived, - { - warnings: ['low_return'], - usd_quoted_gas: 0, - gas_included: false, - gas_included_7702: false, - quoted_time_minutes: 10, - usd_quoted_return: 100, - price_impact: 0, - provider: 'provider_bridge', - best_quote_provider: 'provider_bridge2', - can_submit: true, - usd_balance_source: 0, - }, - ); - expect(trackMetaMetricsFn).toHaveBeenCalledTimes(0); - expect(errorSpy).toHaveBeenCalledTimes(1); - expect(errorSpy).toHaveBeenCalledWith( - 'Error tracking cross-chain swaps MetaMetrics event Unified SwapBridge Quotes Received', - new TypeError("Cannot read properties of undefined (reading 'type')"), - ); + await withController(async ({ rootMessenger }) => { + rootMessenger.call( + 'BridgeController:setLocation', + MetaMetricsSwapsEventSource.TrendingExplore, + ); + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementationOnce(jest.fn()); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + usd_amount_source: 100, + token_symbol_destination: 'USDC', + }, + ); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.QuotesReceived, + { + warnings: ['low_return'], + usd_quoted_gas: 0, + gas_included: false, + gas_included_7702: false, + quoted_time_minutes: 10, + usd_quoted_return: 100, + price_impact: 0, + provider: 'provider_bridge', + best_quote_provider: 'provider_bridge2', + can_submit: true, + usd_balance_source: 0, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(0); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith( + 'Error tracking cross-chain swaps MetaMetrics event Unified SwapBridge Quotes Received', + new TypeError("Cannot read properties of undefined (reading 'type')"), + ); + }); }); }); @@ -3275,36 +3603,38 @@ describe('BridgeController', function () { }); it('should override aggIds and fee in perps request', async () => { - const fetchBridgeQuotesSpy = jest - .spyOn(fetchUtils, 'fetchBridgeQuotes') - .mockResolvedValueOnce({ - quotes: quotesByDecreasingProcessingTime as never, - validationFailures: [], - }); - const expectedControllerState = bridgeController.state; + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockResolvedValueOnce({ + quotes: quotesByDecreasingProcessingTime as never, + validationFailures: [], + }); + const expectedControllerState = bridgeController.state; - const quotes = await rootMessenger.call( - 'BridgeController:fetchQuotes', - { - srcChainId: SolScope.Mainnet, - destChainId: '1', - srcTokenAddress: 'NATIVE', - destTokenAddress: '0x1234', - srcTokenAmount: '1000000', - walletAddress: '0x123', - slippage: 0.5, - aggIds: ['other'], - bridgeIds: ['other', 'debridge'], - gasIncluded: false, - gasIncluded7702: false, - fee: 0, - }, - null, - FeatureId.PERPS, - ); + const quotes = await rootMessenger.call( + 'BridgeController:fetchQuotes', + { + srcChainId: SolScope.Mainnet, + destChainId: '1', + srcTokenAddress: 'NATIVE', + destTokenAddress: '0x1234', + srcTokenAmount: '1000000', + walletAddress: '0x123', + slippage: 0.5, + aggIds: ['other'], + bridgeIds: ['other', 'debridge'], + gasIncluded: false, + gasIncluded7702: false, + fee: 0, + }, + null, + FeatureId.PERPS, + ); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); - expect(fetchBridgeQuotesSpy.mock.calls).toMatchInlineSnapshot(` + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy.mock.calls).toMatchInlineSnapshot(` [ [ { @@ -3338,72 +3668,80 @@ describe('BridgeController', function () { ], ] `); - expect(quotes).toStrictEqual(mockBridgeQuotesSolErc20); - expect(bridgeController.state).toStrictEqual(expectedControllerState); + expect(quotes).toStrictEqual(mockBridgeQuotesSolErc20); + expect(bridgeController.state).toStrictEqual(expectedControllerState); + }, + ); }); it('should throw error if account is not found', async () => { - const fetchBridgeQuotesSpy = jest - .spyOn(fetchUtils, 'fetchBridgeQuotes') - .mockResolvedValueOnce({ - quotes: quotesByDecreasingProcessingTime as never, - validationFailures: [], - }); - const expectedControllerState = bridgeController.state; - - await expect( - rootMessenger.call( - 'BridgeController:fetchQuotes', - { - srcChainId: SolScope.Mainnet, - destChainId: '1', - srcTokenAddress: 'NATIVE', - destTokenAddress: '0x1234', - srcTokenAmount: '1000000', - // walletAddress: '0x123', - slippage: 0.5, - aggIds: ['other'], - bridgeIds: ['other', 'debridge'], - gasIncluded: false, - gasIncluded7702: false, - } as never, - null, - FeatureId.PERPS, - ), - ).rejects.toThrow('Account address is required'); + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockResolvedValueOnce({ + quotes: quotesByDecreasingProcessingTime as never, + validationFailures: [], + }); + const expectedControllerState = bridgeController.state; + + await expect( + rootMessenger.call( + 'BridgeController:fetchQuotes', + { + srcChainId: SolScope.Mainnet, + destChainId: '1', + srcTokenAddress: 'NATIVE', + destTokenAddress: '0x1234', + srcTokenAmount: '1000000', + // walletAddress: '0x123', + slippage: 0.5, + aggIds: ['other'], + bridgeIds: ['other', 'debridge'], + gasIncluded: false, + gasIncluded7702: false, + } as never, + null, + FeatureId.PERPS, + ), + ).rejects.toThrow('Account address is required'); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); - expect(bridgeController.state).toStrictEqual(expectedControllerState); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(bridgeController.state).toStrictEqual(expectedControllerState); + }, + ); }); it('should add aggIds and fee to perps request', async () => { - const fetchBridgeQuotesSpy = jest - .spyOn(fetchUtils, 'fetchBridgeQuotes') - .mockResolvedValueOnce({ - quotes: quotesByDecreasingProcessingTime as never, - validationFailures: [], - }); - const expectedControllerState = bridgeController.state; + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockResolvedValueOnce({ + quotes: quotesByDecreasingProcessingTime as never, + validationFailures: [], + }); + const expectedControllerState = bridgeController.state; - const quotes = await rootMessenger.call( - 'BridgeController:fetchQuotes', - { - srcChainId: SolScope.Mainnet, - destChainId: '1', - srcTokenAddress: 'NATIVE', - destTokenAddress: '0x1234', - srcTokenAmount: '1000000', - walletAddress: '0x123', - slippage: 0.5, - gasIncluded: false, - gasIncluded7702: false, - }, - null, - FeatureId.PERPS, - ); + const quotes = await rootMessenger.call( + 'BridgeController:fetchQuotes', + { + srcChainId: SolScope.Mainnet, + destChainId: '1', + srcTokenAddress: 'NATIVE', + destTokenAddress: '0x1234', + srcTokenAmount: '1000000', + walletAddress: '0x123', + slippage: 0.5, + gasIncluded: false, + gasIncluded7702: false, + }, + null, + FeatureId.PERPS, + ); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); - expect(fetchBridgeQuotesSpy.mock.calls).toMatchInlineSnapshot(` + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy.mock.calls).toMatchInlineSnapshot(` [ [ { @@ -3437,37 +3775,41 @@ describe('BridgeController', function () { ], ] `); - expect(quotes).toStrictEqual(mockBridgeQuotesSolErc20); - expect(bridgeController.state).toStrictEqual(expectedControllerState); + expect(quotes).toStrictEqual(mockBridgeQuotesSolErc20); + expect(bridgeController.state).toStrictEqual(expectedControllerState); + }, + ); }); it('should not add aggIds and fee if featureId is not specified', async () => { - const fetchBridgeQuotesSpy = jest - .spyOn(fetchUtils, 'fetchBridgeQuotes') - .mockResolvedValueOnce({ - quotes: mockBridgeQuotesSolErc20 as never, - validationFailures: [], - }); - const expectedControllerState = bridgeController.state; + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockResolvedValueOnce({ + quotes: mockBridgeQuotesSolErc20 as never, + validationFailures: [], + }); + const expectedControllerState = bridgeController.state; - const quotes = await rootMessenger.call( - 'BridgeController:fetchQuotes', - { - srcChainId: SolScope.Mainnet, - destChainId: '1', - srcTokenAddress: 'NATIVE', - destTokenAddress: '0x1234', - srcTokenAmount: '1000000', - walletAddress: '0x123', - slippage: 0.5, - gasIncluded: false, - gasIncluded7702: false, - }, - null, - ); + const quotes = await rootMessenger.call( + 'BridgeController:fetchQuotes', + { + srcChainId: SolScope.Mainnet, + destChainId: '1', + srcTokenAddress: 'NATIVE', + destTokenAddress: '0x1234', + srcTokenAmount: '1000000', + walletAddress: '0x123', + slippage: 0.5, + gasIncluded: false, + gasIncluded7702: false, + }, + null, + ); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); - expect(fetchBridgeQuotesSpy.mock.calls).toMatchInlineSnapshot(` + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy.mock.calls).toMatchInlineSnapshot(` [ [ { @@ -3492,60 +3834,70 @@ describe('BridgeController', function () { ], ] `); - expect(quotes).toStrictEqual(mockBridgeQuotesSolErc20); - expect(bridgeController.state).toStrictEqual(expectedControllerState); + expect(quotes).toStrictEqual(mockBridgeQuotesSolErc20); + expect(bridgeController.state).toStrictEqual(expectedControllerState); + }, + ); }); it('should preserve gasSponsored flag on quotes', async () => { - const firstQuoteWithFlag: QuoteResponse = { - ...mockBridgeQuotesNativeErc20Eth[0], - quote: { - ...mockBridgeQuotesNativeErc20Eth[0].quote, - gasSponsored: true, - }, - } as QuoteResponse; - const secondQuote: QuoteResponse = - mockBridgeQuotesNativeErc20Eth[1] as QuoteResponse; - const quotesWithFlag: QuoteResponse[] = [firstQuoteWithFlag, secondQuote]; - - const fetchBridgeQuotesSpy = jest - .spyOn(fetchUtils, 'fetchBridgeQuotes') - .mockResolvedValueOnce({ - quotes: quotesWithFlag, - validationFailures: [], - }); + await withController(async ({ rootMessenger }) => { + const firstQuoteWithFlag: QuoteResponse = { + ...mockBridgeQuotesNativeErc20Eth[0], + quote: { + ...mockBridgeQuotesNativeErc20Eth[0].quote, + gasSponsored: true, + }, + } as QuoteResponse; + const secondQuote: QuoteResponse = + mockBridgeQuotesNativeErc20Eth[1] as QuoteResponse; + const quotesWithFlag: QuoteResponse[] = [ + firstQuoteWithFlag, + secondQuote, + ]; + + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockResolvedValueOnce({ + quotes: quotesWithFlag, + validationFailures: [], + }); - const quotes = await rootMessenger.call( - 'BridgeController:fetchQuotes', - makeQuoteRequest(), - ); + const quotes = await rootMessenger.call( + 'BridgeController:fetchQuotes', + makeQuoteRequest(), + ); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); - expect(quotes).toHaveLength(2); - expect(quotes[0].quote.gasSponsored).toBe(true); - expect(quotes[1].quote.gasSponsored).toBeUndefined(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(quotes).toHaveLength(2); + expect(quotes[0].quote.gasSponsored).toBe(true); + expect(quotes[1].quote.gasSponsored).toBeUndefined(); + }); }); }); describe('metadata', () => { - it('includes expected state in debug snapshots', () => { - expect( - deriveStateFromMetadata( - bridgeController.state, - bridgeController.metadata, - 'includeInDebugSnapshot', - ), - ).toMatchInlineSnapshot(`{}`); + it('includes expected state in debug snapshots', async () => { + await withController(async ({ controller: bridgeController }) => { + expect( + deriveStateFromMetadata( + bridgeController.state, + bridgeController.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); }); - it('includes expected state in state logs', () => { - expect( - deriveStateFromMetadata( - bridgeController.state, - bridgeController.metadata, - 'includeInStateLogs', - ), - ).toMatchInlineSnapshot(` + it('includes expected state in state logs', async () => { + await withController(async ({ controller: bridgeController }) => { + expect( + deriveStateFromMetadata( + bridgeController.state, + bridgeController.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` { "assetExchangeRates": {}, "minimumBalanceForRentExemptionInLamports": "0", @@ -3562,26 +3914,30 @@ describe('BridgeController', function () { "tokenWarnings": [], } `); + }); }); - it('persists expected state', () => { - expect( - deriveStateFromMetadata( - bridgeController.state, - bridgeController.metadata, - 'persist', - ), - ).toMatchInlineSnapshot(`{}`); + it('persists expected state', async () => { + await withController(async ({ controller: bridgeController }) => { + expect( + deriveStateFromMetadata( + bridgeController.state, + bridgeController.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(`{}`); + }); }); - it('exposes expected state to UI', () => { - expect( - deriveStateFromMetadata( - bridgeController.state, - bridgeController.metadata, - 'usedInUi', - ), - ).toMatchInlineSnapshot(` + it('exposes expected state to UI', async () => { + await withController(async ({ controller: bridgeController }) => { + expect( + deriveStateFromMetadata( + bridgeController.state, + bridgeController.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` { "assetExchangeRates": {}, "minimumBalanceForRentExemptionInLamports": "0", @@ -3598,6 +3954,7 @@ describe('BridgeController', function () { "tokenWarnings": [], } `); + }); }); }); }); From 030868b8c43b0965f59a915491431dcb2fad91b8 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 2 Apr 2026 11:37:25 +0200 Subject: [PATCH 4/8] Fix `tsx` version --- packages/bridge-controller/package.json | 2 +- .../bridge-status-controller/package.json | 2 +- yarn.lock | 232 +++++++++--------- 3 files changed, 118 insertions(+), 118 deletions(-) diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index 89bf5dcbc61..1c61ab25dab 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -86,7 +86,7 @@ "lodash": "^4.17.21", "nock": "^13.3.1", "ts-jest": "^29.2.5", - "tsx": "^4.21.0", + "tsx": "^4.20.5", "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index c7535c067e2..608c4ac52f6 100644 --- a/packages/bridge-status-controller/package.json +++ b/packages/bridge-status-controller/package.json @@ -75,7 +75,7 @@ "lodash": "^4.17.21", "nock": "^13.3.1", "ts-jest": "^29.2.5", - "tsx": "^4.21.0", + "tsx": "^4.20.5", "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" diff --git a/yarn.lock b/yarn.lock index c8d8d4e5ead..7c23cd311b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -463,184 +463,184 @@ __metadata: languageName: node linkType: hard -"@esbuild/aix-ppc64@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/aix-ppc64@npm:0.27.4" +"@esbuild/aix-ppc64@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/aix-ppc64@npm:0.25.9" conditions: os=aix & cpu=ppc64 languageName: node linkType: hard -"@esbuild/android-arm64@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/android-arm64@npm:0.27.4" +"@esbuild/android-arm64@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/android-arm64@npm:0.25.9" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@esbuild/android-arm@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/android-arm@npm:0.27.4" +"@esbuild/android-arm@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/android-arm@npm:0.25.9" conditions: os=android & cpu=arm languageName: node linkType: hard -"@esbuild/android-x64@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/android-x64@npm:0.27.4" +"@esbuild/android-x64@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/android-x64@npm:0.25.9" conditions: os=android & cpu=x64 languageName: node linkType: hard -"@esbuild/darwin-arm64@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/darwin-arm64@npm:0.27.4" +"@esbuild/darwin-arm64@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/darwin-arm64@npm:0.25.9" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@esbuild/darwin-x64@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/darwin-x64@npm:0.27.4" +"@esbuild/darwin-x64@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/darwin-x64@npm:0.25.9" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@esbuild/freebsd-arm64@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/freebsd-arm64@npm:0.27.4" +"@esbuild/freebsd-arm64@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/freebsd-arm64@npm:0.25.9" conditions: os=freebsd & cpu=arm64 languageName: node linkType: hard -"@esbuild/freebsd-x64@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/freebsd-x64@npm:0.27.4" +"@esbuild/freebsd-x64@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/freebsd-x64@npm:0.25.9" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@esbuild/linux-arm64@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/linux-arm64@npm:0.27.4" +"@esbuild/linux-arm64@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/linux-arm64@npm:0.25.9" conditions: os=linux & cpu=arm64 languageName: node linkType: hard -"@esbuild/linux-arm@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/linux-arm@npm:0.27.4" +"@esbuild/linux-arm@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/linux-arm@npm:0.25.9" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@esbuild/linux-ia32@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/linux-ia32@npm:0.27.4" +"@esbuild/linux-ia32@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/linux-ia32@npm:0.25.9" conditions: os=linux & cpu=ia32 languageName: node linkType: hard -"@esbuild/linux-loong64@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/linux-loong64@npm:0.27.4" +"@esbuild/linux-loong64@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/linux-loong64@npm:0.25.9" conditions: os=linux & cpu=loong64 languageName: node linkType: hard -"@esbuild/linux-mips64el@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/linux-mips64el@npm:0.27.4" +"@esbuild/linux-mips64el@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/linux-mips64el@npm:0.25.9" conditions: os=linux & cpu=mips64el languageName: node linkType: hard -"@esbuild/linux-ppc64@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/linux-ppc64@npm:0.27.4" +"@esbuild/linux-ppc64@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/linux-ppc64@npm:0.25.9" conditions: os=linux & cpu=ppc64 languageName: node linkType: hard -"@esbuild/linux-riscv64@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/linux-riscv64@npm:0.27.4" +"@esbuild/linux-riscv64@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/linux-riscv64@npm:0.25.9" conditions: os=linux & cpu=riscv64 languageName: node linkType: hard -"@esbuild/linux-s390x@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/linux-s390x@npm:0.27.4" +"@esbuild/linux-s390x@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/linux-s390x@npm:0.25.9" conditions: os=linux & cpu=s390x languageName: node linkType: hard -"@esbuild/linux-x64@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/linux-x64@npm:0.27.4" +"@esbuild/linux-x64@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/linux-x64@npm:0.25.9" conditions: os=linux & cpu=x64 languageName: node linkType: hard -"@esbuild/netbsd-arm64@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/netbsd-arm64@npm:0.27.4" +"@esbuild/netbsd-arm64@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/netbsd-arm64@npm:0.25.9" conditions: os=netbsd & cpu=arm64 languageName: node linkType: hard -"@esbuild/netbsd-x64@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/netbsd-x64@npm:0.27.4" +"@esbuild/netbsd-x64@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/netbsd-x64@npm:0.25.9" conditions: os=netbsd & cpu=x64 languageName: node linkType: hard -"@esbuild/openbsd-arm64@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/openbsd-arm64@npm:0.27.4" +"@esbuild/openbsd-arm64@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/openbsd-arm64@npm:0.25.9" conditions: os=openbsd & cpu=arm64 languageName: node linkType: hard -"@esbuild/openbsd-x64@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/openbsd-x64@npm:0.27.4" +"@esbuild/openbsd-x64@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/openbsd-x64@npm:0.25.9" conditions: os=openbsd & cpu=x64 languageName: node linkType: hard -"@esbuild/openharmony-arm64@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/openharmony-arm64@npm:0.27.4" +"@esbuild/openharmony-arm64@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/openharmony-arm64@npm:0.25.9" conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard -"@esbuild/sunos-x64@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/sunos-x64@npm:0.27.4" +"@esbuild/sunos-x64@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/sunos-x64@npm:0.25.9" conditions: os=sunos & cpu=x64 languageName: node linkType: hard -"@esbuild/win32-arm64@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/win32-arm64@npm:0.27.4" +"@esbuild/win32-arm64@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/win32-arm64@npm:0.25.9" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@esbuild/win32-ia32@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/win32-ia32@npm:0.27.4" +"@esbuild/win32-ia32@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/win32-ia32@npm:0.25.9" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@esbuild/win32-x64@npm:0.27.4": - version: 0.27.4 - resolution: "@esbuild/win32-x64@npm:0.27.4" +"@esbuild/win32-x64@npm:0.25.9": + version: 0.25.9 + resolution: "@esbuild/win32-x64@npm:0.25.9" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -3031,7 +3031,7 @@ __metadata: nock: "npm:^13.3.1" reselect: "npm:^5.1.1" ts-jest: "npm:^29.2.5" - tsx: "npm:^4.21.0" + tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" @@ -3067,7 +3067,7 @@ __metadata: lodash: "npm:^4.17.21" nock: "npm:^13.3.1" ts-jest: "npm:^29.2.5" - tsx: "npm:^4.21.0" + tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" @@ -8595,36 +8595,36 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:~0.27.0": - version: 0.27.4 - resolution: "esbuild@npm:0.27.4" - dependencies: - "@esbuild/aix-ppc64": "npm:0.27.4" - "@esbuild/android-arm": "npm:0.27.4" - "@esbuild/android-arm64": "npm:0.27.4" - "@esbuild/android-x64": "npm:0.27.4" - "@esbuild/darwin-arm64": "npm:0.27.4" - "@esbuild/darwin-x64": "npm:0.27.4" - "@esbuild/freebsd-arm64": "npm:0.27.4" - "@esbuild/freebsd-x64": "npm:0.27.4" - "@esbuild/linux-arm": "npm:0.27.4" - "@esbuild/linux-arm64": "npm:0.27.4" - "@esbuild/linux-ia32": "npm:0.27.4" - "@esbuild/linux-loong64": "npm:0.27.4" - "@esbuild/linux-mips64el": "npm:0.27.4" - "@esbuild/linux-ppc64": "npm:0.27.4" - "@esbuild/linux-riscv64": "npm:0.27.4" - "@esbuild/linux-s390x": "npm:0.27.4" - "@esbuild/linux-x64": "npm:0.27.4" - "@esbuild/netbsd-arm64": "npm:0.27.4" - "@esbuild/netbsd-x64": "npm:0.27.4" - "@esbuild/openbsd-arm64": "npm:0.27.4" - "@esbuild/openbsd-x64": "npm:0.27.4" - "@esbuild/openharmony-arm64": "npm:0.27.4" - "@esbuild/sunos-x64": "npm:0.27.4" - "@esbuild/win32-arm64": "npm:0.27.4" - "@esbuild/win32-ia32": "npm:0.27.4" - "@esbuild/win32-x64": "npm:0.27.4" +"esbuild@npm:~0.25.0": + version: 0.25.9 + resolution: "esbuild@npm:0.25.9" + dependencies: + "@esbuild/aix-ppc64": "npm:0.25.9" + "@esbuild/android-arm": "npm:0.25.9" + "@esbuild/android-arm64": "npm:0.25.9" + "@esbuild/android-x64": "npm:0.25.9" + "@esbuild/darwin-arm64": "npm:0.25.9" + "@esbuild/darwin-x64": "npm:0.25.9" + "@esbuild/freebsd-arm64": "npm:0.25.9" + "@esbuild/freebsd-x64": "npm:0.25.9" + "@esbuild/linux-arm": "npm:0.25.9" + "@esbuild/linux-arm64": "npm:0.25.9" + "@esbuild/linux-ia32": "npm:0.25.9" + "@esbuild/linux-loong64": "npm:0.25.9" + "@esbuild/linux-mips64el": "npm:0.25.9" + "@esbuild/linux-ppc64": "npm:0.25.9" + "@esbuild/linux-riscv64": "npm:0.25.9" + "@esbuild/linux-s390x": "npm:0.25.9" + "@esbuild/linux-x64": "npm:0.25.9" + "@esbuild/netbsd-arm64": "npm:0.25.9" + "@esbuild/netbsd-x64": "npm:0.25.9" + "@esbuild/openbsd-arm64": "npm:0.25.9" + "@esbuild/openbsd-x64": "npm:0.25.9" + "@esbuild/openharmony-arm64": "npm:0.25.9" + "@esbuild/sunos-x64": "npm:0.25.9" + "@esbuild/win32-arm64": "npm:0.25.9" + "@esbuild/win32-ia32": "npm:0.25.9" + "@esbuild/win32-x64": "npm:0.25.9" dependenciesMeta: "@esbuild/aix-ppc64": optional: true @@ -8680,7 +8680,7 @@ __metadata: optional: true bin: esbuild: bin/esbuild - checksum: 10/32b46ec22ef78bae6cc141145022a4c0209852c07151f037fbefccc2033ca54e7f33705f8fca198eb7026f400142f64c2dbc9f0d0ce9c0a638ebc472a04abc4a + checksum: 10/fc174ae7f646ad413adb641c7e46f16be575e462ed209866b55d5954d382e5da839e3f3f89a8e42e2b71d48895cc636ba43523011249fe5ff9c63d8d39d3a364 languageName: node linkType: hard @@ -14166,11 +14166,11 @@ __metadata: languageName: node linkType: hard -"tsx@npm:^4.20.5, tsx@npm:^4.21.0": - version: 4.21.0 - resolution: "tsx@npm:4.21.0" +"tsx@npm:^4.20.5": + version: 4.20.5 + resolution: "tsx@npm:4.20.5" dependencies: - esbuild: "npm:~0.27.0" + esbuild: "npm:~0.25.0" fsevents: "npm:~2.3.3" get-tsconfig: "npm:^4.7.5" dependenciesMeta: @@ -14178,7 +14178,7 @@ __metadata: optional: true bin: tsx: dist/cli.mjs - checksum: 10/7afedeff855ba98c47dc28b33d7e8e253c4dc1f791938db402d79c174bdf806b897c1a5f91e5b1259c112520c816f826b4c5d98f0bad7e95b02dec66fedb64d2 + checksum: 10/161420678027c43d07b60b7b6b512cc67ff86ae3cca0641a19b0d3e742c5e262bca57034c4bff6d9346f9269e9ada24b6030e1d2bc890df5e1a9754865d3c08a languageName: node linkType: hard From 3f9565507ed2cfcf56ada3211bbf32f00b6f763a Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 2 Apr 2026 11:55:11 +0200 Subject: [PATCH 5/8] Update changelogs --- packages/bridge-controller/CHANGELOG.md | 23 +++++++++++-------- packages/bridge-controller/src/types.ts | 14 +++++++++++ .../bridge-status-controller/CHANGELOG.md | 20 ++++++++++------ .../bridge-status-controller/src/types.ts | 4 ++++ 4 files changed, 44 insertions(+), 17 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index c7908a2e985..5f21fa2694f 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -9,16 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Expose all public `BridgeController` methods through its messenger - - The following actions are now available: - - `BridgeController:updateBridgeQuoteRequestParams` - - `BridgeController:fetchQuotes` - - `BridgeController:stopPollingForQuotes` - - `BridgeController:setLocation` - - `BridgeController:resetState` - - `BridgeController:setChainIntervalLength` - - `BridgeController:trackUnifiedSwapBridgeEvent` - - Corresponding action types are now exported (e.g. `BridgeControllerResetStateAction`) +- Add action types for all public `BridgeController` methods ([#8367](https://github.com/MetaMask/core/pull/8367)) + - The following types are now available: + - `BridgeControllerUpdateBridgeQuoteRequestParamsAction` + - `BridgeControllerFetchQuotesAction` + - `BridgeControllerStopPollingForQuotesAction` + - `BridgeControllerSetLocationAction` + - `BridgeControllerResetStateAction` + - `BridgeControllerSetChainIntervalLengthAction` + - `BridgeControllerTrackUnifiedSwapBridgeEventAction` ### Changed @@ -34,6 +33,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/assets-controller` from `^3.2.1` to `^4.0.0` ([#8355](https://github.com/MetaMask/core/pull/8355), [#8359](https://github.com/MetaMask/core/pull/8359)) - Bump `@metamask/assets-controllers` from `^103.0.0` to `^103.1.1` ([#8355](https://github.com/MetaMask/core/pull/8355), [#8359](https://github.com/MetaMask/core/pull/8359)) +### Deprecated + +- Deprecate `BridgeControllerAction`, `BridgeUserAction` and `BridgeBackgroundAction` in favor of separate action types ([#8367](https://github.com/MetaMask/core/pull/8367)) + ## [70.0.0] ### Added diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index 0edfcd2318b..0d37064a272 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -335,10 +335,20 @@ export enum RequestStatus { FETCHED, ERROR, } + +/** + * @deprecated Use the separate method action types (e.g., + * `BridgeControllerFetchQuotesAction`) instead. + */ export enum BridgeUserAction { SELECT_DEST_NETWORK = 'selectDestNetwork', UPDATE_QUOTE_PARAMS = 'updateBridgeQuoteRequestParams', } + +/** + * @deprecated Use the separate method action types (e.g., + * `BridgeControllerFetchQuotesAction`) instead. + */ export enum BridgeBackgroundAction { SET_CHAIN_INTERVAL_LENGTH = 'setChainIntervalLength', RESET_STATE = 'resetState', @@ -396,6 +406,10 @@ export type BridgeControllerState = { quoteStreamComplete: QuoteStreamCompleteData | null; }; +/** + * @deprecated Use the separate method action types (e.g., + * `BridgeControllerFetchQuotesAction`) instead. + */ export type BridgeControllerAction< FunctionName extends keyof BridgeController, > = { diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index a22860a2595..af7b90e3854 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add missing action types for public `BridgeStatusController` methods ([#8367](https://github.com/MetaMask/core/pull/8367)) + - The following types are now available: + - `BridgeStatusControllerSubmitTxAction` + - `BridgeStatusControllerSubmitIntentAction` + - `BridgeStatusControllerGetBridgeHistoryItemByTxMetaIdAction` + + ### Changed - Bump `@metamask/accounts-controller` from `^37.1.1` to `^37.2.0` ([#8363](https://github.com/MetaMask/core/pull/8363)) @@ -22,6 +31,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add missing `@metamask/messenger` dependency ([#8318](https://github.com/MetaMask/core/pull/8318)) - Bump `@metamask/controller-utils` from `^11.19.0` to `^11.20.0` ([#8344](https://github.com/MetaMask/core/pull/8344)) +### Deprecated + +- Deprecate `BridgeStatusAction` in favor of separate action types ([#8367](https://github.com/MetaMask/core/pull/8367)) + ## [70.0.4] ### Changed @@ -75,13 +88,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Expose all public `BridgeStatusController` methods through its messenger - - The following actions are now available: - - `BridgeStatusController:submitTx` - - `BridgeStatusController:submitIntent` - - `BridgeStatusController:restartPollingForFailedAttempts` - - `BridgeStatusController:getBridgeHistoryItemByTxMetaId` - - Corresponding action types are now exported (e.g. `BridgeStatusControllerStartPollingForBridgeTxStatusAction`) - Added more unit test coverage for intents and EVM transactions. Also refactored some mocks and code blocks to improve testability ([#8186](https://github.com/MetaMask/core/pull/8186)) ### Changed diff --git a/packages/bridge-status-controller/src/types.ts b/packages/bridge-status-controller/src/types.ts index 8b297011fa5..20ba3f21619 100644 --- a/packages/bridge-status-controller/src/types.ts +++ b/packages/bridge-status-controller/src/types.ts @@ -165,6 +165,10 @@ export type BridgeHistoryItem = { }; }; +/** + * @deprecated Use the separate action types instead (e.g. + * `BridgeStatusControllerStartPollingForBridgeTxStatusAction`). + */ export enum BridgeStatusAction { StartPollingForBridgeTxStatus = 'StartPollingForBridgeTxStatus', WipeBridgeStatus = 'WipeBridgeStatus', From 0aa18511f3a161a342556bb659f636f5634ba62b Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 2 Apr 2026 11:58:12 +0200 Subject: [PATCH 6/8] Fix changelog order --- packages/bridge-controller/CHANGELOG.md | 8 ++++---- packages/bridge-status-controller/CHANGELOG.md | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 5f21fa2694f..dccbd9545b7 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -24,6 +24,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/accounts-controller` from `^37.1.1` to `^37.2.0` ([#8363](https://github.com/MetaMask/core/pull/8363)) - Bump `@metamask/messenger` from `^1.0.0` to `^1.1.0` ([#8364](https://github.com/MetaMask/core/pull/8364)) +### Deprecated + +- Deprecate `BridgeControllerAction`, `BridgeUserAction` and `BridgeBackgroundAction` in favor of separate action types ([#8367](https://github.com/MetaMask/core/pull/8367)) + ## [70.0.1] ### Changed @@ -33,10 +37,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/assets-controller` from `^3.2.1` to `^4.0.0` ([#8355](https://github.com/MetaMask/core/pull/8355), [#8359](https://github.com/MetaMask/core/pull/8359)) - Bump `@metamask/assets-controllers` from `^103.0.0` to `^103.1.1` ([#8355](https://github.com/MetaMask/core/pull/8355), [#8359](https://github.com/MetaMask/core/pull/8359)) -### Deprecated - -- Deprecate `BridgeControllerAction`, `BridgeUserAction` and `BridgeBackgroundAction` in favor of separate action types ([#8367](https://github.com/MetaMask/core/pull/8367)) - ## [70.0.0] ### Added diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index af7b90e3854..6c4064ecd17 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -15,7 +15,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `BridgeStatusControllerSubmitIntentAction` - `BridgeStatusControllerGetBridgeHistoryItemByTxMetaIdAction` - ### Changed - Bump `@metamask/accounts-controller` from `^37.1.1` to `^37.2.0` ([#8363](https://github.com/MetaMask/core/pull/8363)) From ddbcc1b16ca15785c38445880d8453a93c29d32c Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 2 Apr 2026 12:01:37 +0200 Subject: [PATCH 7/8] Add missing mock --- .../src/bridge-status-controller.intent.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts index bb8a5e37e5c..46dcad4c88e 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts @@ -144,6 +144,7 @@ const createMessengerHarness = ( const messenger = { registerActionHandler: jest.fn(), + registerMethodActionHandlers: jest.fn(), registerInitialEventPayload: jest.fn(), // REQUIRED by BaseController subscribe: jest.fn(), publish: jest.fn(), From 56740c766174734a498987932e6926c16e597323 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 2 Apr 2026 12:13:12 +0200 Subject: [PATCH 8/8] Ignore coverage for `method-action-types` file --- packages/bridge-status-controller/jest.config.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/bridge-status-controller/jest.config.js b/packages/bridge-status-controller/jest.config.js index 8c8f26dfad4..b1e1437631d 100644 --- a/packages/bridge-status-controller/jest.config.js +++ b/packages/bridge-status-controller/jest.config.js @@ -16,6 +16,8 @@ module.exports = merge(baseConfig, { coverageProvider: 'v8', + coveragePathIgnorePatterns: ['.*/index\\.ts', '.*-method-action-types\\.ts'], + // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { './src/bridge-status-controller.ts': {