diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json deleted file mode 100644 index 5f8ce437666..00000000000 --- a/eslint-warning-thresholds.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "@typescript-eslint/consistent-type-exports": 19, - "@typescript-eslint/no-base-to-string": 3, - "@typescript-eslint/no-duplicate-enum-values": 2, - "@typescript-eslint/no-unsafe-enum-comparison": 34, - "@typescript-eslint/no-unused-vars": 36, - "@typescript-eslint/prefer-promise-reject-errors": 13, - "@typescript-eslint/prefer-readonly": 145, - "import-x/namespace": 189, - "import-x/no-named-as-default": 1, - "import-x/no-named-as-default-member": 8, - "import-x/order": 205, - "jest/no-conditional-in-test": 129, - "jest/prefer-lowercase-title": 2, - "jest/prefer-strict-equal": 2, - "jsdoc/check-tag-names": 375, - "jsdoc/require-returns": 22, - "jsdoc/tag-lines": 328, - "n/no-unsupported-features/node-builtins": 4, - "n/prefer-global/text-encoder": 4, - "n/prefer-global/text-decoder": 4, - "prettier/prettier": 115, - "promise/always-return": 3, - "promise/catch-or-return": 2, - "promise/param-names": 8, - "no-empty-function": 2, - "no-shadow": 8, - "no-unused-private-class-members": 5 -} diff --git a/packages/assets-controllers/src/RatesController/multichain-rates/MultiChainRatesController.test.ts b/packages/assets-controllers/src/RatesController/multichain-rates/MultiChainRatesController.test.ts new file mode 100644 index 00000000000..ac16a4e1033 --- /dev/null +++ b/packages/assets-controllers/src/RatesController/multichain-rates/MultiChainRatesController.test.ts @@ -0,0 +1,389 @@ +import { ControllerMessenger } from '@metamask/base-controller'; +import nock from 'nock'; +import { useFakeTimers } from 'sinon'; + +import { MultiChainRatesController } from './MultiChainRatesController'; +import { + multiChainAssetsControllerName, + type AllowedActions, + type AllowedEvents, + type MultichainAssetsControllerState, +} from './types'; +import { advanceTime } from '../../../../../tests/helpers'; + +const setupController = ({ + config, + tokens = { + allNonEvmTokens: { + account1: [ + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + 'eip155:1/slip44:60', + 'solana:So11111111111111111111111111111111111111112', + 'solana:EPjFWdd5AufqSSqeM2qZQznF1bpP1Pbjx38cVxBYNRTB', + 'solana:JUP9a7mHxhR4WGcK6RBtkG7xMbRBVU7Q8tqsyHG4d3Z', + ], + account2: ['solana:test1', 'solana:test2'], + }, + }, +}: { + config?: Partial[0]>; + tokens?: Partial; +} = {}) => { + const messenger = new ControllerMessenger(); + + messenger.registerActionHandler('AccountsController:getState', () => ({ + accounts: { + account1: { + type: 'eip155:eoa', + id: 'account1', + options: {}, + metadata: { name: 'Test Account' }, + address: '0x123', + methods: [], + }, + }, + selectedAccount: 'account1', + internalAccounts: { accounts: {}, selectedAccount: 'account1' }, + })); + + messenger.registerActionHandler( + 'MultiChainAssetsController:getState', + jest.fn().mockImplementation(() => tokens), + ); + + const multiChainRatesControllerMessenger = messenger.getRestricted({ + name: 'MultiChainRatesController', + allowedActions: [ + 'AccountsController:getState', + 'MultiChainAssetsController:getState', + ], + allowedEvents: [ + 'AccountsController:selectedAccountChange', + 'MultiChainAssetsController:stateChange', + 'KeyringController:lock', + 'KeyringController:unlock', + ], + }); + + return { + controller: new MultiChainRatesController({ + messenger: multiChainRatesControllerMessenger, + ...config, + includeUsdRate: config?.includeUsdRate ?? false, + }), + messenger, + }; +}; + +describe('MultiChainRatesController', () => { + let clock: sinon.SinonFakeTimers; + + const mockedDate = 1705760550000; + + beforeEach(() => { + clock = useFakeTimers(); + jest.spyOn(Date, 'now').mockReturnValue(mockedDate); + }); + + afterEach(() => { + clock.restore(); + jest.restoreAllMocks(); + }); + describe('constructor', () => { + it('should set default state', () => { + const { controller } = setupController(); + expect(controller.state).toStrictEqual({ conversionRates: {} }); + }); + + it('should poll and update rates in the right interval', async () => { + const pollSpy = jest.spyOn( + MultiChainRatesController.prototype, + '_executePoll', + ); + + const interval = 10; + const { controller } = setupController({ config: { interval } }); + + controller.startPolling({ accountId: '123' }); + + await advanceTime({ clock, duration: 1 }); + expect(pollSpy).toHaveBeenCalled(); + expect(pollSpy).not.toHaveBeenCalledTimes(2); + + await advanceTime({ clock, duration: interval * 1.5 }); + expect(pollSpy).toHaveBeenCalledTimes(2); + }); + }); + + it('should poll and update rates on poll', async () => { + nock('http://localhost:3000') + .get( + '/v3/spot-prices?assetIds=bip122:000000000019d6689c085ae165831e93/slip44:0,eip155:1/slip44:60,solana:So11111111111111111111111111111111111111112,solana:EPjFWdd5AufqSSqeM2qZQznF1bpP1Pbjx38cVxBYNRTB,solana:JUP9a7mHxhR4WGcK6RBtkG7xMbRBVU7Q8tqsyHG4d3ZfalsevsCurrency=usd', + ) + .reply(200, { + 'bip122:000000000019d6689c085ae165831e93/slip44:0': { + usd: 45000.0, + }, + 'eip155:1/slip44:60': { + usd: 3200.0, + }, + 'solana:So11111111111111111111111111111111111111112': { + usd: 25.0, + }, + 'solana:EPjFWdd5AufqSSqeM2qZQznF1bpP1Pbjx38cVxBYNRTB': { + usd: 1.0, + }, + 'solana:JUP9a7mHxhR4WGcK6RBtkG7xMbRBVU7Q8tqsyHG4d3Z': { + usd: 0.01, + }, + }) + .persist(); + const { controller } = setupController(); + // initial state + expect(controller.state.conversionRates).toStrictEqual({}); + + await controller._executePoll({ accountId: 'account1' }); + expect(controller.state.conversionRates).toStrictEqual({ + 'bip122:000000000019d6689c085ae165831e93/slip44:0': { + rate: '45000', + conversionTime: 1705760550000, + expirationTime: 1705846950000, + }, + 'eip155:1/slip44:60': { + rate: '3200', + conversionTime: 1705760550000, + expirationTime: 1705846950000, + }, + 'solana:So11111111111111111111111111111111111111112': { + rate: '25', + conversionTime: 1705760550000, + expirationTime: 1705846950000, + }, + 'solana:EPjFWdd5AufqSSqeM2qZQznF1bpP1Pbjx38cVxBYNRTB': { + rate: '1', + conversionTime: 1705760550000, + expirationTime: 1705846950000, + }, + 'solana:JUP9a7mHxhR4WGcK6RBtkG7xMbRBVU7Q8tqsyHG4d3Z': { + rate: '0.01', + conversionTime: 1705760550000, + expirationTime: 1705846950000, + }, + }); + }); + + it('should update rates when token is added and MultiChainAssetsController:stateChange event is triggered', async () => { + nock('http://localhost:3000') + .get( + '/v3/spot-prices?assetIds=bip122:000000000019d6689c085ae165831e93/slip44:0,eip155:1/slip44:60,solana:So11111111111111111111111111111111111111112,solana:EPjFWdd5AufqSSqeM2qZQznF1bpP1Pbjx38cVxBYNRTB,solana:JUP9a7mHxhR4WGcK6RBtkG7xMbRBVU7Q8tqsyHG4d3ZfalsevsCurrency=usd', + ) + .reply(200, { + 'bip122:000000000019d6689c085ae165831e93/slip44:0': { + usd: 45000.0, + }, + 'eip155:1/slip44:60': { + usd: 3200.0, + }, + 'solana:So11111111111111111111111111111111111111112': { + usd: 25.0, + }, + 'solana:EPjFWdd5AufqSSqeM2qZQznF1bpP1Pbjx38cVxBYNRTB': { + usd: 1.0, + }, + 'solana:JUP9a7mHxhR4WGcK6RBtkG7xMbRBVU7Q8tqsyHG4d3Z': { + usd: 0.01, + }, + }) + .get('/v3/spot-prices?assetIds=solana:testfalsevsCurrency=usd') + .reply(200, { + 'solana:test': { + usd: 1.0, + conversionTime: 1705760550000, + expirationTime: 1705846950000, + }, + }) + .persist(); + + const interval = 10; + const { controller, messenger } = setupController({ config: { interval } }); + + controller.startPolling({ accountId: 'account1' }); + + const expectedRates = { + 'bip122:000000000019d6689c085ae165831e93/slip44:0': { + rate: '45000', + conversionTime: 1705760550000, + expirationTime: 1705846950000, + }, + 'eip155:1/slip44:60': { + rate: '3200', + conversionTime: 1705760550000, + expirationTime: 1705846950000, + }, + 'solana:So11111111111111111111111111111111111111112': { + rate: '25', + conversionTime: 1705760550000, + expirationTime: 1705846950000, + }, + 'solana:EPjFWdd5AufqSSqeM2qZQznF1bpP1Pbjx38cVxBYNRTB': { + rate: '1', + conversionTime: 1705760550000, + expirationTime: 1705846950000, + }, + 'solana:JUP9a7mHxhR4WGcK6RBtkG7xMbRBVU7Q8tqsyHG4d3Z': { + rate: '0.01', + conversionTime: 1705760550000, + expirationTime: 1705846950000, + }, + }; + + await advanceTime({ clock, duration: interval * 1.5 }); + + expect(controller.state.conversionRates).toStrictEqual(expectedRates); + + const newTokenList = { + allNonEvmTokens: { + account1: ['solana:test'], + }, + }; + await advanceTime({ clock, duration: interval * 1.5 }); + + messenger.publish( + `${multiChainAssetsControllerName}:stateChange`, + { + ...newTokenList, + metadata: {}, + allNonEvmIgnoredTokens: {}, + }, + [], + ); + + await advanceTime({ clock, duration: interval * 1.5 }); + + expect(controller.state.conversionRates).toStrictEqual({ + 'solana:test': { + rate: '1', + conversionTime: 1705760550000, + expirationTime: 1705846950000, + }, + }); + }); + + it('should stop polling and rates on interval if unlocked keyring is locked', async () => { + const { controller, messenger } = setupController(); + + controller.setIntervalLength(10); + await controller.start(); + + messenger.publish('KeyringController:lock'); + + await advanceTime({ clock, duration: 10 }); + + expect(controller.state.conversionRates).toStrictEqual({}); + expect(controller.isActive).toBe(false); + + messenger.publish('KeyringController:unlock'); + + await advanceTime({ clock, duration: 10 }); + expect(controller.isActive).toBe(true); + }); + + it('should not update rates if the selected account has been changed', async () => { + nock('http://localhost:3000') + .get( + '/v3/spot-prices?assetIds=bip122:000000000019d6689c085ae165831e93/slip44:0,eip155:1/slip44:60,solana:So11111111111111111111111111111111111111112,solana:EPjFWdd5AufqSSqeM2qZQznF1bpP1Pbjx38cVxBYNRTB,solana:JUP9a7mHxhR4WGcK6RBtkG7xMbRBVU7Q8tqsyHG4d3ZfalsevsCurrency=usd', + ) + .reply(200, { + 'bip122:000000000019d6689c085ae165831e93/slip44:0': { + usd: 45000.0, + }, + 'eip155:1/slip44:60': { + usd: 3200.0, + }, + 'solana:So11111111111111111111111111111111111111112': { + usd: 25.0, + }, + 'solana:EPjFWdd5AufqSSqeM2qZQznF1bpP1Pbjx38cVxBYNRTB': { + usd: 1.0, + }, + 'solana:JUP9a7mHxhR4WGcK6RBtkG7xMbRBVU7Q8tqsyHG4d3Z': { + usd: 0.01, + }, + }) + .get( + '/v3/spot-prices?assetIds=solana:test1,solana:test2falsevsCurrency=usd', + ) + .reply(200, { + 'solana:test1': { + usd: 1.0, + }, + 'solana:test2': { + usd: 2.0, + }, + }) + .persist(); + + const interval = 10; + const { controller, messenger } = setupController({ config: { interval } }); + await controller._executePoll({ accountId: 'account1' }); + + await advanceTime({ clock, duration: interval * 1.5 }); + + expect(controller.state.conversionRates).toStrictEqual({ + 'bip122:000000000019d6689c085ae165831e93/slip44:0': { + rate: '45000', + conversionTime: 1705760550000, + expirationTime: 1705846950000, + }, + 'eip155:1/slip44:60': { + rate: '3200', + conversionTime: 1705760550000, + expirationTime: 1705846950000, + }, + 'solana:So11111111111111111111111111111111111111112': { + rate: '25', + conversionTime: 1705760550000, + expirationTime: 1705846950000, + }, + 'solana:EPjFWdd5AufqSSqeM2qZQznF1bpP1Pbjx38cVxBYNRTB': { + rate: '1', + conversionTime: 1705760550000, + expirationTime: 1705846950000, + }, + 'solana:JUP9a7mHxhR4WGcK6RBtkG7xMbRBVU7Q8tqsyHG4d3Z': { + rate: '0.01', + conversionTime: 1705760550000, + expirationTime: 1705846950000, + }, + }); + + messenger.publish('AccountsController:selectedAccountChange', { + id: 'account2', + type: 'eip155:eoa', + options: {}, + address: '0x123', + methods: [], + metadata: { + name: 'Test Account', + importTime: 1, + keyring: { type: 'eip155:eoa' }, + }, + scopes: [], + }); + + await advanceTime({ clock, duration: interval * 1.5 }); + + expect(controller.state.conversionRates).toStrictEqual({ + 'solana:test1': { + rate: '1', + conversionTime: 1705760550000, + expirationTime: 1705846950000, + }, + 'solana:test2': { + rate: '2', + conversionTime: 1705760550000, + expirationTime: 1705846950000, + }, + }); + }); +}); diff --git a/packages/assets-controllers/src/RatesController/multichain-rates/MultiChainRatesController.ts b/packages/assets-controllers/src/RatesController/multichain-rates/MultiChainRatesController.ts new file mode 100644 index 00000000000..fc2d53deff2 --- /dev/null +++ b/packages/assets-controllers/src/RatesController/multichain-rates/MultiChainRatesController.ts @@ -0,0 +1,324 @@ +import type { AccountsControllerState } from '@metamask/accounts-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { Mutex } from 'async-mutex'; +import type { Draft } from 'immer'; + +import type { + AssetConversionRate, + MarketDataDetailsFromPriceAPi, + MultichainAssetsControllerState, + MultiChainRatesControllerMessenger, + MultiChainRatesControllerOptions, + MultiChainRatesControllerState, +} from './types'; +import { fetchMultiChainRates } from './util'; +import { safelyExecute } from '../../../../controller-utils/src/util'; +import { StaticIntervalPollingController } from '../../../../polling-controller/src/StaticIntervalPollingController'; + +export const name = 'MultiChainRatesController'; + +/** The input to start polling for the {@link MultiChainRatesController} */ +export type MultiChainRatesPollingInput = { + accountId: string; +}; + +const metadata = { + conversionRates: { persist: true, anonymous: true }, +}; + +enum PollState { + Active = 'Active', + Inactive = 'Inactive', +} + +/** + * Get the default {@link MultiChainRatesController} state. + * + * @returns The default {@link MultiChainRatesController} state. + */ +export const getDefaultMultiChainRatesControllerState = + (): MultiChainRatesControllerState => { + return { + conversionRates: {}, + }; + }; + +const DEFAULT_INTERVAL = 180000; + +export class MultiChainRatesController extends StaticIntervalPollingController()< + typeof name, + MultiChainRatesControllerState, + MultiChainRatesControllerMessenger +> { + readonly #mutex = new Mutex(); + + #multiChainAllTokens: MultichainAssetsControllerState['allNonEvmTokens']; + + #accountId: AccountsControllerState['internalAccounts']['selectedAccount']; + + #isUnlocked = true; + + readonly #interval: number; + + #handle?: NodeJS.Timeout; + + #pollState: PollState = PollState.Inactive; + + #disabled: boolean; + + /** + * Creates a MultiChainRatesController instance. + * + * @param options - Constructor options. + * @param options.interval - The polling interval, in milliseconds. + * @param options.disabled - Whether the controller is disabled. + * @param options.messenger - A reference to the messaging system. + * @param options.state - Initial state to set on this controller. + */ + constructor({ + interval = DEFAULT_INTERVAL, + disabled = false, + messenger, + state, + }: MultiChainRatesControllerOptions) { + super({ + name, + metadata, + messenger, + state: { ...getDefaultMultiChainRatesControllerState(), ...state }, + }); + this.setIntervalLength(interval); + this.#interval = interval; + this.#disabled = disabled; + + // Set initial tokens, and subscribe to changes + ({ allNonEvmTokens: this.#multiChainAllTokens } = this.messagingSystem.call( + 'MultiChainAssetsController:getState', + )); + + // Subscribe to multi chain assets state changes + this.messagingSystem.subscribe( + 'MultiChainAssetsController:stateChange', + (assetsState) => this.#onMultiChainAssetsStateChange(assetsState), + ); + + // Subscribe to keyring lock events + this.messagingSystem.subscribe('KeyringController:lock', () => { + this.#isUnlocked = false; + this.stop(); + }); + + // Subscribe to keyring unlock events + this.messagingSystem.subscribe('KeyringController:unlock', async () => { + this.#isUnlocked = true; + await this.start(); + }); + + // Set initial account, and subscribe to changes + ({ + internalAccounts: { selectedAccount: this.#accountId }, + } = this.messagingSystem.call('AccountsController:getState')); + + this.messagingSystem.subscribe( + 'AccountsController:selectedAccountChange', + async (accountsState) => + await this.#onAccountsSelectedAccountChange(accountsState), + ); + } + + /** + * Allows controller to make active and passive polling requests + */ + enable(): void { + this.#disabled = false; + } + + /** + * Blocks controller from making network calls + */ + disable(): void { + this.#disabled = true; + } + + /** + * Start (or restart) polling. + */ + async start() { + this.#stopPoll(); + this.#pollState = PollState.Active; + await this.#poll(); + } + + /** + * Stop polling. + */ + stop() { + this.#stopPoll(); + this.#pollState = PollState.Inactive; + } + + /** + * Clear the active polling timer, if present. + */ + #stopPoll() { + if (this.#handle) { + clearTimeout(this.#handle); + } + } + + /** + * Poll for exchange rate updates. + */ + async #poll() { + if (this.#disabled || !this.#isUnlocked) { + this.#pollState = PollState.Inactive; + return; + } + await safelyExecute(() => this.#updateTokensRates(this.#accountId)); + + // Poll using recursive `setTimeout` instead of `setInterval` so that + // requests don't stack if they take longer than the polling interval + this.#handle = setTimeout(() => { + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#poll(); + }, this.#interval); + } + + get isActive() { + return this.#pollState === PollState.Active; + } + + /** + * Restart polling for the token rate. + * Consider using the new polling approach instead + */ + async restart() { + this.stop(); + await this.start(); + } + + /** + * Executes a function `callback` within a mutex lock to ensure that only one instance of `callback` runs at a time across all invocations of `#withLock`. + * This method is useful for synchronizing access to a resource or section of code that should not be executed concurrently. + * + * @template R - The return type of the function `callback`. + * @param callback - A callback to execute once the lock is acquired. This callback can be synchronous or asynchronous. + * @returns A promise that resolves to the result of the function `callback`. The promise is fulfilled once `callback` has completed execution. + * @example + * async function criticalLogic() { + * // Critical logic code goes here. + * } + * + * // Execute criticalLogic within a lock. + * const result = await this.#withLock(criticalLogic); + */ + async #withLock(callback: () => R) { + const releaseLock = await this.#mutex.acquire(); + try { + return callback(); + } finally { + releaseLock(); + } + } + + /** + * Get the asset ids by account id. or for the selected account id by default + * + * @param accountId - The account id to get the asset ids for. + * @returns The asset ids. + */ + #getAssetIdsBySelectedAccountId(accountId: string): string[] { + const accountIdToUse = accountId ?? this.#accountId; + + if (this.#multiChainAllTokens?.[accountIdToUse]?.length > 0) { + return this.#multiChainAllTokens[accountIdToUse].map((token) => token); + } + return []; + } + + /** + * Updates the rates by fetching new data. + * + * @param accountId - The account ID to update the rates for. + */ + async #updateTokensRates(accountId: string): Promise { + await this.#withLock(async () => { + const assetIds = this.#getAssetIdsBySelectedAccountId(accountId); + if (assetIds.length === 0) { + return []; + } + + const updatedRates = await this.#getRateForTokens(assetIds, false); + + const transformedRates = Object.entries(updatedRates).reduce( + ( + acc: Record, + [key, value], + ): Record => { + if (typeof value === 'object' && 'usd' in value) { + acc[key] = { + rate: (value as { usd: number }).usd.toString(), + conversionTime: Date.now(), + expirationTime: Date.now() + 1000 * 60 * 60 * 24, // 24 hours for now , but need more information on this one + }; + } + return acc; + }, + {}, + ); + + this.update( + ( + state: Draft, + ): MultiChainRatesControllerState => { + return { + ...state, + conversionRates: { + ...transformedRates, + }, + }; + }, + ); + return transformedRates; + }); + } + + /** + * Polls for erc20 token balances. + * + * @param options - The polling options. + * @param options.accountId - The account ID to poll for. + */ + async _executePoll({ accountId }: MultiChainRatesPollingInput) { + if (this.#disabled || !this.#isUnlocked) { + this.#pollState = PollState.Inactive; + return; + } + await this.#updateTokensRates(accountId); + } + + #onMultiChainAssetsStateChange(state: MultichainAssetsControllerState) { + this.#multiChainAllTokens = state.allNonEvmTokens; + } + + async #onAccountsSelectedAccountChange(state: InternalAccount) { + this.#accountId = state.id; + await this.restart(); + } + + /** + * Returns the current rate for a given token. + * + * @param tokenAddresses - The addresses of the tokens to get the rate for. + * @param includeMarketData - Whether to include market data in the response. + * @returns The rate for the token. + */ + async #getRateForTokens( + tokenAddresses: string[], + includeMarketData: boolean, + ): Promise { + const rate = await fetchMultiChainRates(tokenAddresses, includeMarketData); + return rate; + } +} diff --git a/packages/assets-controllers/src/RatesController/multichain-rates/types.ts b/packages/assets-controllers/src/RatesController/multichain-rates/types.ts new file mode 100644 index 00000000000..a7de2981d1a --- /dev/null +++ b/packages/assets-controllers/src/RatesController/multichain-rates/types.ts @@ -0,0 +1,194 @@ +import type { + AccountsControllerGetStateAction, + AccountsControllerSelectedAccountChangeEvent, +} from '@metamask/accounts-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + RestrictedControllerMessenger, +} from '@metamask/base-controller'; +import type { CaipAssetType } from '@metamask/keyring-api'; +import type { KeyringControllerLockEvent } from '@metamask/keyring-controller'; +import type { KeyringControllerUnlockEvent } from '@metamask/keyring-controller'; + +import type { name as multiChainRatesControllerName } from './MultiChainRatesController'; + +export const multiChainAssetsControllerName = 'MultiChainAssetsController'; + +export type Chain = 'solana'; // TODO: Add more non evm chains if supported + +export type AssetConversionRate = { + // The rate of conversion from the source asset to the target asset. It + // means that 1 unit of the `from` asset should be converted to this amount + // of the `to` asset. + rate: string; + + // The UNIX timestamp of when the conversion rate was last updated. + conversionTime: number; + + // The UNIX timestamp of when the conversion rate will expire. + expirationTime: number; +}; + +// Represents an asset unit. +type FungibleAssetUnit = { + // Human-friendly name of the asset unit. + name: string; + + // Ticker symbol of the asset unit. + symbol: string; + + // Number of decimals of the asset unit. + decimals: number; +}; + +// Fungible asset metadata. +type FungibleAssetMetadata = { + // Human-friendly name of the asset. + name: string; + + // Ticker symbol of the asset's main unit. + symbol: string; + + // Whether the asset is native to the chain. + native: boolean; + + // Represents a fungible asset + fungible: true; + + // Base64 representation of the asset icon. + iconBase64: string; + + // List of asset units. + units: FungibleAssetUnit[]; +}; + +export type MarketDataDetailsFromPriceAPi = { + usd: string; +}; + +// Represents the metadata of an asset. +type AssetMetadata = FungibleAssetMetadata; + +export type MultiChainRatesControllerState = { + conversionRates: Record; +}; + +export type MultichainAssetsControllerState = { + metadata: { + [asset: CaipAssetType]: AssetMetadata; + }; + allNonEvmTokens: { [account: string]: CaipAssetType[] }; + allNonEvmIgnoredTokens: { [account: string]: CaipAssetType[] }; +}; + +/** + * Type definition for RatesController state change events. + */ +export type MultiChainAssetsControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof multiChainAssetsControllerName, + MultichainAssetsControllerState + >; + +/** + * Type definition for RatesController state change events. + */ +export type MultiChainRatesControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof multiChainRatesControllerName, + MultiChainRatesControllerState + >; + +/** + * Type definition for the RatesController polling started event. + */ +export type MultiChainRatesControllerPollingStartedEvent = { + type: `${typeof multiChainRatesControllerName}:pollingStarted`; + payload: []; +}; + +/** + * Type definition for the RatesController polling stopped event. + */ +export type MultiChainRatesControllerPollingStoppedEvent = { + type: `${typeof multiChainRatesControllerName}:pollingStopped`; + payload: []; +}; + +/** + * Defines the events that the MultiChainRatesController can emit. + */ +export type MultiChainRatesControllerEvents = + MultiChainRatesControllerStateChangeEvent; + +export type MultiChainRatesControllerGetStateAction = ControllerGetStateAction< + typeof multiChainRatesControllerName, + MultiChainRatesControllerState +>; + +export type MultiChainAssetsControllerGetStateAction = ControllerGetStateAction< + typeof multiChainAssetsControllerName, + MultichainAssetsControllerState +>; + +/** + * All actions that {@link MultiChainRatesController} registers, to be called + * externally. + */ +export type MultiChainRatesControllerActions = + MultiChainRatesControllerGetStateAction; + +/** + * All actions that {@link MultiChainRatesController} calls internally. + */ +export type AllowedActions = + | AccountsControllerGetStateAction + | MultiChainAssetsControllerGetStateAction; + +/** + * All events that {@link MultiChainRatesController} subscribes to internally. + */ +export type AllowedEvents = + | MultiChainAssetsControllerStateChangeEvent + | AccountsControllerSelectedAccountChangeEvent + | KeyringControllerLockEvent + | KeyringControllerUnlockEvent; + +/** + * Defines the actions that the MultiChainRatesController can perform. + */ + +export type MultiChainRatesControllerMessenger = RestrictedControllerMessenger< + typeof multiChainRatesControllerName, + MultiChainRatesControllerActions | AllowedActions, + MultiChainRatesControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; + +/** + * The options required to initialize a RatesController. + */ +export type MultiChainRatesControllerOptions = { + /** + * Whether to include USD rates in the conversion rates. + */ + includeUsdRate: boolean; + /** + * The polling interval in milliseconds. + */ + interval?: number; + /** + * Whether the controller is disabled. + */ + disabled?: boolean; + /** + * The messenger instance for communication. + */ + messenger: MultiChainRatesControllerMessenger; + /** + * The initial state of the controller. + */ + state?: Partial; +}; diff --git a/packages/assets-controllers/src/RatesController/multichain-rates/util.ts b/packages/assets-controllers/src/RatesController/multichain-rates/util.ts new file mode 100644 index 00000000000..63550161d73 --- /dev/null +++ b/packages/assets-controllers/src/RatesController/multichain-rates/util.ts @@ -0,0 +1,21 @@ +import { handleFetch } from '@metamask/controller-utils'; + +import type { MarketDataDetailsFromPriceAPi } from './types'; + +/** + * Fetches the rate for a given token on a specific chain. + * + * @param assetIds - An array of addresses of the tokens to fetch the rate for. + * @param includeMarketData - Whether to include market data in the response. + * @returns A promise that resolves to the rate of the token in USD. + */ +export function fetchMultiChainRates( + assetIds: string[], + includeMarketData: boolean, +): Promise { + const url = `http://localhost:3000/v3/spot-prices?assetIds=${assetIds.join( + ',', + )}${includeMarketData && `&includeMarketData=true`}${`vsCurrency=usd`}`; + + return handleFetch(url); +}