diff --git a/packages/checkout/sdk/src/api/blockscout/blockscoutType.ts b/packages/checkout/sdk/src/api/blockscout/blockscoutType.ts index 3378d0d71a..9bdab8c11c 100644 --- a/packages/checkout/sdk/src/api/blockscout/blockscoutType.ts +++ b/packages/checkout/sdk/src/api/blockscout/blockscoutType.ts @@ -23,6 +23,7 @@ export interface BlockscoutTokenData { decimals: string name: string symbol: string + icon_url: string; type: BlockscoutTokenType } diff --git a/packages/checkout/sdk/src/balances/balances.test.ts b/packages/checkout/sdk/src/balances/balances.test.ts index 9790bf0033..af2ade1b4a 100644 --- a/packages/checkout/sdk/src/balances/balances.test.ts +++ b/packages/checkout/sdk/src/balances/balances.test.ts @@ -315,15 +315,13 @@ describe('balances', () => { }); it('should fail if no wallet address or provider are given', async () => { + jest.spyOn(Blockscout, 'isChainSupported').mockReturnValue(false); + let message; try { await getAllBalances( { - remote: { - getTokensConfig: () => ({ - blockscout: false, - }), - }, + remote: {}, networkMap: testCheckoutConfig.networkMap, } as unknown as CheckoutConfiguration, undefined, @@ -337,15 +335,13 @@ describe('balances', () => { }); it('should fail if no provider is given and indexer is disabled', async () => { + jest.spyOn(Blockscout, 'isChainSupported').mockReturnValue(false); + let message; try { await getAllBalances( { - remote: { - getTokensConfig: () => ({ - blockscout: false, - }), - }, + remote: {}, networkMap: testCheckoutConfig.networkMap, } as unknown as CheckoutConfiguration, undefined, @@ -359,12 +355,11 @@ describe('balances', () => { }); it('should call getBalance and getERC20Balance functions with native and ERC20 tokens', async () => { + jest.spyOn(Blockscout, 'isChainSupported').mockReturnValue(false); + const getAllBalancesResult = await getAllBalances( { remote: { - getTokensConfig: () => ({ - blockscout: false, - }), getHttpClient: () => mockedHttpClient, }, networkMap: testCheckoutConfig.networkMap, @@ -415,7 +410,24 @@ describe('balances', () => { ); }); - it('should call getIndexerBalance', async () => { + it('should call getBlockscoutBalance', async () => { + (tokens.getTokenAllowList as jest.Mock).mockReturnValue({ + tokens: [ + { + name: 'Immutable X', + address: 'native', + symbol: 'IMX', + decimals: 18, + } as TokenInfo, + { + name: ChainName.ETHEREUM, + address: '0x65AA7a21B0f3ce9B478aAC3408fE75b423939b1F', + symbol: 'ETH', + decimals: 18, + } as TokenInfo, + ], + }); + getTokensByWalletAddressMock = jest.fn().mockResolvedValue({ items: [ { @@ -451,9 +463,6 @@ describe('balances', () => { const getAllBalancesResult = await getAllBalances( { remote: { - getTokensConfig: () => ({ - blockscout: true, - }), getHttpClient: () => mockedHttpClient, }, networkMap: testCheckoutConfig.networkMap, @@ -491,7 +500,7 @@ describe('balances', () => { ]); }); - it('should call getIndexerBalance with undefined filterTokens', async () => { + it('should call getBlockscoutBalance with undefined filterTokens', async () => { getTokenAllowListMock = jest.fn().mockReturnValue({ tokens: [], } as GetTokenAllowListResult); @@ -534,9 +543,6 @@ describe('balances', () => { const getAllBalancesResult = await getAllBalances( { remote: { - getTokensConfig: () => ({ - blockscout: true, - }), getHttpClient: () => mockedHttpClient, }, networkMap: new CheckoutConfiguration( @@ -557,7 +563,7 @@ describe('balances', () => { expect(getAllBalancesResult.balances).toEqual([]); }); - it('should call getIndexerBalance and return native balance on ERC20 404', async () => { + it('should call getBlockscoutBalance and return native balance on ERC20 404', async () => { getTokensByWalletAddressMock = jest.fn().mockRejectedValue( { code: HttpStatusCode.NotFound, message: 'not found' }, ); @@ -580,9 +586,6 @@ describe('balances', () => { const getAllBalancesResult = await getAllBalances( { remote: { - getTokensConfig: () => ({ - blockscout: true, - }), getHttpClient: () => mockedHttpClient, }, networkMap: testCheckoutConfig.networkMap, @@ -609,7 +612,18 @@ describe('balances', () => { ]); }); - it('should call getIndexerBalance and return ERC20 balances on native 404', async () => { + it('should call getBlockscoutBalance and return ERC20 balances on native 404', async () => { + (tokens.getTokenAllowList as jest.Mock).mockReturnValue({ + tokens: [ + { + name: ChainName.ETHEREUM, + address: '0x65AA7a21B0f3ce9B478aAC3408fE75b423939b1F', + symbol: 'ETH', + decimals: 18, + } as TokenInfo, + ], + }); + getTokensByWalletAddressMock = jest.fn().mockResolvedValue({ items: [ { @@ -639,9 +653,6 @@ describe('balances', () => { const getAllBalancesResult = await getAllBalances( { remote: { - getTokensConfig: () => ({ - blockscout: true, - }), getHttpClient: () => mockedHttpClient, }, networkMap: testCheckoutConfig.networkMap, @@ -669,7 +680,7 @@ describe('balances', () => { ]); }); - it('should call getIndexerBalance and return empty balance due to 404', async () => { + it('should call getBlockscoutBalance and return empty balance due to 404', async () => { getTokensByWalletAddressMock = jest.fn().mockRejectedValue( { code: HttpStatusCode.NotFound, message: 'not found' }, ); @@ -686,9 +697,6 @@ describe('balances', () => { const getAllBalancesResult = await getAllBalances( { remote: { - getTokensConfig: () => ({ - blockscout: true, - }), getHttpClient: () => mockedHttpClient, }, networkMap: testCheckoutConfig.networkMap, @@ -720,9 +728,6 @@ describe('balances', () => { getAllBalancesResult = await getAllBalances( { remote: { - getTokensConfig: () => ({ - blockscout: true, - }), getHttpClient: () => mockedHttpClient, }, networkMap: testCheckoutConfig.networkMap, @@ -780,7 +785,7 @@ describe('balances', () => { }]; testCases.forEach(async (testCase) => { - it('should call getIndexerBalance and throw error', async () => { + it('should call getBlockscoutBalance and throw error', async () => { getTokensByWalletAddressMock = jest.fn().mockRejectedValue( { code: HttpStatusCode.Forbidden, message: testCase.errorMessage }, ); @@ -797,9 +802,6 @@ describe('balances', () => { await getAllBalances( { remote: { - getTokensConfig: () => ({ - blockscout: true, - }), getHttpClient: () => mockedHttpClient, }, networkMap: testCheckoutConfig.networkMap, @@ -831,11 +833,7 @@ describe('balances', () => { try { await getAllBalances( { - remote: { - getTokensConfig: () => ({ - blockscout: true, - }), - }, + remote: {}, networkMap: testCheckoutConfig.networkMap, } as unknown as CheckoutConfiguration, jest.fn() as unknown as Web3Provider, diff --git a/packages/checkout/sdk/src/balances/balances.ts b/packages/checkout/sdk/src/balances/balances.ts index 35d724b25e..5874f4ec22 100644 --- a/packages/checkout/sdk/src/balances/balances.ts +++ b/packages/checkout/sdk/src/balances/balances.ts @@ -12,10 +12,11 @@ import { import { CheckoutError, CheckoutErrorType, withCheckoutError } from '../errors'; import { getNetworkInfo } from '../network'; import { getERC20TokenInfo, getTokenAllowList } from '../tokens'; -import { CheckoutConfiguration, getL1ChainId } from '../config'; +import { CheckoutConfiguration } from '../config'; import { Blockscout, BlockscoutToken, + BlockscoutTokenData, BlockscoutTokens, BlockscoutTokenType, } from '../api/blockscout'; @@ -96,7 +97,7 @@ const blockscoutClientMap: Map = new Map(); // blockscout map and therefore clear all the cache. export const resetBlockscoutClientMap = () => blockscoutClientMap.clear(); -export const getIndexerBalance = async ( +export const getBlockscoutBalance = async ( config: CheckoutConfiguration, walletAddress: string, chainId: ChainId, @@ -107,7 +108,7 @@ export const getIndexerBalance = async ( const shouldFilter = filterTokens !== undefined; const mapFilterTokens = Object.assign( {}, - ...((filterTokens ?? []).map((t) => ({ [t.address || NATIVE]: t }))), + ...((filterTokens ?? []).map((t) => ({ [t.address?.toLowerCase() || NATIVE]: t }))), ); // Get blockscout client for the given chain @@ -186,7 +187,8 @@ export const getIndexerBalance = async ( const balances: GetBalanceResult[] = []; items.forEach((item) => { - if (shouldFilter && !mapFilterTokens[item.token.address]) return; + const allowlistedToken = mapFilterTokens[item.token.address.toLowerCase()]; + if (shouldFilter && !allowlistedToken) return; const tokenData = item.token || {}; @@ -196,9 +198,12 @@ export const getIndexerBalance = async ( let decimals = parseInt(tokenData.decimals, 10); if (Number.isNaN(decimals)) decimals = DEFAULT_TOKEN_DECIMALS; + const icon = (tokenData as BlockscoutTokenData).icon_url ?? allowlistedToken.icon; + const token = { ...tokenData, decimals, + icon, }; const formattedBalance = utils.formatUnits(item.value, token.decimals); @@ -304,35 +309,18 @@ export const getAllBalances = async ( }, ); - // In order to prevent unnecessary RPC calls - // let's use the Indexer if available for the - // given chain. - let flag = false; - try { - flag = (await config.remote.getTokensConfig(chainId)).blockscout || flag; - } catch (err: any) { - // eslint-disable-next-line no-console - console.error(err); - } - if (forceFetch) { resetBlockscoutClientMap(); } - let address = walletAddress; - if (flag && Blockscout.isChainSupported(chainId)) { - // This is a hack because the widgets are still using the tokens symbol - // to drive the conversions. If we remove all the token symbols from e.g. zkevm - // then we would not have fiat conversions. - // Please remove this hack once https://immutable.atlassian.net/browse/WT-1710 - // is done. - const isL1Chain = getL1ChainId(config) === chainId; - if (!address) address = await web3Provider?.getSigner().getAddress(); + if (Blockscout.isChainSupported(chainId)) { + const address = walletAddress ?? await web3Provider?.getSigner().getAddress(); + try { return await measureAsyncExecution( config, `Time to fetch balances using blockscout for ${chainId}`, - getIndexerBalance(config, address!, chainId, isL1Chain ? tokens : undefined), + getBlockscoutBalance(config, address!, chainId, tokens), ); } catch (error) { // Blockscout rate limiting, fallback to RPC node diff --git a/packages/checkout/sdk/src/config/config.ts b/packages/checkout/sdk/src/config/config.ts index 489e2fb679..3ee0ca5005 100644 --- a/packages/checkout/sdk/src/config/config.ts +++ b/packages/checkout/sdk/src/config/config.ts @@ -14,6 +14,7 @@ import { SANDBOX_CHAIN_ID_NETWORK_MAP, } from '../env'; import { HttpClient } from '../api/http/httpClient'; +import { TokensFetcher } from './tokensFetcher'; export class CheckoutConfigurationError extends Error { public message: string; @@ -67,6 +68,8 @@ export class CheckoutConfiguration { readonly remote: RemoteConfigFetcher; + readonly tokens: TokensFetcher; + readonly environment: Environment; readonly networkMap: NetworkMap; @@ -98,5 +101,10 @@ export class CheckoutConfiguration { isDevelopment: this.isDevelopment, isProduction: this.isProduction, }); + + this.tokens = new TokensFetcher(httpClient, this.remote, { + isDevelopment: this.isDevelopment, + isProduction: this.isProduction, + }); } } diff --git a/packages/checkout/sdk/src/config/remoteConfigFetcher.test.ts b/packages/checkout/sdk/src/config/remoteConfigFetcher.test.ts index 718fb92bfc..53b5be9f69 100644 --- a/packages/checkout/sdk/src/config/remoteConfigFetcher.test.ts +++ b/packages/checkout/sdk/src/config/remoteConfigFetcher.test.ts @@ -121,66 +121,5 @@ describe('RemoteConfig', () => { ); }); }); - - describe('tokens', () => { - it(`should fetch tokens and cache them [${env}]`, async () => { - const mockResponse = { - status: 200, - data: { - connect: { - walletConnect: false, - }, - [ChainId.IMTBL_ZKEVM_DEVNET]: { - allowed: [ - { - address: '0xd686c80dc76766fa16eb95a4ad63d17937c7723c', - decimals: 18, - name: 'token-aa-testnet', - symbol: 'AA', - }, - ], - }, - [ChainId.SEPOLIA]: { - metadata: [ - { - address: '0xd686c80dc76766fa16eb95a4ad63d17937c7723c', - decimals: 18, - name: 'token-aa-testnet', - symbol: 'AA', - }, - ], - }, - }, - } as AxiosResponse; - mockedHttpClient.get.mockResolvedValueOnce(mockResponse); - - const fetcher = new RemoteConfigFetcher(mockedHttpClient, { - isDevelopment: env === ENV_DEVELOPMENT, - isProduction: env !== ENV_DEVELOPMENT && env === Environment.PRODUCTION, - }); - await fetcher.getTokensConfig(ChainId.SEPOLIA); - await fetcher.getTokensConfig(ChainId.IMTBL_ZKEVM_DEVNET); - - expect(mockedHttpClient.get).toHaveBeenCalledTimes(1); - expect(mockedHttpClient.get).toHaveBeenNthCalledWith( - 1, - `${CHECKOUT_CDN_BASE_URL[env as Environment]}/${version}/config/tokens`, - ); - }); - - it(`should return empty array if config missing [${env}]`, async () => { - const mockResponse = { - status: 200, - } as AxiosResponse; - mockedHttpClient.get.mockResolvedValueOnce(mockResponse); - - const fetcher = new RemoteConfigFetcher(mockedHttpClient, { - isDevelopment: env === ENV_DEVELOPMENT, - isProduction: env !== ENV_DEVELOPMENT && env === Environment.PRODUCTION, - }); - - expect(await fetcher.getTokensConfig(ChainId.SEPOLIA)).toEqual({}); - }); - }); }); }); diff --git a/packages/checkout/sdk/src/config/remoteConfigFetcher.ts b/packages/checkout/sdk/src/config/remoteConfigFetcher.ts index 9a8da03228..3acf7be998 100644 --- a/packages/checkout/sdk/src/config/remoteConfigFetcher.ts +++ b/packages/checkout/sdk/src/config/remoteConfigFetcher.ts @@ -1,11 +1,6 @@ import { Environment } from '@imtbl/config'; import { AxiosResponse } from 'axios'; -import { - ChainId, - ChainsTokensConfig, - RemoteConfiguration, - ChainTokensConfig, -} from '../types'; +import { RemoteConfiguration } from '../types'; import { CHECKOUT_CDN_BASE_URL, ENV_DEVELOPMENT } from '../env'; import { HttpClient } from '../api/http'; import { CheckoutError, CheckoutErrorType } from '../errors'; @@ -24,8 +19,6 @@ export class RemoteConfigFetcher { private configCache: RemoteConfiguration | undefined; - private tokensCache: ChainsTokensConfig | undefined; - private version: string = 'v1'; constructor(httpClient: HttpClient, params: RemoteConfigParams) { @@ -54,7 +47,6 @@ export class RemoteConfigFetcher { ); } } - return responseData!; } @@ -80,28 +72,6 @@ export class RemoteConfigFetcher { return this.configCache; } - private async loadConfigTokens(): Promise { - if (this.tokensCache) return this.tokensCache; - - let response: AxiosResponse; - try { - response = await this.httpClient.get( - `${this.getEndpoint()}/${this.version}/config/tokens`, - ); - } catch (err: any) { - throw new CheckoutError( - `Error: ${err.message}`, - CheckoutErrorType.API_ERROR, - { error: err }, - ); - } - - // Ensure that the configuration is valid - this.tokensCache = this.parseResponse(response); - - return this.tokensCache; - } - public async getConfig( key?: keyof RemoteConfiguration, ): Promise< @@ -115,11 +85,5 @@ export class RemoteConfigFetcher { return config[key]; } - public async getTokensConfig(chainId: ChainId): Promise { - const config = await this.loadConfigTokens(); - if (!config || !config[chainId]) return {}; - return config[chainId] ?? []; - } - public getHttpClient = () => this.httpClient; } diff --git a/packages/checkout/sdk/src/config/tokensFetcher.test.ts b/packages/checkout/sdk/src/config/tokensFetcher.test.ts new file mode 100644 index 0000000000..d0fbb00198 --- /dev/null +++ b/packages/checkout/sdk/src/config/tokensFetcher.test.ts @@ -0,0 +1,177 @@ +import { Environment } from '@imtbl/config'; +import { AxiosResponse } from 'axios'; +import { ChainId, RemoteConfiguration } from '../types'; +import { RemoteConfigFetcher } from './remoteConfigFetcher'; +import { ENV_DEVELOPMENT } from '../env'; +import { HttpClient } from '../api/http'; +import { TokensFetcher } from './tokensFetcher'; + +jest.mock('../api/http'); +jest.mock('./remoteConfigFetcher'); + +describe('TokensFetcher', () => { + let mockedHttpClient: jest.Mocked; + let mockedConfigClient: jest.Mocked; + + afterEach(() => { + jest.clearAllMocks(); + }); + + beforeEach(() => { + mockedHttpClient = new HttpClient() as jest.Mocked; + mockedConfigClient = new RemoteConfigFetcher(mockedHttpClient, { + isDevelopment: true, + isProduction: false, + }) as jest.Mocked; + + mockedConfigClient.getConfig.mockResolvedValue({ + [ChainId.IMTBL_ZKEVM_TESTNET]: 'native', + [ChainId.SEPOLIA]: '0xe2629e08f4125d14e446660028bD98ee60EE69F2', + } as unknown as RemoteConfiguration); + }); + + [Environment.PRODUCTION, Environment.SANDBOX, ENV_DEVELOPMENT].forEach( + (env) => { + describe('getTokensConfig', () => { + it(`should fetch tokens and cache them [${env}]`, async () => { + const mockTokensResponse = { + status: 200, + data: { + result: [ + { + chain: { + id: 'eip155:13473', + name: 'imtbl-zkevm-testnet', + }, + contract_address: '0xb8ee289c64c1a0dc0311364721ada8c3180d838c', + decimals: 18, + image_url: 'https://example.com/gog.svg', + is_canonical: true, + name: 'Guild of Guardians', + root_chain_id: 'eip155:11155111', + root_contract_address: '0xfe9df9ebe5fbd94b00247613b6cf7629891954e2', + symbol: 'GOG', + verification_status: 'verified', + }, + { + chain: { + id: 'eip155:13473', + name: 'imtbl-zkevm-testnet', + }, + contract_address: '0xe9E96d1aad82562b7588F03f49aD34186f996478', + decimals: 18, + image_url: 'https://example.com/eth.svg', + is_canonical: true, + name: 'Ethereum', + root_chain_id: 'eip155:11155111', + root_contract_address: '0x0000000000000000000000000000000000000eee', + symbol: 'ETH', + verification_status: 'verified', + }, + { + chain: { + id: 'eip155:13473', + name: 'imtbl-zkevm-testnet', + }, + contract_address: '0x3b2d8a1931736fc321c24864bceee981b11c3c50', + decimals: 6, + image_url: null, + is_canonical: true, + name: 'USDZ', + root_chain_id: null, + root_contract_address: null, + symbol: 'USDZ', + verification_status: 'verified', + }, + { + chain: { + id: 'eip155:13473', + }, + name: 'Invalid token', + contract_address: '0xinvalid', + symbol: null, + decimals: null, + }, + ], + }, + } as AxiosResponse; + mockedHttpClient.get.mockResolvedValueOnce(mockTokensResponse); + + const fetcher = new TokensFetcher( + mockedHttpClient, + mockedConfigClient, + { + isDevelopment: env === ENV_DEVELOPMENT, + isProduction: + env !== ENV_DEVELOPMENT && env === Environment.PRODUCTION, + }, + ); + const tokensZkEVM = await fetcher.getTokensConfig( + ChainId.IMTBL_ZKEVM_TESTNET, + ); + const tokensSepolia = await fetcher.getTokensConfig(ChainId.SEPOLIA); + + // Number of tokens per chain is correct + expect(tokensZkEVM).toHaveLength(4); + expect(tokensSepolia).toHaveLength(3); + + // Tokens are correctly populated + expect(tokensZkEVM.find((token) => token.symbol === 'GOG')).toEqual({ + address: '0xb8ee289c64c1a0dc0311364721ada8c3180d838c', + decimals: 18, + icon: 'https://example.com/gog.svg', + name: 'Guild of Guardians', + symbol: 'GOG', + }); + + // Tokens with invalid info are ignored + expect(tokensZkEVM.find((token) => token.address === '0xinvalid')).toBeUndefined(); + expect(tokensSepolia.find((token) => token.address === '0xinvalid')).toBeUndefined(); + + // IMX token is populated + expect( + tokensZkEVM.find((token) => token.symbol === 'IMX'), + ).toHaveProperty('address', 'native'); + expect( + tokensSepolia.find((token) => token.symbol === 'IMX'), + ).toHaveProperty('address', '0xe2629e08f4125d14e446660028bD98ee60EE69F2'); + + // ETH root contract is mapped to native in L1 + expect( + tokensSepolia.find((token) => token.symbol === 'ETH'), + ).toHaveProperty('address', 'native'); + expect( + tokensZkEVM.find((token) => token.symbol === 'ETH'), + ).toHaveProperty('address', '0xe9e96d1aad82562b7588f03f49ad34186f996478'); + + // HTTP request is cached after first occurrence + expect(mockedHttpClient.get).toHaveBeenCalledTimes(1); + }); + + it(`should return empty array if config missing [${env}]`, async () => { + mockedConfigClient.getConfig.mockResolvedValue({} as unknown as RemoteConfiguration); + + const mockResponse = { + status: 200, + data: { + result: [], + }, + } as AxiosResponse; + mockedHttpClient.get.mockResolvedValueOnce(mockResponse); + + const fetcher = new TokensFetcher( + mockedHttpClient, + mockedConfigClient, + { + isDevelopment: env === ENV_DEVELOPMENT, + isProduction: + env !== ENV_DEVELOPMENT && env === Environment.PRODUCTION, + }, + ); + + expect(await fetcher.getTokensConfig(ChainId.SEPOLIA)).toEqual([]); + }); + }); + }, + ); +}); diff --git a/packages/checkout/sdk/src/config/tokensFetcher.ts b/packages/checkout/sdk/src/config/tokensFetcher.ts new file mode 100644 index 0000000000..0e662fad22 --- /dev/null +++ b/packages/checkout/sdk/src/config/tokensFetcher.ts @@ -0,0 +1,182 @@ +import { Environment } from '@imtbl/config'; +import { AxiosResponse } from 'axios'; +import { + ChainId, ChainSlug, ChainTokensConfig, ImxAddressConfig, TokenInfo, +} from '../types'; +import { ENV_DEVELOPMENT, IMMUTABLE_API_BASE_URL } from '../env'; +import { HttpClient } from '../api/http'; +import { CheckoutError, CheckoutErrorType } from '../errors'; +import { RemoteConfigFetcher } from './remoteConfigFetcher'; + +const INDEXER_ETH_ROOT_CONTRACT_ADDRESS = '0x0000000000000000000000000000000000000eee'; + +type TokensEndpointResult = { + chain: { + id: string; + name: string; + }; + contract_address: string; + decimals: number; + image_url: string | null; + is_canonical: boolean; + name: string; + symbol: string; + root_chain_id: string | null; + root_contract_address: string | null; +}; + +type TokensEndpointResponse = { + result: TokensEndpointResult[]; +}; + +export type RemoteConfigParams = { + isDevelopment: boolean; + isProduction: boolean; +}; + +export class TokensFetcher { + private httpClient: HttpClient; + + private remoteConfig: RemoteConfigFetcher; + + private readonly isDevelopment: boolean; + + private readonly isProduction: boolean; + + private tokensCache: ChainTokensConfig | undefined; + + constructor(httpClient: HttpClient, remoteConfig: RemoteConfigFetcher, params: RemoteConfigParams) { + this.isDevelopment = params.isDevelopment; + this.isProduction = params.isProduction; + this.httpClient = httpClient; + this.remoteConfig = remoteConfig; + } + + private getBaseUrl = () => { + if (this.isDevelopment) return IMMUTABLE_API_BASE_URL[ENV_DEVELOPMENT]; + if (this.isProduction) return IMMUTABLE_API_BASE_URL[Environment.PRODUCTION]; + return IMMUTABLE_API_BASE_URL[Environment.SANDBOX]; + }; + + private getChainSlug = () => { + if (this.isDevelopment) return ChainSlug.IMTBL_ZKEVM_DEVNET; + if (this.isProduction) return ChainSlug.IMTBL_ZKEVM_MAINNET; + return ChainSlug.IMTBL_ZKEVM_TESTNET; + }; + + private async loadTokens(): Promise { + if (this.tokensCache) { + return this.tokensCache; + } + + let response: AxiosResponse; + try { + response = await this.httpClient.get( + `${this.getBaseUrl()}/v1/chains/${this.getChainSlug()}/tokens?verification_status=verified&is_canonical=true`, + ); + } catch (err: any) { + throw new CheckoutError( + `Error: ${err.message}`, + CheckoutErrorType.API_ERROR, + { error: err }, + ); + } + + const responseData = this.parseResponse(response); + + this.tokensCache = await this.getMappingsForTokensResponse(responseData?.result || []); + + return this.tokensCache; + } + + public async getTokensConfig(chainId: ChainId): Promise { + const config = await this.loadTokens(); + if (!config || !config[chainId]) return []; + + return config[chainId] ?? []; + } + + private async getMappingsForTokensResponse(tokenList: TokensEndpointResult[]): Promise { + const tokens: ChainTokensConfig = {}; + + const imxMappings = await this.fetchIMXTokenMappings(); + + Object.keys(imxMappings).forEach((chain) => { + const chainId = parseInt(chain, 10) as ChainId; + tokens[chainId] = []; + + tokens[chainId]?.push({ + address: imxMappings[chain], + decimals: 18, + name: 'IMX', + symbol: 'IMX', + }); + }); + + tokenList.forEach((token) => { + const chainId = parseInt(token.chain.id.split('eip155:').pop() || '', 10) as ChainId; + + if (!token.symbol || !token.decimals) { + return; + } + + if (!tokens[chainId]) { + tokens[chainId] = []; + } + + const tokenInfo: TokenInfo = { + address: token.contract_address.toLowerCase(), + decimals: token.decimals, + name: token.name, + symbol: token.symbol, + icon: token.image_url ?? undefined, + }; + + tokens[chainId]?.push(tokenInfo); + + const rootChainId = parseInt(token.root_chain_id?.split('eip155:').pop() || '', 10) as ChainId; + let address = token.root_contract_address?.toLowerCase(); + + if (rootChainId && address) { + if (!tokens[rootChainId]) { + tokens[rootChainId] = []; + } + + if (address === INDEXER_ETH_ROOT_CONTRACT_ADDRESS) { + address = 'native'; + } + + tokens[rootChainId]?.push({ + ...tokenInfo, + address, + }); + } + }); + + return tokens; + } + + // eslint-disable-next-line class-methods-use-this + private parseResponse(response: AxiosResponse): TokensEndpointResponse | undefined { + let responseData: TokensEndpointResponse = response.data; + if (response.data && typeof response.data !== 'object') { + try { + responseData = JSON.parse(response.data); + } catch (err: any) { + throw new CheckoutError( + 'Invalid token data', + CheckoutErrorType.API_ERROR, + { error: err }, + ); + } + } + + return responseData; + } + + private async fetchIMXTokenMappings() { + return (await this.remoteConfig.getConfig( + 'imxAddressMapping', + )) as ImxAddressConfig; + } +} diff --git a/packages/checkout/sdk/src/smartCheckout/allowList/allowListCheck.test.ts b/packages/checkout/sdk/src/smartCheckout/allowList/allowListCheck.test.ts index e8546eac05..c2f6b03f02 100644 --- a/packages/checkout/sdk/src/smartCheckout/allowList/allowListCheck.test.ts +++ b/packages/checkout/sdk/src/smartCheckout/allowList/allowListCheck.test.ts @@ -8,23 +8,23 @@ import { allowListCheckForSwap, } from './allowListCheck'; import { - BridgeConfig, ChainId, - DexConfig, OnRampConfig, OnRampProvider, - OnRampProviderConfig, + OnRampProviderConfig, TokenInfo, } from '../../types'; import { TokenBalanceResult } from '../routing/types'; import { RemoteConfigFetcher } from '../../config/remoteConfigFetcher'; import { HttpClient } from '../../api/http'; +import { TokensFetcher } from '../../config/tokensFetcher'; jest.mock('../../config/remoteConfigFetcher'); +jest.mock('../../config/tokensFetcher'); describe('allowListCheck', () => { let config: CheckoutConfiguration; - let dexConfig: DexConfig; - let bridgeConfig: BridgeConfig; + let tokensL1: TokenInfo[]; + let tokensL2: TokenInfo[]; let onRampConfig: OnRampConfig; let balances: Map; let mockedHttpClient: jest.Mocked; @@ -34,43 +34,60 @@ describe('allowListCheck', () => { mockedHttpClient = new HttpClient() as jest.Mocked; (RemoteConfigFetcher as unknown as jest.Mock).mockReturnValue({ getConfig: jest.fn().mockImplementation((key) => { - let remoteConfig: any; - // eslint-disable-next-line default-case - switch (key) { - case 'bridge': - remoteConfig = bridgeConfig; - break; - case 'dex': - remoteConfig = dexConfig; - break; - case 'onramp': - remoteConfig = onRampConfig; - break; + let remoteConfig: any = {}; + + if (key === 'onramp') { + remoteConfig = onRampConfig; } + return remoteConfig; }), }); + (TokensFetcher as unknown as jest.Mock).mockReturnValue({ + getTokensConfig: jest.fn().mockImplementation((chainId: ChainId) => { + if (chainId === ChainId.IMTBL_ZKEVM_TESTNET) { + return tokensL2; + } + + return tokensL1; + }), + }); + config = new CheckoutConfiguration({ baseConfig: { environment: Environment.SANDBOX }, }, mockedHttpClient); - dexConfig = { - tokens: [{ + tokensL1 = [ + { + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + address: 'native', + }, + { decimals: 18, symbol: 'IMX', name: 'IMX', - }], - }; - bridgeConfig = { - [ChainId.SEPOLIA]: { - tokens: [{ - decimals: 18, - symbol: 'ETH', - name: 'Ethereum', - }], + address: '0xe9E96d1aad82562b7588F03f49aD34186f996478', }, - }; + ]; + + tokensL2 = [ + { + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + address: '0x52A6c53869Ce09a731CD772f245b97A4401d3348', + }, + { + decimals: 18, + symbol: 'IMX', + name: 'IMX', + address: 'native', + }, + ]; + onRampConfig = { [OnRampProvider.TRANSAK]: { publishableApiKey: '', @@ -82,6 +99,7 @@ describe('allowListCheck', () => { fees: {}, } as OnRampProviderConfig, }; + balances = new Map([ [ChainId.IMTBL_ZKEVM_TESTNET, { success: true, @@ -93,6 +111,7 @@ describe('allowListCheck', () => { decimals: 18, symbol: 'IMX', name: 'IMX', + address: 'native', }, }, { @@ -102,6 +121,7 @@ describe('allowListCheck', () => { name: 'Ethereum', symbol: 'ETH', decimals: 18, + address: '0x52A6c53869Ce09a731CD772f245b97A4401d3348', }, }, ], @@ -116,6 +136,7 @@ describe('allowListCheck', () => { name: 'Ethereum', symbol: 'ETH', decimals: 18, + address: 'native', }, }, ], @@ -129,16 +150,28 @@ describe('allowListCheck', () => { const allowList = await allowListCheck(config, balances, availableRoutingOptions); expect(allowList).toEqual({ - swap: [{ - decimals: 18, - symbol: 'IMX', - name: 'IMX', - }], - bridge: [{ - name: 'Ethereum', - symbol: 'ETH', - decimals: 18, - }], + swap: [ + { + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + address: '0x52A6c53869Ce09a731CD772f245b97A4401d3348', + }, + { + decimals: 18, + symbol: 'IMX', + name: 'IMX', + address: 'native', + }, + ], + bridge: [ + { + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + address: 'native', + }, + ], onRamp: [ { decimals: 18, @@ -158,6 +191,7 @@ describe('allowListCheck', () => { name: 'Ethereum', symbol: 'ETH', decimals: 18, + address: 'native', }], onRamp: [], swap: [], @@ -171,11 +205,20 @@ describe('allowListCheck', () => { expect(allowList).toEqual({ bridge: [], onRamp: [], - swap: [{ - decimals: 18, - symbol: 'IMX', - name: 'IMX', - }], + swap: [ + { + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + address: '0x52A6c53869Ce09a731CD772f245b97A4401d3348', + }, + { + decimals: 18, + symbol: 'IMX', + name: 'IMX', + address: 'native', + }, + ], }); }); @@ -215,6 +258,7 @@ describe('allowListCheck', () => { decimals: 18, symbol: 'ETH', name: 'Ethereum', + address: 'native', }]); }); @@ -240,27 +284,27 @@ describe('allowListCheck', () => { name: 'Ethereum', symbol: 'ETH', decimals: 18, + address: 'native', }, }, ], }], ]); - bridgeConfig = { - [ChainId.SEPOLIA]: { - tokens: [{ - address: '0x0000000', - decimals: 18, - symbol: 'MEGA', - name: 'Mega', - }, - { - decimals: 18, - symbol: 'ETH', - name: 'Ethereum', - }], + tokensL1 = [ + { + address: '0x0000000', + decimals: 18, + symbol: 'MEGA', + name: 'Mega', }, - }; + { + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + address: 'native', + }, + ]; const result = await allowListCheckForBridge(config, balances, { bridge: true }); expect(result).toEqual([{ @@ -273,6 +317,7 @@ describe('allowListCheck', () => { decimals: 18, symbol: 'ETH', name: 'Ethereum', + address: 'native', }]); }); @@ -287,27 +332,19 @@ describe('allowListCheck', () => { }); it('should return an empty array if bridge allowlist is empty', async () => { - bridgeConfig = { - [ChainId.IMTBL_ZKEVM_TESTNET]: { - tokens: [], - }, - }; + tokensL1 = []; const result = await allowListCheckForBridge(config, balances, { bridge: true }); expect(result).toEqual([]); }); it('should return an empty array if allowlist tokens have no balance', async () => { - bridgeConfig = { - [ChainId.IMTBL_ZKEVM_TESTNET]: { - tokens: [{ - address: '0x0000000', - decimals: 18, - symbol: 'MEGA', - name: 'Mega', - }], - }, - }; + tokensL1 = [{ + address: '0x0000000', + decimals: 18, + symbol: 'MEGA', + name: 'Mega', + }]; const result = await allowListCheckForBridge(config, balances, { bridge: true }); expect(result).toEqual([]); @@ -317,11 +354,20 @@ describe('allowListCheck', () => { describe('allowListCheckForSwap', () => { it('should return swap allowlist', async () => { const result = await allowListCheckForSwap(config, balances, { swap: true }); - expect(result).toEqual([{ - decimals: 18, - symbol: 'IMX', - name: 'IMX', - }]); + expect(result).toEqual([ + { + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + address: '0x52A6c53869Ce09a731CD772f245b97A4401d3348', + }, + { + decimals: 18, + symbol: 'IMX', + name: 'IMX', + address: 'native', + }, + ]); }); it('should return an empty array if swap option is disabled', async () => { @@ -335,23 +381,19 @@ describe('allowListCheck', () => { }); it('should return an empty array if swap allowlist is empty', async () => { - dexConfig = { - tokens: [], - }; + tokensL2 = []; const result = await allowListCheckForSwap(config, balances, { swap: true }); expect(result).toEqual([]); }); it('should return an empty array if allowlist tokens have no balance', async () => { - dexConfig = { - tokens: [{ - address: '0x0000000', - decimals: 18, - symbol: 'MEGA', - name: 'Mega', - }], - }; + tokensL2 = [{ + address: '0x0000000', + decimals: 18, + symbol: 'MEGA', + name: 'Mega', + }]; const result = await allowListCheckForSwap(config, balances, { swap: true }); expect(result).toEqual([]); diff --git a/packages/checkout/sdk/src/tokens/tokens.test.ts b/packages/checkout/sdk/src/tokens/tokens.test.ts index 1b192e2e0c..30d3d94d5f 100644 --- a/packages/checkout/sdk/src/tokens/tokens.test.ts +++ b/packages/checkout/sdk/src/tokens/tokens.test.ts @@ -8,8 +8,10 @@ import { CheckoutConfiguration } from '../config'; import { ERC20ABI, NATIVE } from '../env'; import { CheckoutErrorType } from '../errors'; import { HttpClient } from '../api/http'; +import { TokensFetcher } from '../config/tokensFetcher'; jest.mock('../config/remoteConfigFetcher'); +jest.mock('../config/tokensFetcher'); jest.mock('ethers', () => ({ ...jest.requireActual('ethers'), // eslint-disable-next-line @typescript-eslint/naming-convention @@ -23,13 +25,13 @@ describe('token related functions', () => { describe('when tokens are not configured', () => { it('should return the empty list of tokens', async () => { - (RemoteConfigFetcher as unknown as jest.Mock).mockReturnValue({ - getTokensConfig: jest.fn().mockResolvedValue({ allowed: [] }), + (TokensFetcher as unknown as jest.Mock).mockReturnValue({ + getTokensConfig: jest.fn().mockResolvedValue([]), }); config = new CheckoutConfiguration({ baseConfig: { environment: Environment.SANDBOX }, }, mockedHttpClient); - await expect( + expect( await getTokenAllowList(config, { type: TokenFilterTypes.ALL, chainId: ChainId.SEPOLIA, @@ -42,28 +44,6 @@ describe('token related functions', () => { describe('getTokenAllowList', () => { const remoteConfigMockReturn = { - getTokensConfig: jest.fn().mockResolvedValue({ - allowed: [ - { - address: '0x1', - decimals: 18, - name: 'token-aa-testnet', - symbol: 'AA', - }, - { - address: '0x2', - decimals: 18, - name: 'token-bb-testnet', - symbol: 'BB', - }, - { - address: '', - decimals: 18, - name: 'token-cc-testnet', - symbol: 'CC', - }, - ], - }), getConfig: jest.fn().mockResolvedValue({ overrides: { rpcURL: 'https://test', @@ -79,6 +59,29 @@ describe('token related functions', () => { }), }; + const remoteTokensMockReturn = { + getTokensConfig: jest.fn().mockResolvedValue([ + { + address: '0x1', + decimals: 18, + name: 'token-aa-testnet', + symbol: 'AA', + }, + { + address: '0x2', + decimals: 18, + name: 'token-bb-testnet', + symbol: 'BB', + }, + { + address: '', + decimals: 18, + name: 'token-cc-testnet', + symbol: 'CC', + }, + ]), + }; + const testcases = [ { text: 'tokens with no filters (ALL type)', @@ -106,6 +109,7 @@ describe('token related functions', () => { }, ], remoteConfigMockReturn, + remoteTokensMockReturn, }, { text: 'exclude token with address', @@ -127,6 +131,7 @@ describe('token related functions', () => { }, ], remoteConfigMockReturn, + remoteTokensMockReturn, }, { text: 'exclude empty address', @@ -148,9 +153,10 @@ describe('token related functions', () => { }, ], remoteConfigMockReturn, + remoteTokensMockReturn, }, { - text: 'tokens with SWAP filter', + text: 'tokens with SWAP filter and blocklist', type: TokenFilterTypes.SWAP, chainId: ChainId.IMTBL_ZKEVM_DEVNET, result: [ @@ -161,13 +167,41 @@ describe('token related functions', () => { symbol: 'BB', }, ], - remoteConfigMockReturn, + remoteConfigMockReturn: { + ...remoteConfigMockReturn, + getConfig: jest.fn() + .mockResolvedValue({ + blocklist: [ + { + address: '', + name: 'token-cc-testnet', + }, + { + address: '0x1', + symbol: 'AA', + }, + ], + }), + }, + remoteTokensMockReturn, }, { text: 'tokens with BRIDGE filter', type: TokenFilterTypes.BRIDGE, chainId: ChainId.IMTBL_ZKEVM_DEVNET, result: [ + { + address: '0x1', + decimals: 18, + name: 'token-aa-testnet', + symbol: 'AA', + }, + { + address: '0x2', + decimals: 18, + name: 'token-bb-testnet', + symbol: 'BB', + }, { address: '', decimals: 18, @@ -175,29 +209,18 @@ describe('token related functions', () => { symbol: 'CC', }, ], - remoteConfigMockReturn: { - ...remoteConfigMockReturn, - getConfig: jest.fn().mockResolvedValue({ - [ChainId.IMTBL_ZKEVM_DEVNET]: { - tokens: [ - { - address: '', - decimals: 18, - name: 'token-cc-testnet', - symbol: 'CC', - }, - ], - }, - }), - }, + remoteConfigMockReturn, + remoteTokensMockReturn, }, { text: 'undefined SWAP tokens list', type: TokenFilterTypes.SWAP, chainId: ChainId.IMTBL_ZKEVM_DEVNET, result: [], + remoteTokensMockReturn: { + getTokensConfig: jest.fn().mockResolvedValue([]), + }, remoteConfigMockReturn: { - getTokensConfig: jest.fn().mockResolvedValue(undefined), getConfig: jest.fn().mockResolvedValue({}), }, }, @@ -206,11 +229,12 @@ describe('token related functions', () => { testcases.forEach((testcase) => { it(`should return the filtered list of allowed tokens for a given ${testcase.text}`, async () => { (RemoteConfigFetcher as unknown as jest.Mock).mockReturnValue(testcase.remoteConfigMockReturn); + (TokensFetcher as unknown as jest.Mock).mockReturnValue(testcase.remoteTokensMockReturn); config = new CheckoutConfiguration({ baseConfig: { environment: Environment.SANDBOX }, }, mockedHttpClient); - await expect( + expect( await getTokenAllowList(config, { type: testcase.type, exclude: testcase.exclude, diff --git a/packages/checkout/sdk/src/tokens/tokens.ts b/packages/checkout/sdk/src/tokens/tokens.ts index 29afd81bdf..44209e973d 100644 --- a/packages/checkout/sdk/src/tokens/tokens.ts +++ b/packages/checkout/sdk/src/tokens/tokens.ts @@ -1,7 +1,6 @@ import { JsonRpcProvider, Web3Provider } from '@ethersproject/providers'; import { Contract } from 'ethers'; import { - BridgeConfig, ChainId, DexConfig, GetTokenAllowListResult, @@ -10,7 +9,7 @@ import { TokenFilterTypes, TokenInfo, } from '../types'; -import { CheckoutConfiguration, getL1ChainId } from '../config'; +import { CheckoutConfiguration, getL1ChainId, getL2ChainId } from '../config'; import { ERC20ABI, NATIVE } from '../env'; import { CheckoutErrorType, withCheckoutError } from '../errors'; import { isMatchingAddress } from '../utils/utils'; @@ -31,17 +30,24 @@ export const getTokenAllowList = async ( ): Promise => { let tokens: TokenInfo[] = []; let onRampConfig: OnRampConfig; - let onBridgeConfig: BridgeConfig; + let blockedTokens: string[]; const targetChainId = chainId ?? getL1ChainId(config); + const dexChainId = getL2ChainId(config); switch (type) { case TokenFilterTypes.SWAP: + tokens = (await config.tokens.getTokensConfig(dexChainId)); + // Fetch tokens from dex-tokens config because // Dex needs to have a whitelisted list of tokens due to // legal reasons. - tokens = ((await config.remote.getConfig('dex')) as DexConfig) - .tokens || []; + blockedTokens = ( + ((await config.remote.getConfig('dex')) as DexConfig)?.blocklist || [] + ).map((token) => token.address.toLowerCase()); + + tokens = tokens.filter((token) => token.address && !blockedTokens.includes(token.address)); + break; case TokenFilterTypes.ONRAMP: onRampConfig = (await config.remote.getConfig('onramp')) as OnRampConfig; @@ -50,14 +56,9 @@ export const getTokenAllowList = async ( tokens = onRampConfig[OnRampProvider.TRANSAK]?.tokens || []; break; case TokenFilterTypes.BRIDGE: - onBridgeConfig = ((await config.remote.getConfig('bridge')) as BridgeConfig); - if (!onBridgeConfig) tokens = []; - - tokens = onBridgeConfig[targetChainId]?.tokens || []; - break; case TokenFilterTypes.ALL: default: - tokens = (await config.remote.getTokensConfig(targetChainId)).allowed as TokenInfo[]; + tokens = (await config.tokens.getTokensConfig(targetChainId)); } if (!exclude || exclude?.length === 0) return { tokens }; diff --git a/packages/checkout/sdk/src/types/config.ts b/packages/checkout/sdk/src/types/config.ts index b12cf3e4e6..12c5f1163d 100644 --- a/packages/checkout/sdk/src/types/config.ts +++ b/packages/checkout/sdk/src/types/config.ts @@ -146,6 +146,16 @@ export type DexConfig = { tokens?: TokenInfo[]; /** An array of secondary fees to be applied to swaps */ secondaryFees?: SecondaryFee[]; + /** An array of tokens to be blocked from the DEX */ + blocklist?: BlockedToken[]; +}; + +/** + * A type representing a token that is blocked from the DEX. + */ +export type BlockedToken = { + address: string; + symbol: string; }; /** @@ -233,18 +243,6 @@ export type GasEstimateSwapTokenConfig = { /** * A type that represents the tokens configuration for chain. */ -export type ChainsTokensConfig = { - [key in ChainId]: ChainTokensConfig; -}; - -/** - * A type representing all the feature flags available. - * @property {TokenInfo[] | undefined} allowed - - * @property {boolean | undefined} blockscout - - */ export type ChainTokensConfig = { - /** List of allowed tokens for a given chain. */ - allowed?: TokenInfo[]; - /** Feature flag to enable/disable blockscout integration. */ - blockscout?: boolean; + [key in ChainId]?: TokenInfo[]; }; diff --git a/packages/checkout/widgets-lib/src/lib/balance.ts b/packages/checkout/widgets-lib/src/lib/balance.ts index aa1da13cc4..556914f29d 100644 --- a/packages/checkout/widgets-lib/src/lib/balance.ts +++ b/packages/checkout/widgets-lib/src/lib/balance.ts @@ -74,7 +74,7 @@ export const getAllowedBalances = async ({ ...balanceResult, token: { ...balanceResult.token, - icon: getTokenImageByAddress( + icon: balanceResult.token.icon ?? getTokenImageByAddress( checkout.config.environment as Environment, isNativeToken(balanceResult.token.address) ? balanceResult.token.symbol @@ -86,7 +86,7 @@ export const getAllowedBalances = async ({ // Map token icon assets to allowlist allowList.tokens = allowList.tokens.map((token) => ({ ...token, - icon: getTokenImageByAddress( + icon: token.icon ?? getTokenImageByAddress( checkout.config.environment as Environment, isNativeToken(token.address) ? token.symbol : token.address ?? '', ), diff --git a/packages/checkout/widgets-lib/src/widgets/sale/functions/fetchFundingBalancesUtils.ts b/packages/checkout/widgets-lib/src/widgets/sale/functions/fetchFundingBalancesUtils.ts index 58d4e21350..1e09a33bcf 100644 --- a/packages/checkout/widgets-lib/src/widgets/sale/functions/fetchFundingBalancesUtils.ts +++ b/packages/checkout/widgets-lib/src/widgets/sale/functions/fetchFundingBalancesUtils.ts @@ -76,7 +76,7 @@ const getTokenInfo = ( return { ...tokenInfo, - icon: getTokenImageByAddress(environment, address), + icon: tokenInfo.icon ?? getTokenImageByAddress(environment, address), }; };