Skip to content

Commit

Permalink
feat: export exchange rates fct (#3657)
Browse files Browse the repository at this point in the history
## Explanation

Export a function from assets controller to fetch token exchange rates.

This function will be used by extension to compute user token fiat
amount and display it on the import token modal confirmation page.

## References

* Fixes
[#12345](https://consensyssoftware.atlassian.net/browse/MMASSETS-88)
* Related to
[#67890](MetaMask/metamask-extension#22263)

## Changelog

<!--
If you're making any consumer-facing changes, list those changes here as
if you were updating a changelog, using the template below as a guide.

(CATEGORY is one of BREAKING, ADDED, CHANGED, DEPRECATED, REMOVED, or
FIXED. For security-related issues, follow the Security Advisory
process.)

Please take care to name the exact pieces of the API you've added or
changed (e.g. types, interfaces, functions, or methods).

If there are any breaking changes, make sure to offer a solution for
consumers to follow once they upgrade to the changes.

Finally, if you're only making changes to development scripts or tests,
you may replace the template below with "None".
-->


### `@metamask/assets-controllers`

- **<CATEGORY>**: Updated `assetsUtil `file to export
`fetchAndMapExchangeRates `function

## 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
- [ ] I've highlighted breaking changes using the "BREAKING" category
above as appropriate
  • Loading branch information
sahar-fehri authored Dec 21, 2023
1 parent 8259340 commit cd78c1f
Show file tree
Hide file tree
Showing 4 changed files with 219 additions and 8 deletions.
10 changes: 2 additions & 8 deletions packages/assets-controllers/src/TokenRatesController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type { PreferencesState } from '@metamask/preferences-controller';
import type { Hex } from '@metamask/utils';
import { isEqual } from 'lodash';

import { reduceInBatchesSerially } from './assetsUtil';
import { reduceInBatchesSerially, TOKEN_PRICES_BATCH_SIZE } from './assetsUtil';
import { fetchExchangeRate as fetchNativeCurrencyExchangeRate } from './crypto-compare';
import type { AbstractTokenPricesService } from './token-prices-service/abstract-token-prices-service';
import type { TokensState } from './TokensController';
Expand Down Expand Up @@ -69,7 +69,7 @@ export interface TokenRatesConfig extends BaseConfig {
// This interface was created before this ESLint rule was added.
// Convert to a `type` in a future major version.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface ContractExchangeRates {
export interface ContractExchangeRates {
[address: string]: number | undefined;
}

Expand All @@ -96,12 +96,6 @@ export interface TokenRatesState extends BaseState {
>;
}

/**
* The maximum number of token addresses that should be sent to the Price API in
* a single request.
*/
const TOKEN_PRICES_BATCH_SIZE = 100;

/**
* Uses the CryptoCompare API to fetch the exchange rate between one currency
* and another, i.e., the multiplier to apply the amount of one currency in
Expand Down
144 changes: 144 additions & 0 deletions packages/assets-controllers/src/assetsUtil.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ import {
ChainId,
convertHexToDecimal,
toHex,
toChecksumHexAddress,
} from '@metamask/controller-utils';
import { add0x, type Hex } from '@metamask/utils';

import * as assetsUtil from './assetsUtil';
import type { Nft, NftMetadata } from './NftController';
import type { AbstractTokenPricesService } from './token-prices-service';

const DEFAULT_IPFS_URL_FORMAT = 'ipfs://';
const ALTERNATIVE_IPFS_URL_FORMAT = 'ipfs://ipfs/';
Expand Down Expand Up @@ -436,4 +439,145 @@ describe('assetsUtil', () => {
expect(timestampsIncreasing).toBe(true);
});
});

describe('fetchAndMapExchangeRates', () => {
it('should return empty object when chainId not supported', async () => {
const testTokenAddress = '0x7BEF710a5759d197EC0Bf621c3Df802C2D60D848';
const mockPriceService = createMockPriceService();

jest
.spyOn(mockPriceService, 'validateChainIdSupported')
.mockReturnValue(false);

const result = await assetsUtil.fetchTokenContractExchangeRates({
tokenPricesService: mockPriceService,
nativeCurrency: 'ETH',
tokenAddresses: [testTokenAddress],
chainId: '0x0',
});

expect(result).toStrictEqual({});
});

it('should return empty object when nativeCurrency not supported', async () => {
const testTokenAddress = '0x7BEF710a5759d197EC0Bf621c3Df802C2D60D848';
const mockPriceService = createMockPriceService();
jest
.spyOn(mockPriceService, 'validateCurrencySupported')
.mockReturnValue(false);

const result = await assetsUtil.fetchTokenContractExchangeRates({
tokenPricesService: mockPriceService,
nativeCurrency: 'X',
tokenAddresses: [testTokenAddress],
chainId: '0x1',
});

expect(result).toStrictEqual({});
});

it('should return successfully with a number of tokens less than 100', async () => {
const testTokenAddress = '0x7BEF710a5759d197EC0Bf621c3Df802C2D60D848';
const testNativeCurrency = 'ETH';
const testChainId = '0x1';
const mockPriceService = createMockPriceService();
jest
.spyOn(mockPriceService, 'validateCurrencySupported')
.mockReturnValue(true);

jest
.spyOn(mockPriceService, 'validateChainIdSupported')
.mockReturnValue(true);

jest.spyOn(mockPriceService, 'fetchTokenPrices').mockResolvedValue({
[testTokenAddress]: {
tokenAddress: testTokenAddress,
value: 0.0004588648479937523,
currency: testNativeCurrency,
},
});

const result = await assetsUtil.fetchTokenContractExchangeRates({
tokenPricesService: mockPriceService,
nativeCurrency: testNativeCurrency,
tokenAddresses: [testTokenAddress],
chainId: testChainId,
});

expect(result).toMatchObject({
[testTokenAddress]: 0.0004588648479937523,
});
});

it('should fetch successfully in batches of 100', async () => {
const mockPriceService = createMockPriceService();
const tokenAddresses = [...new Array(200).keys()]
.map(buildAddress)
.sort();

const testNativeCurrency = 'ETH';
const testChainId = '0x1';
jest
.spyOn(mockPriceService, 'validateCurrencySupported')
.mockReturnValue(true);

jest
.spyOn(mockPriceService, 'validateChainIdSupported')
.mockReturnValue(true);

const fetchTokenPricesSpy = jest.spyOn(
mockPriceService,
'fetchTokenPrices',
);

await assetsUtil.fetchTokenContractExchangeRates({
tokenPricesService: mockPriceService,
nativeCurrency: testNativeCurrency,
tokenAddresses: tokenAddresses as Hex[],
chainId: testChainId,
});

expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(2);
expect(fetchTokenPricesSpy).toHaveBeenNthCalledWith(1, {
chainId: testChainId,
tokenAddresses: tokenAddresses.slice(0, 100),
currency: testNativeCurrency,
});
expect(fetchTokenPricesSpy).toHaveBeenNthCalledWith(2, {
chainId: testChainId,
tokenAddresses: tokenAddresses.slice(100),
currency: testNativeCurrency,
});
});
});
});

/**
* Constructs a checksum Ethereum address.
*
* @param number - The address as a decimal number.
* @returns The address as an 0x-prefixed ERC-55 mixed-case checksum address in
* hexadecimal format.
*/
function buildAddress(number: number) {
return toChecksumHexAddress(add0x(number.toString(16).padStart(40, '0')));
}

/**
* Creates a mock for token prices service.
*
* @returns The mocked functions of token prices service.
*/
function createMockPriceService(): AbstractTokenPricesService {
return {
validateChainIdSupported(_chainId: unknown): _chainId is Hex {
return true;
},
validateCurrencySupported(_currency: unknown): _currency is string {
return true;
},
async fetchTokenPrices() {
return {};
},
};
}
72 changes: 72 additions & 0 deletions packages/assets-controllers/src/assetsUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { BigNumber } from '@ethersproject/bignumber';
import {
convertHexToDecimal,
GANACHE_CHAIN_ID,
toChecksumHexAddress,
} from '@metamask/controller-utils';
import type { Hex } from '@metamask/utils';
import { BN, stripHexPrefix } from 'ethereumjs-util';
Expand All @@ -16,6 +17,14 @@ import type {
OpenSeaV2Nft,
} from './NftController';
import type { ApiNft, ApiNftContract } from './NftDetectionController';
import type { AbstractTokenPricesService } from './token-prices-service';
import { type ContractExchangeRates } from './TokenRatesController';

/**
* The maximum number of token addresses that should be sent to the Price API in
* a single request.
*/
export const TOKEN_PRICES_BATCH_SIZE = 100;

/**
* Compares nft metadata entries to any nft entry.
Expand Down Expand Up @@ -388,3 +397,66 @@ export function mapOpenSeaContractV2ToV1(
},
};
}

/**
* Retrieves token prices for a set of contract addresses in a specific currency and chainId.
*
* @param args - The arguments to function.
* @param args.tokenPricesService - An object in charge of retrieving token prices.
* @param args.nativeCurrency - The native currency to request price in.
* @param args.tokenAddresses - The list of contract addresses.
* @param args.chainId - The chainId of the tokens.
* @returns The prices for the requested tokens.
*/
export async function fetchTokenContractExchangeRates({
tokenPricesService,
nativeCurrency,
tokenAddresses,
chainId,
}: {
tokenPricesService: AbstractTokenPricesService;
nativeCurrency: string;
tokenAddresses: Hex[];
chainId: Hex;
}): Promise<ContractExchangeRates> {
const isChainIdSupported =
tokenPricesService.validateChainIdSupported(chainId);
const isCurrencySupported =
tokenPricesService.validateCurrencySupported(nativeCurrency);

if (!isChainIdSupported || !isCurrencySupported) {
return {};
}

const tokenPricesByTokenAddress = await reduceInBatchesSerially<
Hex,
Awaited<ReturnType<AbstractTokenPricesService['fetchTokenPrices']>>
>({
values: tokenAddresses,
batchSize: TOKEN_PRICES_BATCH_SIZE,
eachBatch: async (allTokenPricesByTokenAddress, batch) => {
const tokenPricesByTokenAddressForBatch =
await tokenPricesService.fetchTokenPrices({
tokenAddresses: batch,
chainId,
currency: nativeCurrency,
});

return {
...allTokenPricesByTokenAddress,
...tokenPricesByTokenAddressForBatch,
};
},
initialResult: {},
});

return Object.entries(tokenPricesByTokenAddress).reduce(
(obj, [tokenAddress, tokenPrice]) => {
return {
...obj,
[toChecksumHexAddress(tokenAddress)]: tokenPrice?.value,
};
},
{},
);
}
1 change: 1 addition & 0 deletions packages/assets-controllers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ export {
isTokenDetectionSupportedForNetwork,
formatIconUrlWithProxy,
getFormattedIpfsUrl,
fetchTokenContractExchangeRates,
} from './assetsUtil';
export { CodefiTokenPricesServiceV2 } from './token-prices-service';

0 comments on commit cd78c1f

Please sign in to comment.