Skip to content

Commit

Permalink
fix: multichainToken rate for non evm (#5175)
Browse files Browse the repository at this point in the history
---

## 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
salimtb authored Feb 7, 2025
1 parent e607b7f commit 1fbade9
Show file tree
Hide file tree
Showing 5 changed files with 937 additions and 0 deletions.
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();
});
});
Loading

0 comments on commit 1fbade9

Please sign in to comment.