diff --git a/src/providers/token-properties-provider.ts b/src/providers/token-properties-provider.ts index 0c1f267b0..bbe43eed5 100644 --- a/src/providers/token-properties-provider.ts +++ b/src/providers/token-properties-provider.ts @@ -44,6 +44,7 @@ export class TokenPropertiesProvider implements ITokenPropertiesProvider { private chainId: ChainId, private tokenPropertiesCache: ICache, private tokenFeeFetcher: ITokenFeeFetcher, + private tokenFeeFetcherFallback: ITokenFeeFetcher | undefined = undefined, private allowList = DEFAULT_ALLOWLIST, private positiveCacheEntryTTL = POSITIVE_CACHE_ENTRY_TTL, private negativeCacheEntryTTL = NEGATIVE_CACHE_ENTRY_TTL @@ -55,6 +56,7 @@ export class TokenPropertiesProvider implements ITokenPropertiesProvider { ): Promise { const tokenToResult: TokenPropertiesMap = {}; + // Note: Before enabling more ChainIds, make sure that provided tokenFeeFetchers supports them. if ( !providerConfig?.enableFeeOnTransferFeeFetching || this.chainId !== ChainId.MAINNET @@ -62,7 +64,7 @@ export class TokenPropertiesProvider implements ITokenPropertiesProvider { return tokenToResult; } - const addressesToFetchFeesOnchain: string[] = []; + const addressesToFetchFees: string[] = []; const addressesRaw = this.buildAddressesRaw(tokens); const addressesCacheKeys = this.buildAddressesCacheKeys(tokens); @@ -103,27 +105,51 @@ export class TokenPropertiesProvider implements ITokenPropertiesProvider { tokenValidationResult: TokenValidationResult.UNKN, }; } else { - addressesToFetchFeesOnchain.push(address); + addressesToFetchFees.push(address); } } - if (addressesToFetchFeesOnchain.length > 0) { + if (addressesToFetchFees.length > 0) { let tokenFeeMap: TokenFeeMap = {}; try { tokenFeeMap = await this.tokenFeeFetcher.fetchFees( - addressesToFetchFeesOnchain, + addressesToFetchFees, providerConfig ); } catch (err) { log.error( { err }, - `Error fetching fees for tokens ${addressesToFetchFeesOnchain}` + `Error fetching fees for tokens ${addressesToFetchFees}` ); } + if (this.tokenFeeFetcherFallback) { + // If a fallback fetcher is provided, we will use it to fetch fees for tokens that were not fetched by the primary fetcher. + const addressesToFetchFeesWithFallbackFetcher = addressesToFetchFees.filter( + (address) => !tokenFeeMap[address] + ); + if (addressesToFetchFeesWithFallbackFetcher.length > 0) { + try { + const tokenFeeMapFromFallback = await this.tokenFeeFetcherFallback.fetchFees( + addressesToFetchFeesWithFallbackFetcher, + providerConfig + ); + tokenFeeMap = { + ...tokenFeeMap, + ...tokenFeeMapFromFallback, + }; + } catch (err) { + log.error( + { err }, + `Error fetching fees for tokens ${addressesToFetchFeesWithFallbackFetcher} using fallback` + ); + } + } + } + await Promise.all( - addressesToFetchFeesOnchain.map((address) => { + addressesToFetchFees.map((address) => { const tokenFee = tokenFeeMap[address]; const tokenFeeResultExists: BigNumber | undefined = tokenFee && (tokenFee.buyFeeBps || tokenFee.sellFeeBps); diff --git a/test/unit/providers/token-properties-provider.test.ts b/test/unit/providers/token-properties-provider.test.ts index 14d1411ff..5bdb1b5d0 100644 --- a/test/unit/providers/token-properties-provider.test.ts +++ b/test/unit/providers/token-properties-provider.test.ts @@ -164,6 +164,168 @@ describe('TokenPropertiesProvider', () => { } }); + + it('succeeds to get token properties in a single batch when main fetcher fails but fallback succeeds', async function() { + + let mockTokenFeeFetcherFallback: sinon.SinonStubbedInstance + mockTokenFeeFetcherFallback = sinon.createStubInstance(OnChainTokenFeeFetcher) + + const underlyingCache: NodeCache = new NodeCache({ stdTTL: 3600, useClones: false }) + const tokenPropertiesResultCache: NodeJSCache = new NodeJSCache(underlyingCache); + const tokenPropertiesProvider = new TokenPropertiesProvider( + ChainId.MAINNET, + tokenPropertiesResultCache, + mockTokenFeeFetcher, + mockTokenFeeFetcherFallback + ) + const currentEpochTimeInSeconds = Math.floor(Date.now() / 1000); + + const token1 = new Token(1, '0x0000000000000000000000000000000000000012', 18); + const token2 = new Token(1, '0x0000000000000000000000000000000000000034', 18); + const token3 = new Token(1, '0x0000000000000000000000000000000000000056', 18); + + const tokens = [token1, token2, token3] + + mockTokenFeeFetcher.fetchFees.callsFake(async (_) => { + throw new Error("Something went wrong") + }); + + mockTokenFeeFetcherFallback.fetchFees.callsFake(async (addresses) => { + const tokenToResult: TokenFeeMap = {}; + addresses.forEach((address) => { + tokenToResult[address] = { + buyFeeBps: BigNumber.from(parseInt(address[address.length - 2]!)), + sellFeeBps: BigNumber.from(parseInt(address[address.length - 1]!)) + } + }); + + return tokenToResult + }); + + const tokenPropertiesMap = await tokenPropertiesProvider.getTokensProperties(tokens, { enableFeeOnTransferFeeFetching: true }); + + for (const token of tokens) { + const address = token.address.toLowerCase() + expect(tokenPropertiesMap[address]).toBeDefined(); + expect(tokenPropertiesMap[address]?.tokenFeeResult).toBeDefined(); + const expectedBuyFeeBps = tokenPropertiesMap[address]?.tokenFeeResult?.buyFeeBps + const expectedSellFeeBps = tokenPropertiesMap[address]?.tokenFeeResult?.sellFeeBps + assertExpectedTokenProperties(tokenPropertiesMap[address], expectedBuyFeeBps, expectedSellFeeBps, TokenValidationResult.FOT); + + const cachedTokenProperties = await tokenPropertiesResultCache.get(CACHE_KEY(ChainId.MAINNET, token.address.toLowerCase())) + expect(cachedTokenProperties).toBeDefined(); + assertExpectedTokenProperties(cachedTokenProperties, expectedBuyFeeBps, expectedSellFeeBps, TokenValidationResult.FOT); + + underlyingCache.getTtl(CACHE_KEY(ChainId.MAINNET, token.address.toLowerCase())) + expect(Math.floor((underlyingCache.getTtl(CACHE_KEY(ChainId.MAINNET, token.address.toLowerCase())) ?? 0) / 1000)).toEqual(currentEpochTimeInSeconds + POSITIVE_CACHE_ENTRY_TTL); + } + }); + + + it('succeeds to get token properties, some from primary fetcher, some from secondary fetcher', async function() { + + let mockTokenFeeFetcherFallback: sinon.SinonStubbedInstance + mockTokenFeeFetcherFallback = sinon.createStubInstance(OnChainTokenFeeFetcher) + + const underlyingCache: NodeCache = new NodeCache({ stdTTL: 3600, useClones: false }) + const tokenPropertiesResultCache: NodeJSCache = new NodeJSCache(underlyingCache); + const tokenPropertiesProvider = new TokenPropertiesProvider( + ChainId.MAINNET, + tokenPropertiesResultCache, + mockTokenFeeFetcher, + mockTokenFeeFetcherFallback + ) + const currentEpochTimeInSeconds = Math.floor(Date.now() / 1000); + + const token1 = new Token(1, '0x0000000000000000000000000000000000000012', 18); + const token2 = new Token(1, '0x0000000000000000000000000000000000000034', 18); + const token3 = new Token(1, '0x0000000000000000000000000000000000000056', 18); + + const tokens = [token1, token2, token3] + + mockTokenFeeFetcher.fetchFees.callsFake(async (_) => { + return { + '0x0000000000000000000000000000000000000012': { + 'buyFeeBps': BigNumber.from(213), + 'sellFeeBps': BigNumber.from(800) + }, + '0x0000000000000000000000000000000000000034': { + 'buyFeeBps': BigNumber.from(213), + 'sellFeeBps': BigNumber.from(800) + } + } + }); + + mockTokenFeeFetcherFallback.fetchFees.callsFake(async (_) => { + return { + '0x0000000000000000000000000000000000000056': { + 'buyFeeBps': BigNumber.from(213), + 'sellFeeBps': BigNumber.from(800) + } + } + }); + + const tokenPropertiesMap = await tokenPropertiesProvider.getTokensProperties(tokens, { enableFeeOnTransferFeeFetching: true }); + + for (const token of tokens) { + const address = token.address.toLowerCase() + expect(tokenPropertiesMap[address]).toBeDefined(); + expect(tokenPropertiesMap[address]?.tokenFeeResult).toBeDefined(); + const expectedBuyFeeBps = tokenPropertiesMap[address]?.tokenFeeResult?.buyFeeBps + const expectedSellFeeBps = tokenPropertiesMap[address]?.tokenFeeResult?.sellFeeBps + assertExpectedTokenProperties(tokenPropertiesMap[address], expectedBuyFeeBps, expectedSellFeeBps, TokenValidationResult.FOT); + + const cachedTokenProperties = await tokenPropertiesResultCache.get(CACHE_KEY(ChainId.MAINNET, token.address.toLowerCase())) + expect(cachedTokenProperties).toBeDefined(); + assertExpectedTokenProperties(cachedTokenProperties, expectedBuyFeeBps, expectedSellFeeBps, TokenValidationResult.FOT); + + underlyingCache.getTtl(CACHE_KEY(ChainId.MAINNET, token.address.toLowerCase())) + expect(Math.floor((underlyingCache.getTtl(CACHE_KEY(ChainId.MAINNET, token.address.toLowerCase())) ?? 0) / 1000)).toEqual(currentEpochTimeInSeconds + POSITIVE_CACHE_ENTRY_TTL); + } + }); + + + it('all token fee fetch failed for primary and fallback fetcher', async function() { + let mockTokenFeeFetcherFallback: sinon.SinonStubbedInstance + mockTokenFeeFetcherFallback = sinon.createStubInstance(OnChainTokenFeeFetcher) + + const underlyingCache: NodeCache = new NodeCache({ stdTTL: 3600, useClones: false }) + const tokenPropertiesResultCache: NodeJSCache = new NodeJSCache(underlyingCache); + const tokenPropertiesProvider = new TokenPropertiesProvider( + ChainId.MAINNET, + tokenPropertiesResultCache, + mockTokenFeeFetcher, + mockTokenFeeFetcherFallback + ) + const currentEpochTimeInSeconds = Math.floor(Date.now() / 1000); + + const token1 = new Token(1, '0x0000000000000000000000000000000000000012', 18); + const token2 = new Token(1, '0x0000000000000000000000000000000000000034', 18); + const token3 = new Token(1, '0x0000000000000000000000000000000000000056', 18); + + const tokens = [token1, token2, token3] + + mockTokenFeeFetcher.fetchFees.withArgs(tokens.map(token => token.address)).throws(new Error('Failed to fetch fees for token 1')); + mockTokenFeeFetcherFallback.fetchFees.withArgs(tokens.map(token => token.address)).throws(new Error('Failed to fetch fees for token 1')); + + const tokenPropertiesMap = await tokenPropertiesProvider.getTokensProperties(tokens, { enableFeeOnTransferFeeFetching: true }); + + for (const token of tokens) { + const address = token.address.toLowerCase() + expect(tokenPropertiesMap[address]).toBeDefined(); + expect(tokenPropertiesMap[address]?.tokenFeeResult).toBeUndefined(); + expect(tokenPropertiesMap[address]?.tokenValidationResult).toBeUndefined(); + + const cachedTokenProperties = await tokenPropertiesResultCache.get(CACHE_KEY(ChainId.MAINNET, token.address.toLowerCase())) + expect(cachedTokenProperties).toBeDefined(); + expect(cachedTokenProperties?.tokenFeeResult).toBeUndefined(); + expect(cachedTokenProperties?.tokenValidationResult).toBeUndefined(); + + underlyingCache.getTtl(CACHE_KEY(ChainId.MAINNET, token.address.toLowerCase())) + expect(Math.floor((underlyingCache.getTtl(CACHE_KEY(ChainId.MAINNET, token.address.toLowerCase())) ?? 0) / 1000)).toEqual(currentEpochTimeInSeconds + NEGATIVE_CACHE_ENTRY_TTL); + } + }); + it('all token fee fetch failed', async function() { const underlyingCache: NodeCache = new NodeCache({ stdTTL: 3600, useClones: false }) const tokenPropertiesResultCache: NodeJSCache = new NodeJSCache(underlyingCache);