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 */}
-
-
- {"Symbol"} |
- {currentToken.symbol} |
-
-
- {"Name"} |
- {currentToken.displayName} |
-
- {currentToken.logo && (
-
- {"Logo"} |
-
-
- |
-
- )}
-
- {"Source Chain"} |
- {sourceChainId} |
-
-
- {"Balance"} |
- {splBalance?.toString() ?? erc20Balance?.toString() ?? "-"} |
-
-
- {"Target Chain"} |
- {targetChainId} |
-
-
- {"Loading?"} |
- {isLoading.toString()} |
-
-
- {txResults.length > 0 && (
- <>
- {"Tx results"}
-
-
- {"Chain ID"} |
- {"Tx ID"} |
-
- {txResults.map(({ chainId, txId }) => (
-
- {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 @@
+
\ 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 @@
+
+
+
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"}
-
-
+
+
-
-
+
+
);