Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix: quote provider has the enable fot flag awareness #399

Merged
merged 11 commits into from
Sep 15, 2023
84 changes: 63 additions & 21 deletions src/providers/v2/quote-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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[] }>;
}

Expand All @@ -44,22 +48,25 @@ 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[] = [];

Expand All @@ -75,14 +82,32 @@ 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;
}
jsy1218 marked this conversation as resolved.
Show resolved Hide resolved
} else {
const [outputAmountNew] = pair.getOutputAmount(outputAmount);
outputAmount = outputAmountNew;
Expand All @@ -98,12 +123,29 @@ 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);
}
Expand Down
34 changes: 2 additions & 32 deletions src/routers/alpha-router/alpha-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1178,42 +1178,12 @@ export class AlphaRouter
cacheMode !== CacheMode.Darkmode &&
swapRouteFromChain
) {
const tokenPropertiesMap = await this.tokenPropertiesProvider.getTokensProperties([tokenIn, tokenOut], providerConfig);
jsy1218 marked this conversation as resolved.
Show resolved Hide resolved

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,
Expand Down
2 changes: 1 addition & 1 deletion src/routers/alpha-router/quoters/v2-quoter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export class V2Quoter extends BaseQuoter<V2CandidatePools, V2Route> {
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,
Expand Down
55 changes: 55 additions & 0 deletions test/test-util/mock-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
133 changes: 133 additions & 0 deletions test/unit/providers/v2/quote-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { V2QuoteProvider, V2Route, WETH9 } from '../../../../src';
import { ProviderConfig } from '../../../../src/providers/provider';
import { BLAST, BLAST_WITHOUT_TAX, BULLET, BULLET_WITHOUT_TAX, STETH } from '../../../test-util/mock-data';
import JSBI from 'jsbi';
import { ChainId, CurrencyAmount, Fraction, Token } from '@uniswap/sdk-core';
import { computeAllV2Routes } from '../../../../src/routers/alpha-router/functions/compute-all-routes';
import { Pair } from '@uniswap/v2-sdk';
import { BigNumber } from 'ethers';

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<CurrencyAmount<Token>> = [
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 = [undefined]

enableFeeOnTransferFeeFetching.forEach((enableFeeOnTransferFeeFetching) => {
describe(`fee-on-transfer flag enableFeeOnTransferFeeFetching = ${enableFeeOnTransferFeeFetching}`, () => {
const v2Routes: Array<V2Route> = 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)

console.log(JSON.stringify(routesWithQuotes))

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()
})
})
})
})
})
})
Loading