diff --git a/apps/cowswap-frontend/src/legacy/state/price/hooks.ts b/apps/cowswap-frontend/src/legacy/state/price/hooks.ts index 798c0e7bd9..b6f4ee84e6 100644 --- a/apps/cowswap-frontend/src/legacy/state/price/hooks.ts +++ b/apps/cowswap-frontend/src/legacy/state/price/hooks.ts @@ -5,15 +5,15 @@ import { SupportedChainId as ChainId } from '@cowprotocol/cow-sdk' import { useDispatch, useSelector } from 'react-redux' import { - updateQuote, - UpdateQuoteParams, ClearQuoteParams, getNewQuote, GetQuoteParams, refreshQuote, - SetQuoteErrorParams, - setQuoteError, RefreshQuoteParams, + setQuoteError, + SetQuoteErrorParams, + updateQuote, + UpdateQuoteParams, } from './actions' import { QuoteInformationObject, QuotesMap } from './reducer' diff --git a/apps/cowswap-frontend/src/legacy/state/swap/TradeGp.ts b/apps/cowswap-frontend/src/legacy/state/swap/TradeGp.ts index d98caa57cd..6f80a40406 100644 --- a/apps/cowswap-frontend/src/legacy/state/swap/TradeGp.ts +++ b/apps/cowswap-frontend/src/legacy/state/swap/TradeGp.ts @@ -13,7 +13,7 @@ interface FeeInformation { amount: string } -export type FeeForTrade = { feeAsCurrency: CurrencyAmount } & Pick +export type FeeForTrade = { feeAsCurrency: CurrencyAmount } & FeeInformation type TradeExecutionPrice = CanonicalMarketParams | undefined> & { price?: PriceInformation } diff --git a/apps/cowswap-frontend/src/modules/swap/helpers/getSwapButtonState.ts b/apps/cowswap-frontend/src/modules/swap/helpers/getSwapButtonState.ts index f1ef7b1ee6..5404004120 100644 --- a/apps/cowswap-frontend/src/modules/swap/helpers/getSwapButtonState.ts +++ b/apps/cowswap-frontend/src/modules/swap/helpers/getSwapButtonState.ts @@ -1,9 +1,11 @@ import { Token } from '@uniswap/sdk-core' import { QuoteError } from 'legacy/state/price/actions' +import { QuoteInformationObject } from 'legacy/state/price/reducer' import TradeGp from 'legacy/state/swap/TradeGp' import { getEthFlowEnabled } from 'modules/swap/helpers/getEthFlowEnabled' +import { isQuoteExpired } from 'modules/tradeQuote/utils/isQuoteExpired' import { ApprovalState } from 'common/hooks/useApproveState' @@ -18,6 +20,7 @@ export enum SwapButtonState { TransferToSmartContract = 'TransferToSmartContract', UnsupportedToken = 'UnsupportedToken', FetchQuoteError = 'FetchQuoteError', + QuoteExpired = 'QuoteExpired', OfflineBrowser = 'OfflineBrowser', Loading = 'Loading', WalletIsNotConnected = 'WalletIsNotConnected', @@ -39,7 +42,7 @@ export interface SwapButtonStateParams { isReadonlyGnosisSafeUser: boolean isSwapUnsupported: boolean isBundlingSupported: boolean - quoteError: QuoteError | undefined | null + quote: QuoteInformationObject | undefined | null inputError?: string approvalState: ApprovalState feeWarningAccepted: boolean @@ -66,7 +69,8 @@ const quoteErrorToSwapButtonState: { [key in QuoteError]: SwapButtonState | null } export function getSwapButtonState(input: SwapButtonStateParams): SwapButtonState { - const { quoteError, approvalState, isPermitSupported, amountsForSignature } = input + const { quote, approvalState, isPermitSupported, amountsForSignature } = input + const quoteError = quote?.error // show approve flow when: no error on inputs, not approved or pending, or approved in current session // never show if price impact is above threshold @@ -104,6 +108,10 @@ export function getSwapButtonState(input: SwapButtonStateParams): SwapButtonStat return SwapButtonState.ReadonlyGnosisSafeUser } + if (isQuoteExpired(quote?.fee?.expirationDate) === true) { + return SwapButtonState.QuoteExpired + } + if (!input.isNativeIn && showApproveFlow) { if (input.isBundlingSupported) { return SwapButtonState.ApproveAndSwap diff --git a/apps/cowswap-frontend/src/modules/swap/hooks/useFlowContext.ts b/apps/cowswap-frontend/src/modules/swap/hooks/useFlowContext.ts index da502128cb..482e725397 100644 --- a/apps/cowswap-frontend/src/modules/swap/hooks/useFlowContext.ts +++ b/apps/cowswap-frontend/src/modules/swap/hooks/useFlowContext.ts @@ -89,7 +89,7 @@ export function useBaseFlowContextSetup(): BaseFlowContextSetup { const { allowsOffchainSigning } = useWalletDetails() const gnosisSafeInfo = useGnosisSafeInfo() const { recipient } = useSwapState() - const { v2Trade: trade } = useDerivedSwapInfo() + const { v2Trade: trade, allowedSlippage } = useDerivedSwapInfo() const swapZeroFee = useSwapZeroFee() const appData = useAppData() @@ -107,7 +107,6 @@ export function useBaseFlowContextSetup(): BaseFlowContextSetup { const isSafeEthFlow = useIsSafeEthFlow() const getCachedPermit = useGetCachedPermit() - const { allowedSlippage } = useDerivedSwapInfo() const [inputAmountWithSlippage, outputAmountWithSlippage] = useSwapAmountsWithSlippage() const sellTokenContract = useTokenContract(getAddress(inputAmountWithSlippage?.currency) || undefined, true) diff --git a/apps/cowswap-frontend/src/modules/swap/hooks/useSwapButtonContext.ts b/apps/cowswap-frontend/src/modules/swap/hooks/useSwapButtonContext.ts index 4796cf66dd..632560b33f 100644 --- a/apps/cowswap-frontend/src/modules/swap/hooks/useSwapButtonContext.ts +++ b/apps/cowswap-frontend/src/modules/swap/hooks/useSwapButtonContext.ts @@ -132,7 +132,7 @@ export function useSwapButtonContext(input: SwapButtonInput): SwapButtonsContext isSwapUnsupported, isNativeIn: isNativeInSwap, wrappedToken, - quoteError: quote?.error, + quote, inputError: swapInputError, approvalState, feeWarningAccepted, @@ -166,6 +166,6 @@ function useHasEnoughWrappedBalanceForSwap(inputAmount?: CurrencyAmountError loading price. Try again later. ), + [SwapButtonState.QuoteExpired]: () => ( + + Quote expired. Refreshing... + + ), [SwapButtonState.UnsupportedToken]: () => ( Unsupported token diff --git a/apps/cowswap-frontend/src/modules/swap/pure/TradeRates/index.cosmos.tsx b/apps/cowswap-frontend/src/modules/swap/pure/TradeRates/index.cosmos.tsx index c23d86bc0f..c0c915b593 100644 --- a/apps/cowswap-frontend/src/modules/swap/pure/TradeRates/index.cosmos.tsx +++ b/apps/cowswap-frontend/src/modules/swap/pure/TradeRates/index.cosmos.tsx @@ -20,7 +20,11 @@ const trade = new TradeGp({ inputAmountWithoutFee: CurrencyAmount.fromRawAmount(currency, amount * 10 ** 18), outputAmount: CurrencyAmount.fromRawAmount(currency, output * 10 ** 18), outputAmountWithoutFee: CurrencyAmount.fromRawAmount(currency, (output - 3) * 10 ** 18), - fee: { feeAsCurrency: CurrencyAmount.fromRawAmount(currency, 3 * 10 ** 18), amount: '50' }, + fee: { + feeAsCurrency: CurrencyAmount.fromRawAmount(currency, 3 * 10 ** 18), + amount: '50', + expirationDate: new Date().toISOString(), + }, executionPrice: new Price(currency, currencyOut, 1, 4), tradeType: TradeType.EXACT_INPUT, quoteId: 10000, diff --git a/apps/cowswap-frontend/src/modules/swap/pure/TradeSummary/index.cosmos.tsx b/apps/cowswap-frontend/src/modules/swap/pure/TradeSummary/index.cosmos.tsx index e6029d9b64..531636f97b 100644 --- a/apps/cowswap-frontend/src/modules/swap/pure/TradeSummary/index.cosmos.tsx +++ b/apps/cowswap-frontend/src/modules/swap/pure/TradeSummary/index.cosmos.tsx @@ -1,6 +1,6 @@ import { COW, GNO } from '@cowprotocol/common-const' import { SupportedChainId } from '@cowprotocol/cow-sdk' -import { CurrencyAmount, TradeType, Price, Percent } from '@uniswap/sdk-core' +import { CurrencyAmount, Percent, Price, TradeType } from '@uniswap/sdk-core' import TradeGp from 'legacy/state/swap/TradeGp' @@ -20,7 +20,11 @@ const trade = new TradeGp({ inputAmountWithoutFee: CurrencyAmount.fromRawAmount(currency, amount * 10 ** 18), outputAmount: CurrencyAmount.fromRawAmount(currency, output * 10 ** 18), outputAmountWithoutFee: CurrencyAmount.fromRawAmount(currency, (output - 3) * 10 ** 18), - fee: { feeAsCurrency: CurrencyAmount.fromRawAmount(currency, 3 * 10 ** 18), amount: '50' }, + fee: { + feeAsCurrency: CurrencyAmount.fromRawAmount(currency, 3 * 10 ** 18), + amount: '50', + expirationDate: new Date().toISOString(), + }, executionPrice: new Price(currency, currencyOut, 1, 4), tradeType: TradeType.EXACT_INPUT, quoteId: 10000, diff --git a/apps/cowswap-frontend/src/modules/swap/services/ethFlow/index.ts b/apps/cowswap-frontend/src/modules/swap/services/ethFlow/index.ts index 8c2b32b226..994a58f4b6 100644 --- a/apps/cowswap-frontend/src/modules/swap/services/ethFlow/index.ts +++ b/apps/cowswap-frontend/src/modules/swap/services/ethFlow/index.ts @@ -1,4 +1,4 @@ -import { reportAppDataWithHooks } from '@cowprotocol/common-utils' +import { reportAppDataWithHooks, reportPlaceOrderWithExpiredQuote } from '@cowprotocol/common-utils' import { CowEvents } from '@cowprotocol/events' import { Percent } from '@uniswap/sdk-core' @@ -14,6 +14,7 @@ import { addPendingOrderStep } from 'modules/trade/utils/addPendingOrderStep' import { tradeFlowAnalytics } from 'modules/trade/utils/analytics' import { logTradeFlow } from 'modules/trade/utils/logger' import { getSwapErrorMessage } from 'modules/trade/utils/swapErrorHelper' +import { isQuoteExpired } from 'modules/tradeQuote/utils/isQuoteExpired' import { calculateUniqueOrderId } from './steps/calculateUniqueOrderId' @@ -36,7 +37,7 @@ export async function ethFlow( swapZeroFee, } = ethFlowContext const { - trade: { inputAmount, outputAmount }, + trade: { inputAmount, outputAmount, fee }, } = context const tradeAmounts = { inputAmount, outputAmount } @@ -66,6 +67,12 @@ export async function ethFlow( ) try { + // Do not proceed if fee is expired + if (isQuoteExpired(fee.expirationDate)) { + reportPlaceOrderWithExpiredQuote({ ...orderParamsOriginal, fee }) + throw new Error('Quote expired. Please refresh.') + } + logTradeFlow('ETH FLOW', 'STEP 4: sign order') const { order, txReceipt } = await signEthFlowOrderStep(orderId, orderParams, contract, addInFlightOrderId).finally( () => { diff --git a/apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx b/apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx index 19dbfa1523..84f624b44e 100644 --- a/apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx +++ b/apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx @@ -96,6 +96,7 @@ export const tradeButtonsMap: Record ) }, + [TradeFormValidation.QuoteExpired]: { text: 'Quote expired. Refreshing...' }, [TradeFormValidation.WalletNotConnected]: (context) => { return ( diff --git a/apps/cowswap-frontend/src/modules/tradeFormValidation/services/validateTradeForm.ts b/apps/cowswap-frontend/src/modules/tradeFormValidation/services/validateTradeForm.ts index 9fe854ae6f..2767e3e935 100644 --- a/apps/cowswap-frontend/src/modules/tradeFormValidation/services/validateTradeForm.ts +++ b/apps/cowswap-frontend/src/modules/tradeFormValidation/services/validateTradeForm.ts @@ -1,5 +1,8 @@ import { getIsNativeToken, isAddress, isFractionFalsy } from '@cowprotocol/common-utils' +import { TradeType } from 'modules/trade' +import { isQuoteExpired } from 'modules/tradeQuote/utils/isQuoteExpired' + import { ApprovalState } from 'common/hooks/useApproveState' import { TradeFormValidation, TradeFormValidationContext } from '../types' @@ -88,5 +91,14 @@ export function validateTradeForm(context: TradeFormValidationContext): TradeFor return TradeFormValidation.ApproveRequired } + if ( + !isWrapUnwrap && + derivedTradeState.tradeType !== TradeType.LIMIT_ORDER && + !tradeQuote.isLoading && + isQuoteExpired(tradeQuote.response?.expiration) === true + ) { + return TradeFormValidation.QuoteExpired + } + return null } diff --git a/apps/cowswap-frontend/src/modules/tradeFormValidation/types.ts b/apps/cowswap-frontend/src/modules/tradeFormValidation/types.ts index d2cbfad2b9..ea3cc9364c 100644 --- a/apps/cowswap-frontend/src/modules/tradeFormValidation/types.ts +++ b/apps/cowswap-frontend/src/modules/tradeFormValidation/types.ts @@ -25,6 +25,7 @@ export enum TradeFormValidation { // Quote loading indicator QuoteLoading, + QuoteExpired, // Balances BalancesNotLoaded, diff --git a/apps/cowswap-frontend/src/modules/tradeQuote/utils/isQuoteExpired.ts b/apps/cowswap-frontend/src/modules/tradeQuote/utils/isQuoteExpired.ts new file mode 100644 index 0000000000..8b7c8bc4ff --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tradeQuote/utils/isQuoteExpired.ts @@ -0,0 +1,14 @@ +import ms from 'ms.macro' +import { Nullish } from 'types' + +const EXPIRATION_TIME_DELTA = ms`5s` + +export function isQuoteExpired(expirationDate: Nullish): boolean | undefined { + if (!expirationDate) { + return undefined + } + + const expirationTime = new Date(expirationDate).getTime() + + return Date.now() > expirationTime - EXPIRATION_TIME_DELTA +} diff --git a/libs/common-utils/src/sentry.ts b/libs/common-utils/src/sentry.ts index 90745bce86..06636f5d01 100644 --- a/libs/common-utils/src/sentry.ts +++ b/libs/common-utils/src/sentry.ts @@ -15,3 +15,10 @@ export function reportAppDataWithHooks(params: Record): void { contexts: { params }, }) } + +export function reportPlaceOrderWithExpiredQuote(params: Record): void { + Sentry.captureException('Attempt to place order with expired quote', { + tags: { errorType: 'placeOrderWithExpiredQuote' }, + contexts: { params }, + }) +}