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..c1aa81699 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"; @@ -127,3 +128,26 @@ export const TokenSearchConfigIcon = ({ }: Pick): ReactElement => ( ); + +type WormholeTokenIconProps = { + readonly token: WormholeToken; + readonly showFullName: boolean; +}; + +export const WormholeTokenIcon = ({ + token, + showFullName, +}: WormholeTokenIconProps): ReactElement => { + const { logo, symbol, displayName } = token; + return ( +
+ + {showFullName ? `${symbol} - ${displayName}` : `${symbol}`} +
+ ); +}; diff --git a/apps/ui/src/components/WormholeForm.tsx b/apps/ui/src/components/WormholeForm.tsx deleted file mode 100644 index ddee5d793..000000000 --- a/apps/ui/src/components/WormholeForm.tsx +++ /dev/null @@ -1,329 +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 => { - 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..723758dea --- /dev/null +++ b/apps/ui/src/components/WormholeForm/WormholeChainSelect.tsx @@ -0,0 +1,66 @@ +import { + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiSuperSelect, + EuiText, +} from "@elastic/eui"; + +import type { SupportedChainId } from "../../config"; +import { WORMHOLE_ECOSYSTEMS } from "../../config"; + +import "./WormholeForm.scss"; + +interface Props { + readonly chains: readonly SupportedChainId[]; + readonly selectedChainId: SupportedChainId; + readonly onSelectChain: (chain: SupportedChainId) => void; + readonly label?: string; +} + +export const WormholeChainSelect = ({ + chains, + selectedChainId, + onSelectChain, + label, +}: Props) => { + const chainOptions = chains.map((chainId) => ({ + value: String(chainId), + inputDisplay: ( + + + + + + + {WORMHOLE_ECOSYSTEMS[chainId].displayName} + + + + ), + append: ( + + ), + selected: chainId === selectedChainId, + })); + + return ( + <> + + + onSelectChain(parseInt(value, 10) as SupportedChainId) + } + className="euiButton--primary" + itemClassName="chainSelectItem" + hasDividers + /> + + + ); +}; diff --git a/apps/ui/src/components/WormholeForm/WormholeForm.scss b/apps/ui/src/components/WormholeForm/WormholeForm.scss new file mode 100644 index 000000000..65c4c6602 --- /dev/null +++ b/apps/ui/src/components/WormholeForm/WormholeForm.scss @@ -0,0 +1,41 @@ +.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: 540px) { + .chainSelectItem { + .euiContextMenuItem__text { + max-width: 70%; + .euiFlexGroup--responsive { + display: flex; + flex-wrap: nowrap; + } + } + } +} + +@media only screen and (min-width: 540px) { + .titleConnectWrapper { + 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..f94713987 --- /dev/null +++ b/apps/ui/src/components/WormholeForm/WormholeForm.tsx @@ -0,0 +1,316 @@ +import type { ChainId } from "@certusone/wormhole-sdk"; +import { CHAIN_ID_SOLANA } 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 { 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 { WormholeChainSelect } from "./WormholeChainSelect"; +import { WormholeTokenSelect } from "./WormholeTokenSelect"; +import { WormholeTxListItem } from "./WormholeTxListItem"; + +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 submitForm = 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, + }); + }; + + const handleFormSubmit = (e: FormEvent): void => { + e.preventDefault(); + setIsConfirmModalVisible(true); + }; + + const handleConfirmModalCancel = (): void => { + setIsConfirmModalVisible(false); + }; + + const handleConfirmModalConfirm = () => { + setIsConfirmModalVisible(false); + submitForm().catch((e) => { + console.error(e); + notify("Error", String(e), "error"); + setError(String(e)); + }); + }; + + 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 || value.gt(balance)) { + errors = [...errors, t("general.amount_of_tokens_exceed_balance")]; + } else { + errors = []; + } + setAmountErrors(errors); + }, + [balance, t], + ); + + const handleTransferAmountChange = useCallback( + (value: string): void => { + const newAmount = new Decimal(value || 0); + setInputAmount(newAmount); + checkAmountErrors(newAmount); + }, + [setInputAmount, checkAmountErrors], + ); + + useEffect(() => { + setSourceChainId(sourceChains[0]); + }, [sourceChains]); + + useEffect(() => { + if (targetChainId === sourceChainId) { + setTargetChainId(targetChains[0]); + } + }, [targetChains, targetChainId, sourceChainId]); + + return ( + + + + +

{t("nav.wormhole")}

+
+
+ + + +
+ + + + + + 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..027363759 --- /dev/null +++ b/apps/ui/src/components/WormholeForm/WormholeTokenModal.scss @@ -0,0 +1,40 @@ +.wormholeModal { + min-height: 50vh; + max-width: 430px; +} +.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..3ca3933d4 --- /dev/null +++ b/apps/ui/src/components/WormholeForm/WormholeTokenModal.tsx @@ -0,0 +1,90 @@ +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("token_search_modal.search_tokens")} + + + + + {(list, search) => ( + <> + {search} + {list} + + )} + + + + ); +}; diff --git a/apps/ui/src/components/WormholeForm/WormholeTokenSelect.tsx b/apps/ui/src/components/WormholeForm/WormholeTokenSelect.tsx new file mode 100644 index 000000000..d85e7a667 --- /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/WormholeTxListItem.tsx b/apps/ui/src/components/WormholeForm/WormholeTxListItem.tsx new file mode 100644 index 000000000..c3a31ee23 --- /dev/null +++ b/apps/ui/src/components/WormholeForm/WormholeTxListItem.tsx @@ -0,0 +1,81 @@ +import { + CHAIN_ID_ACALA, + CHAIN_ID_ARBITRUM, + CHAIN_ID_AURORA, + CHAIN_ID_AVAX, + CHAIN_ID_BSC, + CHAIN_ID_CELO, + CHAIN_ID_ETH, + 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, +} from "@certusone/wormhole-sdk"; +import type { ChainId } from "@certusone/wormhole-sdk"; +import { EuiListGroupItem } from "@elastic/eui"; +import type { FC } from "react"; + +interface Props { + readonly chainId: ChainId; + readonly txId: string; +} + +const getHref = (chainId: ChainId, txId: string): string => { + switch (chainId) { + case CHAIN_ID_SOLANA: + return `https://solana.fm/tx/${txId}`; + case CHAIN_ID_ETH: + return `https://etherscan.io/tx/${txId}`; + case CHAIN_ID_BSC: + return `https://bscscan.com/tx/${txId}`; + case CHAIN_ID_AVAX: + return `https://snowtrace.io/tx/${txId}`; + case CHAIN_ID_POLYGON: + return `https://polygonscan.com/tx/${txId}`; + case CHAIN_ID_AURORA: + return `https://aurorascan.dev/tx/${txId}`; + case CHAIN_ID_FANTOM: + return `https://ftmscan.com/tx/${txId}`; + case CHAIN_ID_KARURA: + return `https://blockscout.karura.network/tx/${txId}`; + case CHAIN_ID_ACALA: + return `https://blockscout.acala.network/tx/${txId}`; + case CHAIN_ID_CELO: + return `https://explorer.celo.org/mainnet/tx/${txId}`; + case CHAIN_ID_ARBITRUM: + return `https://arbiscan.io/tx/${txId}`; + case CHAIN_ID_GNOSIS: + return `https://gnosisscan.io/tx/${txId}`; + case CHAIN_ID_KLAYTN: + return `https://scope.klaytn.com/tx/${txId}`; + case CHAIN_ID_MOONBEAM: + return `https://moonscan.io/tx/${txId}`; + case CHAIN_ID_NEON: + return `https://neonscan.org/tx/${txId}`; + case CHAIN_ID_OASIS: + return `https://www.oasisscan.com/transactions/${txId}`; + case CHAIN_ID_OPTIMISM: + return `https://optimistic.etherscan.io/tx/${txId}`; + default: + throw new Error("Unknown chainId"); + } +}; + +export const WormholeTxListItem: FC = ({ chainId, txId }) => { + const href = getHref(chainId, txId); + return ( + + ); +}; 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..7f0576106 100644 --- a/apps/ui/src/config/wormhole.ts +++ b/apps/ui/src/config/wormhole.ts @@ -1,5 +1,37 @@ +import type { 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, +} 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 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 POLYGON_SVG from "../images/ecosystems/polygon.svg"; +import SOLANA_SVG from "../images/ecosystems/solana.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 +46,127 @@ 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 interface WormholeEcosystem { + readonly displayName: string; + readonly logo: string | null; + readonly nativeTokenSymbol: string; +} + +export type SupportedChainId = EVMChainId | typeof CHAIN_ID_SOLANA; + +export const WORMHOLE_ECOSYSTEMS: ReadonlyRecord< + SupportedChainId, + WormholeEcosystem +> = { + [CHAIN_ID_SOLANA]: { + displayName: "Solana", + logo: SOLANA_SVG, + nativeTokenSymbol: "SOL", + }, + [CHAIN_ID_ETH]: { + displayName: "Ethereum", + logo: ETHEREUM_SVG, + nativeTokenSymbol: "ETH", + }, + [CHAIN_ID_BSC]: { + displayName: "BNB Chain", + logo: BNB_SVG, + nativeTokenSymbol: "BNB", + }, + [CHAIN_ID_AVAX]: { + displayName: "Avalanche", + logo: AVALANCHE_SVG, + nativeTokenSymbol: "AVAX", + }, + [CHAIN_ID_POLYGON]: { + displayName: "Polygon", + logo: POLYGON_SVG, + nativeTokenSymbol: "MATIC", + }, + [CHAIN_ID_AURORA]: { + displayName: "Aurora", + logo: AURORA_SVG, + nativeTokenSymbol: "ETH", + }, + [CHAIN_ID_FANTOM]: { + displayName: "Fantom", + logo: FANTOM_SVG, + nativeTokenSymbol: "FTM", + }, + [CHAIN_ID_KARURA]: { + displayName: "Karura", + logo: KARURA_SVG, + nativeTokenSymbol: "KAR", + }, + [CHAIN_ID_ACALA]: { + displayName: "Acala", + logo: ACALA_SVG, + nativeTokenSymbol: "ACA", + }, + [CHAIN_ID_KLAYTN]: { + displayName: "Klaytn", + logo: null, + nativeTokenSymbol: "KLAY", + }, + [CHAIN_ID_OASIS]: { + displayName: "Oasis", + logo: null, + nativeTokenSymbol: "ROSE", + }, + [CHAIN_ID_ARBITRUM]: { + displayName: "Arbitrum", + logo: null, + nativeTokenSymbol: "", + }, + [CHAIN_ID_CELO]: { + displayName: "Celo", + logo: null, + nativeTokenSymbol: "CELO", + }, + [CHAIN_ID_OPTIMISM]: { + displayName: "Optimism", + logo: null, + nativeTokenSymbol: "OP", + }, + [CHAIN_ID_GNOSIS]: { + displayName: "Gnosis", + logo: null, + nativeTokenSymbol: "GNO", + }, + [CHAIN_ID_MOONBEAM]: { + displayName: "Moonbeam", + logo: null, + nativeTokenSymbol: "GLMR", + }, + [CHAIN_ID_NEON]: { + displayName: "Neon", + logo: null, + nativeTokenSymbol: "NEON", + }, + [CHAIN_ID_ETHEREUM_ROPSTEN]: { + displayName: "Ropsten", + logo: ETHEREUM_SVG, + nativeTokenSymbol: "ETH", + }, +}; 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/hooks/wormhole/useWormholeTransfer.ts b/apps/ui/src/hooks/wormhole/useWormholeTransfer.ts index adedceaa3..0aac24f4d 100644 --- a/apps/ui/src/hooks/wormhole/useWormholeTransfer.ts +++ b/apps/ui/src/hooks/wormhole/useWormholeTransfer.ts @@ -34,9 +34,8 @@ export const useWormholeTransfer = () => { if (isEVMChain(targetChainId)) { return await transferEvmToEvm(transfer); } - throw new Error( - `Transfer from ${sourceChainId} to ${targetChainId} not supported`, - ); + + throw new Error(`Transfer from ${sourceChainId} to unsupported chain.`); } }); }; diff --git a/apps/ui/src/locales/en/translation.json b/apps/ui/src/locales/en/translation.json index 794293776..0b83971d1 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,21 @@ "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.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 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.transfer_info": "Transfer information:" } diff --git a/apps/ui/src/models/wormhole/transfer.ts b/apps/ui/src/models/wormhole/transfer.ts index 007e40f76..a921a96a6 100644 --- a/apps/ui/src/models/wormhole/transfer.ts +++ b/apps/ui/src/models/wormhole/transfer.ts @@ -1,19 +1,19 @@ -import type { ChainId } from "@certusone/wormhole-sdk"; import type { WrappedTokenInfo } from "@swim-io/core"; import { findOrThrow } from "@swim-io/utils"; import type Decimal from "decimal.js"; +import type { SupportedChainId } from "../../config"; import { ECOSYSTEM_LIST } from "../../config"; import { formatWormholeAddress } from "./formatWormholeAddress"; export interface TxResult { - readonly chainId: ChainId; + readonly chainId: SupportedChainId; readonly txId: string; } export interface WormholeTokenDetails { - readonly chainId: ChainId; + readonly chainId: SupportedChainId; readonly address: string; readonly decimals: number; } @@ -37,7 +37,7 @@ export interface WormholeTransfer { } export const getWrappedTokenInfoFromNativeDetails = ( - sourceChainId: ChainId, + sourceChainId: SupportedChainId, nativeDetails: WormholeTokenDetails, ): WrappedTokenInfo | undefined => { if (sourceChainId === nativeDetails.chainId) { 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..778ba9d32 100644 --- a/apps/ui/src/pages/WormholePage.tsx +++ b/apps/ui/src/pages/WormholePage.tsx @@ -1,19 +1,10 @@ -import { - EuiPage, - EuiPageBody, - EuiPageContent, - EuiPageContentBody, - EuiSpacer, - EuiTitle, -} from "@elastic/eui"; +import { EuiPage, EuiPageBody, EuiPageContent } from "@elastic/eui"; import type { ReactElement } from "react"; 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")); @@ -21,14 +12,17 @@ const WormholePage = (): ReactElement => { return ( - - - -

{"Wormhole"}

-
- - -
+ +