diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index b7e550eeb84..0b962128da3 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `currentCurrency` state (ISO 4217 code, default `'usd'`) and `setCurrentCurrency(currentCurrency)` to `AssetsController`. Changing the currency updates state and triggers a one-off price refetch so displayed prices use the new currency ([#7991](https://github.com/MetaMask/core/pull/7991)) + ### Changed +- `PriceDataSourceOptions` now requires `getCurrentCurrency: () => SupportedCurrency` instead of optional `currency?: SupportedCurrency` ([#7991](https://github.com/MetaMask/core/pull/7991)) - Refactor data source tests to use shared `MockAssetControllerMessenger` fixture ([#7958](https://github.com/MetaMask/core/pull/7958)) - Export `STAKING_INTERFACE` from the staked balance fetcher for use with the staking contract ABI. - `StakedBalanceDataSource` teardown now uses the messenger's `clearEventSubscriptions`; custom messenger implementations must support it for correct cleanup. diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index b0ffe5a3fd6..a2da33db7f5 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -169,6 +169,7 @@ describe('AssetsController', () => { assetsPrice: {}, customAssets: {}, assetPreferences: {}, + selectedCurrency: 'usd', }); }); }); @@ -182,6 +183,7 @@ describe('AssetsController', () => { assetsPrice: {}, customAssets: {}, assetPreferences: {}, + selectedCurrency: 'usd', }); }); }); @@ -198,6 +200,7 @@ describe('AssetsController', () => { }, assetsBalance: {}, customAssets: {}, + selectedCurrency: 'eur', }; await withController({ state: initialState }, ({ controller }) => { @@ -207,6 +210,7 @@ describe('AssetsController', () => { name: 'USD Coin', decimals: 6, }); + expect(controller.state.selectedCurrency).toBe('eur'); }); }); @@ -254,6 +258,7 @@ describe('AssetsController', () => { assetsBalance: {}, assetsPrice: {}, customAssets: {}, + selectedCurrency: 'usd', }); // Action handlers should NOT be registered when disabled @@ -275,6 +280,7 @@ describe('AssetsController', () => { assetsBalance: {}, assetsPrice: {}, customAssets: {}, + selectedCurrency: 'usd', }); // Action handlers should be registered @@ -724,6 +730,54 @@ describe('AssetsController', () => { }); }); + describe('setSelectedCurrency', () => { + it('updates selectedCurrency in state', async () => { + await withController(({ controller }) => { + expect(controller.state.selectedCurrency).toBe('usd'); + + controller.setSelectedCurrency('eur'); + expect(controller.state.selectedCurrency).toBe('eur'); + + controller.setSelectedCurrency('gbp'); + expect(controller.state.selectedCurrency).toBe('gbp'); + }); + }); + + it('returns early when new currency is same as current', async () => { + await withController(({ controller }) => { + expect(controller.state.selectedCurrency).toBe('usd'); + + const getAssetsSpy = jest.spyOn(controller, 'getAssets'); + + controller.setSelectedCurrency('usd'); + + expect(controller.state.selectedCurrency).toBe('usd'); + expect(getAssetsSpy).not.toHaveBeenCalled(); + + getAssetsSpy.mockRestore(); + }); + }); + + it('calls getAssets with forceUpdate and price dataType to refresh prices', async () => { + await withController(({ controller }) => { + const getAssetsSpy = jest.spyOn(controller, 'getAssets'); + + controller.setSelectedCurrency('eur'); + + expect(getAssetsSpy).toHaveBeenCalledTimes(1); + expect(getAssetsSpy).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + forceUpdate: true, + dataTypes: ['price'], + }), + ); + + getAssetsSpy.mockRestore(); + }); + }); + }); + describe('events', () => { it('publishes balanceChanged event when balance updates', async () => { await withController(async ({ controller, messenger }) => { diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index ba421627e37..cf5d58377e0 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -13,6 +13,7 @@ import type { ApiPlatformClient, BackendWebSocketServiceActions, BackendWebSocketServiceEvents, + SupportedCurrency, } from '@metamask/core-backend'; import type { KeyringControllerLockEvent, @@ -143,6 +144,8 @@ export type AssetsControllerState = { customAssets: { [accountId: string]: Caip19AssetId[] }; /** UI preferences per asset (e.g. hidden) */ assetPreferences: { [assetId: string]: AssetPreferences }; + /** Currently-active ISO 4217 currency code */ + selectedCurrency: SupportedCurrency; }; /** @@ -157,6 +160,7 @@ export function getDefaultAssetsControllerState(): AssetsControllerState { assetsPrice: {}, customAssets: {}, assetPreferences: {}, + selectedCurrency: 'usd', }; } @@ -350,6 +354,12 @@ const stateMetadata: StateMetadata = { includeInDebugSnapshot: false, usedInUi: true, }, + selectedCurrency: { + persist: true, + includeInStateLogs: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, }; // ============================================================================ @@ -605,6 +615,7 @@ export class AssetsController extends BaseController< }); this.#priceDataSource = new PriceDataSource({ queryApiClient, + getSelectedCurrency: (): SupportedCurrency => this.state.selectedCurrency, ...priceDataSourceConfig, }); this.#detectionMiddleware = new DetectionMiddleware(); @@ -1133,6 +1144,39 @@ export class AssetsController extends BaseController< }); } + // ============================================================================ + // CURRENT CURRENCY MANAGEMENT + // ============================================================================ + + /** + * Set the current currency. + * + * @param selectedCurrency - The ISO 4217 currency code to set. + */ + setSelectedCurrency(selectedCurrency: SupportedCurrency): void { + const previousCurrency = this.state.selectedCurrency; + + if (previousCurrency === selectedCurrency) { + return; + } + + this.update((state) => { + state.selectedCurrency = selectedCurrency; + }); + + log('Current currency changed', { + previousCurrency, + selectedCurrency, + }); + + this.getAssets(this.#selectedAccounts, { + forceUpdate: true, + dataTypes: ['price'], + }).catch((error) => { + log('Failed to fetch asset prices after current currency change', error); + }); + } + // ============================================================================ // SUBSCRIPTIONS // ============================================================================ diff --git a/packages/assets-controller/src/data-sources/PriceDataSource.test.ts b/packages/assets-controller/src/data-sources/PriceDataSource.test.ts index 123c09ae3fe..dc4047f278f 100644 --- a/packages/assets-controller/src/data-sources/PriceDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/PriceDataSource.test.ts @@ -1,3 +1,4 @@ +import type { SupportedCurrency } from '@metamask/core-backend'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { PriceDataSourceOptions } from './PriceDataSource'; @@ -108,14 +109,14 @@ function setupController( options: { priceResponse?: Record; balanceState?: Record>; - currency?: 'usd' | 'eur'; + getSelectedCurrency?: () => SupportedCurrency; pollInterval?: number; } = {}, ): SetupResult { const { priceResponse = {}, balanceState = {}, - currency, + getSelectedCurrency = (): SupportedCurrency => 'usd', pollInterval, } = options; @@ -124,11 +125,9 @@ function setupController( const controllerOptions: PriceDataSourceOptions = { queryApiClient: apiClient as unknown as PriceDataSourceOptions['queryApiClient'], + getSelectedCurrency, }; - if (currency) { - controllerOptions.currency = currency; - } if (pollInterval) { controllerOptions.pollInterval = pollInterval; } @@ -254,7 +253,7 @@ describe('PriceDataSource', () => { it('fetch uses custom currency', async () => { const { controller, apiClient, getAssetsState } = setupController({ - currency: 'eur', + getSelectedCurrency: () => 'eur', balanceState: { 'mock-account-id': { [MOCK_NATIVE_ASSET]: { amount: '1000000000000000000' }, diff --git a/packages/assets-controller/src/data-sources/PriceDataSource.ts b/packages/assets-controller/src/data-sources/PriceDataSource.ts index 3d635acf440..f7dec90fae5 100644 --- a/packages/assets-controller/src/data-sources/PriceDataSource.ts +++ b/packages/assets-controller/src/data-sources/PriceDataSource.ts @@ -39,8 +39,8 @@ export type PriceDataSourceConfig = { export type PriceDataSourceOptions = PriceDataSourceConfig & { /** ApiPlatformClient for API calls with caching */ queryApiClient: ApiPlatformClient; - /** Currency to fetch prices in (default: 'usd') */ - currency?: SupportedCurrency; + /** Function returning the currently-active ISO 4217 currency code */ + getSelectedCurrency: () => SupportedCurrency; }; // ============================================================================ @@ -110,7 +110,7 @@ export class PriceDataSource { return PriceDataSource.controllerName; } - readonly #currency: SupportedCurrency; + readonly #getSelectedCurrency: () => SupportedCurrency; readonly #pollInterval: number; @@ -129,7 +129,7 @@ export class PriceDataSource { > = new Map(); constructor(options: PriceDataSourceOptions) { - this.#currency = options.currency ?? 'usd'; + this.#getSelectedCurrency = options.getSelectedCurrency; this.#pollInterval = options.pollInterval ?? DEFAULT_POLL_INTERVAL; this.#apiClient = options.queryApiClient; } @@ -219,7 +219,7 @@ export class PriceDataSource { */ async #fetchSpotPrices(assetIds: string[]): Promise { return this.#apiClient.prices.fetchV3SpotPrices(assetIds, { - currency: this.#currency, + currency: this.#getSelectedCurrency(), includeMarketData: true, }); }