Skip to content

Commit

Permalink
fix(assets-controllers): Prevent overlapping token rate updates
Browse files Browse the repository at this point in the history
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.

Fixes #3606
  • Loading branch information
Gudahtt committed Dec 11, 2023
1 parent c106e64 commit 1d51c3f
Show file tree
Hide file tree
Showing 2 changed files with 194 additions and 27 deletions.
86 changes: 86 additions & 0 deletions packages/assets-controllers/src/TokenRatesController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1972,6 +1972,79 @@ describe('TokenRatesController', () => {
},
);
});

it('only updates rates once when called twice', async () => {
const tokenAddresses = [
'0x0000000000000000000000000000000000000001',
'0x0000000000000000000000000000000000000002',
];
const fetchTokenPricesMock = jest.fn().mockResolvedValue({
[tokenAddresses[0]]: {
currency: 'ETH',
tokenContractAddress: tokenAddresses[0],
value: 0.001,
},
[tokenAddresses[1]]: {
currency: 'ETH',
tokenContractAddress: tokenAddresses[1],
value: 0.002,
},
});
const tokenPricesService = buildMockTokenPricesService({
fetchTokenPrices: fetchTokenPricesMock,
});
await withController(
{ options: { tokenPricesService } },
async ({ controller, controllerEvents }) => {
const updateExchangeRates = async () =>
await callUpdateExchangeRatesMethod({
allTokens: {
[toHex(1)]: {
[controller.config.selectedAddress]: [
{
address: tokenAddresses[0],
decimals: 18,
symbol: 'TST1',
aggregators: [],
},
{
address: tokenAddresses[1],
decimals: 18,
symbol: 'TST2',
aggregators: [],
},
],
},
},
chainId: toHex(1),
controller,
controllerEvents,
method,
nativeCurrency: 'ETH',
});

await Promise.all([updateExchangeRates(), updateExchangeRates()]);

expect(fetchTokenPricesMock).toHaveBeenCalledTimes(1);
expect(controller.state).toMatchInlineSnapshot(`
Object {
"contractExchangeRates": Object {
"0x0000000000000000000000000000000000000001": 0.001,
"0x0000000000000000000000000000000000000002": 0.002,
},
"contractExchangeRatesByChainId": Object {
"0x1": Object {
"ETH": Object {
"0x0000000000000000000000000000000000000001": 0.001,
"0x0000000000000000000000000000000000000002": 0.002,
},
},
},
}
`);
},
);
});
});
});

Expand Down Expand Up @@ -2059,9 +2132,22 @@ async function withController<ReturnValue>(
});
} finally {
controller.stop();
await flushPromises();
}
}

/**
* Resolve all pending promises.
*
* This method is used for async tests that use fake timers.
* See https://stackoverflow.com/a/58716087 and https://jestjs.io/docs/timer-mocks.
*
* TODO: migrate this to @metamask/utils
*/
async function flushPromises(): Promise<void> {
await new Promise(jest.requireActual('timers').setImmediate);
}

/**
* Call an "update exchange rates" method with the given parameters.
*
Expand Down
135 changes: 108 additions & 27 deletions packages/assets-controllers/src/TokenRatesController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ export class TokenRatesController extends PollingControllerV1<

#tokenPricesService: AbstractTokenPricesService;

#inProcessExchangeRateUpdate: Record<Hex, Promise<void>> = {};

/**
* Name of this controller used during composition
*/
Expand Down Expand Up @@ -360,36 +362,59 @@ export class TokenRatesController extends PollingControllerV1<
return;
}

const newContractExchangeRates = await this.#fetchAndMapExchangeRates({
tokenContractAddresses,
chainId,
nativeCurrency,
});
if (chainId in this.#inProcessExchangeRateUpdate) {
// This prevents redundant updates
// This promise is resolved after the in-progress update has finished,
// and state has been updated.
await this.#inProcessExchangeRateUpdate[chainId];
return;
}

const {
promise: inProgressUpdate,
resolve: updateSucceeded,
reject: updateFailed,
} = deferredPromise({ suppressUnhandledRejection: true });
this.#inProcessExchangeRateUpdate[chainId] = inProgressUpdate;

try {
const newContractExchangeRates = await this.#fetchAndMapExchangeRates({
tokenContractAddresses,
chainId,
nativeCurrency,
});

const existingContractExchangeRates = this.state.contractExchangeRates;
const updatedContractExchangeRates =
chainId === this.config.chainId &&
nativeCurrency === this.config.nativeCurrency
? newContractExchangeRates
: existingContractExchangeRates;

const existingContractExchangeRatesForChainId =
this.state.contractExchangeRatesByChainId[chainId] ?? {};
const updatedContractExchangeRatesForChainId = {
...this.state.contractExchangeRatesByChainId,
[chainId]: {
...existingContractExchangeRatesForChainId,
[nativeCurrency]: {
...existingContractExchangeRatesForChainId[nativeCurrency],
...newContractExchangeRates,
const existingContractExchangeRates = this.state.contractExchangeRates;
const updatedContractExchangeRates =
chainId === this.config.chainId &&
nativeCurrency === this.config.nativeCurrency
? newContractExchangeRates
: existingContractExchangeRates;

const existingContractExchangeRatesForChainId =
this.state.contractExchangeRatesByChainId[chainId] ?? {};
const updatedContractExchangeRatesForChainId = {
...this.state.contractExchangeRatesByChainId,
[chainId]: {
...existingContractExchangeRatesForChainId,
[nativeCurrency]: {
...existingContractExchangeRatesForChainId[nativeCurrency],
...newContractExchangeRates,
},
},
},
};
};

this.update({
contractExchangeRates: updatedContractExchangeRates,
contractExchangeRatesByChainId: updatedContractExchangeRatesForChainId,
});
this.update({
contractExchangeRates: updatedContractExchangeRates,
contractExchangeRatesByChainId: updatedContractExchangeRatesForChainId,
});
updateSucceeded();
} catch (error: unknown) {
updateFailed(error);
throw error;
} finally {
delete this.#inProcessExchangeRateUpdate[chainId];
}
}

/**
Expand Down Expand Up @@ -548,4 +573,60 @@ export class TokenRatesController extends PollingControllerV1<
}
}

/**
* A deferred Promise.
*
* A deferred Promise is one that can be resolved or rejected independently of
* the Promise construction.
*/
type DeferredPromise = {
/**
* The Promise that has been deferred.
*/
promise: Promise<void>;
/**
* A function that resolves the Promise.
*/
resolve: () => void;
/**
* A function that rejects the Promise.
*/
reject: (error: unknown) => void;
};

/**
* Create a defered Promise.
*
* TODO: Migrate this to utils
*
* @param args - The arguments.
* @param args.suppressUnhandledRejection - This option adds an empty error handler
* to the Promise to suppress the UnhandledPromiseRejection error. This can be
* useful if the deferred Promise is sometimes intentionally not used.
* @returns A deferred Promise.
*/
function deferredPromise({
suppressUnhandledRejection = false,
}: {
suppressUnhandledRejection: boolean;
}): DeferredPromise {
let resolve: DeferredPromise['resolve'];
let reject: DeferredPromise['reject'];
const promise = new Promise<void>(
(innerResolve: () => void, innerReject: () => void) => {
resolve = innerResolve;
reject = innerReject;
},
);

if (suppressUnhandledRejection) {
promise.catch((_error) => {
// This handler is used to suppress the UnhandledPromiseRejection error
});
}

// @ts-expect-error We know that these are assigned, but TypeScript doesn't
return { promise, resolve, reject };
}

export default TokenRatesController;

0 comments on commit 1d51c3f

Please sign in to comment.