diff --git a/src/providers/v2/quote-provider.ts b/src/providers/v2/quote-provider.ts index b22ba9d51..e03d67906 100644 --- a/src/providers/v2/quote-provider.ts +++ b/src/providers/v2/quote-provider.ts @@ -8,7 +8,9 @@ import { import { V2Route } from '../../routers/router'; import { CurrencyAmount } from '../../util/amounts'; import { log } from '../../util/log'; +import { metric, MetricLoggerUnit } from '../../util/metric'; import { routeToString } from '../../util/routes'; +import { ProviderConfig } from '../provider'; // Quotes can be null (e.g. pool did not have enough liquidity). export type V2AmountQuote = { @@ -21,12 +23,14 @@ export type V2RouteWithQuotes = [V2Route, V2AmountQuote[]]; export interface IV2QuoteProvider { getQuotesManyExactIn( amountIns: CurrencyAmount[], - routes: V2Route[] + routes: V2Route[], + providerConfig: ProviderConfig ): Promise<{ routesWithQuotes: V2RouteWithQuotes[] }>; getQuotesManyExactOut( amountOuts: CurrencyAmount[], - routes: V2Route[] + routes: V2Route[], + providerConfig: ProviderConfig ): Promise<{ routesWithQuotes: V2RouteWithQuotes[] }>; } @@ -44,22 +48,35 @@ export class V2QuoteProvider implements IV2QuoteProvider { public async getQuotesManyExactIn( amountIns: CurrencyAmount[], - routes: V2Route[] + routes: V2Route[], + providerConfig: ProviderConfig ): Promise<{ routesWithQuotes: V2RouteWithQuotes[] }> { - return this.getQuotes(amountIns, routes, TradeType.EXACT_INPUT); + return this.getQuotes( + amountIns, + routes, + TradeType.EXACT_INPUT, + providerConfig + ); } public async getQuotesManyExactOut( amountOuts: CurrencyAmount[], - routes: V2Route[] + routes: V2Route[], + providerConfig: ProviderConfig ): Promise<{ routesWithQuotes: V2RouteWithQuotes[] }> { - return this.getQuotes(amountOuts, routes, TradeType.EXACT_OUTPUT); + return this.getQuotes( + amountOuts, + routes, + TradeType.EXACT_OUTPUT, + providerConfig + ); } private async getQuotes( amounts: CurrencyAmount[], routes: V2Route[], - tradeType: TradeType + tradeType: TradeType, + providerConfig: ProviderConfig ): Promise<{ routesWithQuotes: V2RouteWithQuotes[] }> { const routesWithQuotes: V2RouteWithQuotes[] = []; @@ -75,14 +92,58 @@ export class V2QuoteProvider implements IV2QuoteProvider { let outputAmount = amount.wrapped; for (const pair of route.pairs) { - if (pair.token0.equals(outputAmount.currency) && pair.token0.sellFeeBps?.gt(BigNumber.from(0))) { - const outputAmountWithSellFeeBps = CurrencyAmount.fromRawAmount(pair.token0, outputAmount.quotient); - const [outputAmountNew] = pair.getOutputAmount(outputAmountWithSellFeeBps); - outputAmount = outputAmountNew; - } else if (pair.token1.equals(outputAmount.currency) && pair.token1.sellFeeBps?.gt(BigNumber.from(0))) { - const outputAmountWithSellFeeBps = CurrencyAmount.fromRawAmount(pair.token1, outputAmount.quotient); - const [outputAmountNew] = pair.getOutputAmount(outputAmountWithSellFeeBps); - outputAmount = outputAmountNew; + if (amount.wrapped.currency.sellFeeBps) { + // this should never happen, but just in case it happens, + // there is a bug in sor. We need to log this and investigate. + const error = + new Error(`Sell fee bps should not exist on output amount + ${JSON.stringify(amount)} on amounts ${JSON.stringify(amounts)} + on routes ${JSON.stringify(routes)}`); + + // artificially create error object and pass in log.error so that + // it also log the stack trace + log.error( + { error }, + 'Sell fee bps should not exist on output amount' + ); + metric.putMetric( + 'V2_QUOTE_PROVIDER_INCONSISTENT_SELL_FEE_BPS_VS_FEATURE_FLAG', + 1, + MetricLoggerUnit.Count + ); + } + + if (providerConfig.enableFeeOnTransferFeeFetching) { + if ( + pair.token0.equals(outputAmount.currency) && + pair.token0.sellFeeBps?.gt(BigNumber.from(0)) + ) { + const outputAmountWithSellFeeBps = + CurrencyAmount.fromRawAmount( + pair.token0, + outputAmount.quotient + ); + const [outputAmountNew] = pair.getOutputAmount( + outputAmountWithSellFeeBps + ); + outputAmount = outputAmountNew; + } else if ( + pair.token1.equals(outputAmount.currency) && + pair.token1.sellFeeBps?.gt(BigNumber.from(0)) + ) { + const outputAmountWithSellFeeBps = + CurrencyAmount.fromRawAmount( + pair.token1, + outputAmount.quotient + ); + const [outputAmountNew] = pair.getOutputAmount( + outputAmountWithSellFeeBps + ); + outputAmount = outputAmountNew; + } else { + const [outputAmountNew] = pair.getOutputAmount(outputAmount); + outputAmount = outputAmountNew; + } } else { const [outputAmountNew] = pair.getOutputAmount(outputAmount); outputAmount = outputAmountNew; @@ -98,12 +159,49 @@ export class V2QuoteProvider implements IV2QuoteProvider { for (let i = route.pairs.length - 1; i >= 0; i--) { const pair = route.pairs[i]!; - if (pair.token0.equals(inputAmount.currency) && pair.token0.buyFeeBps?.gt(BigNumber.from(0))) { - const inputAmountWithBuyFeeBps = CurrencyAmount.fromRawAmount(pair.token0, inputAmount.quotient); - [inputAmount] = pair.getInputAmount(inputAmountWithBuyFeeBps); - } else if (pair.token1.equals(inputAmount.currency) && pair.token1.buyFeeBps?.gt(BigNumber.from(0))) { - const inputAmountWithSellFeeBps = CurrencyAmount.fromRawAmount(pair.token1, inputAmount.quotient); - [inputAmount] = pair.getInputAmount(inputAmountWithSellFeeBps); + if (amount.wrapped.currency.buyFeeBps) { + // this should never happen, but just in case it happens, + // there is a bug in sor. We need to log this and investigate. + const error = + new Error(`Buy fee bps should not exist on input amount + ${JSON.stringify(amount)} on amounts ${JSON.stringify(amounts)} + on routes ${JSON.stringify(routes)}`); + + // artificially create error object and pass in log.error so that + // it also log the stack trace + log.error( + { error }, + 'Buy fee bps should not exist on input amount' + ); + metric.putMetric( + 'V2_QUOTE_PROVIDER_INCONSISTENT_BUY_FEE_BPS_VS_FEATURE_FLAG', + 1, + MetricLoggerUnit.Count + ); + } + + if (providerConfig.enableFeeOnTransferFeeFetching) { + if ( + pair.token0.equals(inputAmount.currency) && + pair.token0.buyFeeBps?.gt(BigNumber.from(0)) + ) { + const inputAmountWithBuyFeeBps = CurrencyAmount.fromRawAmount( + pair.token0, + inputAmount.quotient + ); + [inputAmount] = pair.getInputAmount(inputAmountWithBuyFeeBps); + } else if ( + pair.token1.equals(inputAmount.currency) && + pair.token1.buyFeeBps?.gt(BigNumber.from(0)) + ) { + const inputAmountWithBuyFeeBps = CurrencyAmount.fromRawAmount( + pair.token1, + inputAmount.quotient + ); + [inputAmount] = pair.getInputAmount(inputAmountWithBuyFeeBps); + } else { + [inputAmount] = pair.getInputAmount(inputAmount); + } } else { [inputAmount] = pair.getInputAmount(inputAmount); } diff --git a/src/routers/alpha-router/alpha-router.ts b/src/routers/alpha-router/alpha-router.ts index 069253574..b9bd02af7 100644 --- a/src/routers/alpha-router/alpha-router.ts +++ b/src/routers/alpha-router/alpha-router.ts @@ -35,7 +35,7 @@ import { StaticV2SubgraphProvider, StaticV3SubgraphProvider, SwapRouterProvider, - TokenPropertiesProvider, TokenValidationResult, + TokenPropertiesProvider, UniswapMulticallProvider, URISubgraphProvider, V2QuoteProvider, @@ -1178,42 +1178,12 @@ export class AlphaRouter cacheMode !== CacheMode.Darkmode && swapRouteFromChain ) { - const tokenPropertiesMap = await this.tokenPropertiesProvider.getTokensProperties([tokenIn, tokenOut], providerConfig); - - const tokenInWithFotTax = - (tokenPropertiesMap[tokenIn.address.toLowerCase()] - ?.tokenValidationResult === TokenValidationResult.FOT) ? - new Token( - tokenIn.chainId, - tokenIn.address, - tokenIn.decimals, - tokenIn.symbol, - tokenIn.name, - true, // at this point we know it's valid token address - tokenPropertiesMap[tokenIn.address.toLowerCase()]?.tokenFeeResult?.buyFeeBps, - tokenPropertiesMap[tokenIn.address.toLowerCase()]?.tokenFeeResult?.sellFeeBps - ) : tokenIn; - - const tokenOutWithFotTax = - (tokenPropertiesMap[tokenOut.address.toLowerCase()] - ?.tokenValidationResult === TokenValidationResult.FOT) ? - new Token( - tokenOut.chainId, - tokenOut.address, - tokenOut.decimals, - tokenOut.symbol, - tokenOut.name, - true, // at this point we know it's valid token address - tokenPropertiesMap[tokenOut.address.toLowerCase()]?.tokenFeeResult?.buyFeeBps, - tokenPropertiesMap[tokenOut.address.toLowerCase()]?.tokenFeeResult?.sellFeeBps - ) : tokenOut; - // Generate the object to be cached const routesToCache = CachedRoutes.fromRoutesWithValidQuotes( swapRouteFromChain.routes, this.chainId, - tokenInWithFotTax, - tokenOutWithFotTax, + tokenIn, + tokenOut, protocols.sort(), // sort it for consistency in the order of the protocols. await blockNumber, tradeType, diff --git a/src/routers/alpha-router/quoters/v2-quoter.ts b/src/routers/alpha-router/quoters/v2-quoter.ts index 63695eb92..50ba548ae 100644 --- a/src/routers/alpha-router/quoters/v2-quoter.ts +++ b/src/routers/alpha-router/quoters/v2-quoter.ts @@ -12,12 +12,21 @@ import { IV2SubgraphProvider, TokenValidationResult, } from '../../../providers'; -import { CurrencyAmount, log, metric, MetricLoggerUnit, routeToString, } from '../../../util'; +import { + CurrencyAmount, + log, + metric, + MetricLoggerUnit, + routeToString, +} from '../../../util'; import { V2Route } from '../../router'; import { AlphaRouterConfig } from '../alpha-router'; import { V2RouteWithValidQuote } from '../entities'; import { computeAllV2Routes } from '../functions/compute-all-routes'; -import { CandidatePoolsBySelectionCriteria, V2CandidatePools, } from '../functions/get-candidate-pools'; +import { + CandidatePoolsBySelectionCriteria, + V2CandidatePools, +} from '../functions/get-candidate-pools'; import { IGasModel, IV2GasModelFactory } from '../gas-models'; import { BaseQuoter } from './base-quoter'; @@ -146,7 +155,7 @@ export class V2Quoter extends BaseQuoter { log.info( `Getting quotes for V2 for ${routes.length} routes with ${amounts.length} amounts per route.` ); - const { routesWithQuotes } = await quoteFn(amounts, routes); + const { routesWithQuotes } = await quoteFn(amounts, routes, _routingConfig); const v2GasModel = await this.v2GasModelFactory.buildGasModel({ chainId: this.chainId, diff --git a/test/test-util/mock-data.ts b/test/test-util/mock-data.ts index e92a07504..c0d71f926 100644 --- a/test/test-util/mock-data.ts +++ b/test/test-util/mock-data.ts @@ -361,3 +361,58 @@ export const mockTokenList: TokenList = { }, ], }; + +export const BLAST_WITHOUT_TAX = new Token( + ChainId.MAINNET, + '0x3ed643e9032230f01c6c36060e305ab53ad3b482', + 18, + 'BLAST', + 'BLAST', +) +export const BLAST = new Token( + ChainId.MAINNET, + '0x3ed643e9032230f01c6c36060e305ab53ad3b482', + 18, + 'BLAST', + 'BLAST', + false, + BigNumber.from(400), + BigNumber.from(10000) +) +export const BULLET_WITHOUT_TAX = new Token( + ChainId.MAINNET, + '0x8ef32a03784c8Fd63bBf027251b9620865bD54B6', + 8, + 'BULLET', + 'Bullet Game Betting Token', + false +) +export const BULLET = new Token( + ChainId.MAINNET, + '0x8ef32a03784c8Fd63bBf027251b9620865bD54B6', + 8, + 'BULLET', + 'Bullet Game Betting Token', + false, + BigNumber.from(500), + BigNumber.from(500) +) +export const STETH_WITHOUT_TAX = new Token( + ChainId.MAINNET, + '0xae7ab96520de3a18e5e111b5eaab095312d7fe84', + 18, + 'stETH', + 'stETH', + false +) +// stETH is a special case (rebase token), that would make the token include buyFeeBps and sellFeeBps of 0 as always +export const STETH = new Token( + ChainId.MAINNET, + '0xae7ab96520de3a18e5e111b5eaab095312d7fe84', + 18, + 'stETH', + 'stETH', + false, + BigNumber.from(0), + BigNumber.from(0) +) diff --git a/test/unit/providers/v2/quote-provider.test.ts b/test/unit/providers/v2/quote-provider.test.ts new file mode 100644 index 000000000..28753726c --- /dev/null +++ b/test/unit/providers/v2/quote-provider.test.ts @@ -0,0 +1,237 @@ +import { ChainId, CurrencyAmount, Fraction, Token } from '@uniswap/sdk-core'; +import { Pair } from '@uniswap/v2-sdk'; +import { BigNumber } from 'ethers'; +import JSBI from 'jsbi'; +import { V2QuoteProvider, V2Route, WETH9 } from '../../../../src'; +import { ProviderConfig } from '../../../../src/providers/provider'; +import { computeAllV2Routes } from '../../../../src/routers/alpha-router/functions/compute-all-routes'; +import { + BLAST, + BLAST_WITHOUT_TAX, + BULLET, + BULLET_WITHOUT_TAX, + STETH, +} from '../../../test-util/mock-data'; + +const tokenIn = BULLET_WITHOUT_TAX; +const tokenOut = BLAST_WITHOUT_TAX; + +const inputBulletOriginalAmount = JSBI.BigInt(10); +const inputBulletCurrencyAmount = CurrencyAmount.fromRawAmount( + tokenIn, + JSBI.exponentiate(inputBulletOriginalAmount, JSBI.BigInt(tokenIn.decimals)) +); +const wethOriginalAmount = JSBI.BigInt(10); +const wethCurrencyAmount = CurrencyAmount.fromRawAmount( + WETH9[ChainId.MAINNET], + JSBI.exponentiate( + wethOriginalAmount, + JSBI.BigInt(WETH9[ChainId.MAINNET].decimals) + ) +); +const stEthOriginalAmount = JSBI.BigInt(10); +const stEthCurrencyAmount = CurrencyAmount.fromRawAmount( + STETH, + JSBI.exponentiate(stEthOriginalAmount, JSBI.BigInt(STETH.decimals)) +); +const blastOriginalAmount = JSBI.BigInt(10); +const blastCurrencyAmount = CurrencyAmount.fromRawAmount( + BLAST, + JSBI.exponentiate(blastOriginalAmount, JSBI.BigInt(BLAST.decimals)) +); + +// split input amount by 10%, 20%, 30%, 40% +const inputBulletCurrencyAmounts: Array> = [ + inputBulletCurrencyAmount.multiply(new Fraction(10, 100)), + inputBulletCurrencyAmount.multiply(new Fraction(20, 100)), + inputBulletCurrencyAmount.multiply(new Fraction(30, 100)), + inputBulletCurrencyAmount.multiply(new Fraction(40, 100)), +]; + +const amountFactorForReserves = JSBI.BigInt(100); +const bulletReserve = CurrencyAmount.fromRawAmount( + BULLET, + inputBulletCurrencyAmount.multiply(amountFactorForReserves).quotient +); +const bulletWithoutTaxReserve = CurrencyAmount.fromRawAmount( + BULLET_WITHOUT_TAX, + inputBulletCurrencyAmount.multiply(amountFactorForReserves).quotient +); +const WETHReserve = CurrencyAmount.fromRawAmount( + WETH9[ChainId.MAINNET], + wethCurrencyAmount.multiply(amountFactorForReserves).quotient +); +const bulletWETHPool = new Pair(bulletReserve, WETHReserve); +const bulletWithoutTaxWETHPool = new Pair(bulletWithoutTaxReserve, WETHReserve); +const blastReserve = CurrencyAmount.fromRawAmount( + BLAST, + blastCurrencyAmount.multiply(amountFactorForReserves).quotient +); +const blastWithoutTaxReserve = CurrencyAmount.fromRawAmount( + BLAST_WITHOUT_TAX, + blastCurrencyAmount.multiply(amountFactorForReserves).quotient +); +const WETHBlastPool = new Pair(WETHReserve, blastReserve); +const WETHBlastWithoutTaxPool = new Pair(WETHReserve, blastWithoutTaxReserve); +const stETHReserve = CurrencyAmount.fromRawAmount( + STETH, + stEthCurrencyAmount.multiply(amountFactorForReserves).quotient +); +const stETHWithoutTaxReserve = CurrencyAmount.fromRawAmount( + STETH, + stEthCurrencyAmount.multiply(amountFactorForReserves).quotient +); +const bulletSTETHPool = new Pair(bulletReserve, stETHReserve); +const bulletWithoutTaxSTETHWithoutTaxPool = new Pair( + bulletWithoutTaxReserve, + stETHWithoutTaxReserve +); +const stETHBlastPool = new Pair(stETHReserve, blastReserve); +const stETHWithoutTaxBlastWithoutTaxPool = new Pair( + stETHWithoutTaxReserve, + blastWithoutTaxReserve +); + +const poolsWithTax: Pair[] = [ + bulletWETHPool, + WETHBlastPool, + bulletSTETHPool, + stETHBlastPool, +]; +const poolsWithoutTax: Pair[] = [ + bulletWithoutTaxWETHPool, + WETHBlastWithoutTaxPool, + bulletWithoutTaxSTETHWithoutTaxPool, + stETHWithoutTaxBlastWithoutTaxPool, +]; + +const quoteProvider = new V2QuoteProvider(); + +describe('QuoteProvider', () => { + const enableFeeOnTransferFeeFetching = [true, false, undefined]; + + enableFeeOnTransferFeeFetching.forEach((enableFeeOnTransferFeeFetching) => { + describe(`fee-on-transfer flag enableFeeOnTransferFeeFetching = ${enableFeeOnTransferFeeFetching}`, () => { + const v2Routes: Array = computeAllV2Routes( + tokenIn, + tokenOut, + enableFeeOnTransferFeeFetching ? poolsWithTax : poolsWithoutTax, + 7 + ); + const providerConfig: ProviderConfig = { + enableFeeOnTransferFeeFetching: enableFeeOnTransferFeeFetching, + }; + + // we are leaving exact out, since fot can't quote exact out + it('should return correct quote for exact in', async () => { + const { routesWithQuotes } = await quoteProvider.getQuotesManyExactIn( + inputBulletCurrencyAmounts, + v2Routes, + providerConfig + ); + expect(routesWithQuotes.length).toEqual(2); + + routesWithQuotes.forEach(([route, quote]) => { + expect(quote.length).toEqual(inputBulletCurrencyAmounts.length); + expect(route.path.length).toEqual(3); + + inputBulletCurrencyAmounts.map((inputAmount, index) => { + let currentInputAmount = inputAmount; + + for (let i = 0; i < route.path.length - 1; i++) { + const token = route.path[i]!; + const nextToken = route.path[i + 1]!; + const pair = route.pairs.find( + (pair) => + pair.involvesToken(token) && pair.involvesToken(nextToken) + )!; + + if ( + pair.reserve0.currency.equals(BULLET) || + pair.reserve0.currency.equals(BLAST) + ) { + if (enableFeeOnTransferFeeFetching) { + expect(pair.reserve0.currency.sellFeeBps).toBeDefined(); + expect(pair.reserve0.currency.buyFeeBps).toBeDefined(); + } else { + expect( + pair.reserve0.currency.sellFeeBps === undefined || + pair.reserve0.currency.sellFeeBps.eq(BigNumber.from(0)) + ).toBeTruthy(); + expect( + pair.reserve0.currency.buyFeeBps === undefined || + pair.reserve0.currency.buyFeeBps.eq(BigNumber.from(0)) + ).toBeTruthy(); + } + } + + if ( + pair.reserve1.currency.equals(BULLET) || + pair.reserve1.currency.equals(BLAST) + ) { + if (enableFeeOnTransferFeeFetching) { + expect(pair.reserve1.currency.sellFeeBps).toBeDefined(); + expect(pair.reserve1.currency.buyFeeBps).toBeDefined(); + } else { + expect( + pair.reserve1.currency.sellFeeBps === undefined || + pair.reserve1.currency.sellFeeBps.eq(BigNumber.from(0)) + ).toBeTruthy(); + expect( + pair.reserve1.currency.buyFeeBps === undefined || + pair.reserve1.currency.buyFeeBps.eq(BigNumber.from(0)) + ).toBeTruthy(); + } + } + + const [outputAmount] = pair.getOutputAmount(currentInputAmount); + currentInputAmount = outputAmount; + + if (enableFeeOnTransferFeeFetching) { + if (nextToken.equals(tokenOut)) { + expect(nextToken.sellFeeBps).toBeDefined(); + expect(nextToken.buyFeeBps).toBeDefined(); + } + } else { + expect( + nextToken.sellFeeBps === undefined || + nextToken.sellFeeBps.eq(BigNumber.from(0)) + ).toBeTruthy(); + expect( + nextToken.buyFeeBps === undefined || + nextToken.buyFeeBps.eq(BigNumber.from(0)) + ).toBeTruthy(); + } + } + + // This is the raw input amount from tokenIn, no fot tax applied + // this is important to assert, since interface expects no fot tax applied + // for tokenIn, see https://www.notion.so/router-sdk-changes-for-fee-on-transfer-support-856392a72df64d628efb7b7a29ed9034?d=8d45715a31364360885eaa7e8bdd3370&pvs=4 + expect(inputAmount.toExact()).toEqual( + quote[index]!.amount.toExact() + ); + + // we need to account for the round down/up during quote, + // 0.001 should be small enough rounding error + // this is the post fot tax quote amount + // this is the most important assertion, since interface & mobile + // uses this post fot tax quote amount to calculate the quote from each route + expect( + CurrencyAmount.fromRawAmount( + tokenOut, + quote[index]!.quote!.toString() + ) + .subtract(currentInputAmount) + .lessThan( + CurrencyAmount.fromFractionalAmount(tokenOut, 1, 1000) + ) + ); + + expect(route.input.equals(tokenIn)).toBeTruthy(); + expect(route.output.equals(tokenOut)).toBeTruthy(); + }); + }); + }); + }); + }); +});