diff --git a/packages/synapse-interface/components/StateManagedBridge/AnimatedProgressCircle.tsx b/packages/synapse-interface/components/StateManagedBridge/AnimatedProgressCircle.tsx new file mode 100644 index 0000000000..d3e6eca82c --- /dev/null +++ b/packages/synapse-interface/components/StateManagedBridge/AnimatedProgressCircle.tsx @@ -0,0 +1,67 @@ +import { BridgeQuote } from '@/utils/types' +import { useState, useEffect, useMemo } from 'react' + +export const BridgeQuoteResetTimer = ({ + bridgeQuote, + hasValidQuote, + duration, // in ms +}: { + bridgeQuote: BridgeQuote + hasValidQuote: boolean + duration: number +}) => { + const memoizedTimer = useMemo(() => { + if (hasValidQuote) { + return ( + + ) + } + return null + }, [bridgeQuote, hasValidQuote, duration]) + + return memoizedTimer +} + +const AnimatedProgressCircle = ({ + animateKey, + duration, +}: { + animateKey: string + duration: number +}) => { + const [animationKey, setAnimationKey] = useState(0) + + useEffect(() => { + setAnimationKey((prevKey) => prevKey + 1) + }, [animateKey]) + + return ( + + + + + + + ) +} + +const convertMsToSeconds = (ms: number) => { + return Math.ceil(ms / 1000) +} diff --git a/packages/synapse-interface/components/StateManagedBridge/BridgeTransactionButton.tsx b/packages/synapse-interface/components/StateManagedBridge/BridgeTransactionButton.tsx index 44aa9a3fb5..eda7260452 100644 --- a/packages/synapse-interface/components/StateManagedBridge/BridgeTransactionButton.tsx +++ b/packages/synapse-interface/components/StateManagedBridge/BridgeTransactionButton.tsx @@ -12,6 +12,7 @@ import { useBridgeDisplayState, useBridgeState } from '@/slices/bridge/hooks' import { TransactionButton } from '@/components/buttons/TransactionButton' import { useBridgeValidations } from './hooks/useBridgeValidations' import { segmentAnalyticsEvent } from '@/contexts/SegmentAnalyticsProvider' +import { useConfirmNewBridgePrice } from './hooks/useConfirmNewBridgePrice' export const BridgeTransactionButton = ({ approveTxn, @@ -19,6 +20,7 @@ export const BridgeTransactionButton = ({ isApproved, isBridgePaused, isTyping, + isQuoteStale, }) => { const dispatch = useAppDispatch() const { openConnectModal } = useConnectModal() @@ -48,6 +50,12 @@ export const BridgeTransactionButton = ({ debouncedFromValue, } = useBridgeState() const { bridgeQuote, isLoading } = useBridgeQuoteState() + const { + hasSameSelectionsAsPreviousQuote, + hasQuoteOutputChanged, + hasUserConfirmedChange, + onUserAcceptChange, + } = useConfirmNewBridgePrice() const { isWalletPending } = useWalletState() const { showDestinationWarning, isDestinationWarningAccepted } = @@ -73,6 +81,7 @@ export const BridgeTransactionButton = ({ isBridgeQuoteAmountGreaterThanInputForRfq || (isConnected && !hasValidQuote) || (isConnected && !hasSufficientBalance) || + (isConnected && isQuoteStale) || (destinationAddress && !isAddress(destinationAddress)) let buttonProperties @@ -97,6 +106,16 @@ export const BridgeTransactionButton = ({ label: t('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: t('Bridge {symbol}', { symbol: fromToken?.symbol }), @@ -144,11 +163,6 @@ export const BridgeTransactionButton = ({ label: t('Invalid bridge quote'), onClick: null, } - } else if (!isLoading && isConnected && !hasSufficientBalance) { - buttonProperties = { - label: t('Insufficient balance'), - onClick: null, - } } else if (destinationAddress && !isAddress(destinationAddress)) { buttonProperties = { label: t('Invalid Destination address'), @@ -167,6 +181,13 @@ export const BridgeTransactionButton = ({ onClick: () => switchChain({ chainId: fromChainId }), pendingLabel: t('Switching chains'), } + } else if (hasQuoteOutputChanged && !hasUserConfirmedChange) { + buttonProperties = { + label: 'Confirm new quote', + 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 c891391fcd..5af733cb91 100644 --- a/packages/synapse-interface/components/StateManagedBridge/OutputContainer.tsx +++ b/packages/synapse-interface/components/StateManagedBridge/OutputContainer.tsx @@ -17,7 +17,11 @@ import { useBridgeQuoteState } from '@/slices/bridgeQuote/hooks' import { useBridgeValidations } from './hooks/useBridgeValidations' import { useTranslations } from 'next-intl' -export const OutputContainer = () => { +interface OutputContainerProps { + isQuoteStale: boolean +} + +export const OutputContainer = ({ isQuoteStale }: OutputContainerProps) => { const { address } = useAccount() const { bridgeQuote, isLoading } = useBridgeQuoteState() const { showDestinationAddress } = useBridgeDisplayState() @@ -33,6 +37,8 @@ export const OutputContainer = () => { } }, [bridgeQuote, hasValidInput, hasValidQuote]) + const inputClassName = isQuoteStale ? 'opacity-50' : undefined + return (
@@ -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] }