Skip to content

Commit

Permalink
fix(eth-flow): prevent orders with expired quotes (cowprotocol#3965)
Browse files Browse the repository at this point in the history
* fix: use a hook only once in the same hook

* feat: add isQuoteExpired utils fn

* fix: disble button when quote is expired

* feat: pass the whole FeeInformation object to FeeForTrade

* feat: add reportPlaceOrderWithExpiredQuote sentry helper

* fix: abort ethflow if quote is expired

* chore: fix build
  • Loading branch information
alfetopito authored Mar 4, 2024
1 parent 992b518 commit c49cf28
Show file tree
Hide file tree
Showing 15 changed files with 84 additions and 18 deletions.
8 changes: 4 additions & 4 deletions apps/cowswap-frontend/src/legacy/state/price/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
2 changes: 1 addition & 1 deletion apps/cowswap-frontend/src/legacy/state/swap/TradeGp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ interface FeeInformation {
amount: string
}

export type FeeForTrade = { feeAsCurrency: CurrencyAmount<Currency> } & Pick<FeeInformation, 'amount'>
export type FeeForTrade = { feeAsCurrency: CurrencyAmount<Currency> } & FeeInformation

type TradeExecutionPrice = CanonicalMarketParams<CurrencyAmount<Currency> | undefined> & { price?: PriceInformation }

Expand Down
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -18,6 +20,7 @@ export enum SwapButtonState {
TransferToSmartContract = 'TransferToSmartContract',
UnsupportedToken = 'UnsupportedToken',
FetchQuoteError = 'FetchQuoteError',
QuoteExpired = 'QuoteExpired',
OfflineBrowser = 'OfflineBrowser',
Loading = 'Loading',
WalletIsNotConnected = 'WalletIsNotConnected',
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export function useSwapButtonContext(input: SwapButtonInput): SwapButtonsContext
isSwapUnsupported,
isNativeIn: isNativeInSwap,
wrappedToken,
quoteError: quote?.error,
quote,
inputError: swapInputError,
approvalState,
feeWarningAccepted,
Expand Down Expand Up @@ -166,6 +166,6 @@ function useHasEnoughWrappedBalanceForSwap(inputAmount?: CurrencyAmount<Currency
const { currencies } = useDerivedSwapInfo()
const wrappedBalance = useCurrencyAmountBalance(currencies.INPUT ? getWrappedToken(currencies.INPUT) : undefined)

// is an native currency trade but wrapped token has enough balance
// is a native currency trade but wrapped token has enough balance
return !!(wrappedBalance && inputAmount && !wrappedBalance.lessThan(inputAmount))
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { COW, GNO } from '@cowprotocol/common-const'
import { currencyAmountToTokenAmount } from '@cowprotocol/common-utils'
import { SupportedChainId } from '@cowprotocol/cow-sdk'
import { CurrencyAmount, TradeType, Price } from '@uniswap/sdk-core'
import { CurrencyAmount, Price, TradeType } from '@uniswap/sdk-core'

import TradeGp from 'legacy/state/swap/TradeGp'

Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ const swapButtonStateMap: { [key in SwapButtonState]: (props: SwapButtonsContext
<Trans>Error loading price. Try again later.</Trans>
</GreyCard>
),
[SwapButtonState.QuoteExpired]: () => (
<GreyCard style={{ textAlign: 'center' }}>
<Trans>Quote expired. Refreshing...</Trans>
</GreyCard>
),
[SwapButtonState.UnsupportedToken]: () => (
<GreyCard style={{ textAlign: 'center' }}>
<Trans>Unsupported token</Trans>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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,
Expand Down
11 changes: 9 additions & 2 deletions apps/cowswap-frontend/src/modules/swap/services/ethFlow/index.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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'

Expand All @@ -36,7 +37,7 @@ export async function ethFlow(
swapZeroFee,
} = ethFlowContext
const {
trade: { inputAmount, outputAmount },
trade: { inputAmount, outputAmount, fee },
} = context
const tradeAmounts = { inputAmount, outputAmount }

Expand Down Expand Up @@ -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(
() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export const tradeButtonsMap: Record<TradeFormValidation, ButtonErrorConfig | Bu
</TradeFormBlankButton>
)
},
[TradeFormValidation.QuoteExpired]: { text: 'Quote expired. Refreshing...' },
[TradeFormValidation.WalletNotConnected]: (context) => {
return (
<TradeFormBlankButton onClick={context.connectWallet || undefined} disabled={!context.connectWallet}>
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export enum TradeFormValidation {

// Quote loading indicator
QuoteLoading,
QuoteExpired,

// Balances
BalancesNotLoaded,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import ms from 'ms.macro'
import { Nullish } from 'types'

const EXPIRATION_TIME_DELTA = ms`5s`

export function isQuoteExpired(expirationDate: Nullish<string>): boolean | undefined {
if (!expirationDate) {
return undefined
}

const expirationTime = new Date(expirationDate).getTime()

return Date.now() > expirationTime - EXPIRATION_TIME_DELTA
}
7 changes: 7 additions & 0 deletions libs/common-utils/src/sentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,10 @@ export function reportAppDataWithHooks(params: Record<any, any>): void {
contexts: { params },
})
}

export function reportPlaceOrderWithExpiredQuote(params: Record<any, any>): void {
Sentry.captureException('Attempt to place order with expired quote', {
tags: { errorType: 'placeOrderWithExpiredQuote' },
contexts: { params },
})
}

0 comments on commit c49cf28

Please sign in to comment.