-
-
Notifications
You must be signed in to change notification settings - Fork 205
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix: multichainToken rate for non evm (#5175)
--- ## 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.
- Loading branch information
Showing
5 changed files
with
937 additions
and
0 deletions.
There are no files selected for viewing
378 changes: 378 additions & 0 deletions
378
...s-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<typeof MultiChainAssetsRatesController>[0] | ||
>; | ||
accountsAssets?: InternalAccount[]; | ||
} = {}) => { | ||
const messenger = new Messenger<AllowedActions, AllowedEvents>(); | ||
|
||
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(); | ||
}); | ||
}); |
Oops, something went wrong.