From 08edac12b4efa31898b8569628d1a50629c6b176 Mon Sep 17 00:00:00 2001 From: sebipap Date: Fri, 14 Jul 2023 15:17:07 -0300 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8get=20exa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BridgeContent/SocketPlugIn/index.tsx | 8 +- .../SocketTxHistory/AssetAmount.tsx | 9 +- components/DropdownMenu/index.tsx | 2 +- components/GetExa/AssetSelector.tsx | 97 +++ components/GetExa/ChainSelector.tsx | 79 +++ components/GetExa/ReviewRoute.tsx | 194 ++++++ components/GetExa/Route.tsx | 84 +++ components/GetExa/RouteSteps.tsx | 72 ++ components/GetExa/Routes.tsx | 39 ++ components/GetExa/SelectRoute.tsx | 232 +++++++ components/GetExa/TXStatus.tsx | 121 ++++ components/GetExa/index.tsx | 51 ++ components/Velodrome/StakingModal/index.tsx | 3 +- contexts/GetEXAContext.tsx | 628 ++++++++++++++++++ hooks/useBalance.ts | 4 +- hooks/useERC20.ts | 6 +- hooks/useEXA.ts | 10 + hooks/usePrices.ts | 3 +- hooks/useSocketAssets.ts | 14 +- hooks/useSocketChains.ts | 93 +++ hooks/useSwapper.ts | 6 + package-lock.json | 6 +- pages/governance.tsx | 41 +- types/Bridge.tsx | 8 +- utils/socket.ts | 16 +- 25 files changed, 1777 insertions(+), 49 deletions(-) create mode 100644 components/GetExa/AssetSelector.tsx create mode 100644 components/GetExa/ChainSelector.tsx create mode 100644 components/GetExa/ReviewRoute.tsx create mode 100644 components/GetExa/Route.tsx create mode 100644 components/GetExa/RouteSteps.tsx create mode 100644 components/GetExa/Routes.tsx create mode 100644 components/GetExa/SelectRoute.tsx create mode 100644 components/GetExa/TXStatus.tsx create mode 100644 components/GetExa/index.tsx create mode 100644 contexts/GetEXAContext.tsx create mode 100644 hooks/useSocketChains.ts create mode 100644 hooks/useSwapper.ts diff --git a/components/BridgeContent/SocketPlugIn/index.tsx b/components/BridgeContent/SocketPlugIn/index.tsx index c09906b70..328280104 100644 --- a/components/BridgeContent/SocketPlugIn/index.tsx +++ b/components/BridgeContent/SocketPlugIn/index.tsx @@ -12,14 +12,12 @@ import { optimism } from 'viem/chains'; import useAnalytics from 'hooks/useAnalytics'; import { hexToRgb } from './utils'; import useAssetAddresses from 'hooks/useAssetAddresses'; -import { Asset, TokensResponse } from 'types/Bridge'; +import { Asset, NATIVE_TOKEN_ADDRESS, TokensResponse } from 'types/Bridge'; const DynamicBridge = dynamic(() => import('@socket.tech/plugin').then((mod) => mod.Bridge), { ssr: false, }); -const NATIVE_TOKEN = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'; - type Props = { updateRoutes: () => void; }; @@ -53,7 +51,7 @@ const SocketPlugIn = ({ updateRoutes }: Props) => { }, [fetchAssets]); const tokenList = useMemo(() => { - const markets = [...assets, NATIVE_TOKEN]; + const markets = [...assets, NATIVE_TOKEN_ADDRESS]; if (!tokens) return []; return tokens @@ -144,7 +142,7 @@ const SocketPlugIn = ({ updateRoutes }: Props) => { API_KEY={process.env.NEXT_PUBLIC_SOCKET_API_KEY || ''} defaultSourceNetwork={chain?.id || optimism.id} defaultDestNetwork={optimism.id} - defaultDestToken={NATIVE_TOKEN} + defaultDestToken={NATIVE_TOKEN_ADDRESS} customize={{ primary: hexToRgb(palette.components.bg), secondary: hexToRgb(palette.components.bg), diff --git a/components/BridgeContent/SocketTxHistory/AssetAmount.tsx b/components/BridgeContent/SocketTxHistory/AssetAmount.tsx index 1fd36b6a4..fd294a3c3 100644 --- a/components/BridgeContent/SocketTxHistory/AssetAmount.tsx +++ b/components/BridgeContent/SocketTxHistory/AssetAmount.tsx @@ -6,7 +6,12 @@ import Image from 'next/image'; import formatNumber from 'utils/formatNumber'; import { formatUnits } from 'viem'; -type Props = { asset: Asset; amount: number; mobile?: boolean; chains?: Chain[] }; +type Props = { + asset: Pick; + amount: number; + mobile?: boolean; + chains?: Chain[]; +}; const AssetAmount = ({ asset, amount, mobile, chains }: Props) => { const chain = useMemo(() => { @@ -17,7 +22,7 @@ const AssetAmount = ({ asset, amount, mobile, chains }: Props) => { {asset.symbol}({ }, }} > - {Object.values(options).map((o) => ( + {options.map((o) => ( (typeof value === 'bigint' ? String(value) : value))} onClick={() => { diff --git a/components/GetExa/AssetSelector.tsx b/components/GetExa/AssetSelector.tsx new file mode 100644 index 000000000..835e7f0d0 --- /dev/null +++ b/components/GetExa/AssetSelector.tsx @@ -0,0 +1,97 @@ +import React, { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import DropdownMenu from 'components/DropdownMenu'; +import { Asset } from 'types/Bridge'; +import { optimism } from 'wagmi/chains'; +import { Box, Skeleton, Typography } from '@mui/material'; +import Image from 'next/image'; +import useBalance from 'hooks/useBalance'; +import formatNumber from 'utils/formatNumber'; +import { useGetEXA } from 'contexts/GetEXAContext'; +import useSocketAssets from 'hooks/useSocketAssets'; + +type AssetOptionProps = { + asset?: Asset; + option?: boolean; + optionSize?: number; + selectedSize?: number; + chainId: number; +}; + +function AssetOption({ asset, option = false, optionSize = 17, selectedSize = 14, chainId }: AssetOptionProps) { + const size = option ? optionSize : selectedSize; + const balance = useBalance(asset?.symbol, asset?.address as `0x${string}`, true, chainId); + + if (!asset) { + return ; + } + + return ( + + {asset.logoURI && ( + {asset.symbol} + )} + <> + {option ? ( + <> + + + + {asset.name} + + + {asset.symbol} + + + + + {balance && Number(balance) ? formatNumber(balance) : ''} + + + ) : ( + + {asset.symbol} + + )} + + + ); +} + +function AssetSelector() { + const { t } = useTranslation(); + const { setAsset, asset, chain } = useGetEXA(); + const assets = useSocketAssets(); + + if (!assets) return null; + + return ( + } + renderOption={(o: Asset) => } + data-testid="modal-asset-selector" + /> + ); +} + +export default memo(AssetSelector); diff --git a/components/GetExa/ChainSelector.tsx b/components/GetExa/ChainSelector.tsx new file mode 100644 index 000000000..cf532fa01 --- /dev/null +++ b/components/GetExa/ChainSelector.tsx @@ -0,0 +1,79 @@ +import React, { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box, Skeleton, Typography } from '@mui/material'; +import DropdownMenu from 'components/DropdownMenu'; +import { useGetEXA } from 'contexts/GetEXAContext'; +import Image from 'next/image'; +import { Chain } from 'types/Bridge'; +type AssetOptionProps = { + chain?: Chain; + option?: boolean; + optionSize?: number; + selectedSize?: number; +}; + +function ChainOption({ chain, option = false, optionSize = 17, selectedSize = 14 }: AssetOptionProps) { + const size = option ? optionSize : selectedSize; + + if (!chain) { + return ; + } + + return ( + + {chain.icon && ( + {chain.name} + )} + + {chain.name} + + + ); +} + +const ChainSelector = ({ disabled }: { disabled?: boolean }) => { + const { t } = useTranslation(); + + const { setChain: onChainChange, chains, chain } = useGetEXA(); + + const handleChainChange = useCallback( + (value: string) => { + const c = chains?.find(({ name }) => value === name); + if (c) onChainChange(c); + }, + [chains, onChainChange], + ); + + if (!chains) return ; + + return ( + name)} + onChange={handleChainChange} + renderValue={} + renderOption={(o: string) => o === name)} />} + data-testid="modal-asset-selector" + disabled={disabled} + /> + ); +}; + +export default memo(ChainSelector); diff --git a/components/GetExa/ReviewRoute.tsx b/components/GetExa/ReviewRoute.tsx new file mode 100644 index 000000000..8203a2957 --- /dev/null +++ b/components/GetExa/ReviewRoute.tsx @@ -0,0 +1,194 @@ +import React, { memo } from 'react'; + +import { Box, Typography, Table, TableBody, TableRow, TableCell, Avatar, useTheme, Skeleton } from '@mui/material'; +import { optimism } from 'wagmi/chains'; +import { TXStep, useGetEXA } from 'contexts/GetEXAContext'; +import { LoadingButton } from '@mui/lab'; +import { useTranslation } from 'react-i18next'; + +import formatNumber from 'utils/formatNumber'; +import Image from 'next/image'; +import { formatEther } from 'viem'; +import ModalAlert from 'components/common/modal/ModalAlert'; +import { ArrowForward } from '@mui/icons-material'; + +const ReviewRoute = () => { + const { t } = useTranslation(); + const { chain, asset, route, qtyOut, qtyOutUSD, qtyIn, txStep, protocol, txError, approve, socketSubmit } = + useGetEXA(); + const { palette } = useTheme(); + + if (!asset?.logoURI || !chain || !qtyOut || !protocol) { + return; + } + + return ( + + + {t('Transaction Summary')} + + + + + + + {t('From')} + + + + {} + + {formatNumber(qtyIn)} {asset.symbol} + + + + + + {chain.chainId === optimism.id ? optimism.name : chain.name} + + + + + + + + {t('To')} + + + + + + {formatNumber(formatEther(qtyOut))} EXA + + + + + + {optimism.name} + + + + + + +
+ + + + + {t('Estimated Output')} + + + + + + EXA {formatNumber(formatEther(qtyOut))} + + + + {qtyOutUSD !== undefined && ~${formatNumber(formatEther(qtyOutUSD))}} + + + + + + {t('Dex')} + + {protocol && ( + + + + {protocol.displayName} + + + )} + + + + {t('Gas Fee')} + + {route ? ( + route.totalGasFeesInUsd && ( + + ${formatNumber(route.totalGasFeesInUsd)} + + ) + ) : ( + + )} + + + + {t('Swap Slippage')} + + + {route ? ( + `${route.userTxs[0].swapSlippage || route.userTxs[0].steps?.[0]?.bridgeSlippage}%` + ) : ( + + )} + + + + +
+
+ + {txError?.status && } + {txStep === TXStep.CONFIRM || txStep === TXStep.CONFIRM_PENDING ? ( + + {t('Confirm')} + + ) : ( + + {t('Approve')} + + )} +
+ ); +}; + +export default memo(ReviewRoute); diff --git a/components/GetExa/Route.tsx b/components/GetExa/Route.tsx new file mode 100644 index 000000000..a5cb284ae --- /dev/null +++ b/components/GetExa/Route.tsx @@ -0,0 +1,84 @@ +import { Box, Typography } from '@mui/material'; +import { useGetEXA } from 'contexts/GetEXAContext'; +import Image from 'next/image'; +import React, { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Route } from 'types/Bridge'; +import formatNumber from 'utils/formatNumber'; + +type Props = { + route: Route; + tags: ('fastest' | 'best return')[]; +}; + +const Route = ({ route, tags }: Props) => { + const { userTxs, toAmount, totalGasFeesInUsd, routeId } = route; + const { route: selectedRoute, setRoute } = useGetEXA(); + + const protocol = + userTxs?.[userTxs.length - 1]?.protocol || userTxs?.[0].steps?.[(userTxs[0]?.stepCount || 0) - 1].protocol; + + const [{ serviceTime }] = userTxs; + + const { t } = useTranslation(); + + const isSelected = selectedRoute?.routeId === routeId; + + return ( + setRoute(route)} + sx={{ cursor: 'pointer' }} + > + {protocol?.displayName + + + + {protocol?.displayName}{' '} + {serviceTime && ~ {Math.round(serviceTime / 60)} min} + + + {tags.map((tag) => ( + + + {t(tag.toUpperCase())} + + + ))} + + + + Est. Output: {formatNumber(Number(toAmount) / 1e18)} EXA + Gas fees: ${formatNumber(totalGasFeesInUsd)} + + + + ); +}; + +export default memo(Route); diff --git a/components/GetExa/RouteSteps.tsx b/components/GetExa/RouteSteps.tsx new file mode 100644 index 000000000..dbed9e35a --- /dev/null +++ b/components/GetExa/RouteSteps.tsx @@ -0,0 +1,72 @@ +import React, { memo, useMemo } from 'react'; + +import { useGetEXA } from 'contexts/GetEXAContext'; +import { Avatar, Box, Tooltip, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { optimism } from 'wagmi/chains'; +import { ETH } from 'hooks/useSocketAssets'; + +const ChainAsset = ({ symbol, icon, chainId }: { symbol: string; icon?: string; chainId: number }) => { + const { chains } = useGetEXA(); + + const chain = useMemo(() => chains?.find((c) => c.chainId === chainId), [chainId, chains]); + const { t } = useTranslation(); + + return ( + + + + + + + ); +}; + +const Protocol = ({ icon, displayName }: { icon?: string; displayName?: string }) => ( + + + +); + +const RouteSteps = () => { + const { route, asset, chain } = useGetEXA(); + + if (!route) return null; + + const [{ steps }] = route.userTxs; + + const displaySteps = [ + asset ? : undefined, + ...(steps?.flatMap((step) => [ + , + , + ]) || + route.userTxs.flatMap((tx) => [ + , + , + ])), + , + , + ]; + + return ( + + {displaySteps.map((step, index) => ( + <> + {index !== 0 && ( + + {' -> '} + + )} + {step} + + ))} + + ); +}; + +export default memo(RouteSteps); diff --git a/components/GetExa/Routes.tsx b/components/GetExa/Routes.tsx new file mode 100644 index 000000000..e6b627e43 --- /dev/null +++ b/components/GetExa/Routes.tsx @@ -0,0 +1,39 @@ +import React, { Box, Skeleton } from '@mui/material'; +import { useGetEXA } from 'contexts/GetEXAContext'; +import { memo } from 'react'; +import Route from './Route'; + +const Routes = () => { + const { routes } = useGetEXA(); + + const fastestRouteId = routes?.sort( + (r1, r2) => (r1.userTxs[0].serviceTime || 0) - (r2.userTxs[0].serviceTime || 0), + )[0]?.routeId; + + const bestReturnRouteId = routes?.sort( + (r1, r2) => Number(r2.toAmount) - r2.totalGasFeesInUsd - (Number(r1.toAmount) - r1.totalGasFeesInUsd), + )[0]?.routeId; + + return ( + + {!routes ? ( + + ) : routes.length > 0 ? ( + routes.map((route) => ( + + )) + ) : ( + 'no routes' + )} + + ); +}; + +export default memo(Routes); diff --git a/components/GetExa/SelectRoute.tsx b/components/GetExa/SelectRoute.tsx new file mode 100644 index 000000000..321391bcd --- /dev/null +++ b/components/GetExa/SelectRoute.tsx @@ -0,0 +1,232 @@ +import React, { memo, useCallback } from 'react'; + +import { ArrowBack, ChevronRight, Edit } from '@mui/icons-material'; +import { Box, Typography, Input, Skeleton, Button, Alert } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import ChainSelector from './ChainSelector'; +import Image from 'next/image'; +import { Screen, TXStep, useGetEXA } from 'contexts/GetEXAContext'; +import { useNetwork, useSwitchNetwork } from 'wagmi'; +import { useWeb3 } from 'hooks/useWeb3'; +import RouteSteps from './RouteSteps'; +import Routes from './Routes'; +import SocketAssetSelector from 'components/SocketAssetSelector'; +import { optimism } from 'wagmi/chains'; +import { useEXABalance, useEXAPrice } from 'hooks/useEXA'; +import { formatEther, formatUnits } from 'viem'; +import formatNumber from 'utils/formatNumber'; +import { LoadingButton } from '@mui/lab'; + +const SelectRoute = () => { + const { + chain, + route, + qtyOut, + qtyIn, + routes, + asset, + txStep, + socketError, + qtyOutUSD, + assets, + txError, + setScreen, + setQtyIn, + setRoute, + setAsset, + submit, + } = useGetEXA(); + const { t } = useTranslation(); + const { chain: walletChain } = useNetwork(); + const { connect } = useWeb3(); + const isConnected = !!walletChain; + const { walletAddress } = useWeb3(); + const exaPrice = useEXAPrice(); + const nativeSwap = asset?.symbol === 'ETH' && chain?.chainId === optimism.id; + const { data: exaBalance } = useEXABalance({ watch: true }); + const insufficientBalance = Boolean(asset && qtyIn && Number(qtyIn) > asset.amount); + const { switchNetwork, isLoading: switchIsLoading } = useSwitchNetwork(); + + const handleSubmit = useCallback(() => { + if (nativeSwap) return submit(); + setScreen(Screen.REVIEW_ROUTE); + }, [nativeSwap, setScreen, submit]); + + return ( + <> + + + {t('Get EXA')} + + + + + + {t('Pay with')}: + + + + + {t('Network')}: + + + + {chain && ( + + + {t('Asset')}: + + {assets && asset ? ( + + ) : ( + + )} + + )} + + + setQtyIn(value)} + /> + {asset ? ( + setQtyIn(String(asset?.amount))} + sx={{ + '&:hover': { + textDecoration: 'underline', + cursor: 'pointer', + }, + }} + > + {t('Balance')}: {formatNumber(asset.amount, asset.symbol)} + + ) : ( + + )} + + + + + + + + + + {qtyOutUSD !== undefined ? ( + + {t('Receive')}: ${formatNumber(formatEther(qtyOutUSD))} + + ) : ( + + )} + + + EXA + EXA + + + + {!nativeSwap && route === undefined && qtyIn ? ( + + ) : ( + + )} + {exaBalance !== undefined ? ( + + Balance: {formatNumber(formatUnits(exaBalance, 18))} + + ) : ( + + )} + + + + {exaPrice ? <> 1 EXA = ${formatNumber(formatEther(exaPrice))} : null} + + + {!nativeSwap && !!qtyIn && ( + + + + {t('Route')} + + {routes && routes.length > 1 && ( + + )} + + {socketError?.status ? ( + {socketError.message} + ) : routes?.length === 0 ? ( + {t('No routes where found')} + ) : route === null ? ( + + ) : route === undefined ? ( + + ) : ( + + )} + + )} + {txError?.status && {txError.message}} + + {isConnected ? ( + walletChain?.id !== chain?.chainId ? ( + switchNetwork?.(chain?.chainId)} + variant="contained" + loading={switchIsLoading} + data-testid="modal-switch-network" + > + {t('Please switch to {{network}} network', { network: chain?.name })} + + ) : ( + + {insufficientBalance + ? t('Insufficient {{symbol}} balance', { symbol: asset?.symbol }) + : `${t('Get')} ${qtyOut ? `${formatNumber(formatUnits(qtyOut, 18))} ` : ''}EXA`} + + ) + ) : ( + + )} + + ); +}; + +export default memo(SelectRoute); diff --git a/components/GetExa/TXStatus.tsx b/components/GetExa/TXStatus.tsx new file mode 100644 index 000000000..2d98b564c --- /dev/null +++ b/components/GetExa/TXStatus.tsx @@ -0,0 +1,121 @@ +import React, { memo } from 'react'; + +import { useTranslation } from 'react-i18next'; +import CheckIcon from '@mui/icons-material/Check'; +import CloseIcon from '@mui/icons-material/Close'; +import { Box, Button, CircularProgress, Typography } from '@mui/material'; + +import { CircularProgressWithIcon } from 'components/OperationsModal/ModalGif'; +import { useGetEXA } from 'contexts/GetEXAContext'; +import { Hash } from 'viem'; + +const SpinnerThing = ({ + status, + hash, + url, +}: { + status: 'loading' | 'success' | 'error' | 'processing'; + hash?: Hash; + url: string; +}) => { + const { t } = useTranslation(); + const isLoading = status === 'processing' || status === 'loading'; + return ( + + {isLoading && } + {status === 'success' && ( + + } + /> + )} + {status === 'error' && ( + + } + /> + )} + + + {isLoading && t('Transaction processing...')} + {status === 'success' && t('Transaction Success')} + {status === 'error' && t('Transaction Error')} + + + + + + + ); +}; + +const BridgeTXStatus = () => { + const { bridgeStatus, isBridge, tx } = useGetEXA(); + const { sourceTxStatus, destinationTxStatus, sourceTransactionHash } = bridgeStatus || { + sourceTxStatus: 'PENDING', + destinationTxStatus: 'PENDING', + sourceTransactionHash: undefined, + }; + const socketScanURL = `https://socketscan.io/tx/${sourceTransactionHash}`; + const optimisticEtherscanURL = `https://optimistic.etherscan.io/tx/${sourceTransactionHash}`; + const bridgeTXProps = { + status: + sourceTxStatus === 'PENDING' || destinationTxStatus === 'PENDING' + ? 'loading' + : sourceTxStatus === 'COMPLETED' && destinationTxStatus === 'COMPLETED' + ? 'success' + : 'error', + hash: sourceTransactionHash, + url: socketScanURL, + } as const; + const swapTXProps = { status: tx?.status || 'loading', hash: tx?.hash || '0x0', url: optimisticEtherscanURL }; + return ( + + + + ); +}; + +export default memo(BridgeTXStatus); diff --git a/components/GetExa/index.tsx b/components/GetExa/index.tsx new file mode 100644 index 000000000..9c2e71f96 --- /dev/null +++ b/components/GetExa/index.tsx @@ -0,0 +1,51 @@ +import React, { memo } from 'react'; + +import { Box, IconButton } from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; + +import ReviewRoute from './ReviewRoute'; +import SelectRoute from './SelectRoute'; +import { useGetEXA, Screen } from 'contexts/GetEXAContext'; +import TXStatus from './TXStatus'; + +const GetEXA = () => { + const { screen, setScreen } = useGetEXA(); + return ( + + {screen !== Screen.SELECT_ROUTE && ( + setScreen(Screen.SELECT_ROUTE)} + sx={{ + padding: 0, + ml: 'auto', + color: 'grey.900', + mb: '-51px', + zIndex: 2, + }} + > + + + )} + { + { + [Screen.SELECT_ROUTE]: , + [Screen.REVIEW_ROUTE]: , + [Screen.TX_STATUS]: , + }[screen] + } + + ); +}; + +export default memo(GetEXA); diff --git a/components/Velodrome/StakingModal/index.tsx b/components/Velodrome/StakingModal/index.tsx index fd39d3585..23ce66835 100644 --- a/components/Velodrome/StakingModal/index.tsx +++ b/components/Velodrome/StakingModal/index.tsx @@ -40,7 +40,7 @@ import { waitForTransaction } from '@wagmi/core'; import { ModalBox, ModalBoxCell, ModalBoxRow } from 'components/common/modal/ModalBox'; import SocketAssetSelector from 'components/SocketAssetSelector'; import useSocketAssets from 'hooks/useSocketAssets'; -import { AssetBalance } from 'types/Bridge'; +import { AssetBalance, NATIVE_TOKEN_ADDRESS } from 'types/Bridge'; import ModalInput from 'components/OperationsModal/ModalInput'; import Link from 'next/link'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; @@ -64,7 +64,6 @@ import useDelayedEffect from 'hooks/useDelayedEffect'; import { gasLimit } from 'utils/gas'; import { useModal } from 'contexts/ModalContext'; -const NATIVE_TOKEN_ADDRESS = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'; const MIN_SUPPLY = parseEther('0.002'); const PROTO_STAKER_DOCS = 'https://docs.exact.ly/guides/periphery/proto-staker'; diff --git a/contexts/GetEXAContext.tsx b/contexts/GetEXAContext.tsx new file mode 100644 index 000000000..b8053c8da --- /dev/null +++ b/contexts/GetEXAContext.tsx @@ -0,0 +1,628 @@ +import React, { + useCallback, + useEffect, + createContext, + useContext, + useState, + type FC, + type PropsWithChildren, + useMemo, +} from 'react'; + +import { + type Hash, + Hex, + encodeFunctionData, + parseEther, + parseUnits, + zeroAddress, + hexToBigInt, + keccak256, + encodeAbiParameters, +} from 'viem'; +import * as wagmiChains from 'wagmi/chains'; + +import { useNetwork, useSignTypedData, useWalletClient } from 'wagmi'; +import { optimism } from 'wagmi/chains'; + +import { + type Route, + type Chain, + type DestinationCallData, + type AssetBalance, + NATIVE_TOKEN_ADDRESS, + Protocol, + ActiveRoute, + BridgeStatus, +} from 'types/Bridge'; +import useSocketAssets from 'hooks/useSocketAssets'; +import handleOperationError from 'utils/handleOperationError'; +import type { ErrorData } from 'types/Error'; +import type { Transaction } from 'types/Transaction'; +import { swapperABI } from 'types/abi'; +import { useEXAETHPrice, useEXAPrice } from 'hooks/useEXA'; +import { useWeb3 } from 'hooks/useWeb3'; +import { + socketActiveRoutes, + socketBridgeStatus, + socketBuildTX, + socketChains, + socketQuote, + socketRequest, +} from 'utils/socket'; +import { useSwapper } from 'hooks/useSwapper'; +import { useTranslation } from 'react-i18next'; +import useERC20 from 'hooks/useERC20'; +import { gasLimit } from 'utils/gas'; +import useIsContract from 'hooks/useIsContract'; +import useIsPermit from 'hooks/useIsPermit'; +import usePermit2 from 'hooks/usePermit2'; +import { MAX_UINT256, WEI_PER_ETHER } from 'utils/const'; +import { waitForTransaction } from '@wagmi/core'; +import dayjs from 'dayjs'; +import { splitSignature } from '@ethersproject/bytes'; +import useDelayedEffect from 'hooks/useDelayedEffect'; + +const DESTINATION_CHAIN = optimism.id; + +export enum Screen { + SELECT_ROUTE = 'SELECT_ROUTE', + REVIEW_ROUTE = 'REVIEW', + TX_STATUS = 'TX_STATUS', +} + +export enum TXStep { + APPROVE = 'APPROVE', + APPROVE_PENDING = 'APPROVE_PENDING', + CONFIRM = 'CONFIRM', + CONFIRM_PENDING = 'CONFIRM_PENDING', +} + +type SetProp = (value: C[Key]) => void; + +type ContextValues = { + qtyIn: string; + txError?: ErrorData; + socketError?: ErrorData; + screen: Screen; + chain?: Chain; + asset?: AssetBalance; + chains?: Chain[]; + assets?: AssetBalance[]; + routes?: Route[]; + destinationCallData?: DestinationCallData; + route?: Route | null; + tx?: Transaction; + qtyOut?: bigint; + txStep?: TXStep; + protocol?: Protocol; + qtyOutUSD?: bigint; + activeRoutes?: ActiveRoute[]; + bridgeStatus?: BridgeStatus; + isBridge: boolean; + setChain: SetProp; + setAsset: SetProp; + setRoute: SetProp; + setScreen: SetProp; + setQtyIn: SetProp; + setTXStep: SetProp; + socketSubmit: () => void; + submit: () => void; + approve: () => void; +}; + +const GetEXAContext = createContext(null); + +export const GetEXAProvider: FC = ({ children }) => { + const [screen, setScreen] = useState(Screen.SELECT_ROUTE); + const [qtyIn, setQtyIn] = useState(''); + const [txError, setTXError] = useState(); + const [socketError, setSocketError] = useState(); + const [chain, setChain] = useState(); + const [asset, setAsset] = useState(); + const [chains, setChains] = useState(); + const [routes, setRoutes] = useState(); + const [destinationCallData, setDestinationCallData] = useState(); + const [route, setRoute] = useState(); + const [tx, setTX] = useState(); + const [txStep, setTXStep] = useState(TXStep.APPROVE); + const [activeRoutes, setActiveRoutes] = useState(); + const [bridgeStatus, setBridgeStatus] = useState(); + + const { chain: activeChain } = useNetwork(); + const { walletAddress, opts, chain: appChain } = useWeb3(); + + const { data: walletClient } = useWalletClient({ chainId: chain?.chainId }); + const { t } = useTranslation(); + const swapper = useSwapper(); + const exaethPrice = useEXAETHPrice(); + const assets = useSocketAssets(); + const chainAssets = useMemo( + () => assets.filter(({ chainId }) => chainId === chain?.chainId), + [assets, chain?.chainId], + ); + + const exaPrice = useEXAPrice(); + const isBridge = chain?.chainId !== appChain.id; + + const fetchChains = useCallback(async () => { + const allChains = await socketChains(); + const assetsWithBalance = await socketRequest[]>('balances', { + userAddress: walletAddress || zeroAddress, + }); + if (!assetsWithBalance || assetsWithBalance.length === 0) { + setChains(allChains); + return; + } + const usedChainIds = assetsWithBalance.map(({ chainId }) => chainId); + setChains(allChains?.filter(({ chainId }) => usedChainIds.find((id) => id === chainId))); + }, [setChains, walletAddress]); + + const fetchRoutes = useCallback(async () => { + if (!asset || !chain || !walletAddress || !swapper) return; + + setRoutes(undefined); + setSocketError(undefined); + + if (parseEther(qtyIn) === 0n) { + return; + } + + const destinationPayload = encodeFunctionData({ + abi: swapperABI, + functionName: 'swap', + args: [walletAddress, 0n, 0n], + }); + try { + const quote = await socketQuote({ + fromChainId: chain.chainId, + toChainId: DESTINATION_CHAIN, + fromTokenAddress: asset.address, + fromAmount: parseUnits(qtyIn, asset.decimals), + userAddress: walletAddress || zeroAddress, + toTokenAddress: NATIVE_TOKEN_ADDRESS, + destinationPayload, + recipient: swapper.address, + destinationGasLimit: 2000000n, + }); + + setRoutes(quote?.routes || []); + setDestinationCallData(quote?.destinationCallData); + setRoute(quote?.routes[0]); + } catch (e) { + setRoutes([]); + setRoute(undefined); + setSocketError({ message: t('Error fetching routes from socket'), status: true }); + } + }, [asset, chain, qtyIn, swapper, t, walletAddress]); + + const erc20 = useERC20(asset?.address === NATIVE_TOKEN_ADDRESS ? undefined : asset?.address, chain?.chainId); + + const isMultiSig = useIsContract(); + const isPermit = useIsPermit(); + const permit2 = usePermit2(); + + const approveSameChain = useCallback(async () => { + if (!walletAddress || !erc20 || !swapper || !asset || !opts || !permit2) return; + try { + const minimumApprovalAmount = parseUnits(qtyIn, asset.decimals); + const approvePermit2 = !(await isPermit(asset.address)); + + if (await isMultiSig(walletAddress)) { + const allowance = await erc20.read.allowance([walletAddress, swapper?.address], opts); + + if (allowance < minimumApprovalAmount) { + const args = [swapper.address, minimumApprovalAmount] as const; + const gas = await erc20.estimateGas.approve(args, opts); + const hash = await erc20.write.approve(args, { + ...opts, + gasLimit: gasLimit(gas), + }); + await waitForTransaction({ hash }); + } + } else if (approvePermit2) { + const allowance = await erc20.read.allowance([walletAddress, permit2.address], opts); + + if (allowance < minimumApprovalAmount) { + const args = [permit2.address, MAX_UINT256] as const; + const gas = await erc20.estimateGas.approve(args, opts); + const hash = await erc20.write.approve(args, { + ...opts, + gasLimit: gasLimit(gas), + }); + setTX({ status: 'processing', hash }); + const { status, transactionHash } = await waitForTransaction({ hash }); + setTX({ status: status ? 'success' : 'error', hash: transactionHash }); + } + setTXStep(TXStep.CONFIRM); + } + } catch (err) { + setTXError({ message: t('Error approving token'), status: true }); + } + }, [asset, erc20, isMultiSig, isPermit, opts, permit2, qtyIn, swapper, t, walletAddress]); + + const { signTypedDataAsync } = useSignTypedData(); + + const sign = useCallback(async () => { + if (!walletAddress || !asset || !erc20 || !permit2 || !swapper) return; + + const deadline = BigInt(dayjs().unix() + 3_600); + const value = parseUnits(qtyIn || '0', asset.decimals); + const chainId = appChain.id; + + if (await isPermit(asset.address)) { + const nonce = await erc20.read.nonces([walletAddress], opts); + const name = await erc20.read.name(opts); + + const { v, r, s } = await signTypedDataAsync({ + primaryType: 'Permit', + domain: { + name, + version: '1', + chainId, + verifyingContract: erc20.address, + }, + types: { + Permit: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + }, + message: { + owner: walletAddress, + spender: swapper.address, + value, + nonce, + deadline, + }, + }).then(splitSignature); + + const permit = { + owner: walletAddress, + value, + deadline, + ...{ v, r: r as Hex, s: s as Hex }, + } as const; + + return { type: 'permit', value: permit } as const; + } + + const signature = await signTypedDataAsync({ + primaryType: 'PermitTransferFrom', + domain: { + name: 'Permit2', + chainId, + verifyingContract: permit2.address, + }, + types: { + PermitTransferFrom: [ + { name: 'permitted', type: 'TokenPermissions' }, + { name: 'spender', type: 'address' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + TokenPermissions: [ + { name: 'token', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + }, + message: { + permitted: { + token: asset.address, + amount: value, + }, + spender: swapper.address, + deadline, + nonce: hexToBigInt( + keccak256( + encodeAbiParameters( + [ + { name: 'sender', type: 'address' }, + { name: 'token', type: 'address' }, + { name: 'assets', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + [walletAddress, asset.address, value, deadline], + ), + ), + ), + }, + }); + + const permit = { + owner: walletAddress, + amount: value, + deadline, + signature, + } as const; + + return { type: 'permit2', value: permit } as const; + }, [appChain.id, asset, erc20, isPermit, opts, permit2, qtyIn, signTypedDataAsync, swapper, walletAddress]); + + const approveCrossChain = useCallback(async () => { + if (asset?.symbol === 'ETH') setTXStep(TXStep.CONFIRM); + + if (screen !== Screen.REVIEW_ROUTE || !walletAddress || !walletClient || !route || !erc20 || !opts) return; + + const { + userTxs: [{ approvalData }], + } = route; + + const supportedChains = Object.values(wagmiChains); + + const crossChainOpts = { + ...opts, + chain: supportedChains.find((c) => c.id === chain?.chainId), + }; + + if (!approvalData) { + setTXStep(TXStep.CONFIRM); + return; + } + + setTXStep(TXStep.APPROVE_PENDING); + try { + const allowance = await erc20.read.allowance([walletAddress, approvalData.allowanceTarget], crossChainOpts); + + const minimumApprovalAmount = BigInt(approvalData.minimumApprovalAmount); + + if (allowance < minimumApprovalAmount) { + const args = [approvalData.allowanceTarget, minimumApprovalAmount] as const; + const gas = await erc20.estimateGas.approve(args, opts); + const hash = await erc20.write.approve(args, { + ...crossChainOpts, + gasLimit: gasLimit(gas), + }); + await waitForTransaction({ hash }); + } + setTXStep(TXStep.CONFIRM); + } catch (err) { + if (err instanceof Error) { + setTXError({ status: true, message: handleOperationError(err) }); + } + setTXStep(TXStep.APPROVE); + } + }, [asset?.symbol, chain?.chainId, erc20, opts, route, screen, walletAddress, walletClient]); + + const confirmBridge = useCallback(async () => { + if (txStep !== TXStep.CONFIRM || !walletClient || !route) return; + + try { + const { txTarget, txData, value } = await socketBuildTX({ route, destinationCallData }); + setTXStep(TXStep.CONFIRM_PENDING); + const txHash_ = await walletClient.sendTransaction({ + to: txTarget, + data: txData, + value: BigInt(value), + }); + + setTX({ status: 'processing', hash: txHash_ }); + const { status, transactionHash } = await waitForTransaction({ hash: txHash_ }); + setTX({ status: status ? 'success' : 'error', hash: transactionHash }); + } catch (err) { + setTXError({ status: true, message: handleOperationError(err) }); + setTXStep(TXStep.CONFIRM); + } + }, [destinationCallData, route, txStep, walletClient]); + + const socketSubmit = useCallback(async () => { + const minEXA = 0n; + const keepETH = 0n; + if (isBridge) return confirmBridge(); + + if (!walletAddress || !route || !swapper || !erc20?.address || !opts || !asset) return; + const { swap } = swapper.write; + + setTXStep(TXStep.CONFIRM_PENDING); + + let hash: Hash; + + try { + const { txData } = await socketBuildTX({ route }); + if (await isMultiSig(walletAddress)) { + const amount = parseUnits(qtyIn, asset.decimals); + const args = [erc20.address, amount, txData, minEXA, keepETH] as const; + const gas = await swapper.estimateGas.swap(args, opts); + + hash = await swap(args, { + ...opts, + gasLimit: gasLimit(gas), + }); + } else { + const permit = await sign(); + if (!permit) return; + + switch (permit.type) { + case 'permit': { + const args = [erc20.address, permit.value, txData, minEXA, keepETH] as const; + const gas = await swapper.estimateGas.swap(args, opts); + hash = await swap(args, { + ...opts, + gasLimit: gasLimit(gas), + }); + break; + } + case 'permit2': { + const args = [erc20.address, permit.value, txData, minEXA, keepETH] as const; + const gas = await swapper.estimateGas.swap(args, opts); + hash = await swap(args, { + ...opts, + gasLimit: gasLimit(gas), + }); + break; + } + } + + if (!hash) return; + setScreen(Screen.TX_STATUS); + setTX({ status: 'processing', hash }); + const { status, transactionHash } = await waitForTransaction({ hash }); + setTX({ status: status ? 'success' : 'error', hash: transactionHash }); + } + } catch (err) { + setTXError({ status: true, message: handleOperationError(err) }); + } finally { + setTXStep(undefined); + } + }, [asset, isBridge, confirmBridge, erc20, isMultiSig, opts, qtyIn, route, sign, swapper, walletAddress]); + + const submit = useCallback(async () => { + if (!walletClient || !walletAddress || !swapper) return; + setTXStep(TXStep.CONFIRM_PENDING); + + const data = encodeFunctionData({ + abi: swapperABI, + functionName: 'swap', + args: [walletAddress, 0n, 0n], + }); + try { + const txHash_ = await walletClient.sendTransaction({ + to: swapper.address, + data, + value: parseEther(qtyIn), + }); + setTX({ status: 'processing', hash: txHash_ }); + const { status, transactionHash } = await waitForTransaction({ hash: txHash_ }); + setTX({ status: status ? 'success' : 'error', hash: transactionHash }); + setScreen(Screen.TX_STATUS); + } catch (err) { + setTXError({ status: true, message: handleOperationError(err) }); + } finally { + setTXStep(undefined); + } + }, [qtyIn, setTXError, setScreen, setTXStep, swapper, walletAddress, walletClient]); + + useEffect(() => { + if (!bridge) return; + if (bridgeStatus) { + const { destinationTxStatus, sourceTxStatus } = bridgeStatus; + const bridgeInProgess = sourceTxStatus === 'PENDING' || destinationTxStatus === 'PENDING'; + if (!bridgeInProgess) return; + } + const fetchBridgeStatus = async () => { + if (!tx?.hash || !chain) return; + try { + const response = await socketBridgeStatus({ + transactionHash: tx.hash, + fromChainId: chain.chainId, + toChainId: optimism.id, + }); + + setBridgeStatus(response); + } catch (err) { + setSocketError({ status: true, message: handleOperationError(err) }); + } + }; + const interval = setInterval(() => { + fetchBridgeStatus(); + }, 2000); + return () => clearInterval(interval); + }, [isBridge, bridgeStatus, chain, tx?.hash]); + + const { isLoading: routesLoading } = useDelayedEffect({ + effect: fetchRoutes, + delay: 1000, + }); + + useEffect(() => { + fetchChains(); + }, [fetchChains]); + + useEffect(() => { + if (!chains) return; + const id = activeChain?.id || optimism.id; + const activeNetwork = chains.find(({ chainId }) => chainId === id); + if (activeNetwork) setChain(activeNetwork); + }, [activeChain?.id, chains, setChain]); + + useEffect(() => { + setAsset(chainAssets[0]); + }, [chainAssets]); + + const fetchActiveRoutes = useCallback(async () => { + if (!walletAddress) return; + const response = await socketActiveRoutes({ userAddress: walletAddress }); + setActiveRoutes(response.activeRoutes); + }, [walletAddress]); + + useEffect(() => { + fetchActiveRoutes(); + }, [fetchActiveRoutes]); + + const nativeSwap = asset?.symbol === 'ETH' && chain?.chainId === optimism.id; + const qtyOut = + qtyIn === '' + ? 0n + : exaethPrice !== undefined + ? nativeSwap + ? (parseEther(qtyIn) * WEI_PER_ETHER) / exaethPrice + : route + ? (BigInt(route.toAmount) * WEI_PER_ETHER) / exaethPrice + : 0n + : undefined; + + const value: ContextValues = { + setScreen: (s: ContextValues['screen']) => { + setScreen(s); + if (s === Screen.REVIEW_ROUTE && asset?.symbol === 'ETH') { + setTXStep(TXStep.CONFIRM); + } + if (s === Screen.SELECT_ROUTE) { + setTXStep(undefined); + setTXError(undefined); + } + }, + setAsset: (a: ContextValues['asset']) => { + setAsset(a); + setQtyIn(''); + }, + + setChain: (c: ContextValues['chain']) => { + setChain(c); + const chainAssets_ = assets.filter(({ chainId }) => chainId === c?.chainId); + setAsset(chainAssets_[0]); + setQtyIn(''); + }, + setQtyIn, + setTXStep, + approve: isBridge ? approveCrossChain : approveSameChain, + setRoute, + socketSubmit, + submit, + screen, + qtyIn, + txError, + socketError, + chain, + asset, + chains, + routes, + assets: chainAssets, + route: routesLoading ? undefined : route, + tx, + txStep, + qtyOut, + protocol: + route?.userTxs?.[route?.userTxs.length - 1]?.protocol || + route?.userTxs?.[0].steps?.[(route.userTxs[0]?.stepCount || 0) - 1].protocol, + qtyOutUSD: qtyOut !== undefined && exaPrice ? (qtyOut * exaPrice) / WEI_PER_ETHER : undefined, + activeRoutes, + bridgeStatus, + isBridge, + }; + + return {children}; +}; + +export const useGetEXA = () => { + const ctx = useContext(GetEXAContext); + if (!ctx) { + throw new Error('Using GetExaContext outside of provider'); + } + return ctx; +}; + +export default GetEXAContext; diff --git a/hooks/useBalance.ts b/hooks/useBalance.ts index dc0e80bd1..1843efc3c 100644 --- a/hooks/useBalance.ts +++ b/hooks/useBalance.ts @@ -3,13 +3,13 @@ import { formatUnits } from 'viem'; import { Address, useBalance } from 'wagmi'; import { useWeb3 } from './useWeb3'; -export default (symbol?: string, asset?: Address, useERC20 = false): string | undefined => { +export default (symbol?: string, asset?: Address, useERC20 = false, chainId?: number): string | undefined => { const { walletAddress, chain } = useWeb3(); const { data, error } = useBalance({ address: walletAddress, token: symbol === 'WETH' && !useERC20 ? undefined : asset, - chainId: chain.id, + chainId: chainId ?? chain.id, }); return useMemo(() => { diff --git a/hooks/useERC20.ts b/hooks/useERC20.ts index 9eb5f134f..56d8bc2f0 100644 --- a/hooks/useERC20.ts +++ b/hooks/useERC20.ts @@ -5,7 +5,7 @@ import { erc20ABI } from 'types/abi'; import { ERC20 } from 'types/contracts'; import { useWeb3 } from './useWeb3'; -export default (address?: Address): ERC20 | undefined => { +export default (address?: Address, chainId?: number): ERC20 | undefined => { const { chain } = useWeb3(); const { data: walletClient } = useWalletClient(); @@ -13,12 +13,12 @@ export default (address?: Address): ERC20 | undefined => { if (!walletClient || !address) return; const contract = getContract({ - chainId: chain.id, + chainId: chainId || chain.id, address, abi: erc20ABI, walletClient, }); return contract; - }, [address, chain, walletClient]); + }, [address, chain.id, chainId, walletClient]); }; diff --git a/hooks/useEXA.ts b/hooks/useEXA.ts index f78f65338..3f2ee27cc 100644 --- a/hooks/useEXA.ts +++ b/hooks/useEXA.ts @@ -12,6 +12,9 @@ import { } from 'types/abi'; import useContract from './useContract'; import useAccountData from './useAccountData'; +import usePrices from './usePrices'; +import { NATIVE_TOKEN_ADDRESS } from 'types/Bridge'; +import { WAD } from 'utils/queryRates'; export const useEXA = () => { return useContract('EXA', exaABI); @@ -93,3 +96,10 @@ export const useEXAPrice = () => { })[0]; }, [accountData]); }; + +export const useEXAETHPrice = () => { + const prices = usePrices(); + const ETHPrice = prices[NATIVE_TOKEN_ADDRESS]; + const EXAPrice = useEXAPrice(); + return useMemo(() => (EXAPrice ? (EXAPrice * WAD) / ETHPrice : undefined), [ETHPrice, EXAPrice]); +}; diff --git a/hooks/usePrices.ts b/hooks/usePrices.ts index da38a64ea..faef46a7b 100644 --- a/hooks/usePrices.ts +++ b/hooks/usePrices.ts @@ -1,8 +1,7 @@ import { Hex } from 'viem'; import useAccountData from './useAccountData'; import { useMemo } from 'react'; - -const NATIVE_TOKEN_ADDRESS = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'; +import { NATIVE_TOKEN_ADDRESS } from 'types/Bridge'; const usePrices = (): Record => { const { accountData } = useAccountData(); diff --git a/hooks/useSocketAssets.ts b/hooks/useSocketAssets.ts index 14e4dabcf..e57491213 100644 --- a/hooks/useSocketAssets.ts +++ b/hooks/useSocketAssets.ts @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect } from 'react'; -import { AssetBalance } from 'types/Bridge'; +import { AssetBalance, NATIVE_TOKEN_ADDRESS } from 'types/Bridge'; import { useWeb3 } from './useWeb3'; import { socketRequest } from 'utils/socket'; import usePrices from './usePrices'; @@ -8,9 +8,9 @@ import { Hex } from 'viem'; import VELO_ from '@exactly/protocol/deployments/optimism/VELO.json' assert { type: 'json' }; import useVELO from './useVELO'; -const ETH = { +export const ETH = { chainId: 10, - address: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + address: NATIVE_TOKEN_ADDRESS, name: 'Ether', symbol: 'ETH', decimals: 18, @@ -34,9 +34,9 @@ const VELO = { usdAmount: null, } satisfies Omit & { amount: null; usdAmount: null }; -export default (disableFetch?: boolean) => { +export default (disableFetch?: boolean, chainId?: number) => { const [assets, setAssets] = useState([ETH]); - const { walletAddress, chain } = useWeb3(); + const { walletAddress } = useWeb3(); const prices = usePrices(); const veloBalance = useBalance(VELO.symbol, VELO.address, true); const { veloPrice } = useVELO(); @@ -52,7 +52,7 @@ export default (disableFetch?: boolean) => { setAssets( [...result, VELO] - .filter(({ chainId }) => chainId === chain.id) + .filter((asset) => chainId === undefined || asset.chainId === chainId) .map((asset) => { const price = prices[asset.address.toLowerCase() as Hex]; const amount = asset.amount ?? Number(veloBalance); @@ -83,7 +83,7 @@ export default (disableFetch?: boolean) => { : b.usdAmount - a.usdAmount, ), ); - }, [chain.id, disableFetch, prices, veloBalance, veloPrice, walletAddress]); + }, [chainId, disableFetch, prices, veloBalance, veloPrice, walletAddress]); useEffect(() => { fetchAssets(); diff --git a/hooks/useSocketChains.ts b/hooks/useSocketChains.ts new file mode 100644 index 000000000..6022dd2c7 --- /dev/null +++ b/hooks/useSocketChains.ts @@ -0,0 +1,93 @@ +import { useState, useCallback, useEffect } from 'react'; +import { AssetBalance, NATIVE_TOKEN_ADDRESS } from 'types/Bridge'; +import { useWeb3 } from './useWeb3'; +import { socketRequest } from 'utils/socket'; +import usePrices from './usePrices'; +import useBalance from './useBalance'; +import { Hex } from 'viem'; +import VELO_ from '@exactly/protocol/deployments/optimism/VELO.json' assert { type: 'json' }; +import useVELO from './useVELO'; + +export const ETH = { + chainId: 10, + address: NATIVE_TOKEN_ADDRESS, + name: 'Ether', + symbol: 'ETH', + decimals: 18, + chainAgnosticId: null, + icon: '/img/assets/WETH.svg', + logoURI: '/img/assets/WETH.svg', + amount: 0, + usdAmount: 0, +} satisfies AssetBalance; + +const VELO = { + chainId: 10, + address: VELO_.address as Hex, + name: 'Velodrome', + symbol: 'VELO', + decimals: 18, + chainAgnosticId: null, + icon: 'https://velodrome.finance/velodrome.svg', + logoURI: 'https://velodrome.finance/velodrome.svg', + amount: null, + usdAmount: null, +} satisfies Omit & { amount: null; usdAmount: null }; + +export default (disableFetch?: boolean) => { + const [assets, setAssets] = useState([ETH]); + const { walletAddress, chain } = useWeb3(); + const prices = usePrices(); + const veloBalance = useBalance(VELO.symbol, VELO.address, true); + const { veloPrice } = useVELO(); + + const fetchAssets = useCallback(async () => { + if (!walletAddress || !process.env.NEXT_PUBLIC_SOCKET_API_KEY || disableFetch) return; + + const result = await socketRequest[]>('balances', { userAddress: walletAddress }); + + if (result.length === 0) { + return setAssets([ETH]); + } + + setAssets( + [...result, VELO] + .filter(({ chainId }) => chainId === chain.id) + .map((asset) => { + const price = prices[asset.address.toLowerCase() as Hex]; + const amount = asset.amount ?? Number(veloBalance); + return { + ...asset, + amount, + usdAmount: price ? amount * (Number(price) / 1e18) : undefined, + ...(asset.symbol === 'ETH' + ? { + name: 'Ether', + icon: '/img/assets/WETH.svg', + logoURI: '/img/assets/WETH.svg', + } + : asset.symbol === 'VELO' + ? { + usdAmount: veloPrice ? amount * veloPrice : undefined, + } + : {}), + }; + }) + .sort((a, b) => + a.usdAmount === undefined && b.usdAmount === undefined + ? a.symbol.localeCompare(b.symbol) + : a.usdAmount === undefined + ? 1 + : b.usdAmount === undefined + ? -1 + : b.usdAmount - a.usdAmount, + ), + ); + }, [chain.id, disableFetch, prices, veloBalance, veloPrice, walletAddress]); + + useEffect(() => { + fetchAssets(); + }, [fetchAssets]); + + return assets; +}; diff --git a/hooks/useSwapper.ts b/hooks/useSwapper.ts new file mode 100644 index 000000000..0052e53cd --- /dev/null +++ b/hooks/useSwapper.ts @@ -0,0 +1,6 @@ +import { swapperABI } from 'types/abi'; +import useContract from './useContract'; + +export const useSwapper = () => { + return useContract('Swapper', swapperABI); +}; diff --git a/package-lock.json b/package-lock.json index 44234275e..75e7c9a40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3522,11 +3522,13 @@ }, "node_modules/@openzeppelin/contracts": { "version": "4.9.2", - "license": "MIT" + "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-4.9.2.tgz", + "integrity": "sha512-mO+y6JaqXjWeMh9glYVzVu8HYPGknAAnWyxTRhGeckOruyXQMNnlcW6w/Dx9ftLeIQk6N+ZJFuVmTwF7lEIFrg==" }, "node_modules/@openzeppelin/contracts-upgradeable": { "version": "4.9.2", - "license": "MIT" + "resolved": "https://registry.npmjs.org/@openzeppelin/contracts-upgradeable/-/contracts-upgradeable-4.9.2.tgz", + "integrity": "sha512-siviV3PZV/fHfPaoIC51rf1Jb6iElkYWnNYZ0leO23/ukXuvOyoC/ahy8jqiV7g+++9Nuo3n/rk5ajSN/+d/Sg==" }, "node_modules/@pkgr/utils": { "version": "2.4.2", diff --git a/pages/governance.tsx b/pages/governance.tsx index 40596dc53..e00b7559d 100644 --- a/pages/governance.tsx +++ b/pages/governance.tsx @@ -11,6 +11,7 @@ import Delegation from 'components/governance/Delegation'; import VotingPower from 'components/governance/VotingPower'; import Proposals from 'components/governance/Proposals'; import useMerkleTree from 'hooks/useMerkleTree'; +import GetEXA from 'components/GetExa'; const Governance: NextPage = () => { const { t } = useTranslation(); @@ -20,7 +21,7 @@ const Governance: NextPage = () => { usePageView('/governance', 'Governance'); return ( - + {t('Exactly DAO Governance')} @@ -34,23 +35,29 @@ const Governance: NextPage = () => { /> - {isConnected || impersonateActive ? ( - (palette.mode === 'dark' ? 'grey.100' : 'white')} - > - {mTree.canClaim && } - - - + + + + {isConnected || impersonateActive ? ( + (palette.mode === 'dark' ? 'grey.100' : 'white')} + > + {mTree.canClaim && } + + + + + ) : ( + + )} - ) : ( - - )} + ); }; diff --git a/types/Bridge.tsx b/types/Bridge.tsx index cff100a60..20a0b467d 100644 --- a/types/Bridge.tsx +++ b/types/Bridge.tsx @@ -1,6 +1,6 @@ import { SvgIconProps } from '@mui/material'; import { ComponentType } from 'react'; -import { Address } from 'viem'; +import { Address, Hash } from 'viem'; export type ActiveRoutesResponse = { success: boolean; @@ -271,10 +271,12 @@ export type BridgeStatus = { sourceTxStatus: Status; destinationTxStatus: Status; destinationTransactionHash: string; - sourceTransactionHash: string; + sourceTransactionHash: Hash; }; export type DestinationCallData = { destinationPayload: string; - destinationGasLimit: bigint; + destinationGasLimit: string; }; + +export const NATIVE_TOKEN_ADDRESS = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'; diff --git a/utils/socket.ts b/utils/socket.ts index f7f041fac..f082f20e4 100644 --- a/utils/socket.ts +++ b/utils/socket.ts @@ -1,4 +1,4 @@ -import type { Asset, BridgeStatus, Chain, DestinationCallData, Route } from 'types/Bridge'; +import type { ActiveRoute, Asset, BridgeStatus, Chain, DestinationCallData, Route } from 'types/Bridge'; export const socketRequest = async ( path: string, @@ -16,6 +16,9 @@ export const socketRequest = async ( }, body: JSON.stringify(body, (_, value) => (typeof value === 'bigint' ? String(value) : value)), }); + + if (!response.ok) throw new Error(`Socket request failed with status ${response.status}`); + const { result } = (await response.json()) as { result: Result }; return result; }; @@ -50,7 +53,7 @@ export const socketQuote = async ({ fromTokenAddress: `0x${string}`; fromAmount: bigint; userAddress: `0x${string}`; - recipient: `0x${string}`; + recipient?: `0x${string}`; toTokenAddress: `0x${string}`; destinationPayload?: `0x${string}`; destinationGasLimit?: bigint; @@ -65,12 +68,12 @@ export const socketQuote = async ({ fromTokenAddress, toTokenAddress, userAddress, - recipient, sort: 'output', singleTxOnly: 'true', defaultSwapSlippage: '1', uniqueRoutesPerBridge: 'true', isContractCall: String(!!destinationPayload), + ...(recipient ? { recipient } : {}), ...(destinationPayload && { destinationPayload }), ...(destinationGasLimit && { destinationGasLimit: destinationGasLimit.toString() }), }); @@ -96,3 +99,10 @@ export const socketBridgeStatus = async ({ fromChainId: String(fromChainId), toChainId: String(toChainId), }); + +export const socketActiveRoutes = async ({ userAddress }: { userAddress: string }) => + socketRequest<{ + activeRoutes: ActiveRoute[]; + }>('route/active-routes/users', { + userAddress, + });