diff --git a/packages/synapse-interface/components/StateManagedBridge/BridgeTransactionButton.tsx b/packages/synapse-interface/components/StateManagedBridge/BridgeTransactionButton.tsx
index 0bde7cc3ae..975d139e27 100644
--- a/packages/synapse-interface/components/StateManagedBridge/BridgeTransactionButton.tsx
+++ b/packages/synapse-interface/components/StateManagedBridge/BridgeTransactionButton.tsx
@@ -9,8 +9,12 @@ import { useBridgeQuoteState } from '@/slices/bridgeQuote/hooks'
import { setIsDestinationWarningAccepted } from '@/slices/bridgeDisplaySlice'
import { useBridgeDisplayState, useBridgeState } from '@/slices/bridge/hooks'
import { TransactionButton } from '@/components/buttons/TransactionButton'
-import { useBridgeValidations } from './hooks/useBridgeValidations'
+import {
+ useBridgeValidations,
+ constructStringifiedBridgeSelections,
+} from './hooks/useBridgeValidations'
import { segmentAnalyticsEvent } from '@/contexts/SegmentAnalyticsProvider'
+import { useConfirmNewBridgePrice } from './hooks/useConfirmNewBridgePrice'
export const BridgeTransactionButton = ({
approveTxn,
@@ -45,6 +49,12 @@ export const BridgeTransactionButton = ({
debouncedFromValue,
} = useBridgeState()
const { bridgeQuote, isLoading } = useBridgeQuoteState()
+ const {
+ hasSameSelectionsAsPreviousQuote,
+ hasQuoteOutputChanged,
+ hasUserConfirmedChange,
+ onUserAcceptChange,
+ } = useConfirmNewBridgePrice()
const { isWalletPending } = useWalletState()
const { showDestinationWarning, isDestinationWarningAccepted } =
@@ -94,6 +104,16 @@ export const BridgeTransactionButton = ({
label: `Please select an Origin token`,
onClick: null,
}
+ } else if (isConnected && !hasSufficientBalance) {
+ buttonProperties = {
+ label: 'Insufficient balance',
+ onClick: null,
+ }
+ } else if (isLoading && hasSameSelectionsAsPreviousQuote) {
+ buttonProperties = {
+ label: 'Updating quote',
+ onClick: null,
+ }
} else if (isLoading) {
buttonProperties = {
label: `Bridge ${fromToken?.symbol}`,
@@ -141,11 +161,6 @@ export const BridgeTransactionButton = ({
label: 'Invalid bridge quote',
onClick: null,
}
- } else if (!isLoading && isConnected && !hasSufficientBalance) {
- buttonProperties = {
- label: 'Insufficient balance',
- onClick: null,
- }
} else if (destinationAddress && !isAddress(destinationAddress)) {
buttonProperties = {
label: 'Invalid Destination address',
@@ -162,6 +177,13 @@ export const BridgeTransactionButton = ({
onClick: () => switchChain({ chainId: fromChainId }),
pendingLabel: 'Switching chains',
}
+ } else if (hasQuoteOutputChanged && !hasUserConfirmedChange) {
+ buttonProperties = {
+ label: 'Confirm new price',
+ onClick: () => onUserAcceptChange(),
+ className:
+ '!border !border-synapsePurple !from-bgLight !to-bgLight !animate-pulse',
+ }
} else if (!isApproved && hasValidInput && hasValidQuote) {
buttonProperties = {
onClick: approveTxn,
diff --git a/packages/synapse-interface/components/StateManagedBridge/OutputContainer.tsx b/packages/synapse-interface/components/StateManagedBridge/OutputContainer.tsx
index 9f06ed4bd2..03b73bb8f0 100644
--- a/packages/synapse-interface/components/StateManagedBridge/OutputContainer.tsx
+++ b/packages/synapse-interface/components/StateManagedBridge/OutputContainer.tsx
@@ -1,5 +1,5 @@
import { useAccount } from 'wagmi'
-import { useMemo } from 'react'
+import { useMemo, useEffect, useState } from 'react'
import { ChainSelector } from '@/components/ui/ChainSelector'
import { TokenSelector } from '@/components/ui/TokenSelector'
@@ -14,11 +14,13 @@ import { setToChainId, setToToken } from '@/slices/bridge/reducer'
import { useBridgeDisplayState, useBridgeState } from '@/slices/bridge/hooks'
import { useWalletState } from '@/slices/wallet/hooks'
import { useBridgeQuoteState } from '@/slices/bridgeQuote/hooks'
+import { BridgeQuote } from '@/utils/types'
import { useBridgeValidations } from './hooks/useBridgeValidations'
+import { useConfirmNewBridgePrice } from './hooks/useConfirmNewBridgePrice'
export const OutputContainer = () => {
const { address } = useAccount()
- const { bridgeQuote, isLoading } = useBridgeQuoteState()
+ const { bridgeQuote, previousBridgeQuote, isLoading } = useBridgeQuoteState()
const { showDestinationAddress } = useBridgeDisplayState()
const { hasValidInput, hasValidQuote } = useBridgeValidations()
@@ -48,6 +50,9 @@ export const OutputContainer = () => {
showValue={showValue}
isLoading={isLoading}
/>
+ {hasValidQuote && !isLoading && (
+
+ )}
)
@@ -88,3 +93,43 @@ const ToTokenSelector = () => {
/>
)
}
+
+const AnimatedProgressCircle = ({ bridgeQuoteId }) => {
+ const [animationKey, setAnimationKey] = useState(0)
+
+ useEffect(() => {
+ setAnimationKey((prevKey) => prevKey + 1)
+ }, [bridgeQuoteId])
+
+ return (
+
+ )
+}
diff --git a/packages/synapse-interface/components/StateManagedBridge/hooks/useBridgeValidations.ts b/packages/synapse-interface/components/StateManagedBridge/hooks/useBridgeValidations.ts
index e64ac72587..b3f31ab0f6 100644
--- a/packages/synapse-interface/components/StateManagedBridge/hooks/useBridgeValidations.ts
+++ b/packages/synapse-interface/components/StateManagedBridge/hooks/useBridgeValidations.ts
@@ -111,7 +111,7 @@ export const useBridgeValidations = () => {
}
}
-const constructStringifiedBridgeSelections = (
+export const constructStringifiedBridgeSelections = (
originAmount,
originChainId,
originToken,
diff --git a/packages/synapse-interface/components/StateManagedBridge/hooks/useConfirmNewBridgePrice.ts b/packages/synapse-interface/components/StateManagedBridge/hooks/useConfirmNewBridgePrice.ts
new file mode 100644
index 0000000000..2f4a43ab74
--- /dev/null
+++ b/packages/synapse-interface/components/StateManagedBridge/hooks/useConfirmNewBridgePrice.ts
@@ -0,0 +1,110 @@
+import { useState, useEffect, useMemo, useRef } from 'react'
+
+import { useBridgeState } from '@/slices/bridge/hooks'
+import { useBridgeQuoteState } from '@/slices/bridgeQuote/hooks'
+import { constructStringifiedBridgeSelections } from './useBridgeValidations'
+import { BridgeQuote } from '@/utils/types'
+
+export const useConfirmNewBridgePrice = () => {
+ const quoteRef = useRef(null)
+
+ const [hasQuoteOutputChanged, setHasQuoteOutputChanged] =
+ useState(false)
+ const [hasUserConfirmedChange, setHasUserConfirmedChange] =
+ useState(false)
+
+ const { bridgeQuote, previousBridgeQuote } = useBridgeQuoteState()
+ const { debouncedFromValue, fromToken, toToken, fromChainId, toChainId } =
+ useBridgeState()
+
+ const currentBridgeQuoteSelections = useMemo(
+ () =>
+ constructStringifiedBridgeSelections(
+ debouncedFromValue,
+ fromChainId,
+ fromToken,
+ toChainId,
+ toToken
+ ),
+ [debouncedFromValue, fromChainId, fromToken, toChainId, toToken]
+ )
+
+ const previousBridgeQuoteSelections = useMemo(
+ () =>
+ constructStringifiedBridgeSelections(
+ previousBridgeQuote?.inputAmountForQuote,
+ previousBridgeQuote?.originChainId,
+ previousBridgeQuote?.originTokenForQuote,
+ previousBridgeQuote?.destChainId,
+ previousBridgeQuote?.destTokenForQuote
+ ),
+ [previousBridgeQuote]
+ )
+
+ const hasSameSelectionsAsPreviousQuote = useMemo(
+ () => currentBridgeQuoteSelections === previousBridgeQuoteSelections,
+ [currentBridgeQuoteSelections, previousBridgeQuoteSelections]
+ )
+
+ useEffect(() => {
+ const validQuotes =
+ bridgeQuote?.outputAmount && previousBridgeQuote?.outputAmount
+
+ const outputAmountDiffMoreThan1bps = validQuotes
+ ? calculateOutputRelativeDifference(
+ bridgeQuote,
+ quoteRef.current ?? previousBridgeQuote
+ ) > 0.0001
+ : false
+
+ if (
+ validQuotes &&
+ outputAmountDiffMoreThan1bps &&
+ hasSameSelectionsAsPreviousQuote
+ ) {
+ requestUserConfirmChange(previousBridgeQuote)
+ } else {
+ resetConfirm()
+ }
+ }, [bridgeQuote, previousBridgeQuote, hasSameSelectionsAsPreviousQuote])
+
+ const requestUserConfirmChange = (previousQuote: BridgeQuote) => {
+ if (!hasQuoteOutputChanged && !hasUserConfirmedChange) {
+ quoteRef.current = previousQuote
+ setHasQuoteOutputChanged(true)
+ }
+ setHasUserConfirmedChange(false)
+ }
+
+ const resetConfirm = () => {
+ if (hasUserConfirmedChange) {
+ quoteRef.current = null
+ setHasQuoteOutputChanged(false)
+ setHasUserConfirmedChange(false)
+ }
+ }
+
+ const onUserAcceptChange = () => {
+ quoteRef.current = null
+ setHasUserConfirmedChange(true)
+ }
+
+ return {
+ hasSameSelectionsAsPreviousQuote,
+ hasQuoteOutputChanged,
+ hasUserConfirmedChange,
+ onUserAcceptChange,
+ }
+}
+
+const calculateOutputRelativeDifference = (
+ quoteA?: BridgeQuote,
+ quoteB?: BridgeQuote
+) => {
+ if (!quoteA?.outputAmountString || !quoteB?.outputAmountString) return null
+
+ const outputA = parseFloat(quoteA.outputAmountString)
+ const outputB = parseFloat(quoteB.outputAmountString)
+
+ return Math.abs(outputA - outputB) / outputB
+}
diff --git a/packages/synapse-interface/pages/state-managed-bridge/index.tsx b/packages/synapse-interface/pages/state-managed-bridge/index.tsx
index df9e76925a..43b5e65aa2 100644
--- a/packages/synapse-interface/pages/state-managed-bridge/index.tsx
+++ b/packages/synapse-interface/pages/state-managed-bridge/index.tsx
@@ -136,8 +136,6 @@ const StateManagedBridge = () => {
// will have to handle deadlineMinutes here at later time, gets passed as optional last arg in .bridgeQuote()
- /* clear stored bridge quote before requesting new bridge quote */
- dispatch(resetBridgeQuote())
const currentTimestamp: number = getUnixTimeMinutesFromNow(0)
try {
@@ -202,7 +200,6 @@ const StateManagedBridge = () => {
bridgeQuote,
getAndSetBridgeQuote,
isLoading,
- isWalletPending,
quoteTimeout
)
diff --git a/packages/synapse-interface/slices/bridgeQuote/reducer.ts b/packages/synapse-interface/slices/bridgeQuote/reducer.ts
index 8407876035..898769f622 100644
--- a/packages/synapse-interface/slices/bridgeQuote/reducer.ts
+++ b/packages/synapse-interface/slices/bridgeQuote/reducer.ts
@@ -6,11 +6,13 @@ import { fetchBridgeQuote } from './thunks'
export interface BridgeQuoteState {
bridgeQuote: BridgeQuote
+ previousBridgeQuote: BridgeQuote | null
isLoading: boolean
}
export const initialState: BridgeQuoteState = {
bridgeQuote: EMPTY_BRIDGE_QUOTE,
+ previousBridgeQuote: null,
isLoading: false,
}
@@ -24,6 +26,9 @@ export const bridgeQuoteSlice = createSlice({
resetBridgeQuote: (state) => {
state.bridgeQuote = initialState.bridgeQuote
},
+ setPreviousBridgeQuote: (state, action: PayloadAction) => {
+ state.previousBridgeQuote = action.payload
+ },
},
extraReducers: (builder) => {
builder
@@ -44,6 +49,7 @@ export const bridgeQuoteSlice = createSlice({
},
})
-export const { resetBridgeQuote, setIsLoading } = bridgeQuoteSlice.actions
+export const { resetBridgeQuote, setIsLoading, setPreviousBridgeQuote } =
+ bridgeQuoteSlice.actions
export default bridgeQuoteSlice.reducer
diff --git a/packages/synapse-interface/store/middleware/bridgeQuoteHistoryMiddleware.ts b/packages/synapse-interface/store/middleware/bridgeQuoteHistoryMiddleware.ts
new file mode 100644
index 0000000000..f58c09ae03
--- /dev/null
+++ b/packages/synapse-interface/store/middleware/bridgeQuoteHistoryMiddleware.ts
@@ -0,0 +1,25 @@
+import {
+ Middleware,
+ MiddlewareAPI,
+ Dispatch,
+ AnyAction,
+} from '@reduxjs/toolkit'
+
+export const bridgeQuoteHistoryMiddleware: Middleware =
+ (store: MiddlewareAPI) => (next: Dispatch) => (action: AnyAction) => {
+ const previousState = store.getState()
+ const result = next(action)
+ const currentState = store.getState()
+
+ if (
+ previousState.bridgeQuote.bridgeQuote !==
+ currentState.bridgeQuote.bridgeQuote
+ ) {
+ store.dispatch({
+ type: 'bridgeQuote/setPreviousBridgeQuote',
+ payload: previousState.bridgeQuote.bridgeQuote,
+ })
+ }
+
+ return result
+ }
diff --git a/packages/synapse-interface/store/destinationAddressMiddleware.ts b/packages/synapse-interface/store/middleware/destinationAddressMiddleware.ts
similarity index 100%
rename from packages/synapse-interface/store/destinationAddressMiddleware.ts
rename to packages/synapse-interface/store/middleware/destinationAddressMiddleware.ts
diff --git a/packages/synapse-interface/store/store.ts b/packages/synapse-interface/store/store.ts
index 70f5515a6f..d8cdf3e70a 100644
--- a/packages/synapse-interface/store/store.ts
+++ b/packages/synapse-interface/store/store.ts
@@ -6,7 +6,8 @@ import { api } from '@/slices/api/slice'
import { segmentAnalyticsEvent } from '@/contexts/SegmentAnalyticsProvider'
import { storageKey, persistConfig, persistedReducer } from './reducer'
import { resetReduxCache } from '@/slices/application/actions'
-import { destinationAddressMiddleware } from '@/store/destinationAddressMiddleware'
+import { destinationAddressMiddleware } from '@/store/middleware/destinationAddressMiddleware'
+import { bridgeQuoteHistoryMiddleware } from './middleware/bridgeQuoteHistoryMiddleware'
const checkVersionAndResetCache = (): boolean => {
if (typeof window !== 'undefined') {
@@ -28,7 +29,11 @@ export const store = configureStore({
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
- }).concat(api.middleware, destinationAddressMiddleware),
+ }).concat(
+ api.middleware,
+ destinationAddressMiddleware,
+ bridgeQuoteHistoryMiddleware
+ ),
})
if (checkVersionAndResetCache()) {
diff --git a/packages/synapse-interface/utils/hooks/useStaleQuoteUpdater.ts b/packages/synapse-interface/utils/hooks/useStaleQuoteUpdater.ts
index 05ccd37dc3..f998c777c2 100644
--- a/packages/synapse-interface/utils/hooks/useStaleQuoteUpdater.ts
+++ b/packages/synapse-interface/utils/hooks/useStaleQuoteUpdater.ts
@@ -2,7 +2,6 @@ import { isNull, isNumber } from 'lodash'
import { useEffect, useRef } from 'react'
import { BridgeQuote } from '@/utils/types'
-import { calculateTimeBetween } from '@/utils/time'
import { useIntervalTimer } from '@/utils/hooks/useIntervalTimer'
import { convertUuidToUnix } from '@/utils/convertUuidToUnix'
@@ -14,25 +13,19 @@ export const useStaleQuoteUpdater = (
quote: BridgeQuote,
refreshQuoteCallback: () => Promise,
isQuoteLoading: boolean,
- isWalletPending: boolean,
staleTimeout: number = 15000 // Default 15_000ms or 15s
) => {
const eventListenerRef = useRef void)>(null)
-
+ const timeoutRef = useRef(null)
const quoteTime = quote?.id ? convertUuidToUnix(quote?.id) : null
const isValidQuote = isNumber(quoteTime) && !isNull(quoteTime)
- const currentTime = useIntervalTimer(staleTimeout, !isValidQuote)
+ useIntervalTimer(staleTimeout, !isValidQuote)
useEffect(() => {
- if (isValidQuote && !isQuoteLoading && !isWalletPending) {
- const timeDifference = calculateTimeBetween(currentTime, quoteTime)
- const isStaleQuote = timeDifference >= staleTimeout
-
- if (isStaleQuote) {
- if (eventListenerRef.current) {
- document.removeEventListener('mousemove', eventListenerRef.current)
- }
+ if (isValidQuote && !isQuoteLoading) {
+ timeoutRef.current = setTimeout(() => {
+ eventListenerRef.current = null
const newEventListener = () => {
refreshQuoteCallback()
@@ -44,7 +37,13 @@ export const useStaleQuoteUpdater = (
})
eventListenerRef.current = newEventListener
+ }, staleTimeout)
+ }
+
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current)
}
}
- }, [currentTime, staleTimeout])
+ }, [quote, isQuoteLoading])
}