@@ -48,6 +54,7 @@ export const OutputContainer = () => {
disabled={true}
showValue={showValue}
isLoading={isLoading}
+ className={inputClassName}
/>
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..ddec669c15
--- /dev/null
+++ b/packages/synapse-interface/components/StateManagedBridge/hooks/useConfirmNewBridgePrice.ts
@@ -0,0 +1,111 @@
+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 bpsThreshold = 0.0001 // 1bps
+
+ 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 outputAmountDiffMoreThanThreshold = validQuotes
+ ? calculateOutputRelativeDifference(
+ bridgeQuote,
+ quoteRef.current ?? previousBridgeQuote
+ ) > bpsThreshold
+ : false
+
+ if (
+ validQuotes &&
+ outputAmountDiffMoreThanThreshold &&
+ 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/components/buttons/TransactionButton.tsx b/packages/synapse-interface/components/buttons/TransactionButton.tsx
index e868868dc8..9be7c7ad48 100644
--- a/packages/synapse-interface/components/buttons/TransactionButton.tsx
+++ b/packages/synapse-interface/components/buttons/TransactionButton.tsx
@@ -19,6 +19,7 @@ export const TransactionButton = ({
onClick,
pendingLabel,
label,
+ labelAnimation,
onSuccess,
disabled,
chainId,
@@ -29,6 +30,7 @@ export const TransactionButton = ({
onClick: () => Promise
pendingLabel: string
label: string
+ labelAnimation?: React.ReactNode
onSuccess?: () => void
chainId?: number
style?: CSSProperties
@@ -63,7 +65,9 @@ export const TransactionButton = ({
{pendingLabel}{' '}
>
) : (
- <>{label}>
+ <>
+ {label} {labelAnimation}
+ >
)}
)
diff --git a/packages/synapse-interface/components/ui/AmountInput.tsx b/packages/synapse-interface/components/ui/AmountInput.tsx
index ed352f5176..aebc3fd3a7 100644
--- a/packages/synapse-interface/components/ui/AmountInput.tsx
+++ b/packages/synapse-interface/components/ui/AmountInput.tsx
@@ -11,6 +11,7 @@ interface AmountInputTypes {
showValue: string
handleFromValueChange?: (event: React.ChangeEvent) => void
setIsTyping?: (isTyping: boolean) => void
+ className?: string
}
export function AmountInput({
@@ -20,6 +21,7 @@ export function AmountInput({
showValue,
handleFromValueChange,
setIsTyping,
+ className,
}: AmountInputTypes) {
const debouncedSetIsTyping = useCallback(
debounce((value: boolean) => setIsTyping?.(value), 600),
@@ -38,6 +40,7 @@ export function AmountInput({
placeholder: 'placeholder:text-zinc-500 placeholder:dark:text-zinc-400',
font: 'text-xl md:text-2xl font-medium',
focus: 'focus:outline-none focus:ring-0 focus:border-none',
+ custom: className,
}
return (
diff --git a/packages/synapse-interface/pages/state-managed-bridge/index.tsx b/packages/synapse-interface/pages/state-managed-bridge/index.tsx
index 87f503de4f..99267ea387 100644
--- a/packages/synapse-interface/pages/state-managed-bridge/index.tsx
+++ b/packages/synapse-interface/pages/state-managed-bridge/index.tsx
@@ -66,10 +66,13 @@ import { useBridgeQuoteState } from '@/slices/bridgeQuote/hooks'
import { resetBridgeQuote } from '@/slices/bridgeQuote/reducer'
import { fetchBridgeQuote } from '@/slices/bridgeQuote/thunks'
import { useIsBridgeApproved } from '@/utils/hooks/useIsBridgeApproved'
+import { isTransactionUserRejectedError } from '@/utils/isTransactionUserRejectedError'
+import { BridgeQuoteResetTimer } from '@/components/StateManagedBridge/AnimatedProgressCircle'
+import { useBridgeValidations } from '@/components/StateManagedBridge/hooks/useBridgeValidations'
const StateManagedBridge = () => {
const dispatch = useAppDispatch()
- const { address } = useAccount()
+ const { address, isConnected } = useAccount()
const { synapseSDK } = useSynapseContext()
const router = useRouter()
const { query, pathname } = router
@@ -96,6 +99,8 @@ const StateManagedBridge = () => {
const isApproved = useIsBridgeApproved()
+ const { hasValidQuote } = useBridgeValidations()
+
const { isWalletPending } = useWalletState()
const { showSettingsSlideOver } = useSelector(
@@ -137,8 +142,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 {
@@ -217,7 +220,7 @@ const StateManagedBridge = () => {
}
}
- useStaleQuoteUpdater(
+ const isQuoteStale = useStaleQuoteUpdater(
bridgeQuote,
getAndSetBridgeQuote,
isLoading,
@@ -424,6 +427,10 @@ const StateManagedBridge = () => {
)
}
+ if (isTransactionUserRejectedError) {
+ getAndSetBridgeQuote()
+ }
+
return txErrorHandler(error)
} finally {
dispatch(setIsWalletPending(false))
@@ -467,18 +474,28 @@ const StateManagedBridge = () => {
}}
disabled={isWalletPending}
/>
-
+
-
+
>
)}
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..d7aa8c28a7 100644
--- a/packages/synapse-interface/utils/hooks/useStaleQuoteUpdater.ts
+++ b/packages/synapse-interface/utils/hooks/useStaleQuoteUpdater.ts
@@ -1,50 +1,111 @@
import { isNull, isNumber } from 'lodash'
-import { useEffect, useRef } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { BridgeQuote } from '@/utils/types'
-import { calculateTimeBetween } from '@/utils/time'
import { useIntervalTimer } from '@/utils/hooks/useIntervalTimer'
import { convertUuidToUnix } from '@/utils/convertUuidToUnix'
-/**
- * Refreshes quotes based on selected stale timeout duration.
- * Will refresh quote when browser is active and wallet prompt is not pending.
- */
export const useStaleQuoteUpdater = (
quote: BridgeQuote,
refreshQuoteCallback: () => Promise,
isQuoteLoading: boolean,
isWalletPending: boolean,
- staleTimeout: number = 15000 // Default 15_000ms or 15s
+ staleTimeout: number = 15000, // in ms
+ autoRefreshDuration: number = 60000 // in ms
) => {
+ const [isStale, setIsStale] = useState(false)
const eventListenerRef = useRef void)>(null)
+ const timeoutRef = useRef(null)
+ const autoRefreshIntervalRef = useRef(null)
+ const autoRefreshStartTimeRef = useRef(null)
+ const autoRefreshEndTimeRef = useRef(null)
const quoteTime = quote?.id ? convertUuidToUnix(quote?.id) : null
- const isValidQuote = isNumber(quoteTime) && !isNull(quoteTime)
+ const isValid = isNumber(quoteTime) && !isNull(quoteTime)
- const currentTime = useIntervalTimer(staleTimeout, !isValidQuote)
+ useIntervalTimer(staleTimeout, !isValid)
+ const [moved, reset] = useTrackMouseMove()
+
+ useEffect(() => {
+ if (moved && autoRefreshStartTimeRef.current) {
+ autoRefreshStartTimeRef.current = null
+ reset()
+ }
+ }, [quote])
+
+ // Start auto-refresh logic for 60 seconds
useEffect(() => {
- if (isValidQuote && !isQuoteLoading && !isWalletPending) {
- const timeDifference = calculateTimeBetween(currentTime, quoteTime)
- const isStaleQuote = timeDifference >= staleTimeout
+ if (isValid && !isQuoteLoading && !isWalletPending) {
+ // If auto-refresh has not started yet, initialize the start time
+ if (autoRefreshStartTimeRef.current === null) {
+ autoRefreshStartTimeRef.current = Date.now()
+ }
+
+ const elapsedTime = Date.now() - autoRefreshStartTimeRef.current
- if (isStaleQuote) {
- if (eventListenerRef.current) {
- document.removeEventListener('mousemove', eventListenerRef.current)
- }
+ // If autoRefreshDuration hasn't passed, keep auto-refreshing
+ if (elapsedTime < autoRefreshDuration) {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current)
+ if (autoRefreshIntervalRef.current)
+ clearInterval(autoRefreshIntervalRef.current)
- const newEventListener = () => {
+ autoRefreshIntervalRef.current = setInterval(() => {
refreshQuoteCallback()
+ }, staleTimeout)
+ } else {
+ // If more than autoRefreshDuration have passed, stop auto-refreshing and switch to mousemove logic
+ clearInterval(autoRefreshIntervalRef.current)
+
+ timeoutRef.current = setTimeout(() => {
eventListenerRef.current = null
- }
+ setIsStale(true)
+
+ const newEventListener = () => {
+ refreshQuoteCallback()
+ eventListenerRef.current = null
+ setIsStale(false)
+ }
+
+ document.addEventListener('mousemove', newEventListener, {
+ once: true,
+ })
- document.addEventListener('mousemove', newEventListener, {
- once: true,
- })
+ eventListenerRef.current = newEventListener
+ }, staleTimeout)
+ }
+ }
- eventListenerRef.current = newEventListener
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current)
}
+ if (autoRefreshIntervalRef.current) {
+ clearInterval(autoRefreshIntervalRef.current)
+ }
+ if (autoRefreshEndTimeRef.current) {
+ clearTimeout(autoRefreshEndTimeRef.current)
+ }
+ setIsStale(false)
}
- }, [currentTime, staleTimeout])
+ }, [quote, isQuoteLoading, isWalletPending])
+
+ return isStale
+}
+
+export const useTrackMouseMove = (): [boolean, () => void] => {
+ const [moved, setMoved] = useState(false)
+
+ const onMove = () => setMoved(true)
+ const reset = () => setMoved(false)
+
+ useEffect(() => {
+ document.addEventListener('mousemove', onMove)
+
+ return () => {
+ document.removeEventListener('mousemove', onMove)
+ }
+ }, [])
+
+ return [moved, reset]
}