diff --git a/src/abis/fee-on-transfer-detector.json b/src/abis/fee-on-transfer-detector.json new file mode 100644 index 0000000000..793d0e51b1 --- /dev/null +++ b/src/abis/fee-on-transfer-detector.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" + } +] \ No newline at end of file diff --git a/src/constants/tax.ts b/src/constants/tax.ts deleted file mode 100644 index 634241acb9..0000000000 --- a/src/constants/tax.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { ChainId, Currency, Percent } from '@uniswap/sdk-core' - -import { ZERO_PERCENT } from './misc' - -interface TokenTaxMetadata { - buyFee?: Percent - sellFee?: Percent -} - -const CHAIN_TOKEN_TAX_MAP: { [chainId in number]?: { [address in string]?: TokenTaxMetadata } } = { - [ChainId.MAINNET]: { - // BULLET - '0x8ef32a03784c8fd63bbf027251b9620865bd54b6': { - buyFee: new Percent(5, 100), // 5% - sellFee: new Percent(5, 100), // 5% - }, - // X - '0xabec00542d141bddf58649bfe860c6449807237c': { - buyFee: new Percent(1, 100), // 1% - sellFee: new Percent(1, 100), // 1% - }, - // HarryPotterObamaKnuckles9Inu - '0x2577944fd4b556a99cc5aa0f072e4b944aa088df': { - buyFee: new Percent(1, 100), // 1% - sellFee: new Percent(11, 1000), // 1.1% - }, - // QWN - '0xb354b5da5ea39dadb1cea8140bf242eb24b1821a': { - buyFee: new Percent(5, 100), // 5% - sellFee: new Percent(5, 100), // 5% - }, - // HarryPotterObamaPacMan8Inu - '0x07e0edf8ce600fb51d44f51e3348d77d67f298ae': { - buyFee: new Percent(2, 100), // 2% - sellFee: new Percent(2, 100), // 2% - }, - // KUKU - '0x27206f5a9afd0c51da95f20972885545d3b33647': { - buyFee: new Percent(2, 100), // 2% - sellFee: new Percent(21, 1000), // 2.1% - }, - // AIMBOT - '0x0c48250eb1f29491f1efbeec0261eb556f0973c7': { - buyFee: new Percent(5, 100), // 5% - sellFee: new Percent(5, 100), // 5% - }, - // PYUSD - '0xe0a8ed732658832fac18141aa5ad3542e2eb503b': { - buyFee: new Percent(1, 100), // 1% - sellFee: new Percent(13, 1000), // 1.3% - }, - // ND4 - '0x4f849c55180ddf8185c5cc495ed58c3aea9c9a28': { - buyFee: new Percent(1, 100), // 1% - sellFee: new Percent(1, 100), // 1% - }, - // COCO - '0xcb50350ab555ed5d56265e096288536e8cac41eb': { - buyFee: new Percent(2, 100), // 2% - sellFee: new Percent(26, 1000), // 2.6% - }, - }, -} - -export function getInputTax(currency: Currency): Percent { - if (currency.isNative) return ZERO_PERCENT - - return CHAIN_TOKEN_TAX_MAP[currency.chainId]?.[currency.address.toLowerCase()]?.sellFee ?? ZERO_PERCENT -} - -export function getOutputTax(currency: Currency): Percent { - if (currency.isNative) return ZERO_PERCENT - - return CHAIN_TOKEN_TAX_MAP[currency.chainId]?.[currency.address.toLowerCase()]?.buyFee ?? ZERO_PERCENT -} diff --git a/src/hooks/useDebouncedTrade.test.ts b/src/hooks/useDebouncedTrade.test.ts index d0b334a76e..e68ff26ee1 100644 --- a/src/hooks/useDebouncedTrade.test.ts +++ b/src/hooks/useDebouncedTrade.test.ts @@ -46,8 +46,10 @@ describe('#useBestV3Trade ExactIn', () => { USDCAmount, DAI, RouterPreference.CLIENT, - true, // skipFetch - undefined + /* skipFetch = */ true, + /* account = */ undefined, + /* inputTax = */ undefined, + /* outputTax = */ undefined ) expect(result.current).toEqual({ state: TradeState.NO_ROUTE_FOUND, trade: undefined }) }) @@ -64,8 +66,10 @@ describe('#useDebouncedTrade ExactOut', () => { DAIAmount, USDC_MAINNET, RouterPreference.CLIENT, - true, // skipFetch - undefined + /* skipFetch = */ true, + /* account = */ undefined, + /* inputTax = */ undefined, + /* outputTax = */ undefined ) expect(result.current).toEqual({ state: TradeState.NO_ROUTE_FOUND, trade: undefined }) }) diff --git a/src/hooks/useDebouncedTrade.ts b/src/hooks/useDebouncedTrade.ts index 6892a6ccc4..9bf3fd3c5f 100644 --- a/src/hooks/useDebouncedTrade.ts +++ b/src/hooks/useDebouncedTrade.ts @@ -1,4 +1,4 @@ -import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core' +import { Currency, CurrencyAmount, Percent, TradeType } from '@uniswap/sdk-core' import { useWeb3React } from '@web3-react/core' import { WRAPPED_NATIVE_CURRENCY } from 'constants/tokens' import { DebounceSwapQuoteVariant, useDebounceSwapQuoteFlag } from 'featureFlags/flags/debounceSwapQuote' @@ -22,7 +22,9 @@ export function useDebouncedTrade( amountSpecified?: CurrencyAmount, otherCurrency?: Currency, routerPreferenceOverride?: RouterPreference.X, - account?: string + account?: string, + inputTax?: Percent, + outputTax?: Percent ): { state: TradeState trade?: InterfaceTrade @@ -34,7 +36,9 @@ export function useDebouncedTrade( amountSpecified?: CurrencyAmount, otherCurrency?: Currency, routerPreferenceOverride?: RouterPreference.API | RouterPreference.CLIENT, - account?: string + account?: string, + inputTax?: Percent, + outputTax?: Percent ): { state: TradeState trade?: ClassicTrade @@ -54,7 +58,9 @@ export function useDebouncedTrade( amountSpecified?: CurrencyAmount, otherCurrency?: Currency, routerPreferenceOverride?: RouterPreference, - account?: string + account?: string, + inputTax?: Percent, + outputTax?: Percent ): { state: TradeState trade?: InterfaceTrade @@ -91,6 +97,8 @@ export function useDebouncedTrade( otherCurrency, routerPreferenceOverride ?? routerPreference, skipFetch, - account + account, + inputTax, + outputTax ) } diff --git a/src/hooks/useSwapTaxes.ts b/src/hooks/useSwapTaxes.ts new file mode 100644 index 0000000000..57d08334bc --- /dev/null +++ b/src/hooks/useSwapTaxes.ts @@ -0,0 +1,88 @@ +import * as Sentry from '@sentry/react' +import { InterfaceEventName } from '@uniswap/analytics-events' +import { ChainId, Percent } from '@uniswap/sdk-core' +import { WETH_ADDRESS as getWethAddress } from '@uniswap/universal-router-sdk' +import { useWeb3React } from '@web3-react/core' +import FOT_DETECTOR_ABI from 'abis/fee-on-transfer-detector.json' +import { FeeOnTransferDetector } from 'abis/types' +import { sendAnalyticsEvent } from 'analytics' +import { ZERO_PERCENT } from 'constants/misc' +import { useEffect, useState } from 'react' + +import { useContract } from './useContract' + +const FEE_ON_TRANSFER_DETECTOR_ADDRESS = '0x19C97dc2a25845C7f9d1d519c8C2d4809c58b43f' + +function useFeeOnTransferDetectorContract(): FeeOnTransferDetector | null { + const { account } = useWeb3React() + const contract = useContract(FEE_ON_TRANSFER_DETECTOR_ADDRESS, FOT_DETECTOR_ABI) + + useEffect(() => { + if (contract && account) { + sendAnalyticsEvent(InterfaceEventName.WALLET_PROVIDER_USED, { + source: 'useFeeOnTransferDetectorContract', + contract, + }) + } + }, [account, contract]) + return contract +} + +// TODO(WEB-2787): add tax-fetching for other chains +const WETH_ADDRESS = getWethAddress(ChainId.MAINNET) +const AMOUNT_TO_BORROW = 10000 // smallest amount that has full precision over bps + +const FEE_CACHE: { [address in string]?: { sellTax?: Percent; buyTax?: Percent } } = {} + +async function getSwapTaxes( + fotDetector: FeeOnTransferDetector, + inputTokenAddress: string | undefined, + outputTokenAddress: string | undefined +) { + const addresses = [] + if (inputTokenAddress && FEE_CACHE[inputTokenAddress] === undefined) { + addresses.push(inputTokenAddress) + } + + if (outputTokenAddress && FEE_CACHE[outputTokenAddress] === undefined) { + addresses.push(outputTokenAddress) + } + + try { + if (addresses.length) { + const data = await fotDetector.callStatic.batchValidate(addresses, WETH_ADDRESS, AMOUNT_TO_BORROW) + + addresses.forEach((address, index) => { + const { sellFeeBps, buyFeeBps } = data[index] + const sellTax = new Percent(sellFeeBps.toNumber(), 10000) + const buyTax = new Percent(buyFeeBps.toNumber(), 10000) + + FEE_CACHE[address] = { sellTax, buyTax } + }) + } + } catch (e) { + Sentry.withScope(function (scope) { + scope.setTag('method', 'getSwapTaxes') + scope.setLevel('warning') + Sentry.captureException(e) + }) + } + + const inputTax = (inputTokenAddress ? FEE_CACHE[inputTokenAddress]?.sellTax : ZERO_PERCENT) ?? ZERO_PERCENT + const outputTax = (outputTokenAddress ? FEE_CACHE[outputTokenAddress]?.buyTax : ZERO_PERCENT) ?? ZERO_PERCENT + + return { inputTax, outputTax } +} + +export function useSwapTaxes(inputTokenAddress: string | undefined, outputTokenAddress: string | undefined) { + const fotDetector = useFeeOnTransferDetectorContract() + const [{ inputTax, outputTax }, setTaxes] = useState({ inputTax: ZERO_PERCENT, outputTax: ZERO_PERCENT }) + const { chainId } = useWeb3React() + + useEffect(() => { + if (!fotDetector || chainId !== ChainId.MAINNET) return + getSwapTaxes(fotDetector, inputTokenAddress, outputTokenAddress).then(setTaxes) + }, [fotDetector, inputTokenAddress, outputTokenAddress, chainId]) + + return { inputTax, outputTax } +} diff --git a/src/lib/hooks/routing/useRoutingAPIArguments.ts b/src/lib/hooks/routing/useRoutingAPIArguments.ts index 2a773139e0..2f1ff4b8bc 100644 --- a/src/lib/hooks/routing/useRoutingAPIArguments.ts +++ b/src/lib/hooks/routing/useRoutingAPIArguments.ts @@ -1,5 +1,5 @@ import { SkipToken, skipToken } from '@reduxjs/toolkit/query/react' -import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core' +import { Currency, CurrencyAmount, Percent, TradeType } from '@uniswap/sdk-core' import { useFotAdjustmentsEnabled } from 'featureFlags/flags/fotAdjustments' import { useUniswapXEthOutputEnabled } from 'featureFlags/flags/uniswapXEthOutput' import { useUniswapXExactOutputEnabled } from 'featureFlags/flags/uniswapXExactOutput' @@ -21,6 +21,8 @@ export function useRoutingAPIArguments({ amount, tradeType, routerPreference, + inputTax, + outputTax, }: { account?: string tokenIn?: Currency @@ -28,6 +30,8 @@ export function useRoutingAPIArguments({ amount?: CurrencyAmount tradeType: TradeType routerPreference: RouterPreference | typeof INTERNAL_ROUTER_PREFERENCE_PRICE + inputTax: Percent + outputTax: Percent }): GetQuoteArgs | SkipToken { const uniswapXForceSyntheticQuotes = useUniswapXSyntheticQuoteEnabled() const userDisabledUniswapX = useUserDisabledUniswapX() @@ -58,6 +62,8 @@ export function useRoutingAPIArguments({ uniswapXEthOutputEnabled, uniswapXExactOutputEnabled, fotAdjustmentsEnabled, + inputTax, + outputTax, }, [ account, @@ -71,6 +77,8 @@ export function useRoutingAPIArguments({ userDisabledUniswapX, uniswapXEthOutputEnabled, fotAdjustmentsEnabled, + inputTax, + outputTax, ] ) } diff --git a/src/pages/Swap/index.tsx b/src/pages/Swap/index.tsx index 0812c7946c..a37e80a688 100644 --- a/src/pages/Swap/index.tsx +++ b/src/pages/Swap/index.tsx @@ -31,7 +31,6 @@ import { SwitchLocaleLink } from 'components/SwitchLocaleLink' import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal' import { getChainInfo } from 'constants/chainInfo' import { asSupportedChain, isSupportedChain } from 'constants/chains' -import { getInputTax, getOutputTax } from 'constants/tax' import { getSwapCurrencyId, TOKEN_SHORTHANDS } from 'constants/tokens' import { useCurrency, useDefaultActiveTokens } from 'hooks/Tokens' import { useIsSwapUnsupported } from 'hooks/useIsSwapUnsupported' @@ -279,14 +278,13 @@ export function Swap({ parsedAmount, currencies, inputError: swapInputError, + inputTax, + outputTax, } = swapInfo const [inputTokenHasTax, outputTokenHasTax] = useMemo( - () => [ - !!currencies[Field.INPUT] && !getInputTax(currencies[Field.INPUT]).equalTo(0), - !!currencies[Field.OUTPUT] && !getOutputTax(currencies[Field.OUTPUT]).equalTo(0), - ], - [currencies] + () => [!inputTax.equalTo(0), !outputTax.equalTo(0)], + [inputTax, outputTax] ) useEffect(() => { diff --git a/src/state/routing/types.ts b/src/state/routing/types.ts index 071c649a99..3b9aeed87e 100644 --- a/src/state/routing/types.ts +++ b/src/state/routing/types.ts @@ -47,6 +47,8 @@ export interface GetQuoteArgs { uniswapXExactOutputEnabled: boolean userDisabledUniswapX: boolean fotAdjustmentsEnabled: boolean + inputTax: Percent + outputTax: Percent } // from https://github.com/Uniswap/routing-api/blob/main/lib/handlers/schema.ts diff --git a/src/state/routing/useRoutingAPITrade.ts b/src/state/routing/useRoutingAPITrade.ts index cddb63a296..3bdac4d3f9 100644 --- a/src/state/routing/useRoutingAPITrade.ts +++ b/src/state/routing/useRoutingAPITrade.ts @@ -1,8 +1,9 @@ import { skipToken } from '@reduxjs/toolkit/query/react' -import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core' +import { Currency, CurrencyAmount, Percent, TradeType } from '@uniswap/sdk-core' import { IMetric, MetricLoggerUnit, setGlobalMetric } from '@uniswap/smart-order-router' import { sendTiming } from 'components/analytics' import { AVERAGE_L1_BLOCK_TIME } from 'constants/chainInfo' +import { ZERO_PERCENT } from 'constants/misc' import { useRoutingAPIArguments } from 'lib/hooks/routing/useRoutingAPIArguments' import ms from 'ms' import { useMemo } from 'react' @@ -27,7 +28,9 @@ export function useRoutingAPITrade( otherCurrency: Currency | undefined, routerPreference: typeof INTERNAL_ROUTER_PREFERENCE_PRICE, skipFetch?: boolean, - account?: string + account?: string, + inputTax?: Percent, + outputTax?: Percent ): { state: TradeState trade?: ClassicTrade @@ -40,7 +43,9 @@ export function useRoutingAPITrade( otherCurrency: Currency | undefined, routerPreference: RouterPreference, skipFetch?: boolean, - account?: string + account?: string, + inputTax?: Percent, + outputTax?: Percent ): { state: TradeState trade?: InterfaceTrade @@ -59,7 +64,9 @@ export function useRoutingAPITrade( otherCurrency: Currency | undefined, routerPreference: RouterPreference | typeof INTERNAL_ROUTER_PREFERENCE_PRICE, skipFetch = false, - account?: string + account?: string, + inputTax = ZERO_PERCENT, + outputTax = ZERO_PERCENT ): { state: TradeState trade?: InterfaceTrade @@ -81,6 +88,8 @@ export function useRoutingAPITrade( amount: amountSpecified, tradeType, routerPreference, + inputTax, + outputTax, }) const { isError, data: tradeResult, error, currentData } = useGetQuoteQueryState(queryArgs) diff --git a/src/state/routing/utils.ts b/src/state/routing/utils.ts index ef920f337e..ed8f961e47 100644 --- a/src/state/routing/utils.ts +++ b/src/state/routing/utils.ts @@ -6,9 +6,7 @@ import { DutchOrderInfo, DutchOrderInfoJSON } from '@uniswap/uniswapx-sdk' import { Pair, Route as V2Route } from '@uniswap/v2-sdk' import { FeeAmount, Pool, Route as V3Route } from '@uniswap/v3-sdk' import { asSupportedChain } from 'constants/chains' -import { ZERO_PERCENT } from 'constants/misc' import { RPC_PROVIDERS } from 'constants/providers' -import { getInputTax, getOutputTax } from 'constants/tax' import { isAvalanche, isBsc, isMatic, nativeOnChain } from 'constants/tokens' import { toSlippagePercent } from 'utils/slippage' @@ -215,9 +213,6 @@ export async function transformRoutesToTrade( const approveInfo = await getApproveInfo(account, currencyIn, amount, usdCostPerGas) - const inputTax = args.fotAdjustmentsEnabled ? getInputTax(currencyIn) : ZERO_PERCENT - const outputTax = args.fotAdjustmentsEnabled ? getOutputTax(currencyOut) : ZERO_PERCENT - const classicTrade = new ClassicTrade({ v2Routes: routes @@ -252,8 +247,8 @@ export async function transformRoutesToTrade( isUniswapXBetter, requestId: data.quote.requestId, quoteMethod, - inputTax, - outputTax, + inputTax: args.inputTax, + outputTax: args.outputTax, }) // During the opt-in period, only return UniswapX quotes if the user has turned on the setting, diff --git a/src/state/swap/hooks.tsx b/src/state/swap/hooks.tsx index 85d4cf8b11..8541e36d40 100644 --- a/src/state/swap/hooks.tsx +++ b/src/state/swap/hooks.tsx @@ -3,6 +3,7 @@ import { ChainId, Currency, CurrencyAmount, Percent, TradeType } from '@uniswap/ import { useWeb3React } from '@web3-react/core' import useAutoSlippageTolerance from 'hooks/useAutoSlippageTolerance' import { useDebouncedTrade } from 'hooks/useDebouncedTrade' +import { useSwapTaxes } from 'hooks/useSwapTaxes' import tryParseCurrencyAmount from 'lib/utils/tryParseCurrencyAmount' import { ParsedQs } from 'qs' import { ReactNode, useCallback, useEffect, useMemo } from 'react' @@ -77,6 +78,8 @@ const BAD_RECIPIENT_ADDRESSES: { [address: string]: true } = { export type SwapInfo = { currencies: { [field in Field]?: Currency | null } currencyBalances: { [field in Field]?: CurrencyAmount } + inputTax: Percent + outputTax: Percent parsedAmount?: CurrencyAmount inputError?: ReactNode trade: { @@ -104,6 +107,12 @@ export function useDerivedSwapInfo(state: SwapState, chainId: ChainId | undefine const inputCurrency = useCurrency(inputCurrencyId, chainId) const outputCurrency = useCurrency(outputCurrencyId, chainId) + + const { inputTax, outputTax } = useSwapTaxes( + inputCurrency?.isToken ? inputCurrency.address : undefined, + outputCurrency?.isToken ? outputCurrency.address : undefined + ) + const recipientLookup = useENS(recipient ?? undefined) const to: string | null = (recipient === null ? account : recipientLookup.address) ?? null @@ -123,7 +132,9 @@ export function useDerivedSwapInfo(state: SwapState, chainId: ChainId | undefine parsedAmount, (isExactIn ? outputCurrency : inputCurrency) ?? undefined, undefined, - account + account, + inputTax, + outputTax ) const currencyBalances = useMemo( @@ -198,8 +209,10 @@ export function useDerivedSwapInfo(state: SwapState, chainId: ChainId | undefine trade, autoSlippage, allowedSlippage, + inputTax, + outputTax, }), - [allowedSlippage, autoSlippage, currencies, currencyBalances, inputError, parsedAmount, trade] + [allowedSlippage, autoSlippage, currencies, currencyBalances, inputError, inputTax, outputTax, parsedAmount, trade] ) }