diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 6cb50e69913..3d106ec1f1e 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **BREAKING:** Optional constructor option `getUseAssetsControllerForRates`: when it returns true, exchange rates are read from the new `@metamask/assets-controller` (`AssetsController:getExchangeRatesForBridge`) instead of `MultichainAssetsRatesController`, `TokenRatesController`, and `CurrencyRateController`. ([#8090](https://github.com/MetaMask/core/pull/8090)) + ### Changed - Bump `@metamask/assets-controllers` from `^100.0.3` to `^100.1.0` ([#8107](https://github.com/MetaMask/core/pull/8107)) diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index c48472bdc3d..e59cee1e176 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -53,6 +53,7 @@ "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", "@metamask/accounts-controller": "^36.0.1", + "@metamask/assets-controller": "^2.2.0", "@metamask/assets-controllers": "^100.1.0", "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.19.0", diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index e696b1af665..b2b5805fca2 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -91,6 +91,15 @@ const bridgeConfig = { }, }; +const metricsContext = { + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + stx_enabled: true, + security_warnings: [], + warnings: [], +}; + describe('BridgeController', function () { let bridgeController: BridgeController; @@ -144,6 +153,236 @@ describe('BridgeController', function () { 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', + }; + messengerMock.call.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', + ); + + await bridgeController.updateBridgeQuoteRequestParams( + { + srcChainId: '0x1', + destChainId: '0xa', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + srcTokenAmount: '1000000000000000000', + walletAddress: '0x123', + slippage: 0.5, + }, + metricsContext, + ); + + jest.advanceTimersToNextTimer(); + await flushPromises(); + + expect(messengerMock.call).toHaveBeenCalledWith( + 'MultichainAssetsRatesController:getState', + ); + expect(messengerMock.call).toHaveBeenCalledWith( + 'CurrencyRateController:getState', + ); + expect(messengerMock.call).toHaveBeenCalledWith( + 'TokenRatesController:getState', + ); + expect(messengerMock.call).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'); + + hasSufficientBalanceSpy.mockRestore(); + selectIsAssetExchangeRateInStateSpy.mockRestore(); + }); + + 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, + getUseAssetsControllerForRates: (): boolean => true, + }); + controllerWithAssetsRates.resetState(); + + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(true); + + const bridgeRatesReturn = { + conversionRates: {}, + currencyRates: {}, + marketData: {}, + currentCurrency: 'EUR', + }; + messengerMock.call.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', + ); + + await controllerWithAssetsRates.updateBridgeQuoteRequestParams( + { + srcChainId: '0x1', + destChainId: '0xa', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + srcTokenAmount: '1000000000000000000', + walletAddress: '0x123', + slippage: 0.5, + }, + metricsContext, + ); + + jest.advanceTimersToNextTimer(); + await flushPromises(); + + expect(messengerMock.call).toHaveBeenCalledWith( + 'AssetsController:getExchangeRatesForBridge', + ); + expect(messengerMock.call).not.toHaveBeenCalledWith( + 'MultichainAssetsRatesController:getState', + ); + + expect(selectIsAssetExchangeRateInStateSpy).toHaveBeenCalled(); + const [firstCallSources] = + selectIsAssetExchangeRateInStateSpy.mock.calls[0]; + expect(firstCallSources).toHaveProperty('assetExchangeRates'); + expect(firstCallSources).toHaveProperty('currentCurrency', 'EUR'); + + 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); + + messengerMock.call.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 { + remoteFeatureFlags: { bridgeConfig: { ...bridgeConfig } }, + address: '0x123', + provider: jest.fn(), + } as never; + }, + ); + + const selectIsAssetExchangeRateInStateSpy = jest.spyOn( + selectors, + 'selectIsAssetExchangeRateInState', + ); + + const quoteParams = { + srcChainId: '0x1', + destChainId: '0xa', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + srcTokenAmount: '1000000000000000000', + walletAddress: '0x123', + slippage: 0.5, + }; + + await bridgeController.updateBridgeQuoteRequestParams( + quoteParams, + metricsContext, + ); + + 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(); + }); + }); + it('setBridgeFeatureFlags should fetch and set the bridge feature flags', async function () { const remoteFeatureFlagControllerState = { cacheTimestamp: 1745515389440, @@ -206,15 +445,6 @@ describe('BridgeController', function () { expect(setIntervalLengthSpy).toHaveBeenCalledWith(3); }); - const metricsContext = { - token_symbol_source: 'ETH', - token_symbol_destination: 'USDC', - usd_amount_source: 100, - stx_enabled: true, - security_warnings: [], - warnings: [], - }; - it('updateBridgeQuoteRequestParams should update the quoteRequest state', async function () { messengerMock.call.mockReturnValue({ currentCurrency: 'usd', diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index 1ed63030a80..42ec4f0e95f 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -21,7 +21,10 @@ import { import { CHAIN_IDS } from './constants/chains'; import { SWAPS_CONTRACT_ADDRESSES } from './constants/swaps'; import { TraceName } from './constants/traces'; -import { selectIsAssetExchangeRateInState } from './selectors'; +import { + ExchangeRateSourcesForLookup, + selectIsAssetExchangeRateInState, +} from './selectors'; import { RequestStatus } from './types'; import type { L1GasFees, @@ -196,6 +199,14 @@ export class BridgeController extends StaticIntervalPollingController boolean; + constructor({ messenger, state, @@ -206,6 +217,7 @@ export class BridgeController extends StaticIntervalPollingController; @@ -224,6 +236,12 @@ export class BridgeController extends StaticIntervalPollingController, ) => void; traceFn?: TraceCallback; + /** + * When provided, called to determine whether to use AssetsController for exchange rates. + * When true, rates are read from AssetsController:getExchangeRatesForBridge instead of + * MultichainAssetsRatesController, TokenRatesController, and CurrencyRateController. + */ + getUseAssetsControllerForRates?: () => boolean; }) { super({ name: BRIDGE_CONTROLLER_NAME, @@ -245,6 +263,8 @@ export class BridgeController extends StaticIntervalPollingController fn?.()) as TraceCallback); + this.#getUseAssetsControllerForRates = + getUseAssetsControllerForRates ?? (() => false); // Register action handlers this.messenger.registerActionHandler( @@ -399,7 +419,14 @@ export class BridgeController extends StaticIntervalPollingController { + readonly #getExchangeRateSources = (): ExchangeRateSourcesForLookup => { + if (this.#getUseAssetsControllerForRates()) { + return { + ...this.messenger.call('AssetsController:getExchangeRatesForBridge'), + historicalPrices: {}, + ...this.state, + }; + } return { ...this.messenger.call('MultichainAssetsRatesController:getState'), ...this.messenger.call('CurrencyRateController:getState'), @@ -453,9 +480,10 @@ export class BridgeController extends StaticIntervalPollingController & + Partial> & + Partial> & { + marketData?: + | TokenRatesControllerState['marketData'] + | Record>; + conversionRates?: + | MultichainAssetsRatesControllerState['conversionRates'] + | Record; + }; + export type BridgeAppState = BridgeControllerState & { gasFeeEstimatesByChainId: GasFeeEstimatesByChainId; } & ExchangeRateControllerState & { @@ -122,7 +141,7 @@ export const selectBridgeFeatureFlags = createFeatureFlagsSelector( ); const getExchangeRateByChainIdAndAddress = ( - exchangeRateSources: ExchangeRateControllerState, + exchangeRateSources: ExchangeRateSourcesForLookup, chainId?: GenericQuoteRequest['srcChainId'], rawAddress?: GenericQuoteRequest['srcTokenAddress'], ): ExchangeRate => { @@ -151,27 +170,35 @@ const getExchangeRateByChainIdAndAddress = ( } // If the chain is a non-EVM chain, use the conversion rate from the multichain assets controller if (isNonEvmChainId(chainId)) { - const multichainAssetExchangeRate = conversionRates?.[assetId]; - if (multichainAssetExchangeRate) { + const conversionRatesByKey = conversionRates as + | Record + | undefined; + const multichainAssetExchangeRate = conversionRatesByKey?.[assetId]; + const rate = multichainAssetExchangeRate?.rate; + if (rate) { // The multichain rate is denominated in the user's selected currency. // To get a USD rate, find the user's-currency-to-USD conversion factor from any EVM native currency rate. const nativeCurrencyRate = Object.values(currencyRates ?? {}).find( - (rate) => rate?.conversionRate && rate?.usdConversionRate, + (rateEntry) => + rateEntry?.conversionRate !== undefined && + rateEntry?.conversionRate !== null && + rateEntry?.usdConversionRate !== undefined && + rateEntry?.usdConversionRate !== null, ); const usersCurrencyToUsdRate = - nativeCurrencyRate?.conversionRate && - nativeCurrencyRate?.usdConversionRate + nativeCurrencyRate?.conversionRate !== undefined && + nativeCurrencyRate?.conversionRate !== null && + nativeCurrencyRate?.usdConversionRate !== undefined && + nativeCurrencyRate?.usdConversionRate !== null ? new BigNumber(nativeCurrencyRate.usdConversionRate).div( nativeCurrencyRate.conversionRate, ) : undefined; const usdExchangeRate = usersCurrencyToUsdRate - ? new BigNumber(multichainAssetExchangeRate.rate) - .times(usersCurrencyToUsdRate) - .toString() + ? new BigNumber(rate).times(usersCurrencyToUsdRate).toString() : undefined; return { - exchangeRate: multichainAssetExchangeRate.rate, + exchangeRate: rate, usdExchangeRate, }; } @@ -183,27 +210,35 @@ const getExchangeRateByChainIdAndAddress = ( const evmNativeExchangeRate = currencyRates?.[symbol]; if (evmNativeExchangeRate) { return { - exchangeRate: evmNativeExchangeRate?.conversionRate?.toString(), - usdExchangeRate: evmNativeExchangeRate?.usdConversionRate?.toString(), + exchangeRate: evmNativeExchangeRate.conversionRate?.toString(), + usdExchangeRate: evmNativeExchangeRate.usdConversionRate?.toString(), }; } return {}; } // If the chain is an EVM chain and the asset is not the native asset, use the conversion rate from the token rates controller if (!isNonEvmChainId(chainId)) { - const evmTokenExchangeRates = marketData?.[formatChainIdToHex(chainId)]; + const marketDataByChain = + (marketData as + | Record> + | undefined) ?? {}; + const evmTokenExchangeRates = + marketDataByChain[formatChainIdToHex(chainId)]; const evmTokenExchangeRateForAddress = isStrictHexString(address) ? evmTokenExchangeRates?.[address] : null; - const nativeCurrencyRate = evmTokenExchangeRateForAddress - ? currencyRates[evmTokenExchangeRateForAddress?.currency] - : undefined; + const currencyKey = evmTokenExchangeRateForAddress?.currency; + const nativeCurrencyRate = + currencyKey !== undefined && currencyKey !== null + ? currencyRates?.[currencyKey] + : undefined; + const price = evmTokenExchangeRateForAddress?.price ?? 0; if (evmTokenExchangeRateForAddress && nativeCurrencyRate) { return { - exchangeRate: new BigNumber(evmTokenExchangeRateForAddress.price) + exchangeRate: new BigNumber(price) .multipliedBy(nativeCurrencyRate.conversionRate ?? 0) .toString(), - usdExchangeRate: new BigNumber(evmTokenExchangeRateForAddress.price) + usdExchangeRate: new BigNumber(price) .multipliedBy(nativeCurrencyRate.usdConversionRate ?? 0) .toString(), }; diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index 41217d8578c..12d265eb5b5 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -1,4 +1,5 @@ import type { AccountsControllerGetAccountByAddressAction } from '@metamask/accounts-controller'; +import type { AssetsControllerGetExchangeRatesForBridgeAction } from '@metamask/assets-controller'; import type { GetCurrencyRateState, MultichainAssetsRatesControllerGetStateAction, @@ -400,7 +401,8 @@ export type AllowedActions = | HandleSnapRequest | NetworkControllerFindNetworkClientIdByChainIdAction | NetworkControllerGetNetworkClientByIdAction - | RemoteFeatureFlagControllerGetStateAction; + | RemoteFeatureFlagControllerGetStateAction + | AssetsControllerGetExchangeRatesForBridgeAction; export type AllowedEvents = never; /** diff --git a/packages/bridge-controller/tsconfig.build.json b/packages/bridge-controller/tsconfig.build.json index 33bbb3a6dc8..a8f7180477a 100644 --- a/packages/bridge-controller/tsconfig.build.json +++ b/packages/bridge-controller/tsconfig.build.json @@ -15,7 +15,8 @@ { "path": "../assets-controllers/tsconfig.build.json" }, { "path": "../transaction-controller/tsconfig.build.json" }, { "path": "../multichain-network-controller/tsconfig.build.json" }, - { "path": "../remote-feature-flag-controller/tsconfig.build.json" } + { "path": "../remote-feature-flag-controller/tsconfig.build.json" }, + { "path": "../assets-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] } diff --git a/packages/bridge-controller/tsconfig.json b/packages/bridge-controller/tsconfig.json index 61896deae83..fd36e969bd5 100644 --- a/packages/bridge-controller/tsconfig.json +++ b/packages/bridge-controller/tsconfig.json @@ -15,7 +15,8 @@ { "path": "../assets-controllers" }, { "path": "../multichain-network-controller" }, { "path": "../profile-sync-controller" }, - { "path": "../remote-feature-flag-controller" } + { "path": "../remote-feature-flag-controller" }, + { "path": "../assets-controller" } ], "include": ["../../types", "./src", "./tests"] } diff --git a/yarn.lock b/yarn.lock index d1d841db0bd..dea43cf3e77 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2759,7 +2759,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controller@workspace:packages/assets-controller": +"@metamask/assets-controller@npm:^2.2.0, @metamask/assets-controller@workspace:packages/assets-controller": version: 0.0.0-use.local resolution: "@metamask/assets-controller@workspace:packages/assets-controller" dependencies: @@ -2990,6 +2990,7 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^36.0.1" + "@metamask/assets-controller": "npm:^2.2.0" "@metamask/assets-controllers": "npm:^100.1.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0"