diff --git a/src/abis/TokenFeeDetector.json b/src/abis/TokenFeeDetector.json new file mode 100644 index 000000000..92bb060d3 --- /dev/null +++ b/src/abis/TokenFeeDetector.json @@ -0,0 +1,133 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "_factoryV2", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "PairLookupFailed", + "type": "error" + }, + { + "inputs": [], + "name": "SameToken", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "tokens", + "type": "address[]" + }, + { + "internalType": "address", + "name": "baseToken", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amountToBorrow", + "type": "uint256" + } + ], + "name": "batchValidate", + "outputs": [ + { + "components": [ + { + "internalType": "uint256", + "name": "buyFeeBps", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "sellFeeBps", + "type": "uint256" + } + ], + "internalType": "struct TokenFees[]", + "name": "fotResults", + "type": "tuple[]" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount0", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "uniswapV2Call", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "address", + "name": "baseToken", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amountToBorrow", + "type": "uint256" + } + ], + "name": "validate", + "outputs": [ + { + "components": [ + { + "internalType": "uint256", + "name": "buyFeeBps", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "sellFeeBps", + "type": "uint256" + } + ], + "internalType": "struct TokenFees", + "name": "fotResult", + "type": "tuple" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/src/providers/cache-node.ts b/src/providers/cache-node.ts index 3aed9bf97..35055f80e 100644 --- a/src/providers/cache-node.ts +++ b/src/providers/cache-node.ts @@ -9,6 +9,19 @@ export class NodeJSCache implements ICache { return this.nodeCache.get(key); } + async batchGet(keys: Set): Promise> { + const keysArr = Array.from(keys); + const values = await Promise.all(keysArr.map((key) => this.get(key))); + + const result: Record = {}; + + keysArr.forEach((key, index) => { + result[key] = values[index]; + }); + + return result; + } + async set(key: string, value: T): Promise { return this.nodeCache.set(key, value); } diff --git a/src/providers/cache.ts b/src/providers/cache.ts index 78193b091..f204b78c6 100644 --- a/src/providers/cache.ts +++ b/src/providers/cache.ts @@ -9,6 +9,8 @@ export interface ICache { get(key: string): Promise; + batchGet(keys: Set): Promise>; + set(key: string, value: T): Promise; has(key: string): Promise; diff --git a/src/providers/index.ts b/src/providers/index.ts index 922a0128d..7973a3e21 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -3,6 +3,7 @@ export * from './cache-node'; export * from './caching-gas-provider'; export * from './caching-token-list-provider'; export * from './caching-token-provider'; +export * from './caching/route'; export * from './eip-1559-gas-price-provider'; export * from './eth-estimate-gas-provider'; export * from './eth-gas-station-info-gas-price-provider'; @@ -16,10 +17,10 @@ export * from './simulation-provider'; export * from './static-gas-price-provider'; export * from './swap-router-provider'; export * from './tenderly-simulation-provider'; +export * from './token-properties-provider'; export * from './token-provider'; export * from './token-validator-provider'; export * from './uri-subgraph-provider'; -export * from './caching/route'; export * from './v2/caching-pool-provider'; export * from './v2/caching-subgraph-provider'; export * from './v2/pool-provider'; diff --git a/src/providers/token-fee-fetcher.ts b/src/providers/token-fee-fetcher.ts new file mode 100644 index 000000000..96b9897bc --- /dev/null +++ b/src/providers/token-fee-fetcher.ts @@ -0,0 +1,117 @@ +import { BigNumber } from '@ethersproject/bignumber'; +import { BaseProvider } from '@ethersproject/providers'; +import { ChainId } from '@uniswap/sdk-core'; + +import { TokenFeeDetector__factory } from '../types/other/factories/TokenFeeDetector__factory'; +import { TokenFeeDetector } from '../types/other/TokenFeeDetector'; +import { log, WRAPPED_NATIVE_CURRENCY } from '../util'; + +import { ProviderConfig } from './provider'; + +const DEFAULT_TOKEN_BUY_FEE_BPS = BigNumber.from(0); +const DEFAULT_TOKEN_SELL_FEE_BPS = BigNumber.from(0); + +// on detector failure, assume no fee +export const DEFAULT_TOKEN_FEE_RESULT = { + buyFeeBps: DEFAULT_TOKEN_BUY_FEE_BPS, + sellFeeBps: DEFAULT_TOKEN_SELL_FEE_BPS, +}; + +type Address = string; + +export type TokenFeeResult = { + buyFeeBps?: BigNumber; + sellFeeBps?: BigNumber; +}; +export type TokenFeeMap = Record; + +// address at which the FeeDetector lens is deployed +const FEE_DETECTOR_ADDRESS = (chainId: ChainId) => { + switch (chainId) { + case ChainId.MAINNET: + default: + return '0x19C97dc2a25845C7f9d1d519c8C2d4809c58b43f'; + } +}; + +// Amount has to be big enough to avoid rounding errors, but small enough that +// most v2 pools will have at least this many token units +// 10000 is the smallest number that avoids rounding errors in bps terms +const AMOUNT_TO_FLASH_BORROW = '10000'; +// 1M gas limit per validate call, should cover most swap cases +const GAS_LIMIT_PER_VALIDATE = 1_000_000; + +export interface ITokenFeeFetcher { + fetchFees( + addresses: Address[], + providerConfig?: ProviderConfig + ): Promise; +} + +export class OnChainTokenFeeFetcher implements ITokenFeeFetcher { + private BASE_TOKEN: string; + private readonly contract: TokenFeeDetector; + + constructor( + private chainId: ChainId, + rpcProvider: BaseProvider, + private tokenFeeAddress = FEE_DETECTOR_ADDRESS(chainId), + private gasLimitPerCall = GAS_LIMIT_PER_VALIDATE, + private amountToFlashBorrow = AMOUNT_TO_FLASH_BORROW + ) { + this.BASE_TOKEN = WRAPPED_NATIVE_CURRENCY[this.chainId]?.address; + this.contract = TokenFeeDetector__factory.connect( + this.tokenFeeAddress, + rpcProvider + ); + } + + public async fetchFees( + addresses: Address[], + providerConfig?: ProviderConfig + ): Promise { + const tokenToResult: TokenFeeMap = {}; + + const functionParams = addresses.map((address) => [ + address, + this.BASE_TOKEN, + this.amountToFlashBorrow, + ]) as [string, string, string][]; + + const results = await Promise.all( + functionParams.map(async ([address, baseToken, amountToBorrow]) => { + try { + // We use the validate function instead of batchValidate to avoid poison pill problem. + // One token that consumes too much gas could cause the entire batch to fail. + const feeResult = await this.contract.callStatic.validate( + address, + baseToken, + amountToBorrow, + { + gasLimit: this.gasLimitPerCall, + blockTag: providerConfig?.blockNumber, + } + ); + return { address, ...feeResult }; + } catch (err) { + log.error( + { err }, + `Error calling validate on-chain for token ${address}` + ); + // in case of FOT token fee fetch failure, we return null + // so that they won't get returned from the token-fee-fetcher + // and thus no fee will be applied, and the cache won't cache on FOT tokens with failed fee fetching + return { address, buyFeeBps: undefined, sellFeeBps: undefined }; + } + }) + ); + + results.forEach(({ address, buyFeeBps, sellFeeBps }) => { + if (buyFeeBps || sellFeeBps) { + tokenToResult[address] = { buyFeeBps, sellFeeBps }; + } + }); + + return tokenToResult; + } +} diff --git a/src/providers/token-properties-provider.ts b/src/providers/token-properties-provider.ts new file mode 100644 index 000000000..78e41bdc5 --- /dev/null +++ b/src/providers/token-properties-provider.ts @@ -0,0 +1,152 @@ +import { ChainId, Token } from '@uniswap/sdk-core'; + +import { log } from '../util'; +import { ICache } from './cache'; +import { ProviderConfig } from './provider'; +import { + DEFAULT_TOKEN_FEE_RESULT, + ITokenFeeFetcher, + TokenFeeMap, + TokenFeeResult, +} from './token-fee-fetcher'; +import { + DEFAULT_ALLOWLIST, + ITokenValidatorProvider, + TokenValidationResult, +} from './token-validator-provider'; + +export const DEFAULT_TOKEN_PROPERTIES_RESULT: TokenPropertiesResult = { + tokenFeeResult: DEFAULT_TOKEN_FEE_RESULT, +}; + +type Address = string; +export type TokenPropertiesResult = { + tokenFeeResult?: TokenFeeResult; + tokenValidationResult?: TokenValidationResult; +}; +export type TokenPropertiesMap = Record; + +export interface ITokenPropertiesProvider { + getTokensProperties( + tokens: Token[], + providerConfig?: ProviderConfig + ): Promise; +} + +export class TokenPropertiesProvider implements ITokenPropertiesProvider { + private CACHE_KEY = (chainId: ChainId, address: string) => + `token-properties-${chainId}-${address}`; + + constructor( + private chainId: ChainId, + private tokenValidatorProvider: ITokenValidatorProvider, + private tokenPropertiesCache: ICache, + private tokenFeeFetcher: ITokenFeeFetcher, + private allowList = DEFAULT_ALLOWLIST + ) {} + + public async getTokensProperties( + tokens: Token[], + providerConfig?: ProviderConfig + ): Promise { + const nonAllowlistTokens = tokens.filter( + (token) => !this.allowList.has(token.address.toLowerCase()) + ); + const tokenValidationResults = + await this.tokenValidatorProvider.validateTokens( + nonAllowlistTokens, + providerConfig + ); + const tokenToResult: TokenPropertiesMap = {}; + + tokens.forEach((token) => { + if (this.allowList.has(token.address.toLowerCase())) { + // if the token is in the allowlist, make it UNKNOWN so that we don't fetch the FOT fee on-chain + tokenToResult[token.address.toLowerCase()] = { + tokenValidationResult: TokenValidationResult.UNKN, + }; + } else { + tokenToResult[token.address.toLowerCase()] = { + tokenValidationResult: + tokenValidationResults.getValidationByToken(token), + }; + } + }); + + const addressesToFetchFeesOnchain: string[] = []; + const addressesRaw = this.buildAddressesRaw(tokens); + + const tokenProperties = await this.tokenPropertiesCache.batchGet( + addressesRaw + ); + + // Check if we have cached token validation results for any tokens. + for (const address of addressesRaw) { + const cachedValue = tokenProperties[address]; + if (cachedValue) { + tokenToResult[address] = cachedValue; + } else if ( + tokenToResult[address]?.tokenValidationResult === + TokenValidationResult.FOT + ) { + addressesToFetchFeesOnchain.push(address); + } + } + + if (addressesToFetchFeesOnchain.length > 0) { + let tokenFeeMap: TokenFeeMap = {}; + + try { + tokenFeeMap = await this.tokenFeeFetcher.fetchFees( + addressesToFetchFeesOnchain, + providerConfig + ); + } catch (err) { + log.error( + { err }, + `Error fetching fees for tokens ${addressesToFetchFeesOnchain}` + ); + } + + await Promise.all( + addressesToFetchFeesOnchain.map((address) => { + const tokenFee = tokenFeeMap[address]; + if (tokenFee && (tokenFee.buyFeeBps || tokenFee.sellFeeBps)) { + const tokenResultForAddress = tokenToResult[address]; + + if (tokenResultForAddress) { + tokenResultForAddress.tokenFeeResult = tokenFee; + } + + // update cache concurrently + // at this point, we are confident that the tokens are FOT, so we can hardcode the validation result + return this.tokenPropertiesCache.set( + this.CACHE_KEY(this.chainId, address), + { + tokenFeeResult: tokenFee, + tokenValidationResult: TokenValidationResult.FOT, + } + ); + } else { + return Promise.resolve(true); + } + }) + ); + } + + return tokenToResult; + } + + private buildAddressesRaw(tokens: Token[]): Set { + const addressesRaw = new Set(); + + for (const token of tokens) { + const address = token.address.toLowerCase(); + if (!addressesRaw.has(address)) { + addressesRaw.add(address); + } + } + + return addressesRaw; + } +} diff --git a/src/providers/token-validator-provider.ts b/src/providers/token-validator-provider.ts index ec446ddc0..446bd7171 100644 --- a/src/providers/token-validator-provider.ts +++ b/src/providers/token-validator-provider.ts @@ -8,7 +8,7 @@ import { ICache } from './cache'; import { IMulticallProvider } from './multicall-provider'; import { ProviderConfig } from './provider'; -const DEFAULT_ALLOWLIST = new Set([ +export const DEFAULT_ALLOWLIST = new Set([ // RYOSHI. Does not allow transfers between contracts so fails validation. '0x777E2ae845272a2F540ebf6a3D03734A5a8f618e'.toLowerCase(), ]); diff --git a/src/routers/alpha-router/alpha-router.ts b/src/routers/alpha-router/alpha-router.ts index 14d8aa155..0bbcf1702 100644 --- a/src/routers/alpha-router/alpha-router.ts +++ b/src/routers/alpha-router/alpha-router.ts @@ -24,6 +24,7 @@ import { IOnChainQuoteProvider, IRouteCachingProvider, ISwapRouterProvider, + ITokenPropertiesProvider, IV2QuoteProvider, IV2SubgraphProvider, LegacyGasPriceProvider, @@ -33,16 +34,17 @@ import { Simulator, StaticV2SubgraphProvider, StaticV3SubgraphProvider, - SwapRouterProvider, + SwapRouterProvider, TokenPropertiesProvider, UniswapMulticallProvider, URISubgraphProvider, V2QuoteProvider, V2SubgraphProviderWithFallBacks, - V3SubgraphProviderWithFallBacks, + V3SubgraphProviderWithFallBacks } from '../../providers'; import { CachingTokenListProvider, ITokenListProvider } from '../../providers/caching-token-list-provider'; import { GasPrice, IGasPriceProvider } from '../../providers/gas-price-provider'; import { ProviderConfig } from '../../providers/provider'; +import { OnChainTokenFeeFetcher } from '../../providers/token-fee-fetcher'; import { ITokenProvider, TokenProvider } from '../../providers/token-provider'; import { ITokenValidatorProvider, TokenValidatorProvider, } from '../../providers/token-validator-provider'; import { IV2PoolProvider, V2PoolProvider } from '../../providers/v2/pool-provider'; @@ -208,6 +210,11 @@ export type AlphaRouterParams = { * A provider for caching the best route given an amount, quoteToken, tradeType */ routeCachingProvider?: IRouteCachingProvider; + + /** + * A provider for getting token properties for special tokens like fee-on-transfer tokens. + */ + tokenPropertiesProvider?: ITokenPropertiesProvider; }; export class MapWithLowerCaseKey extends Map { @@ -375,6 +382,7 @@ export class AlphaRouter protected v3Quoter: V3Quoter; protected mixedQuoter: MixedQuoter; protected routeCachingProvider?: IRouteCachingProvider; + protected tokenPropertiesProvider?: ITokenPropertiesProvider; constructor({ chainId, @@ -398,6 +406,7 @@ export class AlphaRouter arbitrumGasDataProvider, simulator, routeCachingProvider, + tokenPropertiesProvider, }: AlphaRouterParams) { this.chainId = chainId; this.provider = provider; @@ -684,6 +693,16 @@ export class AlphaRouter new NodeJSCache(new NodeCache({ stdTTL: 30000, useClones: false })) ); } + if (tokenPropertiesProvider) { + this.tokenPropertiesProvider = tokenPropertiesProvider; + } else if (this.chainId === ChainId.MAINNET) { + this.tokenPropertiesProvider = new TokenPropertiesProvider( + this.chainId, + this.tokenValidatorProvider!, + new NodeJSCache(new NodeCache({ stdTTL: 86400, useClones: false })), + new OnChainTokenFeeFetcher(this.chainId, provider) + ) + } // Initialize the Quoters. // Quoters are an abstraction encapsulating the business logic of fetching routes and quotes. diff --git a/test/unit/providers/cache-node.test.ts b/test/unit/providers/cache-node.test.ts new file mode 100644 index 000000000..88bd5ebef --- /dev/null +++ b/test/unit/providers/cache-node.test.ts @@ -0,0 +1,18 @@ +import NodeCache from 'node-cache'; +import { NodeJSCache } from '../../../build/main'; + +describe('NodeJSCache', () => { + const cache = new NodeJSCache(new NodeCache()) + + it('set keys and batchGet', async () => { + await Promise.all([ + cache.set('key1', 'value1'), + cache.set('key2', 'value2') + ]); + + const batchGet = await cache.batchGet(new Set(['key1', 'key2', 'key3'])); + expect(batchGet['key1']).toEqual('value1'); + expect(batchGet['key2']).toEqual('value2'); + expect(batchGet['key3']).toBeUndefined(); + }); +}); diff --git a/test/unit/providers/token-properties-provider.test.ts b/test/unit/providers/token-properties-provider.test.ts new file mode 100644 index 000000000..94ef21b34 --- /dev/null +++ b/test/unit/providers/token-properties-provider.test.ts @@ -0,0 +1,247 @@ +import { ChainId, Token } from '@uniswap/sdk-core'; +import NodeCache from 'node-cache'; +import sinon from 'sinon'; +import { + ITokenFeeFetcher, + OnChainTokenFeeFetcher, + TokenFeeMap +} from '../../../src/providers/token-fee-fetcher'; +import { BigNumber } from '@ethersproject/bignumber'; +import { + CallSameFunctionOnContractWithMultipleParams, + ICache, + IMulticallProvider, + ITokenPropertiesProvider, + ITokenValidatorProvider, + NodeJSCache, + TokenPropertiesProvider, + TokenPropertiesResult, + TokenValidationResult, + TokenValidatorProvider, + UniswapMulticallConfig, + UniswapMulticallProvider, + USDC_MAINNET +} from '../../../src'; + +describe('TokenPropertiesProvider', () => { + let tokenPropertiesProvider: ITokenPropertiesProvider + let tokenValidatorProvider: ITokenValidatorProvider + let tokenPropertiesResultCache: ICache + let tokenValidationResultCache: ICache + let mockMulticall2Provider: sinon.SinonStubbedInstance> + let mockTokenFeeFetcher: sinon.SinonStubbedInstance + + const CACHE_KEY = (chainId: ChainId, address: string) => + `token-properties-${chainId}-${address}`; + + beforeEach(async () => { + tokenPropertiesResultCache = new NodeJSCache(new NodeCache({ stdTTL: 3600, useClones: false })); + tokenValidationResultCache = new NodeJSCache(new NodeCache({ stdTTL: 3600, useClones: false })); + mockTokenFeeFetcher = sinon.createStubInstance(OnChainTokenFeeFetcher) + mockMulticall2Provider = sinon.createStubInstance(UniswapMulticallProvider) + + tokenValidatorProvider = new TokenValidatorProvider( + ChainId.MAINNET, + mockMulticall2Provider, + tokenValidationResultCache, + ) + + tokenPropertiesProvider = new TokenPropertiesProvider( + ChainId.MAINNET, + tokenValidatorProvider, + tokenPropertiesResultCache, + mockTokenFeeFetcher, + ) + + type functionParams = [string, string[], string][] + mockMulticall2Provider.callSameFunctionOnContractWithMultipleParams.callsFake(async ( + params: CallSameFunctionOnContractWithMultipleParams) => { + return { + blockNumber: BigNumber.from(100), + approxGasUsedPerSuccessCall: 100, + results: params.functionParams.map((_?: functionParams) => { + return ({ + success: true, + result: [TokenValidationResult.FOT] + }) + }) + }; + }) + + mockTokenFeeFetcher.fetchFees.callsFake(async (addresses) => { + const tokenToResult: TokenFeeMap = {}; + addresses.forEach((address) => tokenToResult[address] = { + buyFeeBps: BigNumber.from(213), + sellFeeBps: BigNumber.from(800) + }) + + return tokenToResult + }) + }) + + describe('get token fees by address', () => { + it('succeeds to get token fee and updates cache', async () => { + const token = USDC_MAINNET + + expect(await tokenPropertiesResultCache.get(CACHE_KEY(ChainId.MAINNET, token.address.toLowerCase()))).toBeUndefined(); + const tokenPropertiesMap = await tokenPropertiesProvider.getTokensProperties([token]); + expect(tokenPropertiesMap[token.address.toLowerCase()]).toBeDefined(); + assertExpectedTokenProperties(tokenPropertiesMap[token.address.toLowerCase()], BigNumber.from(213), BigNumber.from(800), TokenValidationResult.FOT); + + const cachedTokenProperties = await tokenPropertiesResultCache.get(CACHE_KEY(ChainId.MAINNET, token.address.toLowerCase())) + expect(cachedTokenProperties).toBeDefined(); + assertExpectedTokenProperties(cachedTokenProperties, BigNumber.from(213), BigNumber.from(800), TokenValidationResult.FOT); + }) + + it('succeeds to get token fee cache hit and second token fee fetcher call is skipped', async function() { + const token = USDC_MAINNET + + expect(await tokenPropertiesResultCache.get(CACHE_KEY(ChainId.MAINNET, token.address.toLowerCase()))).toBeUndefined(); + const tokenPropertiesMap = await tokenPropertiesProvider.getTokensProperties([token]); + expect(tokenPropertiesMap[token.address.toLowerCase()]).toBeDefined(); + assertExpectedTokenProperties(tokenPropertiesMap[token.address.toLowerCase()], BigNumber.from(213), BigNumber.from(800), TokenValidationResult.FOT); + sinon.assert.calledOnce(mockTokenFeeFetcher.fetchFees) + + const cachedTokenProperties = await tokenPropertiesResultCache.get(CACHE_KEY(ChainId.MAINNET, token.address.toLowerCase())) + expect(cachedTokenProperties).toBeDefined(); + assertExpectedTokenProperties(cachedTokenProperties, BigNumber.from(213), BigNumber.from(800), TokenValidationResult.FOT); + sinon.assert.calledOnce(mockTokenFeeFetcher.fetchFees) + }); + + it('succeeds to get token allowlist with no on-chain calls nor caching', async function() { + const allowListToken = new Token(1, '0x777E2ae845272a2F540ebf6a3D03734A5a8f618e', 18); + const tokenPropertiesMap = await tokenPropertiesProvider.getTokensProperties([allowListToken]); + + expect(tokenPropertiesMap[allowListToken.address.toLowerCase()]).toBeDefined(); + expect(tokenPropertiesMap[allowListToken.address.toLowerCase()]?.tokenFeeResult).toBeUndefined(); + assertExpectedTokenProperties(tokenPropertiesMap[allowListToken.address.toLowerCase()], undefined, undefined, TokenValidationResult.UNKN); + + expect(await tokenPropertiesResultCache.get(CACHE_KEY(ChainId.MAINNET, allowListToken.address.toLowerCase()))).toBeUndefined(); + }); + + it('succeeds to get token properties in a single batch', async function() { + 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 (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); + + 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); + } + }); + + it('all tokens in the batch failed to get token validation result, no fees fetched', async function() { + 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 (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 + }); + + type functionParams = [string, string[], string][] + mockMulticall2Provider.callSameFunctionOnContractWithMultipleParams.callsFake(async ( + params: CallSameFunctionOnContractWithMultipleParams) => { + return { + blockNumber: BigNumber.from(100), + approxGasUsedPerSuccessCall: 100, + results: params.functionParams.map(() => { + return { success: false, returnData: 'Not FOT' } + }) + }; + }) + + const tokenPropertiesMap = await tokenPropertiesProvider.getTokensProperties(tokens); + + for (const token of tokens) { + const address = token.address.toLowerCase() + expect(tokenPropertiesMap[address]).toBeDefined(); + expect(tokenPropertiesMap[address]?.tokenFeeResult).toBeUndefined(); + assertExpectedTokenProperties(tokenPropertiesMap[address], undefined, undefined, undefined); + + const cachedTokenProperties = await tokenPropertiesResultCache.get(CACHE_KEY(ChainId.MAINNET, token.address.toLowerCase())) + expect(cachedTokenProperties).toBeUndefined(); + } + }); + + it('all token fee fetch failed', async function() { + 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')); + + const tokenPropertiesMap = await tokenPropertiesProvider.getTokensProperties(tokens); + + for (const token of tokens) { + const address = token.address.toLowerCase() + expect(tokenPropertiesMap[address]).toBeDefined(); + expect(tokenPropertiesMap[address]?.tokenFeeResult).toBeUndefined(); + assertExpectedTokenProperties(tokenPropertiesMap[address], undefined, undefined, TokenValidationResult.FOT); + + const cachedTokenProperties = await tokenPropertiesResultCache.get(CACHE_KEY(ChainId.MAINNET, token.address.toLowerCase())) + expect(cachedTokenProperties).toBeUndefined(); + + } + }); + }); + + function assertExpectedTokenProperties( + tokenProperties?: TokenPropertiesResult, + expectedBuyFeeBps?: BigNumber, + expectedSellFeeBps?: BigNumber, + expectedTokenValidationResult?: TokenValidationResult + ): void { + if (expectedBuyFeeBps) { + expect(tokenProperties?.tokenFeeResult?.buyFeeBps?.eq(expectedBuyFeeBps)).toBeTruthy(); + } else { + expect(tokenProperties?.tokenFeeResult?.buyFeeBps).toBeUndefined(); + } + + if (expectedSellFeeBps) { + expect(tokenProperties?.tokenFeeResult?.sellFeeBps?.eq(expectedSellFeeBps)).toBeTruthy(); + } else { + expect(tokenProperties?.tokenFeeResult?.sellFeeBps).toBeUndefined(); + } + + expect(tokenProperties?.tokenValidationResult).toEqual(expectedTokenValidationResult); + } +}); + + diff --git a/test/unit/routers/alpha-router/alpha-router.test.ts b/test/unit/routers/alpha-router/alpha-router.test.ts index 0bb1de112..a6eef7be8 100644 --- a/test/unit/routers/alpha-router/alpha-router.test.ts +++ b/test/unit/routers/alpha-router/alpha-router.test.ts @@ -14,7 +14,7 @@ import { CacheMode, CachingTokenListProvider, CurrencyAmount, - DAI_MAINNET as DAI, + DAI_MAINNET as DAI, DEFAULT_TOKEN_PROPERTIES_RESULT, ETHGasStationInfoProvider, FallbackTenderlySimulator, MixedRoute, @@ -27,6 +27,7 @@ import { SwapRouterProvider, SwapToRatioStatus, SwapType, + TokenPropertiesProvider, TokenProvider, UniswapMulticallProvider, USDC_MAINNET as USDC, @@ -44,7 +45,7 @@ import { V3RouteWithValidQuote, V3SubgraphPool, V3SubgraphProvider, - WRAPPED_NATIVE_CURRENCY, + WRAPPED_NATIVE_CURRENCY } from '../../../../src'; import { ProviderConfig } from '../../../../src/providers/provider'; import { TokenValidationResult, TokenValidatorProvider, } from '../../../../src/providers/token-validator-provider'; @@ -101,9 +102,11 @@ describe('alpha router', () => { let mockBlockTokenListProvider: sinon.SinonStubbedInstance; let mockTokenValidatorProvider: sinon.SinonStubbedInstance; + let mockTokenPropertiesProvider: sinon.SinonStubbedInstance; let mockFallbackTenderlySimulator: sinon.SinonStubbedInstance; + let inMemoryRouteCachingProvider: InMemoryRouteCachingProvider; let alphaRouter: AlphaRouter; @@ -375,6 +378,13 @@ describe('alpha router', () => { getValidationByToken: () => TokenValidationResult.UNKN, }); + mockTokenPropertiesProvider = sinon.createStubInstance( + TokenPropertiesProvider + ) + mockTokenPropertiesProvider.getTokensProperties.resolves({ + '0x0': DEFAULT_TOKEN_PROPERTIES_RESULT + }) + mockFallbackTenderlySimulator = sinon.createStubInstance( FallbackTenderlySimulator ); @@ -402,7 +412,8 @@ describe('alpha router', () => { swapRouterProvider: mockSwapRouterProvider, tokenValidatorProvider: mockTokenValidatorProvider, simulator: mockFallbackTenderlySimulator, - routeCachingProvider: inMemoryRouteCachingProvider + routeCachingProvider: inMemoryRouteCachingProvider, + tokenPropertiesProvider: mockTokenPropertiesProvider, }); });