diff --git a/apps/cowswap-frontend/src/common/updaters/FeesUpdater.ts b/apps/cowswap-frontend/src/common/updaters/FeesUpdater/index.ts similarity index 63% rename from apps/cowswap-frontend/src/common/updaters/FeesUpdater.ts rename to apps/cowswap-frontend/src/common/updaters/FeesUpdater/index.ts index e5e0fcce0b..a763129628 100644 --- a/apps/cowswap-frontend/src/common/updaters/FeesUpdater.ts +++ b/apps/cowswap-frontend/src/common/updaters/FeesUpdater/index.ts @@ -12,8 +12,6 @@ import ms from 'ms.macro' import { useRefetchQuoteCallback } from 'legacy/hooks/useRefetchPriceCallback' import { useAllQuotes, useIsBestQuoteLoading, useSetQuoteError } from 'legacy/state/price/hooks' -import { QuoteInformationObject } from 'legacy/state/price/reducer' -import { LegacyFeeQuoteParams } from 'legacy/state/price/types' import { isWrappingTrade } from 'legacy/state/swap/utils' import { Field } from 'legacy/state/types' import { useUserTransactionTTL } from 'legacy/state/user/hooks' @@ -22,95 +20,11 @@ import { useAppData } from 'modules/appData' import { useIsEoaEthFlow } from 'modules/swap/hooks/useIsEoaEthFlow' import { useDerivedSwapInfo, useSwapState } from 'modules/swap/hooks/useSwapState' +import { isRefetchQuoteRequired } from './isRefetchQuoteRequired' +import { quoteUsingSameParameters } from './quoteUsingSameParameters' + export const TYPED_VALUE_DEBOUNCE_TIME = 350 export const SWAP_QUOTE_CHECK_INTERVAL = ms`30s` // Every 30s -const RENEW_FEE_QUOTES_BEFORE_EXPIRATION_TIME = ms`30s` // Will renew the quote if there's less than 30 seconds left for the quote to expire -const WAITING_TIME_BETWEEN_EQUAL_REQUESTS = ms`5s` // Prevents from sending the same request to often (max, every 5s) - -type FeeQuoteParams = Omit - -/** - * Returns if the quote has been recently checked - */ -function wasQuoteCheckedRecently(lastQuoteCheck: number): boolean { - return lastQuoteCheck + WAITING_TIME_BETWEEN_EQUAL_REQUESTS > Date.now() -} - -/** - * Returns true if the fee quote expires soon (in less than RENEW_FEE_QUOTES_BEFORE_EXPIRATION_TIME milliseconds) - */ -function isExpiringSoon(quoteExpirationIsoDate: string, threshold: number): boolean { - const feeExpirationDate = Date.parse(quoteExpirationIsoDate) - return feeExpirationDate <= Date.now() + threshold -} - -/** - * Checks if the parameters for the current quote are correct - * - * Quotes are only valid for a given token-pair and amount. If any of these parameter change, the fee needs to be re-fetched - */ -function quoteUsingSameParameters(currentParams: FeeQuoteParams, quoteInfo: QuoteInformationObject): boolean { - const { - amount: currentAmount, - sellToken: currentSellToken, - buyToken: currentBuyToken, - kind: currentKind, - userAddress: currentUserAddress, - receiver: currentReceiver, - appData: currentAppData, - } = currentParams - const { amount, buyToken, sellToken, kind, userAddress, receiver, appData } = quoteInfo - const hasSameReceiver = currentReceiver && receiver ? currentReceiver === receiver : true - - // cache the base quote params without quoteInfo user address to check - const paramsWithoutAddress = - sellToken === currentSellToken && - buyToken === currentBuyToken && - amount === currentAmount && - kind === currentKind && - appData === currentAppData && - hasSameReceiver - // 2 checks: if there's a quoteInfo user address (meaning quote was already calculated once) and one without - // in case user is not connected - return userAddress ? currentUserAddress === userAddress && paramsWithoutAddress : paramsWithoutAddress -} - -/** - * Decides if we need to refetch the fee information given the current parameters (selected by the user), and the current feeInfo (in the state) - */ -function isRefetchQuoteRequired( - isLoading: boolean, - currentParams: FeeQuoteParams, - quoteInformation?: QuoteInformationObject -): boolean { - // If there's no quote/fee information, we always re-fetch - if (!quoteInformation) { - return true - } - - if (!quoteUsingSameParameters(currentParams, quoteInformation)) { - // If the current parameters don't match the fee, the fee information is invalid and needs to be re-fetched - return true - } - - // The query params are the same, so we only ask for a new quote if: - // - If the quote was not queried recently - // - There's not another price query going on right now - // - The quote will expire soon - if (wasQuoteCheckedRecently(quoteInformation.lastCheck)) { - // Don't Re-fetch if it was queried recently - return false - } else if (isLoading) { - // Don't Re-fetch if there's another quote going on with the same params - // It's better to wait for the timeout or resolution. Also prevents an issue of refreshing too fast with slow APIs - return false - } else if (quoteInformation.fee) { - // Re-fetch if the fee is expiring soon - return isExpiringSoon(quoteInformation.fee.expirationDate, RENEW_FEE_QUOTES_BEFORE_EXPIRATION_TIME) - } - - return false -} export function FeesUpdater(): null { const { chainId, account } = useWalletInfo() @@ -219,7 +133,7 @@ export function FeesUpdater(): null { // Callback to re-fetch both the fee and the price const refetchQuoteIfRequired = () => { // if no token is unsupported and needs refetching - const hasToRefetch = !unsupportedToken && isRefetchQuoteRequired(isLoading, quoteParams, quoteInfo) + const hasToRefetch = !unsupportedToken && isRefetchQuoteRequired(isLoading, quoteParams, quoteInfo) // if (hasToRefetch) { // Decide if this is a new quote, or just a refresh diff --git a/apps/cowswap-frontend/src/common/updaters/FeesUpdater/isRefetchQuoteRequired.ts b/apps/cowswap-frontend/src/common/updaters/FeesUpdater/isRefetchQuoteRequired.ts new file mode 100644 index 0000000000..5b4714631b --- /dev/null +++ b/apps/cowswap-frontend/src/common/updaters/FeesUpdater/isRefetchQuoteRequired.ts @@ -0,0 +1,63 @@ +import ms from 'ms.macro' + +import { QuoteInformationObject } from 'legacy/state/price/reducer' +import { LegacyFeeQuoteParams } from 'legacy/state/price/types' + +import { quoteUsingSameParameters } from './quoteUsingSameParameters' + +const RENEW_FEE_QUOTES_BEFORE_EXPIRATION_TIME = ms`30s` // Will renew the quote if there's less than 30 seconds left for the quote to expire +const WAITING_TIME_BETWEEN_EQUAL_REQUESTS = ms`5s` // Prevents from sending the same request to often (max, every 5s) + +type FeeQuoteParams = Omit + +/** + * Returns if the quote has been recently checked + */ +function wasQuoteCheckedRecently(lastQuoteCheck: number): boolean { + return lastQuoteCheck + WAITING_TIME_BETWEEN_EQUAL_REQUESTS > Date.now() +} + +/** + * Returns true if the fee quote expires soon (in less than RENEW_FEE_QUOTES_BEFORE_EXPIRATION_TIME milliseconds) + */ +function isExpiringSoon(quoteExpirationIsoDate: string, threshold: number): boolean { + const feeExpirationDate = Date.parse(quoteExpirationIsoDate) + return feeExpirationDate <= Date.now() + threshold +} + +/** + * Decides if we need to refetch the fee information given the current parameters (selected by the user), and the current feeInfo (in the state) + */ +export function isRefetchQuoteRequired( + isLoading: boolean, + currentParams: FeeQuoteParams, + quoteInformation?: QuoteInformationObject, +): boolean { + // If there's no quote/fee information, we always re-fetch + if (!quoteInformation) { + return true + } + + if (!quoteUsingSameParameters(currentParams, quoteInformation)) { + // If the current parameters don't match the fee, the fee information is invalid and needs to be re-fetched + return true + } + + // The query params are the same, so we only ask for a new quote if: + // - If the quote was not queried recently + // - There's not another price query going on right now + // - The quote will expire soon + if (wasQuoteCheckedRecently(quoteInformation.lastCheck)) { + // Don't Re-fetch if it was queried recently + return false + } else if (isLoading) { + // Don't Re-fetch if there's another quote going on with the same params + // It's better to wait for the timeout or resolution. Also prevents an issue of refreshing too fast with slow APIs + return false + } else if (quoteInformation.fee) { + // Re-fetch if the fee is expiring soon + return isExpiringSoon(quoteInformation.fee.expirationDate, RENEW_FEE_QUOTES_BEFORE_EXPIRATION_TIME) + } + + return false +} diff --git a/apps/cowswap-frontend/src/common/updaters/FeesUpdater/quoteUsingSameParameters.ts b/apps/cowswap-frontend/src/common/updaters/FeesUpdater/quoteUsingSameParameters.ts new file mode 100644 index 0000000000..b41f5e0a66 --- /dev/null +++ b/apps/cowswap-frontend/src/common/updaters/FeesUpdater/quoteUsingSameParameters.ts @@ -0,0 +1,70 @@ +import { QuoteInformationObject } from 'legacy/state/price/reducer' +import { LegacyFeeQuoteParams } from 'legacy/state/price/types' + +import { decodeAppData } from 'modules/appData' + +type FeeQuoteParams = Omit + +/** + * Checks if the parameters for the current quote are correct + * + * Quotes are only valid for a given token-pair and amount. If any of these parameter change, the fee needs to be re-fetched + */ +export function quoteUsingSameParameters(currentParams: FeeQuoteParams, quoteInfo: QuoteInformationObject): boolean { + const { + amount: currentAmount, + sellToken: currentSellToken, + buyToken: currentBuyToken, + kind: currentKind, + userAddress: currentUserAddress, + receiver: currentReceiver, + appData: currentAppData, + } = currentParams + const { amount, buyToken, sellToken, kind, userAddress, receiver, appData } = quoteInfo + const hasSameReceiver = currentReceiver && receiver ? currentReceiver === receiver : true + const hasSameAppData = compareAppDataWithoutQuoteData(appData, currentAppData) + + // cache the base quote params without quoteInfo user address to check + const paramsWithoutAddress = + sellToken === currentSellToken && + buyToken === currentBuyToken && + amount === currentAmount && + kind === currentKind && + hasSameAppData && + hasSameReceiver + // 2 checks: if there's a quoteInfo user address (meaning quote was already calculated once) and one without + // in case user is not connected + return userAddress ? currentUserAddress === userAddress && paramsWithoutAddress : paramsWithoutAddress +} + +/** + * Compares appData without taking into account the `quote` metadata + */ +function compareAppDataWithoutQuoteData(a: T, b: T): boolean { + if (a === b) { + return true + } + const cleanedA = removeQuoteMetadata(a) + const cleanedB = removeQuoteMetadata(b) + + return cleanedA === cleanedB +} + +/** + * If appData is set and is valid, remove `quote` metadata from it + */ +function removeQuoteMetadata(appData: string | undefined): string | undefined { + if (!appData) { + return + } + + const decoded = decodeAppData(appData) + + if (!decoded) { + return + } + + const { metadata: fullMetadata, ...rest } = decoded + const { quote: _, ...metadata } = fullMetadata + return JSON.stringify({ ...rest, metadata }) +} diff --git a/apps/cowswap-frontend/src/legacy/state/price/hooks.ts b/apps/cowswap-frontend/src/legacy/state/price/hooks.ts index b6f4ee84e6..8bd90f0bca 100644 --- a/apps/cowswap-frontend/src/legacy/state/price/hooks.ts +++ b/apps/cowswap-frontend/src/legacy/state/price/hooks.ts @@ -4,6 +4,8 @@ import { SupportedChainId as ChainId } from '@cowprotocol/cow-sdk' import { useDispatch, useSelector } from 'react-redux' +import { useSafeMemoObject } from 'common/hooks/useSafeMemo' + import { ClearQuoteParams, getNewQuote, @@ -71,7 +73,11 @@ export const useGetQuoteAndStatus = (params: QuoteParams): UseGetQuoteAndStatus const isGettingNewQuote = Boolean(isLoading && !quote?.price?.amount) const isRefreshingQuote = Boolean(isLoading && quote?.price?.amount) - return { quote, isGettingNewQuote, isRefreshingQuote } + return useSafeMemoObject({ + quote, + isGettingNewQuote, + isRefreshingQuote, + }) } export const useGetNewQuote = (): GetNewQuoteCallback => { diff --git a/apps/cowswap-frontend/src/modules/appData/index.ts b/apps/cowswap-frontend/src/modules/appData/index.ts index da9816331c..6e5d063d5d 100644 --- a/apps/cowswap-frontend/src/modules/appData/index.ts +++ b/apps/cowswap-frontend/src/modules/appData/index.ts @@ -6,5 +6,6 @@ export { decodeAppData } from './utils/decodeAppData' export { replaceHooksOnAppData, buildAppData, removePermitHookFromAppData } from './utils/buildAppData' export { buildAppDataHooks } from './utils/buildAppDataHooks' export * from './utils/getAppDataHooks' +export * from './utils/decodeAppData' export { addPermitHookToHooks, removePermitHookFromHooks } from './utils/typedHooks' export type { AppDataInfo, UploadAppDataParams, TypedAppDataHooks } from './types' diff --git a/apps/cowswap-frontend/src/modules/swap/containers/Row/RowSlippage/index.tsx b/apps/cowswap-frontend/src/modules/swap/containers/Row/RowSlippage/index.tsx index 681cec67ab..812b97be9e 100644 --- a/apps/cowswap-frontend/src/modules/swap/containers/Row/RowSlippage/index.tsx +++ b/apps/cowswap-frontend/src/modules/swap/containers/Row/RowSlippage/index.tsx @@ -10,6 +10,7 @@ import { useIsEoaEthFlow } from 'modules/swap/hooks/useIsEoaEthFlow' import { useIsSmartSlippageApplied } from 'modules/swap/hooks/useIsSmartSlippageApplied' import { useSetSlippage } from 'modules/swap/hooks/useSetSlippage' import { useSmartSwapSlippage } from 'modules/swap/hooks/useSwapSlippage' +import { useTradePricesUpdate } from 'modules/swap/hooks/useTradePricesUpdate' import { RowSlippageContent } from 'modules/swap/pure/Row/RowSlippageContent' import useNativeCurrency from 'lib/hooks/useNativeCurrency' @@ -37,6 +38,7 @@ export function RowSlippage({ const smartSwapSlippage = useSmartSwapSlippage() const isSmartSlippageApplied = useIsSmartSlippageApplied() const setSlippage = useSetSlippage() + const isTradePriceUpdating = useTradePricesUpdate() const props = useMemo( () => ({ @@ -49,10 +51,11 @@ export function RowSlippage({ slippageTooltip, displaySlippage: `${formatPercent(allowedSlippage)}%`, isSmartSlippageApplied, + isSmartSlippageLoading: isTradePriceUpdating, smartSlippage: smartSwapSlippage && !isEoaEthFlow ? `${formatPercent(new Percent(smartSwapSlippage, 10_000))}%` : undefined, setAutoSlippage: smartSwapSlippage && !isEoaEthFlow ? () => setSlippage(null) : undefined, }), - [chainId, isEoaEthFlow, nativeCurrency.symbol, showSettingOnClick, allowedSlippage, slippageLabel, slippageTooltip, smartSwapSlippage, isSmartSlippageApplied] + [chainId, isEoaEthFlow, nativeCurrency.symbol, showSettingOnClick, allowedSlippage, slippageLabel, slippageTooltip, smartSwapSlippage, isSmartSlippageApplied, isTradePriceUpdating] ) return diff --git a/apps/cowswap-frontend/src/modules/swap/hooks/useSwapState.tsx b/apps/cowswap-frontend/src/modules/swap/hooks/useSwapState.tsx index c506885a3b..d0a98efd80 100644 --- a/apps/cowswap-frontend/src/modules/swap/hooks/useSwapState.tsx +++ b/apps/cowswap-frontend/src/modules/swap/hooks/useSwapState.tsx @@ -312,7 +312,6 @@ export function useDerivedSwapInfo(): DerivedSwapInfo { } // compare input balance to max input based on version - // const [balanceIn, amountIn] = [currencyBalances[Field.INPUT], trade.trade?.maximumAmountIn(allowedSlippage)] // mod const balanceIn = currencyBalances[Field.INPUT] const amountIn = slippageAdjustedSellAmount diff --git a/apps/cowswap-frontend/src/modules/swap/pure/Row/RowSlippageContent/index.cosmos.tsx b/apps/cowswap-frontend/src/modules/swap/pure/Row/RowSlippageContent/index.cosmos.tsx index 40671ff0f1..f180f5effb 100644 --- a/apps/cowswap-frontend/src/modules/swap/pure/Row/RowSlippageContent/index.cosmos.tsx +++ b/apps/cowswap-frontend/src/modules/swap/pure/Row/RowSlippageContent/index.cosmos.tsx @@ -19,6 +19,7 @@ const defaultProps: RowSlippageContentProps = { setAutoSlippage: () => { console.log('setAutoSlippage called!') }, + isSmartSlippageLoading: false } export default diff --git a/apps/cowswap-frontend/src/modules/swap/pure/Row/RowSlippageContent/index.tsx b/apps/cowswap-frontend/src/modules/swap/pure/Row/RowSlippageContent/index.tsx index a3385962ef..c1105bc66c 100644 --- a/apps/cowswap-frontend/src/modules/swap/pure/Row/RowSlippageContent/index.tsx +++ b/apps/cowswap-frontend/src/modules/swap/pure/Row/RowSlippageContent/index.tsx @@ -1,7 +1,7 @@ import { INPUT_OUTPUT_EXPLANATION, MINIMUM_ETH_FLOW_SLIPPAGE, PERCENTAGE_PRECISION } from '@cowprotocol/common-const' import { SupportedChainId } from '@cowprotocol/cow-sdk' import { Command } from '@cowprotocol/types' -import { HoverTooltip, LinkStyledButton, RowFixed, UI } from '@cowprotocol/ui' +import { CenteredDots, HoverTooltip, LinkStyledButton, RowFixed, UI } from '@cowprotocol/ui' import { Percent } from '@uniswap/sdk-core' import { Trans } from '@lingui/macro' @@ -64,7 +64,8 @@ export const getNonNativeSlippageTooltip = () => ( ) -const SUGGESTED_SLIPPAGE_TOOLTIP = "Based on recent volatility for the selected token pair, this is the suggested slippage for ensuring quick execution of your order." +const SUGGESTED_SLIPPAGE_TOOLTIP = + 'Based on recent volatility for the selected token pair, this is the suggested slippage for ensuring quick execution of your order.' export interface RowSlippageContentProps { chainId: SupportedChainId @@ -82,6 +83,7 @@ export interface RowSlippageContentProps { setAutoSlippage?: Command // todo: make them optional smartSlippage?: string isSmartSlippageApplied: boolean + isSmartSlippageLoading: boolean } // TODO: RowDeadlineContent and RowSlippageContent are very similar. Refactor and extract base component? @@ -101,6 +103,7 @@ export function RowSlippageContent(props: RowSlippageContentProps) { setAutoSlippage, smartSlippage, isSmartSlippageApplied, + isSmartSlippageLoading, } = props const tooltipContent = @@ -109,14 +112,33 @@ export function RowSlippageContent(props: RowSlippageContentProps) { // In case the user happened to set the same slippage as the suggestion, do not show the suggestion const suggestedEqualToUserSlippage = smartSlippage && smartSlippage === displaySlippage - const displayDefaultSlippage = isSlippageModified && setAutoSlippage && smartSlippage && !suggestedEqualToUserSlippage && ( - - (Suggested: {smartSlippage}) - - - - - ) + const displayDefaultSlippage = isSlippageModified && + setAutoSlippage && + smartSlippage && + !suggestedEqualToUserSlippage && ( + + {isSmartSlippageLoading ? ( + + ) : ( + <> + (Suggested: {smartSlippage}) + + + + + )} + + ) + + const displaySlippageWithLoader = + isSmartSlippageLoading && isSmartSlippageApplied ? ( + + ) : ( + <> + {displaySlippage} + {displayDefaultSlippage} + + ) return ( @@ -124,10 +146,18 @@ export function RowSlippageContent(props: RowSlippageContentProps) { {showSettingOnClick ? ( - + ) : ( - + )} @@ -136,20 +166,20 @@ export function RowSlippageContent(props: RowSlippageContentProps) { {showSettingOnClick ? ( - - {displaySlippage}{displayDefaultSlippage} - + {displaySlippageWithLoader} ) : ( - - {displaySlippage}{displayDefaultSlippage} - + {displaySlippageWithLoader} )} ) } -type SlippageTextContentsProps = { isEoaEthFlow: boolean; slippageLabel?: React.ReactNode, isDynamicSlippageSet: boolean } +type SlippageTextContentsProps = { + isEoaEthFlow: boolean + slippageLabel?: React.ReactNode + isDynamicSlippageSet: boolean +} function SlippageTextContents({ isEoaEthFlow, slippageLabel, isDynamicSlippageSet }: SlippageTextContentsProps) { return ( diff --git a/apps/cowswap-frontend/src/modules/swap/updaters/SmartSlippageUpdater/calculateBpsFromFeeMultiplier.test.ts b/apps/cowswap-frontend/src/modules/swap/updaters/SmartSlippageUpdater/calculateBpsFromFeeMultiplier.test.ts new file mode 100644 index 0000000000..d2499acddc --- /dev/null +++ b/apps/cowswap-frontend/src/modules/swap/updaters/SmartSlippageUpdater/calculateBpsFromFeeMultiplier.test.ts @@ -0,0 +1,51 @@ +import { USDC } from '@cowprotocol/common-const' +import { CurrencyAmount } from '@uniswap/sdk-core' + +import { calculateBpsFromFeeMultiplier } from './calculateBpsFromFeeMultiplier' + +describe('calculateBpsFromFeeMultiplier', () => { + const sellAmount = CurrencyAmount.fromRawAmount(USDC[1], '1000000') // 1.2 USDC + const feeAmount = CurrencyAmount.fromRawAmount(USDC[1], '200000') // 0.2 USDC + const isSell = true + const multiplierPercentage = 50 + + it('should return undefined for missing parameters', () => { + expect(calculateBpsFromFeeMultiplier(undefined, feeAmount, isSell, multiplierPercentage)).toBeUndefined() + expect(calculateBpsFromFeeMultiplier(sellAmount, undefined, isSell, multiplierPercentage)).toBeUndefined() + expect(calculateBpsFromFeeMultiplier(sellAmount, feeAmount, undefined, multiplierPercentage)).toBeUndefined() + expect(calculateBpsFromFeeMultiplier(sellAmount, feeAmount, isSell, 0)).toBeUndefined() + }) + + it('should return undefined for a negative multiplier percentage', () => { + const result = calculateBpsFromFeeMultiplier(sellAmount, feeAmount, isSell, -50) + expect(result).toBeUndefined() + }) + + it('should calculate the correct percentage for selling with different multiplier percentages', () => { + const testCases = [ + [25, 625], // 25%, 6.25% + [50, 1250], // 50%, 12.5% + [75, 1875], // 75%, 18.75% + ] + + testCases.forEach(([multiplier, expectedResult]) => { + const result = calculateBpsFromFeeMultiplier(sellAmount, feeAmount, isSell, multiplier) + expect(result).toBeDefined() + expect(result).toBe(expectedResult) + }) + }) + + it('should calculate the correct percentage for buying with different multiplier percentages', () => { + const testCases = [ + [25, 417], // 25%, 4.17% + [50, 833], // 50%, 8.33% + [75, 1250], // 75%, 12.5% + ] + + testCases.forEach(([multiplier, expectedResult]) => { + const result = calculateBpsFromFeeMultiplier(sellAmount, feeAmount, !isSell, multiplier) + expect(result).toBeDefined() + expect(result).toBe(expectedResult) + }) + }) +}) diff --git a/apps/cowswap-frontend/src/modules/swap/updaters/SmartSlippageUpdater/calculateBpsFromFeeMultiplier.ts b/apps/cowswap-frontend/src/modules/swap/updaters/SmartSlippageUpdater/calculateBpsFromFeeMultiplier.ts new file mode 100644 index 0000000000..73d161d60d --- /dev/null +++ b/apps/cowswap-frontend/src/modules/swap/updaters/SmartSlippageUpdater/calculateBpsFromFeeMultiplier.ts @@ -0,0 +1,47 @@ +import { Currency, CurrencyAmount, Fraction } from '@uniswap/sdk-core' + +const ONE = new Fraction(1) + +export function calculateBpsFromFeeMultiplier( + sellAmount: CurrencyAmount | undefined, + feeAmount: CurrencyAmount | undefined, + isSell: boolean | undefined, + multiplierPercentage: number, +): number | undefined { + if (!sellAmount || !feeAmount || isSell === undefined || multiplierPercentage <= 0) { + return undefined + } + + const feeMultiplierFactor = new Fraction(100 + multiplierPercentage, 100) // 50% more fee, applied to the whole value => 150% => 15/10 in fraction + + if (isSell) { + // sell + // 1 - ((sellAmount - feeAmount * 1.5) / (sellAmount - feeAmount)) + // 1 - (sellAmount - feeAmount * feeMultiplierFactor) / sellAmount - feeAmount + return percentageToBps( + ONE.subtract( + sellAmount + .subtract(feeAmount.multiply(feeMultiplierFactor)) + // !!! Need to convert to fraction before division to not lose precision + .asFraction.divide(sellAmount.subtract(feeAmount).asFraction), + ), + ) + } else { + // buy + // (sellAmount + feeAmount * 1.5) / (sellAmount + feeAmount) - 1 + // ((sellAmount + feeAmount * feeMultiplierFactor) / (sellAmount - feeAmount)) - 1 + return percentageToBps( + sellAmount + .add(feeAmount.multiply(feeMultiplierFactor)) + // !!! Need to convert to fraction before division to not lose precision + .asFraction.divide(sellAmount.add(feeAmount).asFraction) + .subtract(ONE), + ) + } +} + +function percentageToBps(value: Fraction | undefined): number | undefined { + const bps = value?.multiply(10_000).toFixed(0) + + return bps ? +bps : undefined +} diff --git a/apps/cowswap-frontend/src/modules/swap/updaters/SmartSlippageUpdater/index.ts b/apps/cowswap-frontend/src/modules/swap/updaters/SmartSlippageUpdater/index.ts new file mode 100644 index 0000000000..0a3398cc2e --- /dev/null +++ b/apps/cowswap-frontend/src/modules/swap/updaters/SmartSlippageUpdater/index.ts @@ -0,0 +1,84 @@ +import { useSetAtom } from 'jotai' +import { useEffect, useMemo } from 'react' + +import { useSmartSlippageFromBff } from './useSmartSlippageFromBff' +import { useSmartSlippageFromFeeMultiplier } from './useSmartSlippageFromFeeMultiplier' + +import { useDerivedSwapInfo, useHighFeeWarning } from '../../hooks/useSwapState' +import { smartSwapSlippageAtom } from '../../state/slippageValueAndTypeAtom' +import { useTradeConfirmState } from '../../../trade' + +const MAX_BPS = 5000 // 50% + +export function SmartSlippageUpdater() { + const setSmartSwapSlippage = useSetAtom(smartSwapSlippageAtom) + + const bffSlippageBps = useSmartSlippageFromBff() + // TODO: remove v1 + const tradeSizeSlippageBpsV1 = useSmartSlippageFromFeePercentage() + const feeMultiplierSlippageBps = useSmartSlippageFromFeeMultiplier() + + const { isOpen: isTradeReviewOpen } = useTradeConfirmState() + + useEffect(() => { + // Don't update it once review is open + if (isTradeReviewOpen) { + return + } + // If both are unset, don't use smart slippage + if (feeMultiplierSlippageBps === undefined && bffSlippageBps === undefined) { + setSmartSwapSlippage(null) + return + } + // Add both slippage values, when present + const slippage = (feeMultiplierSlippageBps || 0) + (bffSlippageBps || 0) + + setSmartSwapSlippage(Math.min(slippage, MAX_BPS)) + }, [bffSlippageBps, setSmartSwapSlippage, feeMultiplierSlippageBps, isTradeReviewOpen]) + + // TODO: remove before merging + useEffect(() => { + console.log(`SmartSlippageUpdater`, { + granularSlippage: tradeSizeSlippageBpsV1, + fiftyPercentFeeSlippage: feeMultiplierSlippageBps, + bffSlippageBps, + }) + }, [tradeSizeSlippageBpsV1, feeMultiplierSlippageBps]) + + return null +} + +// TODO: remove +/** + * Calculates smart slippage in bps, based on trade size in relation to fee + */ +function useSmartSlippageFromFeePercentage(): number | undefined { + const { trade } = useDerivedSwapInfo() || {} + const { feePercentage } = useHighFeeWarning(trade) + + const percentage = feePercentage && +feePercentage.toFixed(3) + + return useMemo(() => { + if (percentage === undefined) { + // Unset, return undefined + return + } + if (percentage < 1) { + // bigger volume compared to the fee, trust on smart slippage from BFF + return + } else if (percentage < 5) { + // Between 1 and 5, 2% + return 200 + } else if (percentage < 10) { + // Between 5 and 10, 5% + return 500 + } else if (percentage < 20) { + // Between 10 and 20, 10% + return 1000 + } + // TODO: more granularity? + + // > 20%, cap it at 20% slippage + return 2000 + }, [percentage]) +} diff --git a/apps/cowswap-frontend/src/modules/swap/updaters/SmartSlippageUpdater.ts b/apps/cowswap-frontend/src/modules/swap/updaters/SmartSlippageUpdater/useSmartSlippageFromBff.ts similarity index 70% rename from apps/cowswap-frontend/src/modules/swap/updaters/SmartSlippageUpdater.ts rename to apps/cowswap-frontend/src/modules/swap/updaters/SmartSlippageUpdater/useSmartSlippageFromBff.ts index 7e79d89102..4891535205 100644 --- a/apps/cowswap-frontend/src/modules/swap/updaters/SmartSlippageUpdater.ts +++ b/apps/cowswap-frontend/src/modules/swap/updaters/SmartSlippageUpdater/useSmartSlippageFromBff.ts @@ -1,6 +1,3 @@ -import { useSetAtom } from 'jotai' -import { useEffect } from 'react' - import { BFF_BASE_URL } from '@cowprotocol/common-const' import { useFeatureFlags } from '@cowprotocol/common-hooks' import { getCurrencyAddress } from '@cowprotocol/common-utils' @@ -9,9 +6,7 @@ import { useWalletInfo } from '@cowprotocol/wallet' import ms from 'ms.macro' import useSWR from 'swr' -import { useDerivedTradeState, useIsWrapOrUnwrap } from 'modules/trade' - -import { smartSwapSlippageAtom } from '../state/slippageValueAndTypeAtom' +import { useDerivedTradeState, useIsWrapOrUnwrap } from '../../../trade' const SWR_OPTIONS = { dedupingInterval: ms`1m`, @@ -21,17 +16,16 @@ interface SlippageApiResponse { slippageBps: number } -export function SmartSlippageUpdater() { +export function useSmartSlippageFromBff(): number | undefined { const { isSmartSlippageEnabled } = useFeatureFlags() const { chainId } = useWalletInfo() const { inputCurrency, outputCurrency } = useDerivedTradeState() || {} - const setSmartSwapSlippage = useSetAtom(smartSwapSlippageAtom) const isWrapOrUnwrap = useIsWrapOrUnwrap() const sellTokenAddress = inputCurrency && getCurrencyAddress(inputCurrency).toLowerCase() const buyTokenAddress = outputCurrency && getCurrencyAddress(outputCurrency).toLowerCase() - const slippageBps = useSWR( + return useSWR( !sellTokenAddress || !buyTokenAddress || isWrapOrUnwrap || !isSmartSlippageEnabled ? null : [chainId, sellTokenAddress, buyTokenAddress], @@ -42,12 +36,6 @@ export function SmartSlippageUpdater() { return response.slippageBps }, - SWR_OPTIONS + SWR_OPTIONS, ).data - - useEffect(() => { - setSmartSwapSlippage(typeof slippageBps === 'number' ? slippageBps : null) - }, [slippageBps, setSmartSwapSlippage]) - - return null } diff --git a/apps/cowswap-frontend/src/modules/swap/updaters/SmartSlippageUpdater/useSmartSlippageFromFeeMultiplier.ts b/apps/cowswap-frontend/src/modules/swap/updaters/SmartSlippageUpdater/useSmartSlippageFromFeeMultiplier.ts new file mode 100644 index 0000000000..ffefec77fe --- /dev/null +++ b/apps/cowswap-frontend/src/modules/swap/updaters/SmartSlippageUpdater/useSmartSlippageFromFeeMultiplier.ts @@ -0,0 +1,27 @@ +import { useMemo } from 'react' + +import { useFeatureFlags } from '@cowprotocol/common-hooks' + +import { calculateBpsFromFeeMultiplier } from './calculateBpsFromFeeMultiplier' + +import { useReceiveAmountInfo } from '../../../trade' + +/** + * Calculates smart slippage in bps, based on quoted fee + * + * Apply a multiplying factor to the fee (e.g.: 50%), and from there calculate how much slippage would be needed + * for the limit price to take this much more fee. + * More relevant for small orders in relation to fee amount, negligent for larger orders. + */ +export function useSmartSlippageFromFeeMultiplier(): number | undefined { + const { beforeNetworkCosts, afterNetworkCosts, costs, isSell } = useReceiveAmountInfo() || {} + const sellAmount = isSell ? afterNetworkCosts?.sellAmount : beforeNetworkCosts?.sellAmount + const feeAmount = costs?.networkFee?.amountInSellCurrency + + const { smartSlippageFeeMultiplierPercentage = 50 } = useFeatureFlags() + + return useMemo( + () => calculateBpsFromFeeMultiplier(sellAmount, feeAmount, isSell, smartSlippageFeeMultiplierPercentage), + [isSell, sellAmount, feeAmount, smartSlippageFeeMultiplierPercentage], + ) +}