Skip to content

Commit 1003274

Browse files
authored
fix(assets-controllers): Prevent overlapping token rate updates (#3635)
## Explanation Previously it was possible for two redundant token rate updates to be ongoing at the same time. This is non-optimal for performance reasons, and because the answer might change between the two requests and get persisted in the wrong order. We now guard against this by storing in-progress updates as a private instance variable. Any redundant calls will wait on the in-progress call to finish, then return as a no-op. ## References Fixes #3606 ## Changelog ### `@metamask/assets-controllers` -Fixed: Prevent overlapping token rate updates - This should have no impact to the API at all, it just reduces network traffic ## 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
1 parent 4fbf54d commit 1003274

File tree

2 files changed

+195
-27
lines changed

2 files changed

+195
-27
lines changed

packages/assets-controllers/src/TokenRatesController.test.ts

+86
Original file line numberDiff line numberDiff line change
@@ -1972,6 +1972,79 @@ describe('TokenRatesController', () => {
19721972
},
19731973
);
19741974
});
1975+
1976+
it('only updates rates once when called twice', async () => {
1977+
const tokenAddresses = [
1978+
'0x0000000000000000000000000000000000000001',
1979+
'0x0000000000000000000000000000000000000002',
1980+
];
1981+
const fetchTokenPricesMock = jest.fn().mockResolvedValue({
1982+
[tokenAddresses[0]]: {
1983+
currency: 'ETH',
1984+
tokenContractAddress: tokenAddresses[0],
1985+
value: 0.001,
1986+
},
1987+
[tokenAddresses[1]]: {
1988+
currency: 'ETH',
1989+
tokenContractAddress: tokenAddresses[1],
1990+
value: 0.002,
1991+
},
1992+
});
1993+
const tokenPricesService = buildMockTokenPricesService({
1994+
fetchTokenPrices: fetchTokenPricesMock,
1995+
});
1996+
await withController(
1997+
{ options: { tokenPricesService } },
1998+
async ({ controller, controllerEvents }) => {
1999+
const updateExchangeRates = async () =>
2000+
await callUpdateExchangeRatesMethod({
2001+
allTokens: {
2002+
[toHex(1)]: {
2003+
[controller.config.selectedAddress]: [
2004+
{
2005+
address: tokenAddresses[0],
2006+
decimals: 18,
2007+
symbol: 'TST1',
2008+
aggregators: [],
2009+
},
2010+
{
2011+
address: tokenAddresses[1],
2012+
decimals: 18,
2013+
symbol: 'TST2',
2014+
aggregators: [],
2015+
},
2016+
],
2017+
},
2018+
},
2019+
chainId: toHex(1),
2020+
controller,
2021+
controllerEvents,
2022+
method,
2023+
nativeCurrency: 'ETH',
2024+
});
2025+
2026+
await Promise.all([updateExchangeRates(), updateExchangeRates()]);
2027+
2028+
expect(fetchTokenPricesMock).toHaveBeenCalledTimes(1);
2029+
expect(controller.state).toMatchInlineSnapshot(`
2030+
Object {
2031+
"contractExchangeRates": Object {
2032+
"0x0000000000000000000000000000000000000001": 0.001,
2033+
"0x0000000000000000000000000000000000000002": 0.002,
2034+
},
2035+
"contractExchangeRatesByChainId": Object {
2036+
"0x1": Object {
2037+
"ETH": Object {
2038+
"0x0000000000000000000000000000000000000001": 0.001,
2039+
"0x0000000000000000000000000000000000000002": 0.002,
2040+
},
2041+
},
2042+
},
2043+
}
2044+
`);
2045+
},
2046+
);
2047+
});
19752048
});
19762049
});
19772050

@@ -2059,9 +2132,22 @@ async function withController<ReturnValue>(
20592132
});
20602133
} finally {
20612134
controller.stop();
2135+
await flushPromises();
20622136
}
20632137
}
20642138

2139+
/**
2140+
* Resolve all pending promises.
2141+
*
2142+
* This method is used for async tests that use fake timers.
2143+
* See https://stackoverflow.com/a/58716087 and https://jestjs.io/docs/timer-mocks.
2144+
*
2145+
* TODO: migrate this to @metamask/utils
2146+
*/
2147+
async function flushPromises(): Promise<void> {
2148+
await new Promise(jest.requireActual('timers').setImmediate);
2149+
}
2150+
20652151
/**
20662152
* Call an "update exchange rates" method with the given parameters.
20672153
*

packages/assets-controllers/src/TokenRatesController.ts

+109-27
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ export class TokenRatesController extends PollingControllerV1<
146146

147147
#tokenPricesService: AbstractTokenPricesService;
148148

149+
#inProcessExchangeRateUpdates: Record<`${Hex}:${string}`, Promise<void>> = {};
150+
149151
/**
150152
* Name of this controller used during composition
151153
*/
@@ -360,36 +362,60 @@ export class TokenRatesController extends PollingControllerV1<
360362
return;
361363
}
362364

363-
const newContractExchangeRates = await this.#fetchAndMapExchangeRates({
364-
tokenContractAddresses,
365-
chainId,
366-
nativeCurrency,
367-
});
365+
const updateKey: `${Hex}:${string}` = `${chainId}:${nativeCurrency}`;
366+
if (updateKey in this.#inProcessExchangeRateUpdates) {
367+
// This prevents redundant updates
368+
// This promise is resolved after the in-progress update has finished,
369+
// and state has been updated.
370+
await this.#inProcessExchangeRateUpdates[updateKey];
371+
return;
372+
}
373+
374+
const {
375+
promise: inProgressUpdate,
376+
resolve: updateSucceeded,
377+
reject: updateFailed,
378+
} = createDeferredPromise({ suppressUnhandledRejection: true });
379+
this.#inProcessExchangeRateUpdates[updateKey] = inProgressUpdate;
380+
381+
try {
382+
const newContractExchangeRates = await this.#fetchAndMapExchangeRates({
383+
tokenContractAddresses,
384+
chainId,
385+
nativeCurrency,
386+
});
368387

369-
const existingContractExchangeRates = this.state.contractExchangeRates;
370-
const updatedContractExchangeRates =
371-
chainId === this.config.chainId &&
372-
nativeCurrency === this.config.nativeCurrency
373-
? newContractExchangeRates
374-
: existingContractExchangeRates;
375-
376-
const existingContractExchangeRatesForChainId =
377-
this.state.contractExchangeRatesByChainId[chainId] ?? {};
378-
const updatedContractExchangeRatesForChainId = {
379-
...this.state.contractExchangeRatesByChainId,
380-
[chainId]: {
381-
...existingContractExchangeRatesForChainId,
382-
[nativeCurrency]: {
383-
...existingContractExchangeRatesForChainId[nativeCurrency],
384-
...newContractExchangeRates,
388+
const existingContractExchangeRates = this.state.contractExchangeRates;
389+
const updatedContractExchangeRates =
390+
chainId === this.config.chainId &&
391+
nativeCurrency === this.config.nativeCurrency
392+
? newContractExchangeRates
393+
: existingContractExchangeRates;
394+
395+
const existingContractExchangeRatesForChainId =
396+
this.state.contractExchangeRatesByChainId[chainId] ?? {};
397+
const updatedContractExchangeRatesForChainId = {
398+
...this.state.contractExchangeRatesByChainId,
399+
[chainId]: {
400+
...existingContractExchangeRatesForChainId,
401+
[nativeCurrency]: {
402+
...existingContractExchangeRatesForChainId[nativeCurrency],
403+
...newContractExchangeRates,
404+
},
385405
},
386-
},
387-
};
406+
};
388407

389-
this.update({
390-
contractExchangeRates: updatedContractExchangeRates,
391-
contractExchangeRatesByChainId: updatedContractExchangeRatesForChainId,
392-
});
408+
this.update({
409+
contractExchangeRates: updatedContractExchangeRates,
410+
contractExchangeRatesByChainId: updatedContractExchangeRatesForChainId,
411+
});
412+
updateSucceeded();
413+
} catch (error: unknown) {
414+
updateFailed(error);
415+
throw error;
416+
} finally {
417+
delete this.#inProcessExchangeRateUpdates[updateKey];
418+
}
393419
}
394420

395421
/**
@@ -548,4 +574,60 @@ export class TokenRatesController extends PollingControllerV1<
548574
}
549575
}
550576

577+
/**
578+
* A deferred Promise.
579+
*
580+
* A deferred Promise is one that can be resolved or rejected independently of
581+
* the Promise construction.
582+
*/
583+
type DeferredPromise = {
584+
/**
585+
* The Promise that has been deferred.
586+
*/
587+
promise: Promise<void>;
588+
/**
589+
* A function that resolves the Promise.
590+
*/
591+
resolve: () => void;
592+
/**
593+
* A function that rejects the Promise.
594+
*/
595+
reject: (error: unknown) => void;
596+
};
597+
598+
/**
599+
* Create a defered Promise.
600+
*
601+
* TODO: Migrate this to utils
602+
*
603+
* @param args - The arguments.
604+
* @param args.suppressUnhandledRejection - This option adds an empty error handler
605+
* to the Promise to suppress the UnhandledPromiseRejection error. This can be
606+
* useful if the deferred Promise is sometimes intentionally not used.
607+
* @returns A deferred Promise.
608+
*/
609+
function createDeferredPromise({
610+
suppressUnhandledRejection = false,
611+
}: {
612+
suppressUnhandledRejection: boolean;
613+
}): DeferredPromise {
614+
let resolve: DeferredPromise['resolve'];
615+
let reject: DeferredPromise['reject'];
616+
const promise = new Promise<void>(
617+
(innerResolve: () => void, innerReject: () => void) => {
618+
resolve = innerResolve;
619+
reject = innerReject;
620+
},
621+
);
622+
623+
if (suppressUnhandledRejection) {
624+
promise.catch((_error) => {
625+
// This handler is used to suppress the UnhandledPromiseRejection error
626+
});
627+
}
628+
629+
// @ts-expect-error We know that these are assigned, but TypeScript doesn't
630+
return { promise, resolve, reject };
631+
}
632+
551633
export default TokenRatesController;

0 commit comments

Comments
 (0)