Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ export class CoWBFFClient {
}

log(`Retrieved slippage tolerance from API: ${data.slippageBps} BPS`)
return { slippageBps: data.slippageBps }
return {
slippageBps: data.slippageBps,
// slippageBps: data.slippageBps + Math.floor(Math.random() * 25), // uncomment to test smart slippage re-fetch quote loop problem (fixed) (#6675)
}
Comment on lines +56 to +59
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Remove debug comment before merging.

The commented-out line 58 is for testing the re-fetch loop scenario. This debug code should be removed before merging to prevent accidental activation in production.

🔎 Proposed cleanup
      log(`Retrieved slippage tolerance from API: ${data.slippageBps} BPS`)
-     return {
-       slippageBps: data.slippageBps,
-       // slippageBps: data.slippageBps + Math.floor(Math.random() * 25), // uncomment to test smart slippage re-fetch quote loop problem (fixed) (#6675)
-     }
+     return { slippageBps: data.slippageBps }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return {
slippageBps: data.slippageBps,
// slippageBps: data.slippageBps + Math.floor(Math.random() * 25), // uncomment to test smart slippage re-fetch quote loop problem (fixed) (#6675)
}
return { slippageBps: data.slippageBps }
🤖 Prompt for AI Agents
In apps/cowswap-frontend/src/common/services/bff/cowBffClient.ts around lines 56
to 59, remove the commented-out debug line that mutates slippageBps ("//
slippageBps: data.slippageBps + Math.floor(Math.random() * 25)...") so the
returned object only uses the real value (slippageBps: data.slippageBps); delete
the comment entirely to avoid accidental activation in production and keep the
code clean.

} catch (error) {
log(`Failed to fetch slippage tolerance from API: ${error instanceof Error ? error.message : 'Unknown error'}`)
return EMPTY_SLIPPAGE_RESPONSE
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import { useIsProviderNetworkUnsupported } from 'common/hooks/useIsProviderNetwo
import { useSafeMemo } from 'common/hooks/useSafeMemo'

import { useQuoteParamsRecipient } from './useQuoteParamsRecipient'
import { useTradeQuote } from './useTradeQuote'

import { BRIDGE_QUOTE_ACCOUNT, getBridgeQuoteSigner } from '../utils/getBridgeQuoteSigner'

Expand All @@ -42,15 +41,14 @@ export function useQuoteParams(amount: Nullish<string>, partiallyFillable = fals
const state = useDerivedTradeState()
const volumeFee = useVolumeFee()
const tradeSlippage = useTradeSlippageValueAndType()
const { isLoading: isQuoteLoading } = useTradeQuote()

// Slippage value for quote params:
// - User slippage: always include (re-quotes when user changes it)
// - Smart slippage: only include after quote loads to prevent re-fetch loop and this will only re-fetch when user switches to auto-slippage mode
// - Smart slippage: always include (re-quotes when user changes amount, etc.)
const slippageBps =
tradeSlippage.type === 'user'
? tradeSlippage.value
: tradeSlippage.type === 'smart' && !isQuoteLoading
: tradeSlippage.type === 'smart'
? tradeSlippage.value
: undefined

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export const useSmartSlippageFromQuote = (): number | null => {
const slippageBottomCap = isEthFlow ? MINIMUM_ETH_FLOW_SLIPPAGE_BPS : 0
const slippageTopCap = MAX_SLIPPAGE_BPS

const slippage = tradeQuote?.quote?.quoteResults.suggestedSlippageBps ?? null
// get slippageBps from previous cached result, otherwise quote.quoteResults.suggestedSlippageBps usage causes re-fetch quote loop problem (#6675)
const slippage = tradeQuote?.suggestedSlippageBps || null

if (typeof slippage === 'number') {
if (slippage < slippageBottomCap) return null
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useAtom, useAtomValue } from 'jotai'
import { useLayoutEffect, useRef } from 'react'
import { useEffect, useLayoutEffect, useRef } from 'react'

import { useIsOnline, useIsWindowVisible, usePrevious } from '@cowprotocol/common-hooks'
import { useIsOnline, useIsWindowVisible, usePrevious, useSyncedRef } from '@cowprotocol/common-hooks'
import { getCurrencyAddress } from '@cowprotocol/common-utils'

import ms from 'ms.macro'

import { useIsSmartSlippageApplied } from 'modules/tradeSlippage/hooks/useIsSmartSlippageApplied'

import { usePollQuoteCallback } from './usePollQuoteCallback'
import { useQuoteParams } from './useQuoteParams'
import { useTradeQuote } from './useTradeQuote'
Expand All @@ -17,10 +19,13 @@ import { tradeQuoteCounterAtom } from '../state/tradeQuoteCounterAtom'
import { tradeQuoteInputAtom } from '../state/tradeQuoteInputAtom'
import { TradeQuotePollingParameters } from '../types'
import { isQuoteExpired } from '../utils/quoteDeadline'
import { checkOnlySlippageBpsChanged } from '../utils/quoteParamsChanges'

const ONE_SEC = 1000
const QUOTE_VALIDATION_INTERVAL = ms`2s`
const QUOTE_SLIPPAGE_CHANGE_THROTTLE_INTERVAL = ms`1.5s`

// eslint-disable-next-line max-lines-per-function
export function useTradeQuotePolling(quotePollingParams: TradeQuotePollingParameters): null {
const { isConfirmOpen, isQuoteUpdatePossible } = quotePollingParams

Expand Down Expand Up @@ -50,6 +55,13 @@ export function useTradeQuotePolling(quotePollingParams: TradeQuotePollingParame
// eslint-disable-next-line react-hooks/refs
pollQuoteRef.current = pollQuote

const prevQuoteParamsRef = useRef(quoteParams)
useEffect(() => {
prevQuoteParamsRef.current = quoteParams
}, [quoteParams])

const isSmartSlippageApplied = useSyncedRef(useIsSmartSlippageApplied())

/**
* Reset quote when window is not visible or sell amount has been cleared
*/
Expand All @@ -75,10 +87,29 @@ export function useTradeQuotePolling(quotePollingParams: TradeQuotePollingParame
*/
if (isConfirmOpen) return

if (isSmartSlippageApplied.current) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it should be checked inside quoteUsingSameParameters

const onlySlippageBpsChanged = checkOnlySlippageBpsChanged(
quoteParams,
prevQuoteParamsRef.current,
tradeQuoteRef.current,
)

if (onlySlippageBpsChanged) {
const quoteTimestampDiff = tradeQuoteRef.current.localQuoteTimestamp
? Date.now() - tradeQuoteRef.current.localQuoteTimestamp
: undefined
// in "smart" slippage mode slippageBps updates on every fetch /quote response
// so we should throttle duplicated additional requests caused by following slippageBps updates to prevent re-fetch loop (#6675)
if (typeof quoteTimestampDiff === 'number' && quoteTimestampDiff < QUOTE_SLIPPAGE_CHANGE_THROTTLE_INTERVAL) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another suggestion: instead of throttling by time, we can compare new/prev values and if the difference is less than N% - skip following quote fetch

(ofc only for "smart" slippage mode)

But... sometimes suggestedSlippageBps may jump from 0.5% to 1.25% to 4.75% just one after another 🫠

return
}
}
}

if (pollQuoteRef.current(true)) {
resetQuoteCounter()
}
}, [isConfirmOpen, isQuoteUpdatePossible, quoteParams, resetQuoteCounter])
}, [isConfirmOpen, isQuoteUpdatePossible, isSmartSlippageApplied, quoteParams, resetQuoteCounter])

/**
* Update quote once a QUOTE_POLLING_INTERVAL
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface TradeQuoteState {
hasParamsChanged: boolean
isLoading: boolean
localQuoteTimestamp: number | null
// cached slippageBps from quote response
suggestedSlippageBps: number | null
}

export const DEFAULT_TRADE_QUOTE_STATE: TradeQuoteState = {
Expand All @@ -29,32 +31,41 @@ export const DEFAULT_TRADE_QUOTE_STATE: TradeQuoteState = {
hasParamsChanged: false,
isLoading: false,
localQuoteTimestamp: null,
suggestedSlippageBps: null,
}

export const tradeQuotesAtom = atom<Record<SellTokenAddress, TradeQuoteState | undefined>>({})

export const updateTradeQuoteAtom = atom(
null,
(get, set, _sellTokenAddress: SellTokenAddress, nextState: Partial<TradeQuoteState>) => {
// eslint-disable-next-line complexity
set(tradeQuotesAtom, () => {
const sellTokenAddress = _sellTokenAddress.toLowerCase()
const prevState = get(tradeQuotesAtom)
const prevQuote = prevState[sellTokenAddress] || DEFAULT_TRADE_QUOTE_STATE

const fastPriceQuality = nextState.fetchParams?.priceQuality === PriceQuality.FAST

// Don't update state if Fast quote finished after Optimal quote
if (
prevQuote.fetchParams?.fetchStartTimestamp === nextState.fetchParams?.fetchStartTimestamp &&
nextState.quote &&
nextState.fetchParams?.priceQuality === PriceQuality.FAST
fastPriceQuality
) {
return { ...prevState }
}

const quote = typeof nextState.quote === 'undefined' ? prevQuote.quote : nextState.quote

const update: TradeQuoteState = {
...prevQuote,
...nextState,
quote: typeof nextState.quote === 'undefined' ? prevQuote.quote : nextState.quote,
localQuoteTimestamp: nextState.quote ? Math.ceil(Date.now() / 1000) : null,
quote,
localQuoteTimestamp: nextState.quote ? Date.now() : null,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix timestamp preservation when quote is preserved.

When nextState.quote is undefined, line 62 correctly preserves prevQuote.quote, but line 63 sets localQuoteTimestamp to null because nextState.quote is falsy. This breaks the invariant that a quote and its timestamp should stay synchronized.

Example scenario:

  • Caller passes { isLoading: true } (no quote field)
  • Expected: preserve existing quote and its timestamp
  • Actual: preserve quote but reset timestamp to null
🔎 Proposed fix
-        localQuoteTimestamp: nextState.quote ? Date.now() : null,
+        localQuoteTimestamp: typeof nextState.quote === 'undefined' 
+          ? prevQuote.localQuoteTimestamp 
+          : (nextState.quote ? Date.now() : null),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
localQuoteTimestamp: nextState.quote ? Date.now() : null,
localQuoteTimestamp: typeof nextState.quote === 'undefined'
? prevQuote.localQuoteTimestamp
: (nextState.quote ? Date.now() : null),
🤖 Prompt for AI Agents
In apps/cowswap-frontend/src/modules/tradeQuote/state/tradeQuoteAtom.ts around
line 63, localQuoteTimestamp is being set based on the truthiness of
nextState.quote which causes the timestamp to be nulled when nextState.quote is
undefined even if prevQuote.quote is preserved; change the logic to set
localQuoteTimestamp to Date.now() only when nextState.quote is explicitly
provided (e.g., nextState.quote !== undefined), otherwise preserve
prevQuote.localQuoteTimestamp so the quote and its timestamp remain
synchronized.

// sdk return default suggestedSlippageBps value for PriceQuality.FAST, should ignore it
suggestedSlippageBps:
quote && !fastPriceQuality ? quote.quoteResults.suggestedSlippageBps : prevQuote.suggestedSlippageBps,
}

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ interface QuoteDeadlineParams {
expiration?: string
}

const ONE_SEC = 1000

// 2024-04-16T10:54:01.334Z
const NOW_TIME = 1713264841334

Expand Down Expand Up @@ -43,7 +45,7 @@ const getQuoteState = ({
describe('Quote deadline utils', () => {
describe('getQuoteTimeOffset()', () => {
it('When expected validTo and quote validTo are the same, then should return 0', () => {
const validFor = 60 // 1 minute
const validFor = 60 * ONE_SEC // 1 minute
const localQuoteTimestamp = 1713167232

expect(
Expand All @@ -58,8 +60,8 @@ describe('Quote deadline utils', () => {
})

it('When expected validTo bigger than quote validTo, then should return positive number', () => {
const validFor = 60 // 1 minute
const timeOffset = 120 * 60 // 2 hours
const validFor = 60 * ONE_SEC // 1 minute
const timeOffset = 120 * 60 * ONE_SEC // 2 hours
const localQuoteTimestamp = 1713167232

expect(
Expand All @@ -74,8 +76,8 @@ describe('Quote deadline utils', () => {
})

it('When expected validTo less than quote validTo, then should return positive number', () => {
const validFor = 60 // 1 minute
const timeOffset = -120 * 60 // -2 hours
const validFor = 60 * ONE_SEC // 1 minute
const timeOffset = -120 * 60 * ONE_SEC // -2 hours
const localQuoteTimestamp = 1713167232

expect(
Expand All @@ -97,7 +99,7 @@ describe('Quote deadline utils', () => {
})

it('When time offset is not defined, then should be zero', () => {
const deadline = 10
const deadline = 10 * ONE_SEC // 10 sec
const quoteDeadlineParams = {
validFor: undefined,
quoteValidTo: undefined,
Expand All @@ -108,23 +110,21 @@ describe('Quote deadline utils', () => {
})

it('ValidTo should be now + deadline + timeOffset', () => {
const deadline = 5400 // 1.5 hours
const offset = 3600 // 1 hour
const localQuoteTimestamp = Math.floor(NOW_TIME / 1000)
const deadline = 5400 * ONE_SEC // 1.5 hours
const offset = 3600 * ONE_SEC // 1 hour
const localQuoteTimestamp = NOW_TIME
const quoteDeadlineParams = {
validFor: deadline,
quoteValidTo: localQuoteTimestamp + deadline + offset,
localQuoteTimestamp: localQuoteTimestamp,
}

expect(getOrderValidTo(deadline, getQuoteState(quoteDeadlineParams))).toEqual(
Math.floor(NOW_TIME / 1000 + deadline + offset),
)
expect(getOrderValidTo(deadline, getQuoteState(quoteDeadlineParams))).toEqual(NOW_TIME + deadline + offset)
})

it('When the result is too big, then it should be capped by MAX_VALID_TO_EPOCH', () => {
const deadline = 54000000000000000
const localQuoteTimestamp = Math.floor(NOW_TIME / 1000)
const localQuoteTimestamp = NOW_TIME
const quoteDeadlineParams = {
validFor: deadline,
quoteValidTo: localQuoteTimestamp + deadline,
Expand Down Expand Up @@ -158,8 +158,8 @@ describe('Quote deadline utils', () => {
// Now is 10:54:01, expiration is 10:44:01
const expirationDate = '2024-04-16T10:44:01.334Z'

const deadline = 5400 // 1.5 hours
const localQuoteTimestamp = Math.floor(NOW_TIME / 1000)
const deadline = 5400 * ONE_SEC // 1.5 hours
const localQuoteTimestamp = NOW_TIME
const deadlineParams = {
validFor: deadline,
quoteValidTo: localQuoteTimestamp + deadline,
Expand All @@ -174,8 +174,8 @@ describe('Quote deadline utils', () => {
// Now is 10:54:01, expiration is 11:04:01
const expirationDate = '2024-04-16T11:04:01.334Z'

const localQuoteTimestamp = Math.floor(NOW_TIME / 1000)
const deadline = 5400 // 1.5 hours
const localQuoteTimestamp = NOW_TIME
const deadline = 5400 * ONE_SEC // 1.5 hours
const deadlineParams = {
validFor: deadline,
quoteValidTo: localQuoteTimestamp + deadline,
Expand All @@ -190,9 +190,9 @@ describe('Quote deadline utils', () => {
// Now is 10:54:01, expiration is 10:44:01
const expirationDate = '2024-04-16T10:44:01.334Z'

const deadline = 5400 // 1.5 hours
const offset = 3600 // 1 hour
const localQuoteTimestamp = Math.floor(NOW_TIME / 1000)
const deadline = 5400 * ONE_SEC // 1.5 hours
const offset = 3600 * ONE_SEC // 1 hour
const localQuoteTimestamp = NOW_TIME
const deadlineParams = {
validFor: deadline,
quoteValidTo: localQuoteTimestamp + deadline + offset,
Expand All @@ -208,10 +208,10 @@ describe('Quote deadline utils', () => {
const expirationDate = '2024-04-16T11:54:01.334Z'

it('And quote is not expired yet, then should return false', () => {
const expirationGap = 59 // < 1 min
const localQuoteTimestamp = Math.floor(NOW_TIME / 1000) - expirationGap // Not expired
const expirationGap = 59 * ONE_SEC // < 1 min
const localQuoteTimestamp = NOW_TIME - expirationGap // Not expired

const deadline = 5400 // 1.5 hours
const deadline = 5400 * ONE_SEC // 1.5 hours
const offset = 0
const deadlineParams = {
validFor: deadline,
Expand All @@ -224,10 +224,10 @@ describe('Quote deadline utils', () => {
})

it('And quote is not expired yet, then should return false', () => {
const expirationGap = 60 // 1 min
const localQuoteTimestamp = Math.floor(NOW_TIME / 1000) - expirationGap // Expired
const expirationGap = 60 * ONE_SEC // 1 min
const localQuoteTimestamp = NOW_TIME - expirationGap // Expired

const deadline = 5400 // 1.5 hours
const deadline = 5400 * ONE_SEC // 1.5 hours
const offset = 0
const deadlineParams = {
validFor: deadline,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function isQuoteExpired(state: TradeQuoteState): boolean | undefined {
}

const quoteExpirationTime = new Date(expiration).getTime()
const maxExpirationTime = new Date(state.localQuoteTimestamp * 1000).getTime() + MAX_EXPIRATION_TIME
const maxExpirationTime = new Date(state.localQuoteTimestamp).getTime() + MAX_EXPIRATION_TIME
const expirationTime = Math.min(quoteExpirationTime, maxExpirationTime)

const now = Date.now()
Expand All @@ -51,7 +51,7 @@ export function getQuoteTimeOffset(state: TradeQuoteState): number | undefined {

if (!validFor || !quoteValidTo || !localQuoteTimestamp) return undefined

const expectedValidTo = localQuoteTimestamp + validFor
const expectedValidTo = Math.ceil(localQuoteTimestamp / 1000) + validFor

return expectedValidTo - quoteValidTo
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { QuoteBridgeRequest } from '@cowprotocol/sdk-bridging'

import deepEqual from 'fast-deep-equal'

import type { TradeQuoteState } from '../state/tradeQuoteAtom'

export function checkOnlySlippageBpsChanged(
quoteParams: QuoteBridgeRequest | undefined,
prevQuoteParams: QuoteBridgeRequest | undefined,
tradeQuote: TradeQuoteState,
): boolean {
const onlySlippageBpsChanged =
!tradeQuote.isLoading &&
quoteParams?.slippageBps !== prevQuoteParams?.slippageBps &&
deepEqual(
{ ...quoteParams, slippageBps: undefined, signer: undefined },
{ ...prevQuoteParams, slippageBps: undefined, signer: undefined },
)

return onlySlippageBpsChanged
}
1 change: 1 addition & 0 deletions libs/common-hooks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ export * from './useComponentDestroyedRef'
export * from './useReducedMotionPreference'
export * from './useElementViewportTracking'
export * from './usePreventDoubleExecution'
export * from './useSyncedRef'
23 changes: 23 additions & 0 deletions libs/common-hooks/src/useSyncedRef.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useMemo, useRef } from 'react'

/**
* Like `useRef`, but it returns immutable ref that contains actual value.
*
* @param value
* @see https://github.com/react-hookz/web/blob/master/src/useSyncedRef/index.ts
*/
export function useSyncedRef<T>(value: T): { readonly current: T } {
const ref = useRef(value)

ref.current = value

return useMemo(
() =>
Object.freeze({
get current() {
return ref.current
},
}),
[],
)
}
Loading