Skip to content

Commit

Permalink
feat: multi chain polling for token prices (#28158)
Browse files Browse the repository at this point in the history
<!--
Please submit this PR as a draft initially.
Do not mark it as "Ready for review" until the template has been
completely filled out, and PR status checks have passed at least once.
-->

## **Description**

Multi chain polling for the token rates controller. This will fetch
erc20 token prices across all evm chains.


[![Open in GitHub
Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28158?quickstart=1)

## **Related issues**

## **Manual testing steps**

no visual changes, you should just see the network tab hitting price api
across multiple chains, correct prices when switching chains, when
adding new tokens, and `marketData` updated in state across chains

## **Screenshots/Recordings**

<!-- If applicable, add screenshots and/or recordings to visualize the
before and after of your change. -->

### **Before**

<!-- [screenshots/recordings] -->

### **After**

<!-- [screenshots/recordings] -->

## **Pre-merge author checklist**

- [ ] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask
Extension Coding
Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.

---------

Co-authored-by: MetaMask Bot <[email protected]>
Co-authored-by: sahar-fehri <[email protected]>
  • Loading branch information
3 people authored Nov 4, 2024
1 parent 77b77a8 commit eab6233
Show file tree
Hide file tree
Showing 11 changed files with 183 additions and 41 deletions.
28 changes: 13 additions & 15 deletions app/scripts/metamask-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,7 @@ export default class MetamaskController extends EventEmitter {
state: initState.TokenRatesController,
messenger: tokenRatesMessenger,
tokenPricesService: new CodefiTokenPricesServiceV2(),
disabled: !this.preferencesController.state.useCurrencyRateCheck,
});

this.controllerMessenger.subscribe(
Expand All @@ -1015,9 +1016,9 @@ export default class MetamaskController extends EventEmitter {
const { useCurrencyRateCheck: prevUseCurrencyRateCheck } = prevState;
const { useCurrencyRateCheck: currUseCurrencyRateCheck } = currState;
if (currUseCurrencyRateCheck && !prevUseCurrencyRateCheck) {
this.tokenRatesController.start();
this.tokenRatesController.enable();
} else if (!currUseCurrencyRateCheck && prevUseCurrencyRateCheck) {
this.tokenRatesController.stop();
this.tokenRatesController.disable();
}
}, this.preferencesController.state),
);
Expand Down Expand Up @@ -2590,12 +2591,6 @@ export default class MetamaskController extends EventEmitter {

const preferencesControllerState = this.preferencesController.state;

const { useCurrencyRateCheck } = preferencesControllerState;

if (useCurrencyRateCheck) {
this.tokenRatesController.start();
}

if (this.#isTokenListPollingRequired(preferencesControllerState)) {
this.tokenListController.start();
}
Expand All @@ -2608,12 +2603,6 @@ export default class MetamaskController extends EventEmitter {

const preferencesControllerState = this.preferencesController.state;

const { useCurrencyRateCheck } = preferencesControllerState;

if (useCurrencyRateCheck) {
this.tokenRatesController.stop();
}

if (this.#isTokenListPollingRequired(preferencesControllerState)) {
this.tokenListController.stop();
}
Expand Down Expand Up @@ -3250,6 +3239,7 @@ export default class MetamaskController extends EventEmitter {
backup,
approvalController,
phishingController,
tokenRatesController,
// Notification Controllers
authenticationController,
userStorageController,
Expand Down Expand Up @@ -4016,6 +4006,13 @@ export default class MetamaskController extends EventEmitter {
currencyRateController,
),

tokenRatesStartPolling:
tokenRatesController.startPolling.bind(tokenRatesController),
tokenRatesStopPollingByPollingToken:
tokenRatesController.stopPollingByPollingToken.bind(
tokenRatesController,
),

// GasFeeController
gasFeeStartPollingByNetworkClientId:
gasFeeController.startPollingByNetworkClientId.bind(gasFeeController),
Expand Down Expand Up @@ -6641,12 +6638,13 @@ export default class MetamaskController extends EventEmitter {

/**
* A method that is called by the background when all instances of metamask are closed.
* Currently used to stop polling in the gasFeeController.
* Currently used to stop controller polling.
*/
onClientClosed() {
try {
this.gasFeeController.stopAllPolling();
this.currencyRateController.stopAllPolling();
this.tokenRatesController.stopAllPolling();
this.appStateController.clearPollingTokens();
} catch (error) {
console.error(error);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@
"@metamask/address-book-controller": "^6.0.0",
"@metamask/announcement-controller": "^7.0.0",
"@metamask/approval-controller": "^7.0.0",
"@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A41.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-41.0.0-57b3d695bb.patch",
"@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A42.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-42.0.0-57b3d695bb.patch",
"@metamask/base-controller": "^7.0.0",
"@metamask/bitcoin-wallet-snap": "^0.8.2",
"@metamask/browser-passworder": "^4.3.0",
Expand Down
13 changes: 13 additions & 0 deletions ui/contexts/assetPolling.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React, { ReactNode } from 'react';
import useCurrencyRatePolling from '../hooks/useCurrencyRatePolling';
import useTokenRatesPolling from '../hooks/useTokenRatesPolling';

// This provider is a step towards making controller polling fully UI based.
// Eventually, individual UI components will call the use*Polling hooks to
// poll and return particular data. This polls globally in the meantime.
export const AssetPollingProvider = ({ children }: { children: ReactNode }) => {
useCurrencyRatePolling();
useTokenRatesPolling();

return <>{children}</>;
};
13 changes: 0 additions & 13 deletions ui/contexts/currencyRate.js

This file was deleted.

57 changes: 57 additions & 0 deletions ui/hooks/useMultiPolling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';

type UseMultiPollingOptions<PollingInput> = {
startPolling: (input: PollingInput) => Promise<string>;
stopPollingByPollingToken: (pollingToken: string) => void;
input: PollingInput[];
};

// A hook that manages multiple polling loops of a polling controller.
// Callers provide an array of inputs, and the hook manages starting
// and stopping polling loops for each input.
const useMultiPolling = <PollingInput>(
usePollingOptions: UseMultiPollingOptions<PollingInput>,
) => {
const [polls, setPolls] = useState(new Map());

useEffect(() => {
// start new polls
for (const input of usePollingOptions.input) {
const key = JSON.stringify(input);
if (!polls.has(key)) {
usePollingOptions
.startPolling(input)
.then((token) =>
setPolls((prevPolls) => new Map(prevPolls).set(key, token)),
);
}
}

// stop existing polls
for (const [inputKey, token] of polls.entries()) {
const exists = usePollingOptions.input.some(
(i) => inputKey === JSON.stringify(i),
);

if (!exists) {
usePollingOptions.stopPollingByPollingToken(token);
setPolls((prevPolls) => {
const newPolls = new Map(prevPolls);
newPolls.delete(inputKey);
return newPolls;
});
}
}
}, [usePollingOptions.input && JSON.stringify(usePollingOptions.input)]);

// stop all polling on dismount
useEffect(() => {
return () => {
for (const token of polls.values()) {
usePollingOptions.stopPollingByPollingToken(token);
}
};
}, []);
};

export default useMultiPolling;
40 changes: 40 additions & 0 deletions ui/hooks/useTokenRatesPolling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useSelector } from 'react-redux';
import {
getMarketData,
getNetworkConfigurationsByChainId,
getTokenExchangeRates,
getTokensMarketData,
getUseCurrencyRateCheck,
} from '../selectors';
import {
tokenRatesStartPolling,
tokenRatesStopPollingByPollingToken,
} from '../store/actions';
import useMultiPolling from './useMultiPolling';

const useTokenRatesPolling = ({ chainIds }: { chainIds?: string[] } = {}) => {
// Selectors to determine polling input
const useCurrencyRateCheck = useSelector(getUseCurrencyRateCheck);
const networkConfigurations = useSelector(getNetworkConfigurationsByChainId);

// Selectors returning state updated by the polling
const tokenExchangeRates = useSelector(getTokenExchangeRates);
const tokensMarketData = useSelector(getTokensMarketData);
const marketData = useSelector(getMarketData);

useMultiPolling({
startPolling: tokenRatesStartPolling,
stopPollingByPollingToken: tokenRatesStopPollingByPollingToken,
input: useCurrencyRateCheck
? chainIds ?? Object.keys(networkConfigurations)
: [],
});

return {
tokenExchangeRates,
tokensMarketData,
marketData,
};
};

export default useTokenRatesPolling;
6 changes: 3 additions & 3 deletions ui/pages/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
LegacyMetaMetricsProvider,
} from '../contexts/metametrics';
import { MetamaskNotificationsProvider } from '../contexts/metamask-notifications';
import { CurrencyRateProvider } from '../contexts/currencyRate';
import { AssetPollingProvider } from '../contexts/assetPolling';
import ErrorPage from './error';
import Routes from './routes';

Expand Down Expand Up @@ -49,11 +49,11 @@ class Index extends PureComponent {
<LegacyMetaMetricsProvider>
<I18nProvider>
<LegacyI18nProvider>
<CurrencyRateProvider>
<AssetPollingProvider>
<MetamaskNotificationsProvider>
<Routes />
</MetamaskNotificationsProvider>
</CurrencyRateProvider>
</AssetPollingProvider>
</LegacyI18nProvider>
</I18nProvider>
</LegacyMetaMetricsProvider>
Expand Down
16 changes: 16 additions & 0 deletions ui/selectors/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -578,11 +578,27 @@ export const getTokenExchangeRates = (state) => {
);
};

/**
* Get market data for tokens on the current chain
*
* @param state
* @returns {Record<Hex, import('@metamask/assets-controllers').MarketDataDetails>}
*/
export const getTokensMarketData = (state) => {
const chainId = getCurrentChainId(state);
return state.metamask.marketData?.[chainId];
};

/**
* Get market data for tokens across all chains
*
* @param state
* @returns {Record<Hex, Record<Hex, import('@metamask/assets-controllers').MarketDataDetails>>}
*/
export const getMarketData = (state) => {
return state.metamask.marketData;
};

export function getAddressBook(state) {
const chainId = getCurrentChainId(state);
if (!state.metamask.addressBook[chainId]) {
Expand Down
31 changes: 31 additions & 0 deletions ui/store/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4555,6 +4555,37 @@ export async function currencyRateStopPollingByPollingToken(
await removePollingTokenFromAppState(pollingToken);
}

/**
* Informs the TokenRatesController that the UI requires
* token rate polling for the given chain id.
*
* @param chainId - The chain id to poll token rates on.
* @returns polling token that can be used to stop polling
*/
export async function tokenRatesStartPolling(chainId: string): Promise<string> {
const pollingToken = await submitRequestToBackground(
'tokenRatesStartPolling',
[{ chainId }],
);
await addPollingTokenToAppState(pollingToken);
return pollingToken;
}

/**
* Informs the TokenRatesController that the UI no longer
* requires token rate polling for the given chain id.
*
* @param pollingToken -
*/
export async function tokenRatesStopPollingByPollingToken(
pollingToken: string,
) {
await submitRequestToBackground('tokenRatesStopPollingByPollingToken', [
pollingToken,
]);
await removePollingTokenFromAppState(pollingToken);
}

/**
* Informs the GasFeeController that the UI requires gas fee polling
*
Expand Down
18 changes: 9 additions & 9 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4772,9 +4772,9 @@ __metadata:
languageName: node
linkType: hard

"@metamask/assets-controllers@npm:41.0.0":
version: 41.0.0
resolution: "@metamask/assets-controllers@npm:41.0.0"
"@metamask/assets-controllers@npm:42.0.0":
version: 42.0.0
resolution: "@metamask/assets-controllers@npm:42.0.0"
dependencies:
"@ethereumjs/util": "npm:^8.1.0"
"@ethersproject/address": "npm:^5.7.0"
Expand Down Expand Up @@ -4806,13 +4806,13 @@ __metadata:
"@metamask/keyring-controller": ^17.0.0
"@metamask/network-controller": ^22.0.0
"@metamask/preferences-controller": ^13.0.0
checksum: 10/63f1a9605d692217889511ca161ee614d8e12d7f7233773afb34c4fb6323fad1c29b3a4ee920ef6f84e4b165ffb8764dfd105bdc9bad75084f52a7c876faa4f5
checksum: 10/64d2bd43139ee5c19bd665b07212cd5d5dd41b457dedde3b5db31442292c4d064dc015011f5f001bb423683675fb20898ff652e91d2339ad1d21cc45fa93487a
languageName: node
linkType: hard

"@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A41.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-41.0.0-57b3d695bb.patch":
version: 41.0.0
resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A41.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-41.0.0-57b3d695bb.patch::version=41.0.0&hash=e14ff8"
"@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A42.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-42.0.0-57b3d695bb.patch":
version: 42.0.0
resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A42.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-42.0.0-57b3d695bb.patch::version=42.0.0&hash=e14ff8"
dependencies:
"@ethereumjs/util": "npm:^8.1.0"
"@ethersproject/address": "npm:^5.7.0"
Expand Down Expand Up @@ -4844,7 +4844,7 @@ __metadata:
"@metamask/keyring-controller": ^17.0.0
"@metamask/network-controller": ^22.0.0
"@metamask/preferences-controller": ^13.0.0
checksum: 10/f7d609be61f4e952abd78d996a44131941f1fcd476066d007bed5047d1c887d38e9e9cf117eeb963148674fd9ad6ae87c8384bc8a21d4281628aaab1b60ce7a8
checksum: 10/9a6727b28f88fd2df3f4b1628dd5d8c2f3e73fd4b9cd090f22d175c2522faa6c6b7e9a93d0ec2b2d123a263c8f4116fbfe97f196b99401b28ac8597f522651eb
languageName: node
linkType: hard

Expand Down Expand Up @@ -26397,7 +26397,7 @@ __metadata:
"@metamask/announcement-controller": "npm:^7.0.0"
"@metamask/api-specs": "npm:^0.9.3"
"@metamask/approval-controller": "npm:^7.0.0"
"@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A41.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-41.0.0-57b3d695bb.patch"
"@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A42.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-42.0.0-57b3d695bb.patch"
"@metamask/auto-changelog": "npm:^2.1.0"
"@metamask/base-controller": "npm:^7.0.0"
"@metamask/bitcoin-wallet-snap": "npm:^0.8.2"
Expand Down

0 comments on commit eab6233

Please sign in to comment.