diff --git a/packages/synapse-interface/components/Custom/CustomBridge.tsx b/packages/synapse-interface/components/Custom/CustomBridge.tsx new file mode 100644 index 0000000000..1a6879a95c --- /dev/null +++ b/packages/synapse-interface/components/Custom/CustomBridge.tsx @@ -0,0 +1,548 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Address } from 'viem' +import { useAccount, useSwitchChain } from 'wagmi' +import { useRouter } from 'next/router' +import { getWalletClient, waitForTransactionReceipt } from '@wagmi/core' +import { useTranslations } from 'next-intl' + +import { CHAINS_BY_ID } from '@/constants/chains' +import { segmentAnalyticsEvent } from '@/contexts/SegmentAnalyticsProvider' +import { useBridgeState } from '@/slices/bridge/hooks' +import { BridgeState } from '@/slices/bridge/reducer' +import { setIsWalletPending } from '@/slices/wallet/reducer' +import { useSynapseContext } from '@/utils/providers/SynapseProvider' +import { Token } from '@/utils/types' +import { txErrorHandler } from '@/utils/txErrorHandler' +import { approveToken } from '@/utils/approveToken' +import { stringToBigInt } from '@/utils/bigint/format' +import { + fetchAndStoreSingleNetworkPortfolioBalances, + usePortfolioState, +} from '@/slices/portfolio/hooks' +import { + updatePendingBridgeTransaction, + addPendingBridgeTransaction, + removePendingBridgeTransaction, +} from '@/slices/transactions/actions' +import { useAppDispatch } from '@/store/hooks' +import { getUnixTimeMinutesFromNow } from '@/utils/time' +import { isTransactionReceiptError } from '@/utils/isTransactionReceiptError' +import { wagmiConfig } from '@/wagmiConfig' +import { useWalletState } from '@/slices/wallet/hooks' +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 { useBridgeValidations } from '@/components/StateManagedBridge/hooks/useBridgeValidations' +import { useStaleQuoteUpdater } from '@/components/StateManagedBridge/hooks/useStaleQuoteUpdater' +import { ChainSelect } from './components/ChainSelect' +import { OPTIMISM } from '@/constants/chains/master' +import { CustomAmountInput } from './components/CustomAmountInput' +import { USDC } from '@/constants/tokens/bridgeable' +import { useMaintenance } from '@/components/Maintenance/Maintenance' +import { cleanNumberInput } from '@/utils/cleanNumberInput' +import { useConnectModal } from '@rainbow-me/rainbowkit' +import { TransactionSummary } from './components/TransactionSummary' + +export const CustomBridge = () => { + const dispatch = useAppDispatch() + const { address, isConnected } = useAccount() + const { synapseSDK } = useSynapseContext() + const router = useRouter() + const { query, pathname } = router + + const currentSDKRequestID = useRef(0) + const quoteTimeout = 15000 + + const t = useTranslations('Bridge') + + const [isTyping, setIsTyping] = useState(false) + + const [fromChainId, setFromChainId] = useState(10) + const [fromToken, setFromToken] = useState(USDC) + const [toChainId, setToChainId] = useState(534352) + const [toToken, setToToken] = useState(USDC) + const [fromValue, setFromValue] = useState('0') + + const { balances } = usePortfolioState() + + const fromChainBalances = balances[fromChainId] + const fromTokenBalance = fromChainBalances?.find( + (token) => token.tokenAddress === fromToken.addresses[fromChainId] + ).parsedBalance + + const { destinationAddress }: BridgeState = useBridgeState() + + const { bridgeQuote, isLoading } = useBridgeQuoteState() + + const isApproved = useIsBridgeApproved() + + const { hasValidQuote, hasSufficientBalance } = useBridgeValidations() + + const { isWalletPending } = useWalletState() + + const { + isBridgePaused, + pausedModulesList, + BridgeMaintenanceProgressBar, + BridgeMaintenanceWarningMessage, + } = useMaintenance() + + useEffect(() => { + segmentAnalyticsEvent( + `[Custom Bridge page] arrives`, + { + fromChainId, + query, + pathname, + }, + true + ) + }, [query]) + + useEffect(() => { + if ( + fromToken && + toToken && + fromToken?.decimals[fromChainId] && + stringToBigInt(fromValue, fromToken?.decimals[fromChainId]) > 0n + ) { + console.log('trying to set bridge quote') + getAndSetBridgeQuote() + } else { + dispatch(resetBridgeQuote()) + } + }, [fromChainId, toChainId, fromToken, toToken, fromValue]) + + const getAndSetBridgeQuote = async () => { + currentSDKRequestID.current += 1 + const thisRequestId = currentSDKRequestID.current + + const currentTimestamp: number = getUnixTimeMinutesFromNow(0) + + try { + if (thisRequestId === currentSDKRequestID.current) { + await dispatch( + fetchBridgeQuote({ + synapseSDK, + fromChainId, + toChainId, + fromToken, + toToken, + debouncedFromValue: fromValue, + requestId: thisRequestId, + currentTimestamp, + address, + pausedModulesList, + }) + ) + } + } catch (err) { + console.log(err) + if (thisRequestId === currentSDKRequestID.current) { + dispatch(resetBridgeQuote()) + + return + } + } + } + + const isUpdaterEnabled = + isConnected && + hasValidQuote && + hasSufficientBalance && + isApproved && + !isWalletPending + + const isQuoteStale = useStaleQuoteUpdater( + bridgeQuote, + getAndSetBridgeQuote, + isUpdaterEnabled, + quoteTimeout + ) + + const approveTxn = async () => { + try { + dispatch(setIsWalletPending(true)) + const tx = approveToken( + bridgeQuote?.routerAddress, + fromChainId, + fromToken?.addresses[fromChainId], + stringToBigInt(fromValue, fromToken?.decimals[fromChainId]) + ) + await tx + /** Re-fetch bridge quote to re-check approval state */ + getAndSetBridgeQuote() + } catch (error) { + return txErrorHandler(error) + } finally { + dispatch(setIsWalletPending(false)) + } + } + + const executeBridge = async () => { + const currentTimestamp: number = getUnixTimeMinutesFromNow(0) + + segmentAnalyticsEvent( + `[Custom Bridge] initiates bridge`, + { + id: bridgeQuote.id, + originChainId: fromChainId, + destinationChainId: toChainId, + inputAmount: fromValue, + expectedReceivedAmount: bridgeQuote.outputAmountString, + slippage: bridgeQuote.exchangeRate, + originToken: fromToken?.routeSymbol, + destinationToken: toToken?.routeSymbol, + exchangeRate: BigInt(bridgeQuote.exchangeRate.toString()), + routerAddress: bridgeQuote.routerAddress, + bridgeQuote, + }, + true + ) + + dispatch( + addPendingBridgeTransaction({ + id: currentTimestamp, + originChain: CHAINS_BY_ID[fromChainId], + originToken: fromToken, + originValue: fromValue, + destinationChain: CHAINS_BY_ID[toChainId], + destinationToken: toToken, + transactionHash: undefined, + timestamp: undefined, + isSubmitted: false, + estimatedTime: bridgeQuote.estimatedTime, + bridgeModuleName: bridgeQuote.bridgeModuleName, + destinationAddress: destinationAddress, + routerAddress: bridgeQuote.routerAddress, + }) + ) + try { + dispatch(setIsWalletPending(true)) + const wallet = await getWalletClient(wagmiConfig, { + chainId: fromChainId, + }) + + const payload = await synapseSDK.bridge( + address, + bridgeQuote.routerAddress, + fromChainId, + toChainId, + fromToken?.addresses[fromChainId as keyof Token['addresses']], + stringToBigInt(fromValue, fromToken?.decimals[fromChainId]), + bridgeQuote.originQuery, + bridgeQuote.destQuery + ) + + const tx = await wallet.sendTransaction({ + ...payload, + }) + + segmentAnalyticsEvent(`[Custom Bridge] bridges successfully`, { + id: bridgeQuote.id, + originChainId: fromChainId, + destinationChainId: toChainId, + inputAmount: fromValue, + expectedReceivedAmount: bridgeQuote.outputAmountString, + slippage: bridgeQuote.exchangeRate, + originToken: fromToken?.routeSymbol, + destinationToken: toToken?.routeSymbol, + exchangeRate: BigInt(bridgeQuote.exchangeRate.toString()), + routerAddress: bridgeQuote.routerAddress, + bridgeQuote, + }) + dispatch( + updatePendingBridgeTransaction({ + id: currentTimestamp, + timestamp: undefined, + transactionHash: tx, + isSubmitted: false, + }) + ) + dispatch(resetBridgeQuote()) + setFromValue('') + + await waitForTransactionReceipt(wagmiConfig, { + hash: tx as Address, + timeout: 60_000, + }) + + /** Update Origin Chain token balances after resolved tx or timeout reached */ + /** Assume tx has been actually resolved if above times out */ + dispatch( + fetchAndStoreSingleNetworkPortfolioBalances({ + address, + chainId: fromChainId, + }) + ) + + return tx + } catch (error) { + segmentAnalyticsEvent(`[Custom Bridge] error bridging`, { + errorCode: error.code, + }) + dispatch(removePendingBridgeTransaction(currentTimestamp)) + console.error('Error executing bridge: ', error) + + /** Fetch balances if await transaction receipt times out */ + if (isTransactionReceiptError(error)) { + dispatch( + fetchAndStoreSingleNetworkPortfolioBalances({ + address, + chainId: fromChainId, + }) + ) + } + + if (isTransactionUserRejectedError(error)) { + getAndSetBridgeQuote() + } + + return txErrorHandler(error) + } finally { + dispatch(setIsWalletPending(false)) + } + } + + const handleFromValueChange = ( + event: React.ChangeEvent + ) => { + const swapFromValueString: string = cleanNumberInput(event.target.value) + try { + setFromValue(swapFromValueString) + } catch (error) { + console.error('Invalid value for conversion to BigInteger') + const inputValue = event.target.value + const regex = /^[0-9]*[.,]?[0-9]*$/ + + if (regex.test(inputValue) || inputValue === '') { + setFromValue(inputValue) + } + } + } + + return ( +
+
+ + {isConnected && ( +
+
+ {fromToken?.symbol} +
To Bridge
+
+
+
+ {fromToken?.symbol} +
+ {fromTokenBalance}/{fromToken.symbol} from{' '} + {CHAINS_BY_ID[fromChainId].name} to{' '} + {CHAINS_BY_ID[toChainId].name} +
+
+
+ )} +
+
+ Bridge to {CHAINS_BY_ID[toChainId].name} +
+
+
+ {}} + chain={OPTIMISM} + /> +
+ +
+ {fromToken?.symbol} +
{fromToken?.symbol}
+
+
+
+ {isConnected ? ( + + ) : ( + 'Not connected' + )} +
+ {bridgeQuote?.outputAmountString !== '' && ( +
+
+
+ {bridgeQuote?.estimatedTime} seconds via{' '} + {bridgeQuote?.bridgeModuleName} +
+
+
Receive:
+
+ {bridgeQuote?.outputAmountString} {toToken?.symbol} +
+
+
+
+ )} +
+ Powered by Synapse +
+
+ +
+
+
+
+
+ ) +} + +const TransactionButton = ({ + fromChainId, + toChainId, + fromToken, + bridgeQuote, + fromValue, + fromTokenBalance, + isLoading, + approveTxn, + executeBridge, +}) => { + const { chain, isConnected } = useAccount() + const { openConnectModal } = useConnectModal() + const { switchChain } = useSwitchChain() + + const [isApproving, setIsApproving] = useState(false) + const [isBridging, setIsBridging] = useState(false) + + const buttonClassName = ` + p-2 mb-2 + text-lg font-sans font-medium tracking-wide w-full + shadow-[0_0_0_2px_#00C185,0_0_0_4px_#FF8736,0_0_0_6px_#FFC100] + ` + + const comparableFromTokenBalance = stringToBigInt( + fromTokenBalance, + fromToken.decimals[fromChainId] + ) + const comparableFromValue = stringToBigInt( + fromValue, + fromToken.decimals[fromChainId] + ) + + const isApproved = useMemo(() => { + return ( + fromToken && + bridgeQuote?.allowance && + stringToBigInt(fromValue, fromToken.decimals[fromChainId]) <= + bridgeQuote.allowance + ) + }, [bridgeQuote, fromToken, fromValue, fromChainId]) + + const handleApproveTxn = async () => { + setIsApproving(true) + try { + await approveTxn() + } catch (error) { + console.error('Approval failed', error) + } finally { + setIsApproving(false) + } + } + + const handleBridgeTxn = async () => { + setIsBridging(true) + try { + await executeBridge() + } catch (error) { + console.error('Bridge failed', error) + } finally { + setIsBridging(false) + } + } + + if (isLoading) { + return + } + + if (!isConnected) { + return ( + + ) + } + + if (isConnected && chain.id !== fromChainId) { + return ( + + ) + } + + if (fromValue === '' || fromValue === '0') { + return + } + + if (comparableFromValue > comparableFromTokenBalance) { + return + } + + if (!isApproved) { + return ( + + ) + } + + return ( + + ) +} diff --git a/packages/synapse-interface/components/Custom/components/AnimatedProgressBar.tsx b/packages/synapse-interface/components/Custom/components/AnimatedProgressBar.tsx new file mode 100644 index 0000000000..add3104325 --- /dev/null +++ b/packages/synapse-interface/components/Custom/components/AnimatedProgressBar.tsx @@ -0,0 +1,106 @@ +import { memo } from 'react' + +import { getTimeMinutesFromNow } from '../utils/getTimeMinutesFromNow' + +/** + * @param id unique identifier for progress bar instance + * @param startTime timestamp in seconds + * @param estDuration total duration in seconds + * @param isComplete completion status + */ + +export const AnimatedProgressBar = memo( + ({ + id, + startTime, + estDuration, + isComplete, + }: { + id: string + startTime: number + estDuration: number + isComplete: boolean + }) => { + const currentTime = getTimeMinutesFromNow(0) + const elapsedTime = currentTime - startTime + const remainingTime = estDuration - elapsedTime + const percentElapsed = (elapsedTime / estDuration) * 100 + + const duration = isComplete ? 0.5 : remainingTime + + const synapsePurple = 'hsl(265deg 100% 75%)' + const tailwindGreen400 = 'rgb(74 222 128)' + const height = 3 + + const progressId = `progress-${id}` + const maskId = `mask-${id}` + + return ( + + + + + + + + + + + + + + + + + + {isComplete && ( + + )} + + {isComplete && ( + + )} + + ) + } +) diff --git a/packages/synapse-interface/components/Custom/components/ChainOption.tsx b/packages/synapse-interface/components/Custom/components/ChainOption.tsx new file mode 100644 index 0000000000..c76bb0683e --- /dev/null +++ b/packages/synapse-interface/components/Custom/components/ChainOption.tsx @@ -0,0 +1,41 @@ +import { type Chain } from '@/utils/types' + +export const ChainOption = ({ + option, + isSelected, + onSelect, + isOrigin, +}: { + option: any + isSelected: boolean + onSelect: (option: Chain) => void + isOrigin?: boolean +}) => { + return ( +
  • onSelect(option)} + > +
    + {option?.imgUrl && ( + {`${option?.name} + )} + {option?.name} +
    +
  • + ) +} diff --git a/packages/synapse-interface/components/Custom/components/ChainPopoverSelect.tsx b/packages/synapse-interface/components/Custom/components/ChainPopoverSelect.tsx new file mode 100644 index 0000000000..22229d56f2 --- /dev/null +++ b/packages/synapse-interface/components/Custom/components/ChainPopoverSelect.tsx @@ -0,0 +1,82 @@ +import _ from 'lodash' +import { type Chain } from '@/utils/types' + +import usePopover from '../hooks/usePopoverRef' +import { DownArrow } from '@/components/icons/DownArrow' +import { ChainOption } from './ChainOption' + +type PopoverSelectProps = { + options: Chain[] + onSelect: (selected: Chain) => void + selected: Chain + label: string + isOrigin: boolean +} + +export const ChainPopoverSelect = ({ + options, + onSelect, + selected, + label, + isOrigin, +}: PopoverSelectProps) => { + const { popoverRef, isOpen, togglePopover, closePopover } = usePopover() + + const handleSelect = (option: Chain) => { + onSelect(option) + closePopover() + } + + return ( +
    +
    togglePopover()} + className={` + flex px-2.5 py-1.5 gap-2 items-center rounded + text-[--synapse-select-text] whitespace-nowrap + border border-solid border-zinc-200 dark:border-zinc-700 + cursor-pointer hover:border-[--synapse-focus] + w-[150px] + `} + > + {selected?.chainImg && ( + {`${selected?.name} + )} + {selected?.name || 'Network'} + +
    + {isOpen && ( +
    +
      + {options.map((option, i) => ( + handleSelect(option)} + isOrigin={isOrigin} + /> + ))} +
    +
    + )} +
    + ) +} diff --git a/packages/synapse-interface/components/Custom/components/ChainSelect.tsx b/packages/synapse-interface/components/Custom/components/ChainSelect.tsx new file mode 100644 index 0000000000..0a2a4c0b18 --- /dev/null +++ b/packages/synapse-interface/components/Custom/components/ChainSelect.tsx @@ -0,0 +1,28 @@ +import _ from 'lodash' +import { type Chain } from '@/utils/types' + +import { ChainPopoverSelect } from './ChainPopoverSelect' +import { ETH, OPTIMISM } from '@/constants/chains/master' + +type Props = { + label: 'To' | 'From' + isOrigin: boolean + onChange: (newChain: Chain) => void + chain: Chain +} + +export const ChainSelect = ({ label, isOrigin, chain, onChange }: Props) => { + const options = [OPTIMISM] + + return ( + { + onChange(selected) + }} + selected={chain} + label={label} + isOrigin={isOrigin} + /> + ) +} diff --git a/packages/synapse-interface/components/Custom/components/CustomAmountInput.tsx b/packages/synapse-interface/components/Custom/components/CustomAmountInput.tsx new file mode 100644 index 0000000000..e05960e3c1 --- /dev/null +++ b/packages/synapse-interface/components/Custom/components/CustomAmountInput.tsx @@ -0,0 +1,62 @@ +import { debounce } from 'lodash' +import React, { useCallback } from 'react' +import { NumericFormat } from 'react-number-format' +import { joinClassNames } from '@/utils/joinClassNames' + +interface CustomAmountInputTypes { + inputRef?: React.RefObject + disabled?: boolean + showValue: string + handleFromValueChange?: (event: React.ChangeEvent) => void + setIsTyping?: (isTyping: boolean) => void + className?: string +} + +export function CustomAmountInput({ + inputRef, + disabled = false, + showValue, + handleFromValueChange, + setIsTyping, + className, +}: CustomAmountInputTypes) { + const debouncedSetIsTyping = useCallback( + debounce((value: boolean) => setIsTyping?.(value), 600), + [setIsTyping] + ) + + const handleInputChange = (event: React.ChangeEvent) => { + setIsTyping?.(true) + debouncedSetIsTyping(false) + handleFromValueChange?.(event) + } + + const inputClassNames = { + unset: 'bg-transparent border-none p-0', + layout: 'w-full', + 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/components/Custom/components/DropdownMenu.tsx b/packages/synapse-interface/components/Custom/components/DropdownMenu.tsx new file mode 100644 index 0000000000..d7f0624b12 --- /dev/null +++ b/packages/synapse-interface/components/Custom/components/DropdownMenu.tsx @@ -0,0 +1,44 @@ +import { useRef, useState } from 'react' + +import { DownArrow } from '@/components/icons/DownArrow' +import useCloseOnOutsideClick from '@/utils/hooks/useCloseOnOutsideClick' +import { useCloseOnEscape } from '@/utils/hooks/useCloseOnEscape' + +export const DropdownMenu = ({ menuTitleElement, children }) => { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + const handleClick = () => { + setOpen(!open) + } + + const closeDropdown = () => setOpen(false) + + useCloseOnOutsideClick(ref, closeDropdown) + useCloseOnEscape(closeDropdown) + + return ( +
    +
    + {menuTitleElement} + +
    + + {open && ( +
      + {children} +
    + )} +
    + ) +} diff --git a/packages/synapse-interface/components/Custom/components/MenuItem.tsx b/packages/synapse-interface/components/Custom/components/MenuItem.tsx new file mode 100644 index 0000000000..8b0e3f8856 --- /dev/null +++ b/packages/synapse-interface/components/Custom/components/MenuItem.tsx @@ -0,0 +1,43 @@ +export const MenuItem = ({ + text, + link, + onClick, +}: { + text: string + link: string + onClick?: () => any +}) => { + return ( +
  • + {onClick ? ( +
    + {text} +
    + ) : ( + + {text} + + )} +
  • + ) +} diff --git a/packages/synapse-interface/components/Custom/components/TimeRemaining.tsx b/packages/synapse-interface/components/Custom/components/TimeRemaining.tsx new file mode 100644 index 0000000000..179defef8c --- /dev/null +++ b/packages/synapse-interface/components/Custom/components/TimeRemaining.tsx @@ -0,0 +1,38 @@ +import { useMemo } from 'react' + +export const TimeRemaining = ({ + isComplete, + remainingTime, + isDelayed, + delayedTime, +}: { + isComplete: boolean + remainingTime: number + isDelayed: boolean + delayedTime: number | null +}) => { + if (isComplete) { + return
    Complete!
    + } + + if (isDelayed) { + const delayedTimeInMin = Math.floor(delayedTime / 60) + const absoluteDelayedTime = Math.abs(delayedTimeInMin) + const showDelayedTime = delayedTimeInMin < -1 + return ( +
    + Waiting... {showDelayedTime ? `(${absoluteDelayedTime}m)` : null} +
    + ) + } + + const estTime = useMemo(() => { + if (remainingTime > 60) { + return Math.ceil(remainingTime / 60) + 'm remaining' + } else { + return remainingTime + 's remaining' + } + }, [remainingTime]) + + return
    {estTime}
    +} diff --git a/packages/synapse-interface/components/Custom/components/Transaction.tsx b/packages/synapse-interface/components/Custom/components/Transaction.tsx new file mode 100644 index 0000000000..bbe58bb22c --- /dev/null +++ b/packages/synapse-interface/components/Custom/components/Transaction.tsx @@ -0,0 +1,233 @@ +import { useEffect, useMemo, useCallback } from 'react' +import { type Chain } from '@/utils/types' + +import { useAppDispatch } from '@/store/hooks' +import { getTxBlockExplorerLink } from '../utils/getTxBlockExplorerLink' +import { getExplorerAddressUrl } from '../utils/getExplorerAddressUrl' +import { useBridgeTxStatus } from '../hooks/useBridgeTxStatus' +import { isNull } from '../utils/isNull' +import { + updateTransactionKappa, + completeTransaction, + removeTransaction, +} from '@/slices/_transactions/reducer' +import { useSynapseContext } from '@/utils/providers/SynapseProvider' +import { TimeRemaining } from './TimeRemaining' +import { DropdownMenu } from './DropdownMenu' +import { MenuItem } from './MenuItem' +import { CHAINS_BY_ID } from '@/constants/chains' +import { AnimatedProgressBar } from './AnimatedProgressBar' +import { use_TransactionsState } from '@/slices/_transactions/hooks' + +export const Transaction = ({ + connectedAddress, + originAmount, + originTokenSymbol, + originChainId, + destinationChainId, + originTxHash, + bridgeModuleName, + estimatedTime, + kappa, + timestamp, + currentTime, + isStoredComplete, +}: { + connectedAddress: string + originAmount: string + originTokenSymbol: string + originChainId: number + destinationChainId: number + originTxHash: string + bridgeModuleName: string + estimatedTime: number // in seconds + kappa?: string + timestamp: number + currentTime: number + isStoredComplete: boolean +}) => { + const dispatch = useAppDispatch() + const { transactions } = use_TransactionsState() + + const { synapseSDK } = useSynapseContext() + + const [originTxExplorerLink, originExplorerName] = getTxBlockExplorerLink( + originChainId, + originTxHash + ) + const [destExplorerAddressLink, destExplorerName] = getExplorerAddressUrl( + destinationChainId, + connectedAddress + ) + + const elapsedTime: number = currentTime - timestamp // in seconds + const remainingTime: number = estimatedTime - elapsedTime + + const isEstimatedTimeReached: boolean = useMemo(() => { + if (!currentTime || !estimatedTime || !timestamp) { + return false + } + return currentTime - timestamp > estimatedTime + }, [estimatedTime, currentTime, timestamp]) + + const delayedTime = isEstimatedTimeReached ? remainingTime : null + const delayedTimeInMin = remainingTime ? Math.floor(remainingTime / 60) : null + + const [isTxComplete, _kappa] = useBridgeTxStatus({ + synapseSDK, + originChainId, + destinationChainId, + originTxHash, + bridgeModuleName, + kappa, + checkStatus: !isStoredComplete || isEstimatedTimeReached, + currentTime, + }) + + /** Check if store already marked tx as complete, otherwise check hook status */ + const isTxFinalized = isStoredComplete ?? isTxComplete + + const showTransactionSupport = + !isTxFinalized && delayedTimeInMin ? delayedTimeInMin <= -5 : false + + /** Update tx kappa when available */ + useEffect(() => { + if (_kappa && originTxHash) { + dispatch( + updateTransactionKappa({ originTxHash, kappa: _kappa as string }) + ) + } + }, [_kappa, dispatch]) + + /** Update tx for completion */ + /** Check that we have not already marked tx as complete */ + useEffect(() => { + const txKappa = kappa ?? _kappa + + if (!isStoredComplete && isTxComplete && originTxHash && txKappa) { + dispatch(completeTransaction({ originTxHash, kappa: txKappa as string })) + } + }, [isStoredComplete, isTxComplete, dispatch, transactions]) + + const handleClearTransaction = useCallback(() => { + dispatch(removeTransaction({ originTxHash })) + }, [dispatch]) + + return ( +
    +
    + +
    + + } + > + {!isNull(originTxExplorerLink) && ( + + )} + {!isNull(destExplorerAddressLink) && ( + + )} + + {isTxFinalized && ( + + )} + +
    +
    + {showTransactionSupport && } +
    + +
    +
    + ) +} + +const TransactionBridgeDetail = ({ + tokenAmount, + originTokenSymbol, + destinationChain, +}: { + tokenAmount: string + originTokenSymbol: string + destinationChain: Chain +}) => { + const showAmount = parseFloat(tokenAmount)?.toFixed(6) + + return ( +
    + {showAmount} {originTokenSymbol} to {destinationChain?.name} +
    + ) +} + +const TRANSACTION_SUPPORT_URL = + 'https://docs.synapseprotocol.com/synapse-bridge/synapse-bridge/transaction-support-faq' + +export const TransactionSupport = () => { + return ( +
    +
    What's taking so long?
    + +
    + ) +} diff --git a/packages/synapse-interface/components/Custom/components/TransactionSummary.tsx b/packages/synapse-interface/components/Custom/components/TransactionSummary.tsx new file mode 100644 index 0000000000..a565f44420 --- /dev/null +++ b/packages/synapse-interface/components/Custom/components/TransactionSummary.tsx @@ -0,0 +1,54 @@ +import _ from 'lodash' +import { useState, useEffect } from 'react' +import { useAccount } from 'wagmi' + +import { Transaction } from './Transaction' +import { getTimeMinutesFromNow } from '../utils/getTimeMinutesFromNow' +import { use_TransactionsState } from '@/slices/_transactions/hooks' +import { SCROLL } from '@/constants/chains/master' + +export const TransactionSummary = () => { + const { address, isConnected } = useAccount() + const { transactions } = use_TransactionsState() + + const hasTransactions: boolean = transactions.length > 0 + + const [currentTime, setCurrentTime] = useState( + getTimeMinutesFromNow(0) + ) + + /** Update time to trigger transactions to recheck tx status */ + useEffect(() => { + const interval = setInterval(() => { + setCurrentTime(getTimeMinutesFromNow(0)) + }, 5000) + + return () => { + clearInterval(interval) + } + }, []) + + if (hasTransactions && isConnected) { + const sortedTransactions = _.orderBy(transactions, ['timestamp'], ['desc']) + .filter((t) => t.destinationChain.id === SCROLL.id) + .slice(0, 4) + + return sortedTransactions.map((transaction) => ( + + )) + } +} diff --git a/packages/synapse-interface/components/Custom/constants/explorer.ts b/packages/synapse-interface/components/Custom/constants/explorer.ts new file mode 100644 index 0000000000..1b3e946f13 --- /dev/null +++ b/packages/synapse-interface/components/Custom/constants/explorer.ts @@ -0,0 +1,49 @@ +export const ExplorerLinks = { + 1: 'https://etherscan.com', + 42161: 'https://arbiscan.io', + 56: 'https://bscscan.com', + 43114: 'https://snowtrace.io/', + 7700: 'https://tuber.build/', + 10: 'https://optimistic.etherscan.io', + 137: 'https://polygonscan.com', + 53935: 'https://subnets.avax.network/defi-kingdoms', + 8217: 'https://scope.klaytn.com', + 250: 'https://ftmscan.com', + 25: 'https://cronoscan.com', + 288: 'https://bobascan.com', + 1088: 'https://andromeda-explorer.metis.io', + 1313161554: 'https://explorer.mainnet.aurora.dev', + 1666600000: 'https://explorer.harmony.one', + 1284: 'https://moonbeam.moonscan.io', + 1285: 'https://moonriver.moonscan.io', + 2000: 'https://explorer.dogechain.dog', + 8453: 'https://basescan.org', + 81457: 'https://blastscan.io', + 534352: 'https://scrollscan.com', + 59144: 'https://lineascan.build', +} + +export const ExplorerNames = { + 1: 'Etherscan', + 42161: 'Arbiscan', + 56: 'BscScan', + 43114: 'Snowtrace', + 7700: 'Canto Explorer', + 10: 'Optimism Explorer', + 137: 'PolygonScan', + 53935: 'DFK Subnet Explorer', + 8217: 'Klaytn Explorer', + 250: 'FTMScan', + 25: 'CronoScan', + 288: 'Boba Explorer', + 1088: 'Metis Explorer', + 1313161554: 'Aurora Explorer', + 1666600000: 'Harmony Explorer', + 1284: 'Moonbeam Explorer', + 1285: 'Moonriver Explorer', + 2000: 'Dogechain Explorer', + 8453: 'BaseScan', + 81457: 'Blastscan', + 534352: 'Scrollscan', + 59144: 'LineaScan', +} diff --git a/packages/synapse-interface/components/Custom/hooks/useBridgeTxStatus.tsx b/packages/synapse-interface/components/Custom/hooks/useBridgeTxStatus.tsx new file mode 100644 index 0000000000..783e2160db --- /dev/null +++ b/packages/synapse-interface/components/Custom/hooks/useBridgeTxStatus.tsx @@ -0,0 +1,96 @@ +import { useState, useEffect } from 'react' + +interface UseBridgeTxStatusProps { + synapseSDK: any + originChainId: number + destinationChainId: number + originTxHash: string + bridgeModuleName?: string + kappa?: string + checkStatus: boolean + currentTime: number // used as trigger to refetch status +} + +export const useBridgeTxStatus = ({ + synapseSDK, + originChainId, + destinationChainId, + originTxHash, + bridgeModuleName, + kappa, + checkStatus = false, + currentTime, +}: UseBridgeTxStatusProps): [boolean, string | null] => { + const [isComplete, setIsComplete] = useState(false) + const [fetchedKappa, setFetchedKappa] = useState(kappa ?? null) + + const getKappa = async (): Promise => { + if (!synapseSDK) return null + if (!bridgeModuleName || !originChainId || !originTxHash) return null + try { + const kappa = await synapseSDK.getSynapseTxId( + originChainId, + bridgeModuleName, + originTxHash + ) + + return kappa + } catch (error) { + console.error( + '[Synapse Widget] Error retrieving Synapse Transaction ID: ', + error + ) + return null + } + } + + const getBridgeTxStatus = async ( + destinationChainId: number, + bridgeModuleName: string, + kappa: string + ) => { + if (!synapseSDK) return null + if (!destinationChainId || !bridgeModuleName || !kappa) return null + try { + const status = await synapseSDK.getBridgeTxStatus( + destinationChainId, + bridgeModuleName, + kappa + ) + return status + } catch (error) { + console.error( + '[Synapse Widget] Error resolving Bridge Transaction status: ', + error + ) + return null + } + } + + useEffect(() => { + if (!checkStatus) return + if (isComplete) return + ;(async () => { + if (fetchedKappa === null) { + let _kappa = await getKappa() + setFetchedKappa(_kappa) + } + + if (fetchedKappa) { + const txStatus = await getBridgeTxStatus( + destinationChainId, + bridgeModuleName, + fetchedKappa + ) + + if (txStatus !== null && txStatus === true && fetchedKappa !== null) { + setIsComplete(true) + } else { + setIsComplete(false) + } + } + })() + }, [currentTime, checkStatus, fetchedKappa]) + + return [isComplete, fetchedKappa] +} diff --git a/packages/synapse-interface/components/Custom/hooks/usePopoverRef.ts b/packages/synapse-interface/components/Custom/hooks/usePopoverRef.ts new file mode 100644 index 0000000000..7b7dab96bc --- /dev/null +++ b/packages/synapse-interface/components/Custom/hooks/usePopoverRef.ts @@ -0,0 +1,31 @@ +import { useState, useRef, useEffect } from 'react' + +const usePopover = () => { + const [isOpen, setIsOpen] = useState(false) + const popoverRef = useRef(null) + + useEffect(() => { + const handleClickOutside = (event) => { + if (popoverRef.current && !popoverRef.current.contains(event.target)) { + setIsOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [isOpen]) + + const togglePopover = () => { + setIsOpen(!isOpen) + } + + const closePopover = () => { + setIsOpen(false) + } + + return { popoverRef, isOpen, togglePopover, closePopover } +} + +export default usePopover diff --git a/packages/synapse-interface/components/Custom/utils/getExplorerAddressUrl.ts b/packages/synapse-interface/components/Custom/utils/getExplorerAddressUrl.ts new file mode 100644 index 0000000000..f828c82990 --- /dev/null +++ b/packages/synapse-interface/components/Custom/utils/getExplorerAddressUrl.ts @@ -0,0 +1,17 @@ +import { ExplorerLinks, ExplorerNames } from '../constants/explorer' + +export const getExplorerAddressUrl = (chainId: number, address: string) => { + const blockExplorer = ExplorerLinks[chainId] + + if (blockExplorer && address) { + const explorerUrl = `${blockExplorer}/address/${address}` + const explorerName = ExplorerNames[chainId] + + return [explorerUrl, explorerName] + } + + console.error( + '[Synapse Widget] Error retrieving Native Explorer Address URL: ChainId or Address missing' + ) + return [null, null] +} diff --git a/packages/synapse-interface/components/Custom/utils/getTimeMinutesFromNow.ts b/packages/synapse-interface/components/Custom/utils/getTimeMinutesFromNow.ts new file mode 100644 index 0000000000..26b786caad --- /dev/null +++ b/packages/synapse-interface/components/Custom/utils/getTimeMinutesFromNow.ts @@ -0,0 +1,5 @@ +export const getTimeMinutesFromNow = (minutesFromNow: number): number => { + const currentTimeSeconds = new Date().getTime() / 1000 + + return Math.round(currentTimeSeconds + 60 * minutesFromNow) +} diff --git a/packages/synapse-interface/components/Custom/utils/getTxBlockExplorerLink.ts b/packages/synapse-interface/components/Custom/utils/getTxBlockExplorerLink.ts new file mode 100644 index 0000000000..6975905fb1 --- /dev/null +++ b/packages/synapse-interface/components/Custom/utils/getTxBlockExplorerLink.ts @@ -0,0 +1,17 @@ +import { ExplorerLinks, ExplorerNames } from '../constants/explorer' + +export const getTxBlockExplorerLink = (chainId: number, txHash: string) => { + const blockExplorer = ExplorerLinks[chainId] + + if (blockExplorer && txHash) { + const explorerUrl = `${blockExplorer}/tx/${txHash}` + const explorerName = ExplorerNames[chainId] + + return [explorerUrl, explorerName] + } + + console.error( + '[Synapse Widget] Error retrieving Native Explorer Transaction URL: ChainID or Transaction Hash missing' + ) + return [null, null] +} diff --git a/packages/synapse-interface/components/Custom/utils/isNull.ts b/packages/synapse-interface/components/Custom/utils/isNull.ts new file mode 100644 index 0000000000..e19e046da3 --- /dev/null +++ b/packages/synapse-interface/components/Custom/utils/isNull.ts @@ -0,0 +1,3 @@ +export const isNull = (input: any): boolean => { + return input === null +} diff --git a/packages/synapse-interface/pages/custom/index.tsx b/packages/synapse-interface/pages/custom/index.tsx new file mode 100644 index 0000000000..71171b73a6 --- /dev/null +++ b/packages/synapse-interface/pages/custom/index.tsx @@ -0,0 +1,54 @@ +import deepmerge from 'deepmerge' +import { LandingPageWrapper } from '@/components/layouts/LandingPageWrapper' +import { CustomBridge } from '../../components/Custom/CustomBridge' + +export async function getStaticProps({ locale }) { + const userMessages = (await import(`../../messages/${locale}.json`)).default + const defaultMessages = (await import(`../../messages/en-US.json`)).default + const messages = deepmerge(defaultMessages, userMessages) + + return { + props: { + messages, + }, + } +} + +const CustomPage = () => { + return ( + +
    +
    + + +
    +
    +
    + ) +} + +const HomeContent = () => { + return ( +
    +

    Header message

    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. +

    +

    + Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi + ut aliquip ex ea commodo consequat. +

    +

    + Duis aute irure dolor in reprehenderit in voluptate velit esse cillum + dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non + proident, sunt in culpa qui officia deserunt mollit anim id est laborum. +

    +
    + ) +} + +export default CustomPage