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]) }