From 1fbade9eb0ceff529c2f343d5d823a3707aa53d1 Mon Sep 17 00:00:00 2001 From: Salim TOUBAL Date: Fri, 7 Feb 2025 16:17:44 +0100 Subject: [PATCH] fix: multichainToken rate for non evm (#5175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ## Explanation This pull request introduces the `MultiChainTokensRatesController`, a new controller that manages multi‑chain token conversion rates within MetaMask. Its primary goal is to periodically poll for updated conversion rates of tokens associated with non‑EVM accounts (those using Snap metadata), ensuring that the conversion data remains up‑to‑date across supported chains. **Key changes and features include:** - **Polling Mechanism:** The controller extends `StaticIntervalPollingController` to run periodic polls (default interval of 3 min). During each poll, it triggers an update of token conversion rates for the currently selected account if it is active (i.e. if the keyring is unlocked). - **State Management & Mutex Locking:** The controller stores conversion rates in its state (`conversionRates`), which is updated atomically using a mutex (from `async-mutex`) to prevent race conditions during concurrent updates. - **Event Subscriptions:** - **Keyring Events:** Listens to `KeyringController:lock` and `KeyringController:unlock` events to track if the keyring is active. - **Accounts Events:** Subscribes to `AccountsController:selectedAccountChange` to update the current account and trigger a conversion rate update, and to `AccountsController:accountRemoved` to clean up any state associated with a removed account. - **Account Filtering:** The controller retrieves all multichain accounts from the AccountsController and filters out non‑EVM accounts that have Snap metadata. Currently, only accounts of type `'solana:data-account'` are supported for conversion updates, with a TODO note indicating future Snap support enhancements. - **Integration with Snap:** Conversion updates involve sending a Snap request using a helper method (`#handleSnapRequest`) to the SnapController. It builds a list of asset conversion pairs (from asset to USD via the Swift ISO4217 code) and then updates its state with the returned conversion rates. - **Keyring Client Support:** A dedicated helper (`#getClient`) instantiates a `KeyringClient` to route keyring-related JSON-RPC requests through the SnapController, ensuring a consistent integration with MetaMask’s underlying snap infrastructure. - **Type Safety & Messaging:** Comprehensive TypeScript types are provided for controller state, actions, events, and the messenger interface. This guarantees that interactions with other controllers (such as the AccountsController, KeyringController, and Snaps-related modules) are type‑safe and well‑documented. Overall, this controller enhances the MetaMask architecture by adding robust support for multi‑chain tokens, ensuring that users with non‑EVM accounts receive updated conversion rate information in a timely and thread‑safe manner. --- ## References - Fixes: --- ## Changelog ### `@metamask/multi-chain-tokens-rates-controller` - **ADDED:** New `MultiChainTokensRatesController` to manage and update token conversion rates for non‑EVM accounts with Snap metadata. --- ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- Feel free to adjust any section to better match the specifics of your implementation and project requirements. --- .../MultichainAssetsRatesController.test.ts | 378 +++++++++++++++ .../MultichainAssetsRatesController.ts | 440 ++++++++++++++++++ .../constant.ts | 92 ++++ .../MultichainAssetsRatesController/index.ts | 13 + packages/assets-controllers/src/index.ts | 14 + 5 files changed, 937 insertions(+) create mode 100644 packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts create mode 100644 packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts create mode 100644 packages/assets-controllers/src/MultichainAssetsRatesController/constant.ts create mode 100644 packages/assets-controllers/src/MultichainAssetsRatesController/index.ts diff --git a/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts new file mode 100644 index 0000000000..8a5d063dd6 --- /dev/null +++ b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts @@ -0,0 +1,378 @@ +import { Messenger } from '@metamask/base-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { KeyringClient } from '@metamask/keyring-snap-client'; +import { useFakeTimers } from 'sinon'; + +import { MultiChainAssetsRatesController } from '.'; +import { + type AllowedActions, + type AllowedEvents, +} from './MultichainAssetsRatesController'; + +// A fake non‑EVM account (with Snap metadata) that meets the controller’s criteria. +const fakeNonEvmAccount: InternalAccount = { + id: 'account1', + type: 'solana:data-account', + address: '0x123', + metadata: { + name: 'Test Account', + // @ts-expect-error-next-line + snap: { id: 'test-snap', enabled: true }, + }, + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + options: {}, + methods: [], +}; + +// A fake EVM account (which should be filtered out). +const fakeEvmAccount: InternalAccount = { + id: 'account2', + type: 'eip155:eoa', + address: '0x456', + // @ts-expect-error-next-line + metadata: { name: 'EVM Account' }, + scopes: [], + options: {}, + methods: [], +}; + +const fakeEvmAccount2: InternalAccount = { + id: 'account3', + type: 'bip122:p2wpkh', + address: '0x789', + metadata: { + name: 'EVM Account', + // @ts-expect-error-next-line + snap: { id: 'test-snap', enabled: true }, + }, + scopes: [], + options: {}, + methods: [], +}; + +const fakeEvmAccountWithoutMetadata: InternalAccount = { + id: 'account4', + type: 'bip122:p2wpkh', + address: '0x789', + metadata: { + name: 'EVM Account', + importTime: 0, + keyring: { type: 'bip122' }, + }, + scopes: [], + options: {}, + methods: [], +}; + +// A fake conversion rates response returned by the SnapController. +const fakeAccountRates = { + conversionRates: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + 'swift:0/iso4217:USD': { + rate: '202.11', + conversionTime: 1738539923277, + }, + }, + }, +}; + +const setupController = ({ + config, + accountsAssets = [fakeNonEvmAccount, fakeEvmAccount, fakeEvmAccount2], +}: { + config?: Partial< + ConstructorParameters[0] + >; + accountsAssets?: InternalAccount[]; +} = {}) => { + const messenger = new Messenger(); + + messenger.registerActionHandler( + 'MultichainAssetsController:getState', + () => ({ + accountsAssets: { + account1: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'], + account2: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'], + account3: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'], + }, + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + name: 'Solana', + symbol: 'SOL', + fungible: true, + iconUrl: 'https://example.com/solana.png', + units: [{ symbol: 'SOL', name: 'Solana', decimals: 9 }], + }, + }, + }), + ); + + messenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => accountsAssets, + ); + + messenger.registerActionHandler('CurrencyRateController:getState', () => ({ + currencyRates: {}, + currentCurrency: 'USD', + })); + + const multiChainAssetsRatesControllerMessenger = messenger.getRestricted({ + name: 'MultiChainAssetsRatesController', + allowedActions: [ + 'AccountsController:listMultichainAccounts', + 'SnapController:handleRequest', + 'CurrencyRateController:getState', + 'MultichainAssetsController:getState', + ], + allowedEvents: [ + 'AccountsController:accountAdded', + 'KeyringController:lock', + 'KeyringController:unlock', + 'CurrencyRateController:stateChange', + 'MultichainAssetsController:stateChange', + ], + }); + + return { + controller: new MultiChainAssetsRatesController({ + messenger: multiChainAssetsRatesControllerMessenger, + ...config, + }), + messenger, + }; +}; + +describe('MultiChainAssetsRatesController', () => { + let clock: sinon.SinonFakeTimers; + + const mockedDate = 1705760550000; + + beforeEach(() => { + clock = useFakeTimers(); + jest.spyOn(Date, 'now').mockReturnValue(mockedDate); + }); + + afterEach(() => { + clock.restore(); + jest.restoreAllMocks(); + }); + + it('initializes with an empty conversionRates state', () => { + const { controller } = setupController(); + expect(controller.state).toStrictEqual({ conversionRates: {} }); + }); + + it('updates conversion rates for a valid non-EVM account', async () => { + const { controller, messenger } = setupController(); + + // Stub KeyringClient.listAccountAssets so that the controller “discovers” one asset. + jest + .spyOn(KeyringClient.prototype, 'listAccountAssets') + .mockResolvedValue([ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ]); + + // Override the SnapController:handleRequest handler to return our fake conversion rates. + const snapHandler = jest.fn().mockResolvedValue(fakeAccountRates); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + + // Call updateAssetsRates for the valid non-EVM account. + await controller.updateAssetsRates(); + + // Check that the Snap request was made with the expected parameters. + expect(snapHandler).toHaveBeenCalledWith( + expect.objectContaining({ + handler: 'onAssetsConversion', + origin: 'metamask', + request: { + jsonrpc: '2.0', + method: 'onAssetsConversion', + params: { + conversions: [ + { + from: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + to: 'swift:0/iso4217:USD', + }, + ], + }, + }, + snapId: 'test-snap', + }), + ); + + // The controller state should now contain the conversion rates returned. + expect(controller.state.conversionRates).toStrictEqual( + // fakeAccountRates.conversionRates, + { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + rate: '202.11', + conversionTime: 1738539923277, + currency: 'swift:0/iso4217:USD', + }, + }, + ); + }); + + it('does not update conversion rates if the controller is not active', async () => { + const { controller, messenger } = setupController(); + + // Simulate a keyring lock event to set the controller as inactive. + messenger.publish('KeyringController:lock'); + // Override SnapController:handleRequest and stub listAccountAssets. + const snapHandler = jest.fn().mockResolvedValue(fakeAccountRates); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + jest + .spyOn(KeyringClient.prototype, 'listAccountAssets') + .mockResolvedValue([ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ]); + + await controller.updateAssetsRates(); + // Since the controller is locked, no update should occur. + expect(controller.state.conversionRates).toStrictEqual({}); + expect(snapHandler).not.toHaveBeenCalled(); + }); + + it('resumes update tokens rates when the keyring is unlocked', async () => { + const { controller, messenger } = setupController(); + messenger.publish('KeyringController:lock'); + // Override SnapController:handleRequest and stub listAccountAssets. + const snapHandler = jest.fn().mockResolvedValue(fakeAccountRates); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + jest + .spyOn(KeyringClient.prototype, 'listAccountAssets') + .mockResolvedValue([ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ]); + await controller.updateAssetsRates(); + expect(controller.isActive).toBe(false); + + messenger.publish('KeyringController:unlock'); + await controller.updateAssetsRates(); + + expect(controller.isActive).toBe(true); + }); + + it('calls updateTokensRates when _executePoll is invoked', async () => { + const { controller, messenger } = setupController(); + + jest + .spyOn(KeyringClient.prototype, 'listAccountAssets') + .mockResolvedValue([ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ]); + + messenger.registerActionHandler( + 'SnapController:handleRequest', + async () => ({ + conversionRates: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + 'swift:0/iso4217:USD': { + rate: '202.11', + conversionTime: 1738539923277, + }, + }, + }, + }), + ); + + // Spy on updateAssetsRates. + const updateSpy = jest.spyOn(controller, 'updateAssetsRates'); + await controller._executePoll(); + expect(updateSpy).toHaveBeenCalled(); + }); + + it('calls updateTokensRates when an multichain assets state is updated', async () => { + const { controller, messenger } = setupController(); + + // Spy on updateTokensRates. + const updateSpy = jest + .spyOn(controller, 'updateAssetsRates') + .mockResolvedValue(); + + // Publish a selectedAccountChange event. + // @ts-expect-error-next-line + messenger.publish('MultichainAssetsController:stateChange', { + accountsAssets: { + account3: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'], + }, + }); + // Wait for the asynchronous subscriber to run. + await Promise.resolve(); + expect(updateSpy).toHaveBeenCalled(); + }); + + it('handles partial or empty Snap responses gracefully', async () => { + const { controller, messenger } = setupController(); + + messenger.registerActionHandler('SnapController:handleRequest', () => { + return Promise.resolve({ + conversionRates: { + // Only returning a rate for one asset + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + 'swift:0/iso4217:USD': { + rate: '250.50', + conversionTime: 1738539923277, + }, + }, + }, + }); + }); + + await controller.updateAssetsRates(); + + expect(controller.state.conversionRates).toMatchObject({ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + rate: '250.50', + conversionTime: 1738539923277, + }, + }); + }); + + it('skips all accounts that lack Snap metadata or are EVM', async () => { + const { controller, messenger } = setupController({ + accountsAssets: [fakeEvmAccountWithoutMetadata], + }); + + const snapSpy = jest.fn().mockResolvedValue({ conversionRates: {} }); + messenger.registerActionHandler('SnapController:handleRequest', snapSpy); + + await controller.updateAssetsRates(); + + expect(snapSpy).not.toHaveBeenCalled(); + expect(controller.state.conversionRates).toStrictEqual({}); + }); + + it('updates state when currency is updated', async () => { + const { controller, messenger } = setupController(); + + const snapHandler = jest.fn().mockResolvedValue(fakeAccountRates); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + + const updateSpy = jest.spyOn(controller, 'updateAssetsRates'); + + messenger.publish( + 'CurrencyRateController:stateChange', + { + currentCurrency: 'EUR', + currencyRates: {}, + }, + [], + ); + + expect(updateSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts new file mode 100644 index 0000000000..ebf7b85587 --- /dev/null +++ b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts @@ -0,0 +1,440 @@ +import type { + AccountsControllerListMultichainAccountsAction, + AccountsControllerAccountAddedEvent, +} from '@metamask/accounts-controller'; +import type { + RestrictedMessenger, + ControllerStateChangeEvent, + ControllerGetStateAction, +} from '@metamask/base-controller'; +import { type CaipAssetType, isEvmAccountType } from '@metamask/keyring-api'; +import type { + KeyringControllerLockEvent, + KeyringControllerUnlockEvent, +} from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import type { HandleSnapRequest } from '@metamask/snaps-controllers'; +import type { + SnapId, + AssetConversion, + OnAssetsConversionArguments, + OnAssetsConversionResponse, +} from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import { Mutex } from 'async-mutex'; +import type { Draft } from 'immer'; + +import { MAP_CAIP_CURRENCIES } from './constant'; +import type { + CurrencyRateState, + CurrencyRateStateChange, + GetCurrencyRateState, +} from '../CurrencyRateController'; +import type { + MultichainAssetsControllerGetStateAction, + MultichainAssetsControllerState, + MultichainAssetsControllerStateChangeEvent, +} from '../MultichainAssetsController'; + +/** + * The name of the MultiChainAssetsRatesController. + */ +const controllerName = 'MultiChainAssetsRatesController'; + +/** + * State used by the MultiChainAssetsRatesController to cache token conversion rates. + */ +export type MultichainAssetsRatesControllerState = { + conversionRates: Record; +}; + +/** + * Returns the state of the MultiChainAssetsRatesController. + */ +export type MultichainAssetsRatesControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + MultichainAssetsRatesControllerState + >; + +/** + * Action to update the rates of all supported tokens. + */ +export type MultichainAssetsRatesControllerUpdateRatesAction = { + type: `${typeof controllerName}:updateAssetsRates`; + handler: MultiChainAssetsRatesController['updateAssetsRates']; +}; + +/** + * Constructs the default {@link MultichainAssetsRatesController} state. This allows + * consumers to provide a partial state object when initializing the controller + * and also helps in constructing complete state objects for this controller in + * tests. + * + * @returns The default {@link MultichainAssetsRatesController} state. + */ +export function getDefaultMultichainAssetsRatesControllerState(): MultichainAssetsRatesControllerState { + return { conversionRates: {} }; +} + +/** + * Event emitted when the state of the MultiChainAssetsRatesController changes. + */ +export type MultichainAssetsRatesControllerStateChange = + ControllerStateChangeEvent< + typeof controllerName, + MultichainAssetsRatesControllerState + >; + +/** + * Actions exposed by the MultiChainAssetsRatesController. + */ +export type MultichainAssetsRatesControllerActions = + | MultichainAssetsRatesControllerGetStateAction + | MultichainAssetsRatesControllerUpdateRatesAction; + +/** + * Events emitted by MultiChainAssetsRatesController. + */ +export type MultichainAssetsRatesControllerEvents = + MultichainAssetsRatesControllerStateChange; + +/** + * Actions that this controller is allowed to call. + */ +export type AllowedActions = + | HandleSnapRequest + | AccountsControllerListMultichainAccountsAction + | GetCurrencyRateState + | MultichainAssetsControllerGetStateAction; +/** + * Events that this controller is allowed to subscribe to. + */ +export type AllowedEvents = + | KeyringControllerLockEvent + | KeyringControllerUnlockEvent + | AccountsControllerAccountAddedEvent + | CurrencyRateStateChange + | MultichainAssetsControllerStateChangeEvent; + +/** + * Messenger type for the MultiChainAssetsRatesController. + */ +export type MultichainAssetsRatesControllerMessenger = RestrictedMessenger< + typeof controllerName, + MultichainAssetsRatesControllerActions | AllowedActions, + MultichainAssetsRatesControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; + +/** + * The input for starting polling in MultiChainAssetsRatesController. + */ +export type MultiChainAssetsRatesPollingInput = { + accountId: string; +}; + +const metadata = { + conversionRates: { persist: true, anonymous: true }, +}; + +/** + * Controller that manages multichain token conversion rates. + * + * This controller polls for token conversion rates and updates its state. + */ +export class MultiChainAssetsRatesController extends StaticIntervalPollingController()< + typeof controllerName, + MultichainAssetsRatesControllerState, + MultichainAssetsRatesControllerMessenger +> { + readonly #mutex = new Mutex(); + + #currentCurrency: CurrencyRateState['currentCurrency']; + + #accountsAssets: MultichainAssetsControllerState['accountsAssets']; + + #isUnlocked = true; + + /** + * Creates an instance of MultiChainAssetsRatesController. + * + * @param options - Constructor options. + * @param options.interval - The polling interval in milliseconds. + * @param options.state - The initial state. + * @param options.messenger - A reference to the messaging system. + */ + constructor({ + interval = 18000, + state = {}, + messenger, + }: { + interval?: number; + state?: Partial; + messenger: MultichainAssetsRatesControllerMessenger; + }) { + super({ + name: controllerName, + messenger, + state: { + ...getDefaultMultichainAssetsRatesControllerState(), + ...state, + }, + metadata, + }); + + this.setIntervalLength(interval); + + // Subscribe to keyring lock/unlock events. + this.messagingSystem.subscribe('KeyringController:lock', () => { + this.#isUnlocked = false; + }); + this.messagingSystem.subscribe('KeyringController:unlock', () => { + this.#isUnlocked = true; + }); + + ({ accountsAssets: this.#accountsAssets } = this.messagingSystem.call( + 'MultichainAssetsController:getState', + )); + + ({ currentCurrency: this.#currentCurrency } = this.messagingSystem.call( + 'CurrencyRateController:getState', + )); + + this.messagingSystem.subscribe( + 'CurrencyRateController:stateChange', + async (currencyRatesState: CurrencyRateState) => { + this.#currentCurrency = currencyRatesState.currentCurrency; + await this.updateAssetsRates(); + }, + ); + + this.messagingSystem.subscribe( + 'MultichainAssetsController:stateChange', + async (multiChainAssetsState: MultichainAssetsControllerState) => { + this.#accountsAssets = multiChainAssetsState.accountsAssets; + await this.updateAssetsRates(); + }, + ); + } + + /** + * Executes a poll by updating token conversion rates for the current account. + * + * @returns A promise that resolves when the polling completes. + */ + async _executePoll(): Promise { + await this.updateAssetsRates(); + } + + /** + * Determines whether the controller is active. + * + * @returns True if the keyring is unlocked; otherwise, false. + */ + get isActive(): boolean { + return this.#isUnlocked; + } + + /** + * Checks if an account is a non-EVM account with a Snap. + * + * @param account - The account to check. + * @returns True if the account is non-EVM and has Snap metadata; otherwise, false. + */ + #isNonEvmAccount(account: InternalAccount): boolean { + return ( + !isEvmAccountType(account.type) && account.metadata.snap !== undefined + ); + } + + /** + * Retrieves all multichain accounts from the AccountsController. + * + * @returns An array of internal accounts. + */ + #listMultichainAccounts(): InternalAccount[] { + return this.messagingSystem.call( + 'AccountsController:listMultichainAccounts', + ); + } + + /** + * Filters and returns non-EVM accounts that should have balances. + * + * @returns An array of non-EVM internal accounts. + */ + #listAccounts(): InternalAccount[] { + const accounts = this.#listMultichainAccounts(); + return accounts.filter((account) => this.#isNonEvmAccount(account)); + } + + /** + * Updates token conversion rates for each non-EVM account. + * + * @returns A promise that resolves when the rates are updated. + */ + async updateAssetsRates(): Promise { + const releaseLock = await this.#mutex.acquire(); + + return (async () => { + if (!this.isActive) { + return; + } + const accounts = this.#listAccounts(); + + for (const account of accounts) { + const assets = this.#getAssetsForAccount(account.id); + + // Build the conversions array + const conversions = this.#buildConversions(assets); + + // Retrieve rates from Snap + const accountRates = await this.#handleSnapRequest({ + snapId: account?.metadata.snap?.id as SnapId, + handler: HandlerType.OnAssetsConversion, + params: conversions, + }); + + // Flatten nested rates if needed + const flattenedRates = this.#flattenRates(accountRates); + + // Build the updatedRates object for these assets + const updatedRates = this.#buildUpdatedRates(assets, flattenedRates); + // Apply these updated rates to controller state + this.#applyUpdatedRates(updatedRates); + } + })().finally(() => { + releaseLock(); + }); + } + + /** + * Returns the array of CAIP-19 assets for the given account ID. + * If none are found, returns an empty array. + * + * @param accountId - The account ID to get the assets for. + * @returns An array of CAIP-19 assets. + */ + #getAssetsForAccount(accountId: string): CaipAssetType[] { + return this.#accountsAssets?.[accountId] ?? []; + } + + /** + * Builds a conversions array (from each asset → the current currency). + * + * @param assets - The assets to build the conversions for. + * @returns A conversions array. + */ + #buildConversions(assets: CaipAssetType[]): OnAssetsConversionArguments { + const currency = + MAP_CAIP_CURRENCIES[this.#currentCurrency] ?? MAP_CAIP_CURRENCIES.usd; + return { + conversions: assets.map((asset) => ({ + from: asset, + to: currency, + })), + }; + } + + /** + * Flattens any nested structure in the conversion rates returned by Snap. + * + * @param assetsConversionResponse - The conversion rates to flatten. + * @returns A flattened rates object. + */ + #flattenRates( + assetsConversionResponse: OnAssetsConversionResponse, + ): Record { + const { conversionRates } = assetsConversionResponse; + + return Object.fromEntries( + Object.entries(conversionRates).map(([asset, nestedObj]) => { + // e.g., nestedObj might look like: { "swift:0/iso4217:EUR": { rate, conversionTime } } + const singleValue = Object.values(nestedObj)[0]; + return [asset, singleValue]; + }), + ); + } + + /** + * Builds a rates object that covers all given assets, ensuring that + * any asset not returned by Snap is set to null for both `rate` and `conversionTime`. + * + * @param assets - The assets to build the rates for. + * @param flattenedRates - The rates to merge. + * @returns A rates object that covers all given assets. + */ + #buildUpdatedRates( + assets: CaipAssetType[], + flattenedRates: Record, + ): Record { + const updatedRates: Record< + CaipAssetType, + AssetConversion & { currency: CaipAssetType } + > = {}; + + for (const asset of assets) { + if (flattenedRates[asset]) { + updatedRates[asset] = { + ...(flattenedRates[asset] as AssetConversion), + currency: + MAP_CAIP_CURRENCIES[this.#currentCurrency] ?? + MAP_CAIP_CURRENCIES.usd, + }; + } + } + return updatedRates; + } + + /** + * Merges the new rates into the controller’s state. + * + * @param updatedRates - The new rates to merge. + */ + #applyUpdatedRates( + updatedRates: Record< + string, + { rate: string | null; conversionTime: number | null } + >, + ): void { + this.update((state: Draft) => { + state.conversionRates = { + ...state.conversionRates, + ...updatedRates, + }; + }); + } + + /** + * Forwards a Snap request to the SnapController. + * + * @param args - The request parameters. + * @param args.snapId - The ID of the Snap. + * @param args.handler - The handler type. + * @param args.params - The asset conversions. + * @returns A promise that resolves with the account rates. + */ + async #handleSnapRequest({ + snapId, + handler, + params, + }: { + snapId: SnapId; + handler: HandlerType; + params: OnAssetsConversionArguments; + }): Promise { + return this.messagingSystem.call('SnapController:handleRequest', { + snapId, + origin: 'metamask', + handler, + request: { + jsonrpc: '2.0', + method: handler, + params, + }, + }) as Promise; + } +} diff --git a/packages/assets-controllers/src/MultichainAssetsRatesController/constant.ts b/packages/assets-controllers/src/MultichainAssetsRatesController/constant.ts new file mode 100644 index 0000000000..2fef0e8155 --- /dev/null +++ b/packages/assets-controllers/src/MultichainAssetsRatesController/constant.ts @@ -0,0 +1,92 @@ +import type { CaipAssetType } from '@metamask/utils'; + +/** + * Maps each SUPPORTED_CURRENCIES entry to its CAIP-19 (or CAIP-like) identifier. + * For fiat, we mimic the old “swift:0/iso4217:XYZ” style. + */ +export const MAP_CAIP_CURRENCIES: { + [key: string]: CaipAssetType; +} = { + // ======================== + // Native crypto assets + // ======================== + btc: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + eth: 'eip155:1/slip44:60', + ltc: 'bip122:12a765e31ffd4059bada1e25190f6e98/slip44:2', + + // Bitcoin Cash + bch: 'bip122:000000000000000000651ef99cb9fcbe/slip44:145', + + // Binance Coin + bnb: 'cosmos:Binance-Chain-Tigris/slip44:714', + + // EOS mainnet (chainId = aca376f2...) + eos: 'eos:aca376f2/slip44:194', + + // XRP mainnet + xrp: 'xrpl:mainnet/slip44:144', + + // Stellar Lumens mainnet + xlm: 'stellar:pubnet/slip44:148', + + // Chainlink (ERC20 on Ethereum mainnet) + link: 'eip155:1/erc20:0x514910771af9Ca656af840dff83E8264EcF986CA', + + // Polkadot (chainId = 91b171bb158e2d3848fa23a9f1c25182) + dot: 'polkadot:91b171bb158e2d3848fa23a9f1c25182/slip44:354', + + // Yearn.finance (ERC20 on Ethereum mainnet) + yfi: 'eip155:1/erc20:0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e', + + // ======================== + // Fiat currencies + // ======================== + usd: 'swift:0/iso4217:USD', + aed: 'swift:0/iso4217:AED', + ars: 'swift:0/iso4217:ARS', + aud: 'swift:0/iso4217:AUD', + bdt: 'swift:0/iso4217:BDT', + bhd: 'swift:0/iso4217:BHD', + bmd: 'swift:0/iso4217:BMD', + brl: 'swift:0/iso4217:BRL', + cad: 'swift:0/iso4217:CAD', + chf: 'swift:0/iso4217:CHF', + clp: 'swift:0/iso4217:CLP', + cny: 'swift:0/iso4217:CNY', + czk: 'swift:0/iso4217:CZK', + dkk: 'swift:0/iso4217:DKK', + eur: 'swift:0/iso4217:EUR', + gbp: 'swift:0/iso4217:GBP', + hkd: 'swift:0/iso4217:HKD', + huf: 'swift:0/iso4217:HUF', + idr: 'swift:0/iso4217:IDR', + ils: 'swift:0/iso4217:ILS', + inr: 'swift:0/iso4217:INR', + jpy: 'swift:0/iso4217:JPY', + krw: 'swift:0/iso4217:KRW', + kwd: 'swift:0/iso4217:KWD', + lkr: 'swift:0/iso4217:LKR', + mmk: 'swift:0/iso4217:MMK', + mxn: 'swift:0/iso4217:MXN', + myr: 'swift:0/iso4217:MYR', + ngn: 'swift:0/iso4217:NGN', + nok: 'swift:0/iso4217:NOK', + nzd: 'swift:0/iso4217:NZD', + php: 'swift:0/iso4217:PHP', + pkr: 'swift:0/iso4217:PKR', + pln: 'swift:0/iso4217:PLN', + rub: 'swift:0/iso4217:RUB', + sar: 'swift:0/iso4217:SAR', + sek: 'swift:0/iso4217:SEK', + sgd: 'swift:0/iso4217:SGD', + thb: 'swift:0/iso4217:THB', + try: 'swift:0/iso4217:TRY', + twd: 'swift:0/iso4217:TWD', + uah: 'swift:0/iso4217:UAH', + vef: 'swift:0/iso4217:VEF', + vnd: 'swift:0/iso4217:VND', + zar: 'swift:0/iso4217:ZAR', + xdr: 'swift:0/iso4217:XDR', + xag: 'swift:0/iso4217:XAG', + xau: 'swift:0/iso4217:XAU', +}; diff --git a/packages/assets-controllers/src/MultichainAssetsRatesController/index.ts b/packages/assets-controllers/src/MultichainAssetsRatesController/index.ts new file mode 100644 index 0000000000..c145b3d21c --- /dev/null +++ b/packages/assets-controllers/src/MultichainAssetsRatesController/index.ts @@ -0,0 +1,13 @@ +export type { + MultichainAssetsRatesControllerState, + MultichainAssetsRatesControllerActions, + MultichainAssetsRatesControllerEvents, + MultichainAssetsRatesControllerGetStateAction, + MultichainAssetsRatesControllerStateChange, + MultichainAssetsRatesControllerMessenger, +} from './MultichainAssetsRatesController'; + +export { + MultiChainAssetsRatesController, + getDefaultMultichainAssetsRatesControllerState, +} from './MultichainAssetsRatesController'; diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts index 46a689f9f7..97518b56ee 100644 --- a/packages/assets-controllers/src/index.ts +++ b/packages/assets-controllers/src/index.ts @@ -171,3 +171,17 @@ export type { MultichainAssetsControllerEvents, MultichainAssetsControllerMessenger, } from './MultichainAssetsController'; + +export { + MultiChainAssetsRatesController, + getDefaultMultichainAssetsRatesControllerState, +} from './MultichainAssetsRatesController'; + +export type { + MultichainAssetsRatesControllerState, + MultichainAssetsRatesControllerActions, + MultichainAssetsRatesControllerEvents, + MultichainAssetsRatesControllerGetStateAction, + MultichainAssetsRatesControllerStateChange, + MultichainAssetsRatesControllerMessenger, +} from './MultichainAssetsRatesController';