diff --git a/apps/ui/src/components/Layout/Layout.tsx b/apps/ui/src/components/Layout/Layout.tsx index 4b82b4e66..de3ef4c15 100644 --- a/apps/ui/src/components/Layout/Layout.tsx +++ b/apps/ui/src/components/Layout/Layout.tsx @@ -48,6 +48,9 @@ export const Layout = ({ {/* TODO: Enable when token is launched */} {/* Stake */} + + {t("nav.wormhole")} + {t("nav.help")} diff --git a/apps/ui/src/components/TokenIcon.tsx b/apps/ui/src/components/TokenIcon.tsx index b2b61a417..4690eed15 100644 --- a/apps/ui/src/components/TokenIcon.tsx +++ b/apps/ui/src/components/TokenIcon.tsx @@ -1,12 +1,13 @@ import { EuiIcon } from "@elastic/eui"; import type { TokenProject } from "@swim-io/token-projects"; import { TOKEN_PROJECTS_BY_ID } from "@swim-io/token-projects"; +import type { WormholeToken } from "models"; import type { ComponentProps, ReactElement } from "react"; import { Fragment } from "react"; import { Trans } from "react-i18next"; -import type { EcosystemId, TokenConfig } from "../config"; import { ECOSYSTEMS } from "../config"; +import type { EcosystemId, TokenConfig } from "../config"; import { useIntlListSeparators } from "../hooks"; import type { Amount } from "../models/amount"; @@ -18,6 +19,29 @@ interface TokenIconProps readonly showFullName?: boolean; } +type WormholeTokenIconProps = { + readonly token: WormholeToken; + readonly isSelected: boolean; +}; + +export const WormholeTokenIcon = ({ + token, + isSelected, +}: WormholeTokenIconProps): ReactElement => { + const { logo, symbol, displayName } = token; + return ( +
+ + {isSelected ? `${symbol}` : `${symbol} - ${displayName}`} +
+ ); +}; + type WithIconProps = ComponentProps; const WithIcon = ({ children, ...rest }: WithIconProps) => { return ( diff --git a/apps/ui/src/components/TokenSearchModal.scss b/apps/ui/src/components/TokenSearchModal.scss index c6ecd3836..d0da167d8 100644 --- a/apps/ui/src/components/TokenSearchModal.scss +++ b/apps/ui/src/components/TokenSearchModal.scss @@ -1,6 +1,6 @@ .tokenSearchModal { min-height: 50vh; - max-width: 430px; + max-width: 430px !important; } .modalBody { height: 30vh; diff --git a/apps/ui/src/components/WormholeForm.tsx b/apps/ui/src/components/WormholeForm.tsx deleted file mode 100644 index 48f6c45c4..000000000 --- a/apps/ui/src/components/WormholeForm.tsx +++ /dev/null @@ -1,330 +0,0 @@ -import type { ChainId, EVMChainId } from "@certusone/wormhole-sdk"; -import { - CHAIN_ID_ACALA, - CHAIN_ID_ARBITRUM, - CHAIN_ID_AURORA, - CHAIN_ID_AVAX, - CHAIN_ID_BSC, - CHAIN_ID_CELO, - CHAIN_ID_ETH, - CHAIN_ID_ETHEREUM_ROPSTEN, - CHAIN_ID_FANTOM, - CHAIN_ID_GNOSIS, - CHAIN_ID_KARURA, - CHAIN_ID_KLAYTN, - CHAIN_ID_MOONBEAM, - CHAIN_ID_NEON, - CHAIN_ID_OASIS, - CHAIN_ID_OPTIMISM, - CHAIN_ID_POLYGON, - CHAIN_ID_SOLANA, - CHAIN_ID_TO_NAME, - isEVMChain, -} from "@certusone/wormhole-sdk"; -import type { EuiSelectOption } from "@elastic/eui"; -import { - EuiButton, - EuiForm, - EuiFormRow, - EuiSelect, - EuiSpacer, -} from "@elastic/eui"; -import { ERC20__factory } from "@swim-io/evm-contracts"; -import type { ReadonlyRecord } from "@swim-io/utils"; -import { findOrThrow } from "@swim-io/utils"; -import Decimal from "decimal.js"; -import { utils as ethersUtils } from "ethers"; -import type { FormEvent, ReactElement } from "react"; -import { useEffect, useMemo, useState } from "react"; -import type { UseQueryResult } from "react-query"; -import { useQuery } from "react-query"; - -import { wormholeTokens as rawWormholeTokens } from "../config"; -import { - useEvmWallet, - useUserSolanaTokenBalance, - useWormholeTransfer, -} from "../hooks"; -import type { TxResult, WormholeToken, WormholeTokenDetails } from "../models"; -import { generateId } from "../models"; - -import { EuiFieldIntlNumber } from "./EuiFieldIntlNumber"; - -const EVM_NETWORKS: ReadonlyRecord = { - [CHAIN_ID_ETH]: 1, - [CHAIN_ID_BSC]: 56, - [CHAIN_ID_POLYGON]: 137, - [CHAIN_ID_AVAX]: 43114, - [CHAIN_ID_OASIS]: 42262, - [CHAIN_ID_AURORA]: 1313161554, - [CHAIN_ID_FANTOM]: 250, - [CHAIN_ID_KARURA]: 686, - [CHAIN_ID_ACALA]: 787, - [CHAIN_ID_KLAYTN]: 8217, - [CHAIN_ID_CELO]: 42220, - [CHAIN_ID_MOONBEAM]: 1284, - [CHAIN_ID_NEON]: 245022934, - [CHAIN_ID_ARBITRUM]: 42161, - [CHAIN_ID_OPTIMISM]: 10, - [CHAIN_ID_GNOSIS]: 100, - [CHAIN_ID_ETHEREUM_ROPSTEN]: 3, -}; - -const getDetailsByChainId = ( - token: WormholeToken, - chainId: ChainId, -): WormholeTokenDetails | null => - [token.nativeDetails, ...token.wrappedDetails].find( - (details) => details.chainId === chainId, - ) ?? null; - -const useErc20BalanceQuery = ({ - chainId, - address, - decimals, -}: WormholeTokenDetails): UseQueryResult => { - const { wallet } = useEvmWallet(); - - return useQuery( - ["wormhole", "erc20Balance", chainId, address, wallet?.address], - async () => { - if (!wallet?.address || !isEVMChain(chainId)) { - return null; - } - const evmNetwork = EVM_NETWORKS[chainId]; - await wallet.switchNetwork(evmNetwork); - const { provider } = wallet.signer ?? {}; - if (!provider) { - return null; - } - const erc20Contract = ERC20__factory.connect(address, provider); - try { - const balance = await erc20Contract.balanceOf(wallet.address); - return new Decimal(ethersUtils.formatUnits(balance, decimals)); - } catch { - return new Decimal(0); - } - }, - {}, - ); -}; - -export const WormholeForm = (): ReactElement => { - const wormholeTokens = rawWormholeTokens as readonly WormholeToken[]; - const [currentTokenSymbol, setCurrentTokenSymbol] = useState( - wormholeTokens[0].symbol, - ); - const currentToken = findOrThrow( - wormholeTokens, - (token) => token.symbol === currentTokenSymbol, - ); - const tokenOptions: readonly EuiSelectOption[] = wormholeTokens.map( - (token) => ({ - value: token.symbol, - text: `${token.displayName} (${token.symbol})`, - selected: token.symbol === currentTokenSymbol, - }), - ); - - const sourceChains = useMemo( - () => [ - currentToken.nativeDetails.chainId, - ...currentToken.wrappedDetails.map(({ chainId }) => chainId), - ], - [currentToken], - ); - const [sourceChainId, setSourceChainId] = useState(sourceChains[0]); - const sourceChainOptions = sourceChains.map((chainId) => ({ - value: chainId, - text: CHAIN_ID_TO_NAME[chainId], - selected: chainId === sourceChainId, - })); - - const targetChains = useMemo( - () => sourceChains.filter((option) => option !== sourceChainId), - [sourceChains, sourceChainId], - ); - const [targetChainId, setTargetChainId] = useState(targetChains[0]); - const targetChainOptions = targetChains.map((chainId) => ({ - value: chainId, - text: CHAIN_ID_TO_NAME[chainId], - selected: chainId === targetChainId, - })); - - const [formInputAmount, setFormInputAmount] = useState(""); - const [inputAmount, setInputAmount] = useState(new Decimal(0)); - - const [txResults, setTxResults] = useState([]); - - const { mutateAsync: transfer, isLoading } = useWormholeTransfer(); - - const sourceDetails = getDetailsByChainId(currentToken, sourceChainId); - if (sourceDetails === null) { - throw new Error("Missing source details"); - } - const targetDetails = getDetailsByChainId(currentToken, targetChainId); - const splBalance = useUserSolanaTokenBalance( - sourceChainId === CHAIN_ID_SOLANA ? sourceDetails : null, - { enabled: sourceChainId === CHAIN_ID_SOLANA }, - ); - const { data: erc20Balance = null } = useErc20BalanceQuery(sourceDetails); - - const handleTxResult = (txResult: TxResult): void => { - setTxResults((previousResults) => [...previousResults, txResult]); - }; - - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - (async (): Promise => { - console.log(sourceDetails, targetDetails); - if (targetDetails === null) { - throw new Error("Missing target details"); - } - setTxResults([]); - await transfer({ - interactionId: generateId(), - value: inputAmount, - sourceDetails, - targetDetails, - nativeDetails: currentToken.nativeDetails, - onTxResult: handleTxResult, - }); - })().catch(console.error); - }; - - useEffect(() => { - setSourceChainId(sourceChains[0]); - }, [sourceChains]); - - useEffect(() => { - if (targetChainId === sourceChainId) { - setTargetChainId(targetChains[0]); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [targetChains]); - - return ( - - {/* These tables are only to show what data is available */} - - - - - - - - - - {currentToken.logo && ( - - - - - )} - - - - - - - - - - - - - - - - -
{"Symbol"}{currentToken.symbol}
{"Name"}{currentToken.displayName}
{"Logo"} - {currentToken.displayName} -
{"Source Chain"}{sourceChainId}
{"Balance"}{splBalance?.toString() ?? erc20Balance?.toString() ?? "-"}
{"Target Chain"}{targetChainId}
{"Loading?"}{isLoading.toString()}
- {txResults.length > 0 && ( - <> -

{"Tx results"}

- - - - - - {txResults.map(({ chainId, txId }) => ( - - - - - ))} -
{"Chain ID"}{"Tx ID"}
{chainId}{txId}
- - )} - - - - - { - setCurrentTokenSymbol(event.target.value); - }} - /> - - - - - - { - const newSourceChainId = parseInt( - event.target.value, - 10, - ) as ChainId; - setSourceChainId(newSourceChainId); - }} - /> - - - - - - { - const newTargetChainId = parseInt( - event.target.value, - 10, - ) as ChainId; - setTargetChainId(newTargetChainId); - }} - /> - - - - - - { - setInputAmount(new Decimal(formInputAmount)); - }} - /> - - - - - - - {"Transfer"} - - -
- ); -}; diff --git a/apps/ui/src/components/WormholeForm/WormholeChainSelect.tsx b/apps/ui/src/components/WormholeForm/WormholeChainSelect.tsx new file mode 100644 index 000000000..68f5e8b4a --- /dev/null +++ b/apps/ui/src/components/WormholeForm/WormholeChainSelect.tsx @@ -0,0 +1,81 @@ +import type { ChainId } from "@certusone/wormhole-sdk"; +import { CHAIN_ID_TO_NAME } from "@certusone/wormhole-sdk"; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiSuperSelect, + EuiText, +} from "@elastic/eui"; + +import type { WormholeEcosystemId } from "../../config"; +import { WORMHOLE_ECOSYSTEMS } from "../../config"; + +import "./WormholeForm.scss"; + +interface Props { + readonly chains: readonly ChainId[]; + readonly selectedChainId: ChainId; + readonly onSelectChain: (chain: ChainId) => void; + readonly label?: string; +} + +const WormholeChainSelect = ({ + chains, + selectedChainId, + onSelectChain, + label, +}: Props) => { + const chainOptions = chains.map((chainId) => ({ + value: String(chainId), + inputDisplay: ( + + + + + + + { + WORMHOLE_ECOSYSTEMS[ + CHAIN_ID_TO_NAME[chainId] as WormholeEcosystemId + ].displayName + } + + + + ), + append: ( + + ), + selected: chainId === selectedChainId, + })); + + return ( + <> + + onSelectChain(Number(value) as ChainId)} + className="euiButton--primary" + itemClassName="chainSelectItem" + hasDividers + /> + + + ); +}; + +export default WormholeChainSelect; diff --git a/apps/ui/src/components/WormholeForm/WormholeForm.scss b/apps/ui/src/components/WormholeForm/WormholeForm.scss new file mode 100644 index 000000000..361a94f6a --- /dev/null +++ b/apps/ui/src/components/WormholeForm/WormholeForm.scss @@ -0,0 +1,34 @@ +.wormholeForm { + width: 100%; + max-width: 500px; + transition: all 0.5s ease; + + .euiSuperSelectControl { + .euiFlexGroup--responsive { + display: flex; + flex-wrap: nowrap; + } + } +} + +.chainName { + text-transform: capitalize; +} + +.transactions { + padding: 10; + overflow-x: auto; + transition: all 1s ease-out; +} + +@media only screen and (max-width: 748px) { + .chainSelectItem { + .euiContextMenuItem__text { + max-width: 80%; + .euiFlexGroup--responsive { + display: flex; + flex-wrap: nowrap; + } + } + } +} diff --git a/apps/ui/src/components/WormholeForm/WormholeForm.tsx b/apps/ui/src/components/WormholeForm/WormholeForm.tsx new file mode 100644 index 000000000..bd48005b0 --- /dev/null +++ b/apps/ui/src/components/WormholeForm/WormholeForm.tsx @@ -0,0 +1,323 @@ +import type { ChainId } from "@certusone/wormhole-sdk"; +import { CHAIN_ID_SOLANA, CHAIN_ID_TO_NAME } from "@certusone/wormhole-sdk"; +import { + EuiButton, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiLink, + EuiSpacer, + EuiText, + EuiTitle, +} from "@elastic/eui"; +import { findOrThrow } from "@swim-io/utils"; +import Decimal from "decimal.js"; +import type { FormEvent, ReactElement } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import type { EcosystemId } from "../../config"; +import { wormholeTokens as rawWormholeTokens } from "../../config"; +import { useNotification } from "../../core/store"; +import { useUserSolanaTokenBalance, useWormholeTransfer } from "../../hooks"; +import { useWormholeErc20BalanceQuery } from "../../hooks/wormhole/useWormholeErc20BalanceQuery"; +import { generateId } from "../../models"; +import type { + TxResult, + WormholeToken, + WormholeTokenDetails, +} from "../../models"; +import { ConfirmModal } from "../ConfirmModal"; +import { MultiConnectButton } from "../ConnectButton"; +import { EuiFieldIntlNumber } from "../EuiFieldIntlNumber"; +import { TxListItem } from "../molecules/TxListItem"; + +import WormholeChainSelect from "./WormholeChainSelect"; +import { WormholeTokenSelect } from "./WormholeTokenSelect"; + +import "./WormholeForm.scss"; + +const getDetailsByChainId = ( + token: WormholeToken, + chainId: ChainId, +): WormholeTokenDetails | null => + [token.nativeDetails, ...token.wrappedDetails].find( + (details) => details.chainId === chainId, + ) ?? null; + +export const WormholeForm = (): ReactElement => { + const { t } = useTranslation(); + const { notify } = useNotification(); + const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false); + const [error, setError] = useState(null); + const [inputAmount, setInputAmount] = useState(new Decimal(0)); + const [txResults, setTxResults] = useState([]); + const [amountErrors, setAmountErrors] = useState([]); + const wormholeTokens = rawWormholeTokens as readonly WormholeToken[]; + const { mutateAsync: transfer, isLoading } = useWormholeTransfer(); + + const [currentTokenSymbol, setCurrentTokenSymbol] = useState( + wormholeTokens[0].symbol, + ); + const currentToken = findOrThrow( + wormholeTokens, + (token) => token.symbol === currentTokenSymbol, + ); + + const sourceChains = useMemo( + () => [ + currentToken.nativeDetails.chainId, + ...currentToken.wrappedDetails.map(({ chainId }) => chainId), + ], + [currentToken], + ); + const [sourceChainId, setSourceChainId] = useState(sourceChains[0]); + + const targetChains = useMemo( + () => sourceChains.filter((option) => option !== sourceChainId), + [sourceChains, sourceChainId], + ); + const [targetChainId, setTargetChainId] = useState(targetChains[0]); + + const sourceDetails = getDetailsByChainId(currentToken, sourceChainId); + if (sourceDetails === null) { + throw new Error("Missing source details"); + } + const targetDetails = getDetailsByChainId(currentToken, targetChainId); + const splBalance = useUserSolanaTokenBalance( + sourceChainId === CHAIN_ID_SOLANA ? sourceDetails : null, + { enabled: sourceChainId === CHAIN_ID_SOLANA }, + ); + const { data: erc20Balance = null } = + useWormholeErc20BalanceQuery(sourceDetails); + const balance = splBalance ?? erc20Balance; + + const handleTxResult = (txResult: TxResult): void => { + setTxResults((previousResults) => [...previousResults, txResult]); + }; + + const handleSubmit = () => { + (async (): Promise => { + setTxResults([]); + setError(null); + if (targetDetails === null) { + throw new Error("Missing target details"); + } + await transfer({ + interactionId: generateId(), + value: inputAmount, + sourceDetails, + targetDetails, + nativeDetails: currentToken.nativeDetails, + onTxResult: handleTxResult, + }); + })().catch((e) => { + console.error(e); + notify("Error", String(e), "error"); + setError(String(e)); + }); + }; + + const handleConfirmSubmit = (e: FormEvent): void => { + e.preventDefault(); + setIsConfirmModalVisible(true); + }; + + useEffect(() => { + setSourceChainId(sourceChains[0]); + }, [sourceChains]); + + useEffect(() => { + if (targetChainId === sourceChainId) { + setTargetChainId(targetChains[0]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [targetChains]); + + const handleConfirmModalCancel = (): void => { + setIsConfirmModalVisible(false); + }; + + const handleConfirmModalConfirm = (): void => { + setIsConfirmModalVisible(false); + handleSubmit(); + }; + + const checkAmountErrors = useCallback( + (value: Decimal) => { + let errors: readonly string[] = []; + if (value.isNeg()) { + errors = [...errors, t("general.amount_of_tokens_invalid")]; + } else if (value.lte(0)) { + errors = [...errors, t("general.amount_of_tokens_less_than_one")]; + } else if (!balance || new Decimal(value).gt(balance)) { + errors = [...errors, t("general.amount_of_tokens_exceed_balance")]; + } else { + errors = []; + } + setAmountErrors(errors); + }, + [balance, t], + ); + + const handleTransferAmountChange = useCallback( + (value: string): void => { + let newValue = new Decimal(0); + if (value === "") { + setInputAmount(new Decimal(0)); + } else { + setInputAmount(new Decimal(value)); + newValue = new Decimal(value); + } + checkAmountErrors(newValue); + }, + [checkAmountErrors], + ); + + return ( + + + + +

{t("wormhole_page.title")}

+
+
+ + + +
+ + + + + + setCurrentTokenSymbol(token.symbol) + } + /> + + + + + {`${t("swap_form.user_balance")} ${balance?.toString() || "-"}`} + + } + isInvalid={amountErrors.length > 0} + error={amountErrors} + > + checkAmountErrors(inputAmount)} + isInvalid={amountErrors.length > 0} + /> + + + + + + + + + + + + + + {!inputAmount.isZero() && amountErrors.length === 0 && ( + <> + + + {t("wormhole_page.receiving_amount", { + amount: inputAmount, + token: currentToken.symbol, + })} + + + )} + + 0 + } + > + {isLoading + ? t("wormhole_page.button.bridging") + : t("wormhole_page.button.transfer")} + + + {txResults.length > 0 && ( + <> + + +

{t("wormhole_page.transfer_info")}

+
+ {txResults.map(({ chainId, txId }) => ( +
+ + + +
+ ))} +
+
+ + )} + {error !== null && txResults.length > 0 && ( + <> + + + + {t("wormhole_page.error.message")} + +

{error}

+
+ + )} + + +
+ ); +}; diff --git a/apps/ui/src/components/WormholeForm/WormholeTokenModal.scss b/apps/ui/src/components/WormholeForm/WormholeTokenModal.scss new file mode 100644 index 000000000..90c936ac8 --- /dev/null +++ b/apps/ui/src/components/WormholeForm/WormholeTokenModal.scss @@ -0,0 +1,40 @@ +.wormholeModal { + min-height: 50vh; + max-width: 430px !important; +} +.modalBody { + height: 30vh; + overflow: auto; + .saveToken { + cursor: pointer; + } +} + +.networkPanel { + max-height: 30vh; + display: flex; + justify-content: flex-start; + align-items: center; + padding: 20px; +} +.ecosystemIcon { + width: fit-content; + flex-basis: 0; + .euiButton__text { + display: flex; + justify-content: space-evenly; + align-items: center; + width: 100%; + } +} + +@media only screen and (max-width: 540px) { + .networkPanel { + justify-content: space-around; + overflow-y: auto; + } + .euiModalHeader__title { + text-align: center; + width: 100%; + } +} diff --git a/apps/ui/src/components/WormholeForm/WormholeTokenModal.tsx b/apps/ui/src/components/WormholeForm/WormholeTokenModal.tsx new file mode 100644 index 000000000..1b0eb7de8 --- /dev/null +++ b/apps/ui/src/components/WormholeForm/WormholeTokenModal.tsx @@ -0,0 +1,92 @@ +import type { EuiSelectableOption } from "@elastic/eui"; +import { + EuiModalBody, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSelectable, +} from "@elastic/eui"; +import { useCallback } from "react"; +import type { ReactElement, ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +import type { WormholeToken } from "../../models"; +import { CustomModal } from "../CustomModal"; +import { WormholeTokenIcon } from "../TokenIcon"; + +import "./WormholeTokenModal.scss"; + +type TokenOption = EuiSelectableOption<{ + readonly data: Readonly; +}>; + +const renderTokenOption = (option: TokenOption): ReactNode => ( + +); + +interface Props { + readonly handleClose: () => void; + readonly handleSelectToken: (token: WormholeToken) => void; + readonly tokens: readonly WormholeToken[]; +} + +export const WormholeTokenModal = ({ + handleClose, + handleSelectToken, + tokens, +}: Props): ReactElement => { + const { t } = useTranslation(); + + const options = tokens.map((token) => ({ + label: `${token.symbol} ${token.displayName}`, + searchableLabel: `${token.symbol} ${token.displayName}`, + showIcons: false, + data: token, + })); + + const onSelectToken = useCallback( + (opts: readonly TokenOption[]) => { + const selected = opts.find((token: TokenOption) => token.checked); + if (selected) { + handleSelectToken(selected.data); + handleClose(); + } + }, + [handleClose, handleSelectToken], + ); + + return ( + + + + {t("wormhole_page.token_select_modal.title")} + + + + + {(list, search) => ( + <> + {search} + {list} + + )} + + + + ); +}; + +export {}; diff --git a/apps/ui/src/components/WormholeForm/WormholeTokenSelect.tsx b/apps/ui/src/components/WormholeForm/WormholeTokenSelect.tsx new file mode 100644 index 000000000..23892df14 --- /dev/null +++ b/apps/ui/src/components/WormholeForm/WormholeTokenSelect.tsx @@ -0,0 +1,45 @@ +import { EuiButton } from "@elastic/eui"; +import type { WormholeToken } from "models"; +import type { ReactElement } from "react"; +import { useCallback, useState } from "react"; + +import { WormholeTokenIcon } from "../TokenIcon"; + +import { WormholeTokenModal } from "./WormholeTokenModal"; + +interface Props { + readonly onSelectToken: (token: WormholeToken) => void; + readonly tokens: readonly WormholeToken[]; + readonly selectedToken: WormholeToken; +} + +export const WormholeTokenSelect = ({ + onSelectToken, + selectedToken, + tokens, +}: Props): ReactElement => { + const [showModal, setShowModal] = useState(false); + + const openModal = useCallback(() => setShowModal(true), [setShowModal]); + const closeModal = useCallback(() => setShowModal(false), [setShowModal]); + + return ( + <> + + + + {showModal && ( + + )} + + ); +}; diff --git a/apps/ui/src/components/WormholeForm/index.ts b/apps/ui/src/components/WormholeForm/index.ts new file mode 100644 index 000000000..6739fc40c --- /dev/null +++ b/apps/ui/src/components/WormholeForm/index.ts @@ -0,0 +1 @@ +export * from "./WormholeForm"; diff --git a/apps/ui/src/config/wormhole.ts b/apps/ui/src/config/wormhole.ts index 6a63881cf..36036dc88 100644 --- a/apps/ui/src/config/wormhole.ts +++ b/apps/ui/src/config/wormhole.ts @@ -1,5 +1,41 @@ +import type { ChainId, ChainName, EVMChainId } from "@certusone/wormhole-sdk"; +import { + CHAIN_ID_ACALA, + CHAIN_ID_ARBITRUM, + CHAIN_ID_AURORA, + CHAIN_ID_AVAX, + CHAIN_ID_BSC, + CHAIN_ID_CELO, + CHAIN_ID_ETH, + CHAIN_ID_ETHEREUM_ROPSTEN, + CHAIN_ID_FANTOM, + CHAIN_ID_GNOSIS, + CHAIN_ID_KARURA, + CHAIN_ID_KLAYTN, + CHAIN_ID_MOONBEAM, + CHAIN_ID_NEON, + CHAIN_ID_OASIS, + CHAIN_ID_OPTIMISM, + CHAIN_ID_POLYGON, +} from "@certusone/wormhole-sdk"; +import type { ReadonlyRecord } from "@swim-io/utils"; import { WormholeChainId } from "@swim-io/wormhole"; +import ACALA_SVG from "../images/ecosystems/acala.svg"; +import ALGORAND_SVG from "../images/ecosystems/algorand.svg"; +import APTOS_SVG from "../images/ecosystems/aptos.svg"; +import AURORA_SVG from "../images/ecosystems/aurora.svg"; +import AVALANCHE_SVG from "../images/ecosystems/avalanche.svg"; +import BNB_SVG from "../images/ecosystems/bnb.svg"; +import ETHEREUM_SVG from "../images/ecosystems/ethereum.svg"; +import FANTOM_SVG from "../images/ecosystems/fantom.svg"; +import KARURA_SVG from "../images/ecosystems/karura.svg"; +import KLAYTN_SVG from "../images/ecosystems/klaytn.svg"; +import OASIS_SVG from "../images/ecosystems/oasis.svg"; +import POLYGON_SVG from "../images/ecosystems/polygon.svg"; +import SOLANA_SVG from "../images/ecosystems/solana.svg"; +import TERRA_SVG from "../images/ecosystems/terra.svg"; + // We currently use this with Wormhole SDK’s getSignedVAAWithRetry function. // By default this function retries every 1 second. export const getWormholeRetries = (chainId: WormholeChainId): number => { @@ -14,3 +50,158 @@ export const getWormholeRetries = (chainId: WormholeChainId): number => { return 300; } }; + +export const EVM_NETWORKS: ReadonlyRecord = { + [CHAIN_ID_ETH]: 1, + [CHAIN_ID_BSC]: 56, + [CHAIN_ID_POLYGON]: 137, + [CHAIN_ID_AVAX]: 43114, + [CHAIN_ID_OASIS]: 42262, + [CHAIN_ID_AURORA]: 1313161554, + [CHAIN_ID_FANTOM]: 250, + [CHAIN_ID_KARURA]: 686, + [CHAIN_ID_ACALA]: 787, + [CHAIN_ID_KLAYTN]: 8217, + [CHAIN_ID_CELO]: 42220, + [CHAIN_ID_MOONBEAM]: 1284, + [CHAIN_ID_NEON]: 245022934, + [CHAIN_ID_ARBITRUM]: 42161, + [CHAIN_ID_OPTIMISM]: 10, + [CHAIN_ID_GNOSIS]: 100, + [CHAIN_ID_ETHEREUM_ROPSTEN]: 3, +}; + +export const enum WormholeEcosystemId { + Ethereum = "ethereum", + Bsc = "bsc", + Avalanche = "avalanche", + Polygon = "polygon", + Aurora = "aurora", + Fantom = "fantom", + Karura = "karura", + Acala = "acala", + Aptos = "aptos", + Celo = "celo", + Solana = "solana", + Klaytn = "klaytn", + Neon = "neon", + Oasis = "oasis", + Algorand = "algorand", + Terra = "terra", +} + +export interface WormholeEcosystem { + readonly id: ChainName; + readonly chainId: ChainId; + readonly displayName: string; + readonly logo: string; + readonly nativeTokenSymbol: string; +} + +export const WORMHOLE_ECOSYSTEM_LIST: readonly WormholeEcosystem[] = [ + { + id: WormholeEcosystemId.Aptos, + chainId: WormholeChainId.Aptos, + displayName: "Aptos", + logo: APTOS_SVG, + nativeTokenSymbol: "APT", + }, + { + id: WormholeEcosystemId.Solana, + chainId: WormholeChainId.Solana, + displayName: "Solana", + logo: SOLANA_SVG, + nativeTokenSymbol: "SOL", + }, + { + id: WormholeEcosystemId.Ethereum, + chainId: WormholeChainId.Ethereum, + displayName: "Ethereum", + logo: ETHEREUM_SVG, + nativeTokenSymbol: "ETH", + }, + { + id: WormholeEcosystemId.Bsc, + chainId: WormholeChainId.Bnb, + displayName: "BNB Chain", + logo: BNB_SVG, + nativeTokenSymbol: "BNB", + }, + { + id: WormholeEcosystemId.Avalanche, + chainId: WormholeChainId.Avalanche, + displayName: "Avalanche", + logo: AVALANCHE_SVG, + nativeTokenSymbol: "AVAX", + }, + { + id: WormholeEcosystemId.Polygon, + chainId: WormholeChainId.Polygon, + displayName: "Polygon", + logo: POLYGON_SVG, + nativeTokenSymbol: "MATIC", + }, + { + id: WormholeEcosystemId.Aurora, + chainId: WormholeChainId.Aurora, + displayName: "Aurora", + logo: AURORA_SVG, + nativeTokenSymbol: "ETH", + }, + { + id: WormholeEcosystemId.Fantom, + chainId: WormholeChainId.Fantom, + displayName: "Fantom", + logo: FANTOM_SVG, + nativeTokenSymbol: "FTM", + }, + { + id: WormholeEcosystemId.Karura, + chainId: WormholeChainId.Karura, + displayName: "Karura", + logo: KARURA_SVG, + nativeTokenSymbol: "KAR", + }, + { + id: WormholeEcosystemId.Acala, + chainId: WormholeChainId.Acala, + displayName: "Acala", + logo: ACALA_SVG, + nativeTokenSymbol: "ACA", + }, + { + id: WormholeEcosystemId.Algorand, + chainId: WormholeChainId.Algorand, + displayName: "Algorand", + logo: ALGORAND_SVG, + nativeTokenSymbol: "ALGO", + }, + { + id: WormholeEcosystemId.Klaytn, + chainId: WormholeChainId.Klaytn, + displayName: "Klaytn", + logo: KLAYTN_SVG, + nativeTokenSymbol: "KLAY", + }, + { + id: WormholeEcosystemId.Oasis, + chainId: WormholeChainId.Oasis, + displayName: "Oasis", + logo: OASIS_SVG, + nativeTokenSymbol: "ROSE", + }, + { + id: WormholeEcosystemId.Terra, + chainId: WormholeChainId.Terra, + displayName: "Terra", + logo: TERRA_SVG, + nativeTokenSymbol: "LUNA", + }, +]; + +export const WORMHOLE_ECOSYSTEMS: ReadonlyRecord< + WormholeEcosystemId, + WormholeEcosystem +> = Object.fromEntries( + WORMHOLE_ECOSYSTEM_LIST.map((ecosystem) => [ecosystem.id, ecosystem]), +) as ReadonlyRecord; diff --git a/apps/ui/src/hooks/wormhole/useWormholeErc20BalanceQuery.ts b/apps/ui/src/hooks/wormhole/useWormholeErc20BalanceQuery.ts new file mode 100644 index 000000000..7a91fd600 --- /dev/null +++ b/apps/ui/src/hooks/wormhole/useWormholeErc20BalanceQuery.ts @@ -0,0 +1,41 @@ +import { isEVMChain } from "@certusone/wormhole-sdk"; +import { ERC20__factory } from "@swim-io/evm-contracts"; +import Decimal from "decimal.js"; +import { utils as ethersUtils } from "ethers"; +import type { WormholeTokenDetails } from "models"; +import type { UseQueryResult } from "react-query"; +import { useQuery } from "react-query"; + +import { useEvmWallet } from ".."; +import { EVM_NETWORKS } from "../../config"; + +export const useWormholeErc20BalanceQuery = ({ + chainId, + address, + decimals, +}: WormholeTokenDetails): UseQueryResult => { + const { wallet } = useEvmWallet(); + + return useQuery( + ["wormhole", "erc20Balance", chainId, address, wallet?.address], + async () => { + if (!wallet?.address || !isEVMChain(chainId)) { + return null; + } + const evmNetwork = EVM_NETWORKS[chainId]; + await wallet.switchNetwork(evmNetwork); + const { provider } = wallet.signer ?? {}; + if (!provider) { + return null; + } + const erc20Contract = ERC20__factory.connect(address, provider); + try { + const balance = await erc20Contract.balanceOf(wallet.address); + return new Decimal(ethersUtils.formatUnits(balance, decimals)); + } catch { + return new Decimal(0); + } + }, + {}, + ); +}; diff --git a/apps/ui/src/images/ecosystems/algorand.svg b/apps/ui/src/images/ecosystems/algorand.svg new file mode 100644 index 000000000..8c7a3667b --- /dev/null +++ b/apps/ui/src/images/ecosystems/algorand.svg @@ -0,0 +1 @@ +ALGO_Logos_190320 \ No newline at end of file diff --git a/apps/ui/src/images/ecosystems/celo.svg b/apps/ui/src/images/ecosystems/celo.svg new file mode 100644 index 000000000..076bebe64 --- /dev/null +++ b/apps/ui/src/images/ecosystems/celo.svg @@ -0,0 +1,18 @@ + + + + +Artboard 1 + + + + diff --git a/apps/ui/src/images/ecosystems/klaytn.svg b/apps/ui/src/images/ecosystems/klaytn.svg new file mode 100644 index 000000000..cd2d4908e --- /dev/null +++ b/apps/ui/src/images/ecosystems/klaytn.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/apps/ui/src/images/ecosystems/neon.svg b/apps/ui/src/images/ecosystems/neon.svg new file mode 100644 index 000000000..fd1933868 --- /dev/null +++ b/apps/ui/src/images/ecosystems/neon.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/ui/src/images/ecosystems/oasis.svg b/apps/ui/src/images/ecosystems/oasis.svg new file mode 100644 index 000000000..301d1cc5c --- /dev/null +++ b/apps/ui/src/images/ecosystems/oasis.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/ui/src/images/ecosystems/terra.svg b/apps/ui/src/images/ecosystems/terra.svg new file mode 100644 index 000000000..83be6900d --- /dev/null +++ b/apps/ui/src/images/ecosystems/terra.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/ui/src/locales/en/translation.json b/apps/ui/src/locales/en/translation.json index 794293776..f3db069ad 100644 --- a/apps/ui/src/locales/en/translation.json +++ b/apps/ui/src/locales/en/translation.json @@ -136,6 +136,7 @@ "nav.telegram": "Telegram", "nav.terms_of_service": "Terms of Service", "nav.twitter": "Twitter", + "nav.wormhole": "Wormhole", "new_version_alert.description": "There is a new version of the app available. Please reload the page to continue using the app.", "new_version_alert.title": "New version available", "not_found.body1": "The link you followed may be broken, or the page may have been removed.", @@ -272,5 +273,24 @@ "token_search_modal.search_tokens": "Search tokens", "token_search_modal.title": "Select a token", "token_select_modal.title": "Select chain and token", - "vaa_error.transfer_not_detected": "Transfer not detected by Wormhole guardians. This usually happens when the network is congested. Please retry later." + "vaa_error.transfer_not_detected": "Transfer not detected by Wormhole guardians. This usually happens when the network is congested. Please retry later.", + "wormhole_page.button.bridging": "Bridging ... ", + "wormhole_page.button.transfer": "Transfer", + "wormhole_page.confirm_modal.cancel": "Cancel", + "wormhole_page.confirm_modal.question": "Are you sure you want to transfer ?", + "wormhole_page.confirm_modal.title": "Confirm Transfer", + "wormhole_page.confirm_modal.transfer": "Transfer", + "wormhole_page.custom_token_address": "Custom token address", + "wormhole_page.error.message": "If your transfer was interrupted, check the relevant blockchain explorer for the transaction ID and use link to complete the transfer. ", + "wormhole_page.error.title": "Something went wrong", + "wormhole_page.message.bridged_tokens": "The tokens have entered to the bridge. View transaction: ", + "wormhole_page.message.fetching_vaa": "Fetching signed VAA...", + "wormhole_page.message.signed_vaa": "Fetched signed VAA !", + "wormhole_page.message.transfer_success": "Transfer is completed. View transactions: ", + "wormhole_page.receiving_amount": "You will receive {{amount}} {{token}}.", + "wormhole_page.source_chain": "Source chain", + "wormhole_page.target_chain": "Target chain", + "wormhole_page.title": "Wormhole", + "wormhole_page.token_select_modal.title": "Select token", + "wormhole_page.transfer_info": "Transfer information: " } diff --git a/apps/ui/src/pages/SwapPage/SwapPage.tsx b/apps/ui/src/pages/SwapPage/SwapPage.tsx index 2fe46d2ea..c8b5468c1 100644 --- a/apps/ui/src/pages/SwapPage/SwapPage.tsx +++ b/apps/ui/src/pages/SwapPage/SwapPage.tsx @@ -61,7 +61,7 @@ const SwapPage = (): ReactElement => { ); return ( - + diff --git a/apps/ui/src/pages/WormholePage.scss b/apps/ui/src/pages/WormholePage.scss deleted file mode 100644 index 010e88b94..000000000 --- a/apps/ui/src/pages/WormholePage.scss +++ /dev/null @@ -1,10 +0,0 @@ -.wormholeForm { - width: 500px; - transition: all 0.5s ease; -} - -@media (min-width: 768px) { - .wormholeForm { - min-width: 460px; - } -} diff --git a/apps/ui/src/pages/WormholePage.tsx b/apps/ui/src/pages/WormholePage.tsx index 3d12a9f16..d68c45148 100644 --- a/apps/ui/src/pages/WormholePage.tsx +++ b/apps/ui/src/pages/WormholePage.tsx @@ -3,8 +3,6 @@ import { EuiPageBody, EuiPageContent, EuiPageContentBody, - EuiSpacer, - EuiTitle, } from "@elastic/eui"; import type { ReactElement } from "react"; import { useTranslation } from "react-i18next"; @@ -12,24 +10,27 @@ import { useTranslation } from "react-i18next"; import { WormholeForm } from "../components/WormholeForm"; import { useTitle } from "../hooks"; -import "./WormholePage.scss"; - const WormholePage = (): ReactElement => { const { t } = useTranslation(); useTitle(t("nav.wormhole")); return ( - + - - - -

{"Wormhole"}

-
- + + - -
+
+
);