diff --git a/packages/synapse-interface/components/StateManagedBridge/BridgeQuoteResetTimer.tsx b/packages/synapse-interface/components/StateManagedBridge/BridgeQuoteResetTimer.tsx new file mode 100644 index 0000000000..86717650ba --- /dev/null +++ b/packages/synapse-interface/components/StateManagedBridge/BridgeQuoteResetTimer.tsx @@ -0,0 +1,113 @@ +import { useState, useEffect, useMemo } from 'react' + +import { BridgeQuote } from '@/utils/types' +import { convertMsToSeconds } from '@/utils/time' + +export const BridgeQuoteResetTimer = ({ + bridgeQuote, + isLoading, + isActive, + duration, // in ms +}: { + bridgeQuote: BridgeQuote + isLoading: boolean + isActive: boolean + duration: number +}) => { + const memoizedTimer = useMemo(() => { + if (!isActive) return null + + if (isLoading) { + return <AnimatedLoadingCircle /> + } else { + return ( + <AnimatedProgressCircle + animateKey={bridgeQuote.id} + duration={duration} + /> + ) + } + }, [bridgeQuote, duration, isActive]) + + return memoizedTimer +} + +const AnimatedLoadingCircle = () => { + return ( + <svg + width="24" + height="24" + viewBox="-12 -12 24 24" + stroke="currentcolor" + fill="none" + className="absolute block -rotate-90" + > + <circle r="8" pathLength="1" stroke-dashArray="0.05" stroke-opacity=".5"> + <animate + attributeName="stroke-dashoffset" + to="-1" + dur="2.5s" + repeatCount="indefinite" + /> + </circle> + </svg> + ) +} + +const AnimatedProgressCircle = ({ + animateKey, + duration, +}: { + animateKey: string + duration: number +}) => { + const [animationKey, setAnimationKey] = useState(0) + + useEffect(() => { + setAnimationKey((prevKey) => prevKey + 1) + }, [animateKey]) + + return ( + <svg + key={animationKey} + width="24" + height="24" + viewBox="-12 -12 24 24" + stroke="currentcolor" + fill="none" + className="absolute block -rotate-90" + > + <circle r="8" pathLength="1" stroke-opacity=".25"> + <animate + attributeName="stroke-dashoffset" + to="-1" + dur="2.5s" + repeatCount="indefinite" + /> + <set + attributeName="stroke-dasharray" + to="0.05" + begin={`${convertMsToSeconds(duration)}s`} + /> + <set + attributeName="stroke-opacity" + to="0.5" + begin={`${convertMsToSeconds(duration)}s`} + /> + </circle> + <circle r="8" stroke-dasharray="1" pathLength="1"> + <animate + attributeName="stroke-dashoffset" + values="2; 1" + dur={`${convertMsToSeconds(duration)}s`} + fill="freeze" + /> + <animate + attributeName="stroke-opacity" + values="0; 1" + dur={`${convertMsToSeconds(duration)}s`} + /> + </circle> + </svg> + ) +} diff --git a/packages/synapse-interface/components/StateManagedBridge/BridgeTransactionButton.tsx b/packages/synapse-interface/components/StateManagedBridge/BridgeTransactionButton.tsx index 44aa9a3fb5..86f6006650 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,8 @@ export const BridgeTransactionButton = ({ debouncedFromValue, } = useBridgeState() const { bridgeQuote, isLoading } = useBridgeQuoteState() + const { isPendingConfirmChange, onUserAcceptChange } = + useConfirmNewBridgePrice() const { isWalletPending } = useWalletState() const { showDestinationWarning, isDestinationWarningAccepted } = @@ -73,6 +77,7 @@ export const BridgeTransactionButton = ({ isBridgeQuoteAmountGreaterThanInputForRfq || (isConnected && !hasValidQuote) || (isConnected && !hasSufficientBalance) || + (isConnected && isQuoteStale) || (destinationAddress && !isAddress(destinationAddress)) let buttonProperties @@ -97,6 +102,26 @@ export const BridgeTransactionButton = ({ label: t('Please select an Origin token'), onClick: null, } + } else if (isConnected && !hasSufficientBalance) { + buttonProperties = { + label: t('Insufficient balance'), + onClick: null, + } + } else if (isLoading && hasValidQuote) { + buttonProperties = { + label: isPendingConfirmChange + ? t('Confirm new quote') + : t('Bridge {symbol}', { symbol: fromToken?.symbol }), + pendingLabel: t('Bridge {symbol}', { symbol: fromToken?.symbol }), + onClick: null, + className: ` + ${ + isPendingConfirmChange + ? '!outline !outline-1 !outline-synapsePurple !outline-offset-[-1px] !from-bgLight !to-bgLight' + : '!bg-gradient-to-r !from-fuchsia-500 !to-purple-500 dark:!to-purple-600' + } + !opacity-100`, + } } else if (isLoading) { buttonProperties = { label: t('Bridge {symbol}', { symbol: fromToken?.symbol }), @@ -144,11 +169,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 +187,13 @@ export const BridgeTransactionButton = ({ onClick: () => switchChain({ chainId: fromChainId }), pendingLabel: t('Switching chains'), } + } else if (isApproved && hasValidQuote && isPendingConfirmChange) { + buttonProperties = { + label: t('Confirm new quote'), + onClick: () => onUserAcceptChange(), + className: + '!outline !outline-1 !outline-synapsePurple !outline-offset-[-1px] !from-bgLight !to-bgLight', + } } 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 ( <BridgeSectionContainer> <div className="flex items-center justify-between"> @@ -48,6 +54,7 @@ export const OutputContainer = () => { disabled={true} showValue={showValue} isLoading={isLoading} + className={inputClassName} /> </BridgeAmountContainer> </BridgeSectionContainer> 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..2dbead7f24 --- /dev/null +++ b/packages/synapse-interface/components/StateManagedBridge/hooks/useConfirmNewBridgePrice.ts @@ -0,0 +1,125 @@ +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 triggerQuoteRef = useRef<BridgeQuote | null>(null) + const bpsThreshold = 0.0001 // 1bps + + const [hasQuoteOutputChanged, setHasQuoteOutputChanged] = + useState<boolean>(false) + const [hasUserConfirmedChange, setHasUserConfirmedChange] = + useState<boolean>(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] + ) + + const isPendingConfirmChange = + hasQuoteOutputChanged && + hasSameSelectionsAsPreviousQuote && + !hasUserConfirmedChange + + useEffect(() => { + const validQuotes = + bridgeQuote?.outputAmount && previousBridgeQuote?.outputAmount + + const hasBridgeModuleChanged = + bridgeQuote?.bridgeModuleName !== + (triggerQuoteRef.current?.bridgeModuleName ?? + previousBridgeQuote?.bridgeModuleName) + + const outputAmountDiffMoreThanThreshold = validQuotes + ? calculateOutputRelativeDifference( + bridgeQuote, + triggerQuoteRef.current ?? previousBridgeQuote + ) > bpsThreshold + : false + + if ( + validQuotes && + hasSameSelectionsAsPreviousQuote && + (outputAmountDiffMoreThanThreshold || hasBridgeModuleChanged) + ) { + requestUserConfirmChange(previousBridgeQuote) + } else { + resetConfirm() + } + }, [bridgeQuote, previousBridgeQuote, hasSameSelectionsAsPreviousQuote]) + + const requestUserConfirmChange = (previousQuote: BridgeQuote) => { + if (!hasQuoteOutputChanged && !hasUserConfirmedChange) { + triggerQuoteRef.current = previousQuote + setHasQuoteOutputChanged(true) + } + setHasUserConfirmedChange(false) + } + + const resetConfirm = () => { + if (hasUserConfirmedChange) { + triggerQuoteRef.current = null + setHasQuoteOutputChanged(false) + setHasUserConfirmedChange(false) + } + } + + const onUserAcceptChange = () => { + triggerQuoteRef.current = null + setHasUserConfirmedChange(true) + } + + return { + isPendingConfirmChange, + onUserAcceptChange, + } +} + +const calculateOutputRelativeDifference = ( + currentQuote?: BridgeQuote, + previousQuote?: BridgeQuote +) => { + if (!currentQuote?.outputAmountString || !previousQuote?.outputAmountString) { + return null + } + + const currentOutput = parseFloat(currentQuote.outputAmountString) + const previousOutput = parseFloat(previousQuote.outputAmountString) + + if (previousOutput === 0) { + return null + } + + return (previousOutput - currentOutput) / previousOutput +} diff --git a/packages/synapse-interface/components/StateManagedBridge/hooks/useStaleQuoteUpdater.ts b/packages/synapse-interface/components/StateManagedBridge/hooks/useStaleQuoteUpdater.ts new file mode 100644 index 0000000000..9a9cbf1444 --- /dev/null +++ b/packages/synapse-interface/components/StateManagedBridge/hooks/useStaleQuoteUpdater.ts @@ -0,0 +1,114 @@ +import { useEffect, useRef, useState } from 'react' + +import { BridgeQuote } from '@/utils/types' +import { useIntervalTimer } from '@/utils/hooks/useIntervalTimer' + +export const useStaleQuoteUpdater = ( + quote: BridgeQuote, + refreshQuoteCallback: () => Promise<void>, + enabled: boolean, + staleTimeout: number = 15000, // in ms + autoRefreshDuration: number = 30000 // in ms +) => { + const [isStale, setIsStale] = useState<boolean>(false) + const autoRefreshIntervalRef = useRef<null | NodeJS.Timeout>(null) + const autoRefreshStartTimeRef = useRef<null | number>(null) + const mouseMoveListenerRef = useRef<null | (() => void)>(null) + const manualRefreshRef = useRef<null | NodeJS.Timeout>(null) + + useIntervalTimer(staleTimeout, !enabled) + + const [mouseMoved, resetMouseMove] = useTrackMouseMove() + + const clearManualRefreshTimeout = () => { + if (manualRefreshRef.current) { + clearTimeout(manualRefreshRef.current) + } + } + + const clearAutoRefreshInterval = () => { + if (autoRefreshIntervalRef.current) { + clearInterval(autoRefreshIntervalRef.current) + } + } + + const clearMouseMoveListener = () => { + if (mouseMoveListenerRef.current) { + mouseMoveListenerRef.current = null + } + } + + useEffect(() => { + if (mouseMoved && autoRefreshStartTimeRef.current) { + autoRefreshStartTimeRef.current = null + resetMouseMove() + } + }, [quote]) + + // Start auto-refresh logic for ${autoRefreshDuration}ms seconds + useEffect(() => { + if (enabled) { + // 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 ${autoRefreshDuration}ms hasn't passed, keep auto-refreshing + if (elapsedTime < autoRefreshDuration) { + clearManualRefreshTimeout() + clearAutoRefreshInterval() + + autoRefreshIntervalRef.current = setInterval(() => { + refreshQuoteCallback() + }, staleTimeout) + } else { + // If more than ${autoRefreshDuration}ms have passed, stop auto-refreshing and switch to mousemove logic + clearAutoRefreshInterval() + + manualRefreshRef.current = setTimeout(() => { + clearMouseMoveListener() + setIsStale(true) + + const handleMouseMove = () => { + refreshQuoteCallback() + clearMouseMoveListener() + setIsStale(false) + } + + document.addEventListener('mousemove', handleMouseMove, { + once: true, + }) + + mouseMoveListenerRef.current = handleMouseMove + }, staleTimeout) + } + } + + return () => { + clearManualRefreshTimeout() + clearAutoRefreshInterval() + setIsStale(false) + } + }, [quote, enabled]) + + return isStale +} + +export const useTrackMouseMove = (): [boolean, () => void] => { + const [moved, setMoved] = useState<boolean>(false) + + const onMove = () => setMoved(true) + const onReset = () => setMoved(false) + + useEffect(() => { + document.addEventListener('mousemove', onMove) + + return () => { + document.removeEventListener('mousemove', onMove) + } + }, []) + + return [moved, onReset] +} diff --git a/packages/synapse-interface/components/buttons/TransactionButton.tsx b/packages/synapse-interface/components/buttons/TransactionButton.tsx index e868868dc8..24461d6526 100644 --- a/packages/synapse-interface/components/buttons/TransactionButton.tsx +++ b/packages/synapse-interface/components/buttons/TransactionButton.tsx @@ -12,6 +12,7 @@ const baseClassNames = { disabled: 'disabled:opacity-50 disabled:cursor-not-allowed', background: 'bg-zinc-400 dark:bg-bgLight', gradient: 'enabled:bg-gradient-to-r', + transition: 'transition', } export const TransactionButton = ({ @@ -42,8 +43,8 @@ export const TransactionButton = ({ style={style} disabled={disabled} className={` - ${className} ${joinClassNames(baseClassNames)} + ${className} ${ isPending ? 'from-fuchsia-400 dark:from-fuchsia-900 to-purple-400 dark:to-purple-900' 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<HTMLInputElement>) => 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/messages/ar.json b/packages/synapse-interface/messages/ar.json index b52b6f617d..fc6571c49e 100644 --- a/packages/synapse-interface/messages/ar.json +++ b/packages/synapse-interface/messages/ar.json @@ -57,7 +57,8 @@ "ReceiveWithEllipsis": "استلام…", "All receivable tokens": "جميع الرموز القابلة للاستلام", "Route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}": "تم العثور على مسار لجسر {debouncedFromValue} {fromToken} على {fromChainId} إلى {toToken} على {toChainId}", - "No route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}": "لم يتم العثور على مسار لجسر {debouncedFromValue} {fromToken} على {fromChainId} إلى {toToken} على {toChainId}" + "No route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}": "لم يتم العثور على مسار لجسر {debouncedFromValue} {fromToken} على {fromChainId} إلى {toToken} على {toChainId}", + "Confirm new quote": "تأكيد العرض الجديد" }, "Completed": { "to": "إلى", diff --git a/packages/synapse-interface/messages/en-US.json b/packages/synapse-interface/messages/en-US.json index 4784ee82d4..7c5a99c5d0 100644 --- a/packages/synapse-interface/messages/en-US.json +++ b/packages/synapse-interface/messages/en-US.json @@ -57,7 +57,8 @@ "ReceiveWithEllipsis": "Receive…", "All receivable tokens": "All receivable tokens", "Route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}": "Route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}", - "No route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}": "No route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}" + "No route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}": "No route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}", + "Confirm new quote": "Confirm new quote" }, "Completed": { "to": "to", diff --git a/packages/synapse-interface/messages/es.json b/packages/synapse-interface/messages/es.json index dee3724b09..c6ac15e7d8 100644 --- a/packages/synapse-interface/messages/es.json +++ b/packages/synapse-interface/messages/es.json @@ -57,7 +57,8 @@ "ReceiveWithEllipsis": "Recibir…", "All receivable tokens": "Todos los tokens recibibles", "Route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}": "Ruta encontrada para el puente de {debouncedFromValue} {fromToken} en {fromChainId} a {toToken} en {toChainId}", - "No route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}": "No se encontró ruta para el puente de {debouncedFromValue} {fromToken} en {fromChainId} a {toToken} en {toChainId}" + "No route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}": "No se encontró ruta para el puente de {debouncedFromValue} {fromToken} en {fromChainId} a {toToken} en {toChainId}", + "Confirm new quote": "Confirmar nueva cotización" }, "Completed": { "to": "a", diff --git a/packages/synapse-interface/messages/fr.json b/packages/synapse-interface/messages/fr.json index 2541e4c68f..b5f249aaf1 100644 --- a/packages/synapse-interface/messages/fr.json +++ b/packages/synapse-interface/messages/fr.json @@ -57,7 +57,8 @@ "ReceiveWithEllipsis": "Recevoir…", "All receivable tokens": "Tous les jetons recevables", "Route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}": "Route trouvée pour le transfert de {debouncedFromValue} {fromToken} sur {fromChainId} vers {toToken} sur {toChainId}", - "No route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}": "Aucune route trouvée pour le transfert de {debouncedFromValue} {fromToken} sur {fromChainId} vers {toToken} sur {toChainId}" + "No route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}": "Aucune route trouvée pour le transfert de {debouncedFromValue} {fromToken} sur {fromChainId} vers {toToken} sur {toChainId}", + "Confirm new quote": "Confirmer la nouvelle offre" }, "Completed": { "to": "vers", diff --git a/packages/synapse-interface/messages/jp.json b/packages/synapse-interface/messages/jp.json index 1d23dd39a9..c187de083b 100644 --- a/packages/synapse-interface/messages/jp.json +++ b/packages/synapse-interface/messages/jp.json @@ -57,7 +57,8 @@ "ReceiveWithEllipsis": "受取…", "All receivable tokens": "すべての受取可能トークン", "Route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}": "{fromChainId}の{debouncedFromValue} {fromToken}から{toChainId}の{toToken}へのブリッジのルートが見つかりました", - "No route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}": "{fromChainId}の{debouncedFromValue} {fromToken}から{toChainId}の{toToken}へのブリッジのルートが見つかりませんでした" + "No route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}": "{fromChainId}の{debouncedFromValue} {fromToken}から{toChainId}の{toToken}へのブリッジのルートが見つかりませんでした", + "Confirm new quote": "新しい見積もりを確認" }, "Completed": { "to": "へ", diff --git a/packages/synapse-interface/messages/tr.json b/packages/synapse-interface/messages/tr.json index 34ad00a1f9..dbe44d71b2 100644 --- a/packages/synapse-interface/messages/tr.json +++ b/packages/synapse-interface/messages/tr.json @@ -57,7 +57,8 @@ "ReceiveWithEllipsis": "Al…", "All receivable tokens": "Tüm alınabilir tokenler", "Route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}": "{fromChainId}'deki {debouncedFromValue} {fromToken}'ı {toChainId}'deki {toToken}'a köprüleme için rota bulundu", - "No route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}": "{fromChainId}'deki {debouncedFromValue} {fromToken}'ı {toChainId}'deki {toToken}'a köprüleme için rota bulunamadı" + "No route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}": "{fromChainId}'deki {debouncedFromValue} {fromToken}'ı {toChainId}'deki {toToken}'a köprüleme için rota bulunamadı", + "Confirm new quote": "Yeni teklifi onayla" }, "Completed": { "to": "nereye", diff --git a/packages/synapse-interface/messages/zh-CN.json b/packages/synapse-interface/messages/zh-CN.json index 34a2b7500d..0d5cb9e02d 100644 --- a/packages/synapse-interface/messages/zh-CN.json +++ b/packages/synapse-interface/messages/zh-CN.json @@ -57,7 +57,8 @@ "ReceiveWithEllipsis": "接收…", "All receivable tokens": "所有可接收代币", "Route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}": "找到桥接路线:从 {fromChainId} 的 {fromToken} 到 {toChainId} 的 {toToken}", - "No route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}": "未找到桥接路线:从 {fromChainId} 的 {fromToken} 到 {toChainId} 的 {toToken}" + "No route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}": "未找到桥接路线:从 {fromChainId} 的 {fromToken} 到 {toChainId} 的 {toToken}", + "Confirm new quote": "确认新报价" }, "Completed": { "to": "到", diff --git a/packages/synapse-interface/pages/state-managed-bridge/index.tsx b/packages/synapse-interface/pages/state-managed-bridge/index.tsx index 87f503de4f..315b0651bd 100644 --- a/packages/synapse-interface/pages/state-managed-bridge/index.tsx +++ b/packages/synapse-interface/pages/state-managed-bridge/index.tsx @@ -58,7 +58,6 @@ import { RootState } from '@/store/store' import { getUnixTimeMinutesFromNow } from '@/utils/time' import { isTransactionReceiptError } from '@/utils/isTransactionReceiptError' import { wagmiConfig } from '@/wagmiConfig' -import { useStaleQuoteUpdater } from '@/utils/hooks/useStaleQuoteUpdater' import { useMaintenance } from '@/components/Maintenance/Maintenance' import { screenAddress } from '@/utils/screenAddress' import { useWalletState } from '@/slices/wallet/hooks' @@ -66,10 +65,14 @@ 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/BridgeQuoteResetTimer' +import { useBridgeValidations } from '@/components/StateManagedBridge/hooks/useBridgeValidations' +import { useStaleQuoteUpdater } from '@/components/StateManagedBridge/hooks/useStaleQuoteUpdater' 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, hasSufficientBalance } = 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,11 +220,18 @@ const StateManagedBridge = () => { } } - useStaleQuoteUpdater( + const isUpdaterEnabled = + isConnected && + hasValidQuote && + hasSufficientBalance && + isApproved && + !isBridgePaused && + !isWalletPending + + const isQuoteStale = useStaleQuoteUpdater( bridgeQuote, getAndSetBridgeQuote, - isLoading, - isWalletPending, + isUpdaterEnabled, quoteTimeout ) @@ -424,6 +434,10 @@ const StateManagedBridge = () => { ) } + if (isTransactionUserRejectedError(error)) { + getAndSetBridgeQuote() + } + return txErrorHandler(error) } finally { dispatch(setIsWalletPending(false)) @@ -467,18 +481,29 @@ const StateManagedBridge = () => { }} disabled={isWalletPending} /> - <OutputContainer /> + <OutputContainer isQuoteStale={isQuoteStale} /> <Warning /> <BridgeMaintenanceWarningMessage /> <BridgeExchangeRateInfo /> <ConfirmDestinationAddressWarning /> - <BridgeTransactionButton - isTyping={isTyping} - isApproved={isApproved} - approveTxn={approveTxn} - executeBridge={executeBridge} - isBridgePaused={isBridgePaused} - /> + <div className="relative flex items-center"> + <BridgeTransactionButton + isTyping={isTyping} + isApproved={isApproved} + approveTxn={approveTxn} + executeBridge={executeBridge} + isBridgePaused={isBridgePaused} + isQuoteStale={isQuoteStale} + /> + <div className="absolute flex items-center !right-10 pointer-events-none"> + <BridgeQuoteResetTimer + bridgeQuote={bridgeQuote} + isLoading={isLoading} + isActive={isUpdaterEnabled} + duration={quoteTimeout} + /> + </div> + </div> </> )} </BridgeCard> 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<any>) => { + 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 deleted file mode 100644 index 05ccd37dc3..0000000000 --- a/packages/synapse-interface/utils/hooks/useStaleQuoteUpdater.ts +++ /dev/null @@ -1,50 +0,0 @@ -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' - -/** - * 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<void>, - isQuoteLoading: boolean, - isWalletPending: boolean, - staleTimeout: number = 15000 // Default 15_000ms or 15s -) => { - const eventListenerRef = useRef<null | (() => void)>(null) - - const quoteTime = quote?.id ? convertUuidToUnix(quote?.id) : null - const isValidQuote = isNumber(quoteTime) && !isNull(quoteTime) - - const currentTime = 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) - } - - const newEventListener = () => { - refreshQuoteCallback() - eventListenerRef.current = null - } - - document.addEventListener('mousemove', newEventListener, { - once: true, - }) - - eventListenerRef.current = newEventListener - } - } - }, [currentTime, staleTimeout]) -} diff --git a/packages/synapse-interface/utils/time.ts b/packages/synapse-interface/utils/time.ts index 8e30b5075a..20a8c042f0 100644 --- a/packages/synapse-interface/utils/time.ts +++ b/packages/synapse-interface/utils/time.ts @@ -51,3 +51,7 @@ export const isTimestampToday = (unixTimestamp: number): boolean => { dateFromTimestamp.getFullYear() === currentDate.getFullYear() ) } + +export const convertMsToSeconds = (ms: number) => { + return Math.ceil(ms / 1000) +}