diff --git a/chain-registry b/chain-registry index 5223d955..ce7f1170 160000 --- a/chain-registry +++ b/chain-registry @@ -1 +1 @@ -Subproject commit 5223d9555cb132e4c4ec5a913cfeb65d093a5aa9 +Subproject commit ce7f11704d55063ec4a192f31fb14d20a211f377 diff --git a/initia-registry b/initia-registry index 4352b12d..5f13ed06 160000 --- a/initia-registry +++ b/initia-registry @@ -1 +1 @@ -Subproject commit 4352b12da33fac2bceb14e90e06007e2c4194951 +Subproject commit 5f13ed06eedb4b85d9f6e50be070ff04a3a7525b diff --git a/package-lock.json b/package-lock.json index 01d7aea2..bbc02adc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,6 +72,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-hot-toast": "^2.4.1", + "react-icons": "^5.1.0", "resend": "^3.1.0", "stridejs": "^0.8.0-alpha.5", "tailwind-merge": "^2.2.1", @@ -13500,7 +13501,7 @@ "@cosmjs/proto-signing": "0.32.3", "@cosmjs/stargate": "0.32.3", "@cosmjs/tendermint-rpc": "0.32.3", - "@initia/initia-registry": "0.1.1", + "@initia/initia-registry": "^0.1.1", "@injectivelabs/core-proto-ts": "0.0.x", "@injectivelabs/sdk-ts": "1.x", "@keplr-wallet/unit": "^0.12.67", @@ -32838,6 +32839,14 @@ } } }, + "node_modules/react-icons": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.1.0.tgz", + "integrity": "sha512-D3zug1270S4hbSlIRJ0CUS97QE1yNNKDjzQe3HqY0aefp2CBn9VgzgES27sRR2gOvFK+0CNx/BW0ggOESp6fqQ==", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index 2999cbea..22fc058b 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-hot-toast": "^2.4.1", + "react-icons": "^5.1.0", "resend": "^3.1.0", "stridejs": "^0.8.0-alpha.5", "tailwind-merge": "^2.2.1", diff --git a/src/components/AssetInput.tsx b/src/components/AssetInput.tsx index 7097f8e0..9044f8b5 100644 --- a/src/components/AssetInput.tsx +++ b/src/components/AssetInput.tsx @@ -56,7 +56,7 @@ function AssetInput({ return assetsByChainID(chain.chainID); }, [assetsByChainID, chain, getNativeAssets]); - const account = useAccount(context); + const account = useAccount(chain?.chainID); const isAnyDisclosureOpen = useAnyDisclosureOpen(); @@ -64,7 +64,7 @@ function AssetInput({ address: account?.address, chain, assets, - enabled: !isAnyDisclosureOpen, + enabled: !isAnyDisclosureOpen && context === "source", }); const selectedAssetBalance = useMemo(() => { diff --git a/src/components/AssetSelect/AssetSelectContent.tsx b/src/components/AssetSelect/AssetSelectContent.tsx index b3466e79..5a0c14a7 100644 --- a/src/components/AssetSelect/AssetSelectContent.tsx +++ b/src/components/AssetSelect/AssetSelectContent.tsx @@ -2,7 +2,6 @@ import { ArrowLeftIcon } from "@heroicons/react/20/solid"; import * as ScrollArea from "@radix-ui/react-scroll-area"; import { Asset } from "@skip-router/core"; import { matchSorter } from "match-sorter"; -import Image from "next/image"; import { useEffect, useMemo, useRef, useState } from "react"; import { formatUnits } from "viem"; @@ -94,7 +93,7 @@ function AssetSelectContent({ assets = [], balances, onChange, onClose, showChai className="flex w-full items-center gap-4 rounded-xl p-4 text-left transition-colors hover:bg-[#ECD9D9] focus:-outline-offset-2" onClick={() => (onClose(), onChange?.(asset))} > - {asset.recommendedSymbol {asset && ( - {asset.recommendedSymbol(function Item(props, r })), enabled: !(data.status === "success" || data.status === "failed"), }); - useEffect(() => { if (errorUpdateCount > 4) { txHistory.remove(id); diff --git a/src/components/TransactionDialog/AlertCollapse.tsx b/src/components/PreviewRoute/AlertCollapse.tsx similarity index 100% rename from src/components/TransactionDialog/AlertCollapse.tsx rename to src/components/PreviewRoute/AlertCollapse.tsx diff --git a/src/components/PreviewRoute/ChainStep.tsx b/src/components/PreviewRoute/ChainStep.tsx new file mode 100644 index 00000000..916d2db1 --- /dev/null +++ b/src/components/PreviewRoute/ChainStep.tsx @@ -0,0 +1,446 @@ +import { ArrowRightIcon, FingerPrintIcon, PencilSquareIcon } from "@heroicons/react/20/solid"; +import { RouteResponse } from "@skip-router/core"; +import Image from "next/image"; +import { Dispatch, SetStateAction, useEffect, useMemo } from "react"; +import toast from "react-hot-toast"; +import { FaExternalLinkAlt, FaKeyboard } from "react-icons/fa"; +import { formatUnits } from "viem"; + +import { chainAddresses } from "@/context/chainAddresses"; +import { useAccount } from "@/hooks/useAccount"; +import { useAutoSetAddress } from "@/hooks/useAutoSetAddress"; +import { useBridgeByID } from "@/hooks/useBridges"; +import { useChainByID, useChains } from "@/hooks/useChains"; +import { useAssets, useBroadcastedTxsStatus } from "@/solve"; +import { isCCTPLedgerBrokenInOperation, isEthermintLedgerInOperation } from "@/utils/ledger-warning"; +import { cn } from "@/utils/ui"; + +import { AdaptiveLink } from "../AdaptiveLink"; +import { ExpandArrow } from "../Icons/ExpandArrow"; +import { SimpleTooltip } from "../SimpleTooltip"; +import { BroadcastedTx } from "."; +import { SwapAction, TransferAction } from "./make-actions"; +import { ChainIDWithAction } from "./make-chain-ids-with-actions"; +import { makeStepState } from "./make-step-state"; +import { SetAddressDialog } from "./SetAddressDialog"; + +export const ChainStep = ({ + chainID, + index, + transferAction, + swapAction, + route, + chainIDsWithAction, + isOpen, + broadcastedTxs, + mutationStatus, + setShowLedgerWarning, + isSetAddressDialogOpen, + setIsAddressDialogOpen, + isExpanded, + setIsExpanded, +}: { + chainID: string; + index: number; + transferAction?: TransferAction; + swapAction?: SwapAction; + route: RouteResponse; + chainIDsWithAction: ChainIDWithAction[]; + isOpen: boolean; + broadcastedTxs: BroadcastedTx[]; + mutationStatus: { + isPending: boolean; + isSuccess: boolean; + isError: boolean; + }; + setShowLedgerWarning: Dispatch< + SetStateAction<{ + cctp: boolean; + ethermint: boolean; + }> + >; + isSetAddressDialogOpen: boolean; + setIsAddressDialogOpen: (v: number | undefined) => void; + isExpanded: boolean; + setIsExpanded: Dispatch>; +}) => { + const { data: chain } = useChainByID(chainID); + + const totalChains = chainIDsWithAction.length; + const isDestination = index === totalChains - 1; + const isSource = index === 0; + + const { data: assets } = useAssets(); + + const getAsset = (_chainID: string, denom: string) => assets?.[_chainID]?.find((a) => a.denom === denom); + + const { data: bridge } = useBridgeByID(transferAction?.bridgeID); + + const chainAddress = chainAddresses.get(index); + + const previousChain = index !== 0 && chainIDsWithAction[index - 1]; + const signRequired = (() => { + if (previousChain && previousChain.transferAction?.id === transferAction?.id) { + if (swapAction?.signRequired && transferAction?.signRequired) { + return true; + } + return false; + } + return transferAction?.signRequired || swapAction?.signRequired; + })(); + + useAutoSetAddress({ + chain, + chainID, + index, + enabled: isOpen, + signRequired, + }); + + // tx tracking + const { data: statusData } = useBroadcastedTxsStatus({ + txs: broadcastedTxs, + txsRequired: route.txsRequired, + }); + const stepState = makeStepState({ + statusData, + isDestination: isDestination, + index, + }); + const isSuccess = totalChains === 1 ? mutationStatus.isSuccess : Boolean(stepState?.isSuccess); + const isError = + totalChains === 1 + ? mutationStatus.isError + : route.txsRequired !== broadcastedTxs.length && + mutationStatus.isError && + signRequired && + (broadcastedTxs.length === transferAction?.txIndex || broadcastedTxs.length === swapAction?.txIndex) + ? true + : Boolean(stepState?.isError); + const isLoading = isSource ? mutationStatus.isPending && !isSuccess && !isError : Boolean(stepState?.isLoading); + + const account = useAccount(chainID); + useEffect(() => { + const showCCTPLedgerWarning = isCCTPLedgerBrokenInOperation(route) && account?.wallet?.isLedger === true; + const showEthermintLikeLedgerWarning = isEthermintLedgerInOperation(route) && account?.wallet?.isLedger === true; + + if (signRequired && setShowLedgerWarning) { + setShowLedgerWarning({ + cctp: !!showCCTPLedgerWarning, + ethermint: !!showEthermintLikeLedgerWarning, + }); + } + }, [account, account?.wallet?.isLedger, route, setShowLedgerWarning, signRequired]); + + const swapAsset = + swapAction && + getAsset(swapAction.chainID, isSource && totalChains !== 1 ? swapAction.denomIn : swapAction.denomOut); + const transferAsset = + transferAction && + (getAsset(transferAction.fromChainID, transferAction.denomIn) || + getAsset(transferAction.toChainID, transferAction.denomOut)); + + const { data: chains } = useChains(); + const getChain = (chainID: string) => chains?.find((c) => c.chainID === chainID); + + const intermidiaryChainsImage = useMemo(() => { + return chainIDsWithAction + .filter((c, i) => i !== 0 && i !== totalChains - 1) + .map((c) => { + const chain = getChain(c.chainID); + return { + name: chain?.prettyName, + image: chain?.logoURI || "/logo-fallback.png", + }; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chainIDsWithAction]); + + const isNotFocused = !isDestination && !signRequired && index !== 0; + const isIntermidiaryChain = !(isSource || isDestination || signRequired); + + if (!chain) return null; + if (!isSource && !isDestination && !isExpanded) + return ( + setIsAddressDialogOpen(v ? index : undefined)} + open={isSetAddressDialogOpen} + index={index} + signRequired={Boolean(signRequired)} + isDestination={isDestination} + /> + ); + return ( +
+
+
+
+
+ {chainID} +
+ {signRequired && ( + +
+ +
+
+ )} +
+ {!isDestination && bridge && ( +
+ {transferAction && isExpanded && ( + + {chainID} + + )} + {!isExpanded && ( +
+ {intermidiaryChainsImage.map((c, i) => ( + + {chainID} + + ))} +
+ )} + {!isExpanded && ( + + )} + +
+
+ )} +
+
+ {swapAction && signRequired && !isSource ? ( + + ) : swapAction ? ( + + ) : ( + + )} +
+

{chain?.prettyName}

+ {chainAddress?.address && isIntermidiaryChain && ( + + + + )} + {stepState?.explorerLink && ( + + {stepState.explorerLink.shorthand} + + + )} +
+
+ {chainAddress?.address && !isIntermidiaryChain && ( + <> + {chainAddress?.source !== "input" ? ( + {"wallet"} + ) : ( + + )} + + + + + + )} + {chainAddress?.address && + !isIntermidiaryChain && + !signRequired && + !isSource && + !mutationStatus.isPending && + !isSuccess && ( + + )} +
+
+
+ setIsAddressDialogOpen(v ? index : undefined)} + open={isSetAddressDialogOpen} + index={index} + signRequired={Boolean(signRequired)} + isDestination={isDestination} + /> +
+ ); +}; + +const Asset = ({ + logoURI, + symbol, + amount, + decimals, +}: { + amount?: string; + logoURI?: string; + symbol?: string; + decimals?: number; +}) => { + const amountDisplayed = useMemo(() => { + try { + return formatUnits(BigInt(amount || "0"), decimals ?? 6); + } catch { + return "0"; + } + }, [amount, decimals]); + return ( +
+

{amountDisplayed}

+ {symbol} +

{symbol}

+
+ ); +}; + +const AssetSwap = (props: { + in: { amount?: string; logoURI?: string; symbol?: string; decimals?: number }; + out: { amount?: string; logoURI?: string; symbol?: string; decimals?: number }; +}) => { + return ( +
+ + + +
+ ); +}; diff --git a/src/components/PreviewRoute/SetAddressDialog.tsx b/src/components/PreviewRoute/SetAddressDialog.tsx new file mode 100644 index 00000000..3dbdb700 --- /dev/null +++ b/src/components/PreviewRoute/SetAddressDialog.tsx @@ -0,0 +1,266 @@ +import { fromBech32 } from "@cosmjs/encoding"; +import { ArrowLeftIcon } from "@heroicons/react/20/solid"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { Chain } from "@skip-router/core"; +import { PublicKey } from "@solana/web3.js"; +import Image from "next/image"; +import { useMemo, useState } from "react"; +import { FaKeyboard } from "react-icons/fa"; +import { MdCheck, MdClose } from "react-icons/md"; +import { isAddress } from "viem"; + +import { chainAddresses } from "@/context/chainAddresses"; +import { TrackWalletCtx } from "@/context/track-wallet"; +import { useMakeWallets } from "@/hooks/useMakeWallets"; +import { cn } from "@/utils/ui"; + +import { Dialog, DialogContent } from "../Dialog"; +import { WalletListItem } from "../WalletModal/WalletListItem"; + +export const SetAddressDialog = ({ + open, + onOpen, + chain, + index, + signRequired, + isDestination, +}: { + open: boolean; + onOpen: (v: boolean) => void; + chain: Chain; + index: number; + signRequired: boolean; + isDestination: boolean; +}) => { + const { chainType, chainID, bech32Prefix } = chain; + const { makeWallets } = useMakeWallets(); + const wallets = makeWallets(chainID); + + const [address, setAddress] = useState(""); + const [isEditing, setIsEditing] = useState(false); + + const currentChainAddress = chainAddresses.get(index); + + const validateAddress = (address: string) => { + if (chainType === "cosmos") { + try { + const { prefix } = fromBech32(address); + + return bech32Prefix === prefix; + } catch { + return false; + } + } + if (chainType === "evm") { + try { + return isAddress(address); + } catch (error) { + return false; + } + } + if (chainType === "svm") { + try { + const pk = new PublicKey(address); + return PublicKey.isOnCurve(pk); + } catch (error) { + return false; + } + } + return false; + }; + + const placeholder = useMemo(() => { + if (chainType === "cosmos") { + return `${bech32Prefix}1...`; + } + if (chainType === "evm") { + return "0x..."; + } + if (chainType === "svm") { + return "Enter solanma address..."; + } + return "Enter address..."; + }, [chainType, bech32Prefix]); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const isValid = useMemo(() => validateAddress(address), [address]); + + const save = () => { + chainAddresses.set({ + index, + chainID, + chainType: chain?.chainType as TrackWalletCtx, + address, + source: "input", + }); + setIsEditing(false); + onOpen(false); + }; + + const cancel = () => { + setAddress(chainAddresses.get(index)?.address || ""); + setIsEditing(false); + }; + return ( + onOpen(v)} + open={open} + key={chainID} + > + +
+
+ +

+ Set {isDestination ? "Destination" : "Recovery"} Address +

+ {chain.chainName} +
+ + + + {chainType && + wallets.map((wallet) => { + // currently only svm chainType that have isAvailable + return ( + + + + {chainType === "svm" && wallet.isAvailable !== true && ( +
+ Not Installed +
+ )} +
+ ); + })} + {!signRequired && ( +
+ {isEditing ? ( +
+ setAddress(e.target.value)} + /> + + +
+ ) : ( + + )} +
+ )} +
+ + + + +
+
+
+
+ ); +}; diff --git a/src/components/PreviewRoute/index.tsx b/src/components/PreviewRoute/index.tsx new file mode 100644 index 00000000..438efce1 --- /dev/null +++ b/src/components/PreviewRoute/index.tsx @@ -0,0 +1,485 @@ +import { ArrowLeftIcon, CheckCircleIcon, FingerPrintIcon, InformationCircleIcon } from "@heroicons/react/20/solid"; +import * as Sentry from "@sentry/react"; +import { RouteResponse } from "@skip-router/core"; +import { useMutation } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import toast from "react-hot-toast"; + +import { useAssets } from "@/context/assets"; +import { chainAddresses, useChainAddressesStore } from "@/context/chainAddresses"; +import { useDisclosureKey } from "@/context/disclosures"; +import { useSettingsStore } from "@/context/settings"; +import { trackWallet, TrackWalletCtx } from "@/context/track-wallet"; +import { txHistory } from "@/context/tx-history"; +import { useChains } from "@/hooks/useChains"; +import { useFinalityTimeEstimate } from "@/hooks/useFinalityTimeEstimate"; +import { useMakeWallets } from "@/hooks/useMakeWallets"; +import { useBroadcastedTxsStatus, useSkipClient } from "@/solve"; +import { isUserRejectedRequestError } from "@/utils/error"; +import { getExplorerUrl } from "@/utils/explorer"; +import { randomId } from "@/utils/random"; +import { cn } from "@/utils/ui"; + +import * as AlertCollapse from "./AlertCollapse"; +import { ChainStep } from "./ChainStep"; +import { makeActions } from "./make-actions"; +import { makeChainIDsWithAction } from "./make-chain-ids-with-actions"; +export interface BroadcastedTx { + chainID: string; + txHash: string; + explorerLink: string; +} + +export const PreviewRoute = ({ + route, + disclosure, + isAmountError, +}: { + route: RouteResponse; + disclosure: ReturnType; + isAmountError?: boolean | string; +}) => { + const skipClient = useSkipClient(); + const { getAsset } = useAssets(); + const { data: chains } = useChains(); + const getChain = (chainID: string) => chains?.find((chain) => chain.chainID === chainID); + const { makeWallets } = useMakeWallets(); + + const [isExpanded, setIsExpanded] = useState(false); + const [isOpen, control] = disclosure; + const [indexSetAddressDialogOpen, setIndexIsSetAddressDialogOpen] = useState(); + + const actions = makeActions({ route }); + const chainIDsWithAction = makeChainIDsWithAction({ route, actions }); + + const chainAddressesStore = useChainAddressesStore((state) => state); + const enabledSetAddressIndex = useMemo(() => { + const values = Object.values(chainAddressesStore); + if (values.length === 0) return; + if (!values[values.length - 1]?.address) { + return values.length - 1; + } + return values.findIndex((v) => !v?.address); + }, [chainAddressesStore]); + + const allAddressFilled = route.chainIDs + .map((chainID, index) => { + const chainAddress = chainAddresses.get(index); + + return (Boolean(chainAddress?.address) && chainAddress?.chainID === chainID) === true; + }) + .every((v) => v); + + const [broadcastedTxs, setBroadcastedTxs] = useState([]); + const { data: statusData } = useBroadcastedTxsStatus({ + txs: broadcastedTxs, + txsRequired: route.txsRequired, + }); + + const [_showLedgerWarning, setShowLedgerWarning] = useState({ + cctp: false, + ethermint: false, + }); + const showLedgerWarning = _showLedgerWarning.cctp || _showLedgerWarning.ethermint; + const estimatedFinalityTime = useFinalityTimeEstimate(route); + + async function onSubmit() { + if (!allAddressFilled) throw new Error("All addresses must be filled"); + const historyId = randomId(); + + const userAddresses: Record = {}; + route.chainIDs.forEach((chainID, index) => { + const chainAddress = chainAddresses.get(index); + if (chainID === chainAddress?.chainID && chainAddress?.address) { + userAddresses[chainID] = chainAddress?.address; + } + }); + + const isAddressError = route.chainIDs.some((chainID) => !userAddresses[chainID]); + + if (isAddressError) { + throw new Error("All addresses must be filled"); + } + + try { + await skipClient.executeRoute({ + route, + userAddresses, + validateGasBalance: route.txsRequired === 1, + slippageTolerancePercent: useSettingsStore.getState().slippage, + onTransactionTracked: async (txStatus) => { + const makeExplorerUrl = await getExplorerUrl(txStatus.chainID); + const explorerLink = makeExplorerUrl?.(txStatus.txHash); + + txHistory.addStatus(historyId, route, { + chainId: txStatus.chainID, + txHash: txStatus.txHash, + explorerLink: explorerLink || "#", + }); + + setBroadcastedTxs((v) => { + const txs = [ + ...v, + { + chainID: txStatus.chainID, + txHash: txStatus.txHash, + explorerLink: explorerLink || "#", + }, + ]; + if (route.txsRequired === txs.length) { + toast.success(

You can safely navigate away from this page while your transaction is pending

, { + icon: , + }); + } + return txs; + }); + }, + }); + } catch (err: unknown) { + console.error(err); + if (isUserRejectedRequestError(err)) { + throw new Error("User rejected request"); + } + Sentry.withScope((scope) => { + scope.setUser({ + id: chainAddresses.get(0)?.address, + }); + scope.setTransactionName("Swap.onSubmit"); + scope.setTags({ + sourceChain: route.sourceAssetChainID, + destinationChain: route.destAssetChainID, + sourceAssetDenom: route.sourceAssetDenom, + destinationAssetDenom: route.destAssetDenom, + doesSwap: route.doesSwap, + }); + scope.setExtras({ + sourceAddress: chainAddresses.get(0)?.address, + destinationAddress: chainAddresses.get(route.chainIDs.length - 1)?.address, + sourceChain: route.sourceAssetChainID, + destinationChain: route.destAssetChainID, + userAddresses, + sourceAssetDenom: route.sourceAssetDenom, + destinationAssetDenom: route.destAssetDenom, + amountIn: route.amountIn, + amountOut: route.amountOut, + }); + Sentry.captureException(err); + }); + throw err; + } + } + + const submitMutation = useMutation({ + gcTime: Infinity, + mutationFn: onSubmit, + onMutate: () => { + setIsExpanded(true); + }, + onError: (err: unknown) => { + console.error(err); + toast( + ({ createdAt, id }) => ( +
+

Transaction Failed!

+
+              {err instanceof Error ? `${err.name}: ${err.message}` : String(err)}
+              
+
+ {new Date(createdAt).toISOString()} +
+ +
+ ), + { + ariaProps: { + "aria-live": "assertive", + role: "alert", + }, + duration: Infinity, + }, + ); + }, + }); + + const SubmitButton = () => { + if (allAddressFilled) { + return ( + + ); + } + + const isSignRequired = + enabledSetAddressIndex && chainIDsWithAction[enabledSetAddressIndex]?.transferAction?.signRequired; + + return ( + + ); + }; + + return ( +
+
+
+
+
+ +

Transaction Preview

+
+ {isExpanded && ( + + )} +
+
+ +
+ {chainIDsWithAction.map(({ chainID, transferAction, swapAction }, index) => ( + setIndexIsSetAddressDialogOpen(v)} + isSetAddressDialogOpen={indexSetAddressDialogOpen === index} + isExpanded={isExpanded} + setIsExpanded={setIsExpanded} + isOpen={isOpen} + /> + ))} +
+
+ {statusData?.isSuccess && submitMutation.isSuccess && ( +
+ +

+ {route.doesSwap && + `Successfully swapped ${ + getAsset(route.sourceAssetDenom, route.sourceAssetChainID)?.recommendedSymbol ?? + route.sourceAssetDenom + } for ${getAsset(route.destAssetDenom, route.destAssetChainID)?.recommendedSymbol ?? route.destAssetDenom}`} + {!route.doesSwap && + `Successfully transfered ${ + getAsset(route.sourceAssetDenom, route.sourceAssetChainID)?.recommendedSymbol ?? + route.sourceAssetDenom + } from ${chains?.find((c) => c.chainID === route.sourceAssetChainID)?.prettyName} to ${chains?.find((c) => c.chainID === route.destAssetChainID)?.prettyName}`} +

+
+ )} + + {estimatedFinalityTime !== "" && ( + + EVM bridging finality time is {estimatedFinalityTime} + +

+ This swap contains at least one EVM chain, so it might take longer. Read more about{" "} + + common finality times + + . +

+
+
+ )} + {_showLedgerWarning.cctp && ( + + +

+ WARNING: + ibc.fun does not support signing with Ledger when transferring over CCTP to the Ethereum ecosystem. + We're actively working on fixing this with the Noble/Circle teams. We apologize for the + inconvenience +

+
+
+ )} + {_showLedgerWarning.ethermint && ( + + +

+ WARNING: + ibc.fun does not support signing with Ledger on Ethermint-like chains (e.g. Injective, Dymension, + EVMOS, etc...). We're actively working on fixing this with the Ledger team. We apologize for the + inconvenience. +

+
+
+ )} + {isAmountError && !submitMutation.isPending && !submitMutation.isSuccess && ( +

+ {typeof isAmountError === "string" ? isAmountError : "Insufficient balance."} +

+ )} +
+
+ {!submitMutation.isError && !submitMutation.isSuccess && ( +
+ {route.txsRequired === broadcastedTxs.length ? ( + <> + +

+ Your transaction is being processed. You can safely navigate away from this page. +

+ + ) : route.txsRequired > 1 ? ( + <> +
+
+ +
+

+ {route.txsRequired - broadcastedTxs.length} SIGNATURES{" "} + {submitMutation.isPending ? "REMAINING" : "REQUIRED"} +

+ + ) : null} +
+ )} + + {submitMutation.isPending || submitMutation.isSuccess ? ( + + ) : ( + + )} +
+
+
+ ); +}; + +const HREF_COMMON_FINALITY_TIMES = `https://docs.axelar.dev/learn/txduration#common-finality-time-for-interchain-transactions`; diff --git a/src/components/PreviewRoute/make-actions.ts b/src/components/PreviewRoute/make-actions.ts new file mode 100644 index 00000000..e466487b --- /dev/null +++ b/src/components/PreviewRoute/make-actions.ts @@ -0,0 +1,171 @@ +import { BridgeType, RouteResponse, SwapVenue } from "@skip-router/core"; + +export interface TransferAction { + type: "TRANSFER"; + denomIn: string; + denomOut: string; + fromChainID: string; + toChainID: string; + id: string; + bridgeID?: BridgeType; + signRequired: boolean; + amountIn: string; + amountOut: string; + txIndex: number; +} + +export interface SwapAction { + type: "SWAP"; + denomIn: string; + denomOut: string; + chainID: string; + swapVenue: SwapVenue; + id: string; + signRequired: boolean; + amountIn: string; + amountOut: string; + txIndex: number; +} + +export type Action = TransferAction | SwapAction; + +export const makeActions = ({ route }: { route: RouteResponse }): Action[] => { + const _actions: Action[] = []; + + let swapCount = 0; + let transferCount = 0; + route.operations.forEach((operation, i) => { + const signRequired = (() => { + if (i === 0) { + return true; + } else { + const prevOperation = route.operations[i - 1]; + if (operation.txIndex !== prevOperation.txIndex) { + return true; + } + return false; + } + })(); + + if ("swap" in operation) { + if ("swapIn" in operation.swap) { + _actions.push({ + type: "SWAP", + denomIn: operation.swap.denomIn, + denomOut: operation.swap.denomOut, + chainID: operation.swap.chainID, + id: `swap-${swapCount}-${transferCount}-${i}`, + swapVenue: operation.swap.swapIn.swapVenue, + signRequired, + amountIn: operation.amountIn, + amountOut: operation.amountOut, + txIndex: operation.txIndex, + }); + } + if ("swapOut" in operation.swap) { + _actions.push({ + type: "SWAP", + denomIn: operation.swap.denomIn, + denomOut: operation.swap.denomOut, + chainID: operation.swap.chainID, + id: `swap-${swapCount}-${transferCount}-${i}`, + swapVenue: operation.swap.swapOut.swapVenue, + signRequired, + amountIn: operation.amountIn, + amountOut: operation.amountOut, + txIndex: operation.txIndex, + }); + } + swapCount++; + return; + } + + if ("axelarTransfer" in operation) { + _actions.push({ + type: "TRANSFER", + denomIn: operation.axelarTransfer.denomIn, + denomOut: operation.axelarTransfer.denomOut, + fromChainID: operation.axelarTransfer.fromChainID, + toChainID: operation.axelarTransfer.toChainID, + id: `transfer-${swapCount}-${transferCount}-${i}`, + bridgeID: operation.axelarTransfer.bridgeID, + signRequired, + amountIn: operation.amountIn, + amountOut: operation.amountOut, + txIndex: operation.txIndex, + }); + transferCount++; + return; + } + + if ("cctpTransfer" in operation) { + _actions.push({ + type: "TRANSFER", + denomIn: operation.cctpTransfer.denomIn, + denomOut: operation.cctpTransfer.denomOut, + fromChainID: operation.cctpTransfer.fromChainID, + toChainID: operation.cctpTransfer.toChainID, + id: `transfer-${swapCount}-${transferCount}-${i}`, + bridgeID: operation.cctpTransfer.bridgeID, + signRequired, + amountIn: operation.amountIn, + amountOut: operation.amountOut, + txIndex: operation.txIndex, + }); + transferCount++; + return; + } + + if ("hyperlaneTransfer" in operation) { + _actions.push({ + type: "TRANSFER", + denomIn: operation.hyperlaneTransfer.denomIn, + denomOut: operation.hyperlaneTransfer.denomOut, + fromChainID: operation.hyperlaneTransfer.fromChainID, + toChainID: operation.hyperlaneTransfer.toChainID, + id: `transfer-${swapCount}-${transferCount}-${i}`, + bridgeID: operation.hyperlaneTransfer.bridgeID, + signRequired, + amountIn: operation.amountIn, + amountOut: operation.amountOut, + txIndex: operation.txIndex, + }); + transferCount++; + return; + } + + if ("bankSend" in operation) { + _actions.push({ + type: "TRANSFER", + denomIn: operation.bankSend.denom, + denomOut: operation.bankSend.denom, + fromChainID: operation.bankSend.chainID, + toChainID: operation.bankSend.chainID, + id: `transfer-${swapCount}-${transferCount}-${i}`, + signRequired, + amountIn: operation.amountIn, + amountOut: operation.amountOut, + txIndex: operation.txIndex, + }); + transferCount++; + return; + } + + _actions.push({ + type: "TRANSFER", + denomIn: operation.transfer.denomIn, + denomOut: operation.transfer.denomOut, + fromChainID: operation.transfer.fromChainID, + toChainID: operation.transfer.toChainID, + id: `transfer-${swapCount}-${transferCount}-${i}`, + bridgeID: operation.transfer.bridgeID, + signRequired, + amountIn: operation.amountIn, + amountOut: operation.amountOut, + txIndex: operation.txIndex, + }); + transferCount++; + }); + + return _actions; +}; diff --git a/src/components/PreviewRoute/make-chain-ids-with-actions.ts b/src/components/PreviewRoute/make-chain-ids-with-actions.ts new file mode 100644 index 00000000..82520eeb --- /dev/null +++ b/src/components/PreviewRoute/make-chain-ids-with-actions.ts @@ -0,0 +1,41 @@ +import { RouteResponse } from "@skip-router/core"; + +import { Action, SwapAction, TransferAction } from "./make-actions"; + +export interface ChainIDWithAction { + chainID: string; + transferAction?: TransferAction | undefined; + swapAction: SwapAction | undefined; +} + +export const makeChainIDsWithAction = ({ + route, + actions, +}: { + route: RouteResponse; + actions: Action[]; +}): ChainIDWithAction[] => { + const transferActions = actions.filter((action) => action.type === "TRANSFER") as TransferAction[]; + return route.chainIDs.map((chainID, index) => { + const isFirstChain = index === 0; + const isLastChain = index === route.chainIDs.length - 1; + const prevChainID = !isFirstChain && route.chainIDs[index - 1]; + const nextChainID = !isLastChain && route.chainIDs[index + 1]; + + const chainRoute = isLastChain ? [prevChainID, chainID] : [chainID, nextChainID]; + const transferAction = transferActions.find((action) => { + return action.fromChainID === chainRoute[0] && action.toChainID === chainRoute[1]; + }); + + const swapAction = actions.find((action) => { + if (index === 0 && actions.length <= 2 && action.type === "SWAP" && chainID === action.chainID) { + return true; + } + if (action.type === "SWAP" && chainID === action.chainID) { + return transferAction?.amountOut === action.amountIn && transferAction.toChainID === action.chainID; + } + }) as SwapAction | undefined; + + return { chainID, transferAction, swapAction }; + }); +}; diff --git a/src/components/PreviewRoute/make-step-state.ts b/src/components/PreviewRoute/make-step-state.ts new file mode 100644 index 00000000..bb507fb0 --- /dev/null +++ b/src/components/PreviewRoute/make-step-state.ts @@ -0,0 +1,32 @@ +import { useBroadcastedTxsStatus } from "@/solve"; +import { makeExplorerLink } from "@/utils/link"; + +interface Props { + statusData?: ReturnType["data"]; + isDestination: boolean; + index: number; +} + +export const makeStepState = (props: Props) => { + const { statusData, isDestination, index } = props; + + //format: --- + const data = statusData?.transferSequence[isDestination ? index - 1 : index]; + + if (isDestination) { + return { + isSuccess: data?.state === "TRANSFER_SUCCESS", + isLoading: false, + isError: false, + state: data?.state, + explorerLink: data && data.txs.receiveTx && makeExplorerLink(data.txs.receiveTx.explorerLink), + }; + } + return { + isSuccess: data?.state === "TRANSFER_SUCCESS" || data?.state === "TRANSFER_RECEIVED", + isLoading: data?.state === "TRANSFER_PENDING", + isError: data?.state === "TRANSFER_FAILURE" || data?.state === "TRANSFER_UNKNOWN", + state: data?.state, + explorerLink: data && data.txs.sendTx && makeExplorerLink(data.txs.sendTx.explorerLink), + }; +}; diff --git a/src/components/RouteDisplay/RouteEnd.tsx b/src/components/RouteDisplay/RouteEnd.tsx deleted file mode 100644 index 68849d4a..00000000 --- a/src/components/RouteDisplay/RouteEnd.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import Image from "next/image"; - -import { SimpleTooltip } from "../SimpleTooltip"; - -export interface RouteEndProps { - amount: string; - symbol: string; - chain: string; - logo: string; -} - -export const RouteEnd = ({ amount, symbol, logo, chain }: RouteEndProps) => { - return ( -
-
- {chain} -
-
- -
- {parseFloat(amount).toLocaleString("en-US", { maximumFractionDigits: 8 })} {symbol} -
-
-
On {chain}
-
-
- ); -}; diff --git a/src/components/RouteDisplay/Step.tsx b/src/components/RouteDisplay/Step.tsx deleted file mode 100644 index e401e5ae..00000000 --- a/src/components/RouteDisplay/Step.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid"; - -import { Spinner } from "../Icons/Spinner"; - -export const Step = { - SuccessState: () => ( -
- -
- ), - FailureState: () => ( -
- -
- ), - LoadingState: () => ( -
- -
- ), - DefaultState: () => ( -
- ), -}; diff --git a/src/components/RouteDisplay/SwapStep.tsx b/src/components/RouteDisplay/SwapStep.tsx deleted file mode 100644 index 7e1c52a9..00000000 --- a/src/components/RouteDisplay/SwapStep.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { SwapVenue } from "@skip-router/core"; -import Image from "next/image"; -import { useMemo } from "react"; - -import { SWAP_VENUES } from "@/constants/swap-venues"; -import { useAssets } from "@/context/assets"; -import { useBroadcastedTxsStatus } from "@/solve"; -import { onImageError } from "@/utils/image"; - -import { AdaptiveLink } from "../AdaptiveLink"; -import { Gap } from "../common/Gap"; -import { Action } from "./make-actions"; -import { makeStepState } from "./make-step-state"; -import { Step } from "./Step"; - -export interface SwapAction { - type: "SWAP"; - sourceAsset: string; - destinationAsset: string; - chain: string; - venue: SwapVenue; - id: string; -} - -export interface SwapStepProps { - action: SwapAction; - actions: Action[]; - statusData?: ReturnType["data"]; -} - -export const SwapStep = ({ action, actions, statusData }: SwapStepProps) => { - const { getAsset } = useAssets(); - - const assetIn = useMemo(() => { - return getAsset(action.sourceAsset, action.chain); - }, [action.chain, action.sourceAsset, getAsset]); - - const assetOut = useMemo(() => { - return getAsset(action.destinationAsset, action.chain); - }, [action.chain, action.destinationAsset, getAsset]); - - // swap venue from api don't have pretty name, so we still use the name from the constant - const venue = SWAP_VENUES[action.venue.name]; - - const { explorerLink, state, operationIndex, operationTypeIndex } = makeStepState({ - actions, - action, - statusData, - }); - - const isSwapFirstStep = operationIndex === 0 && operationTypeIndex === 0; - - // as for swap operations, we can assume that the swap is successful if the previous transfer state is TRANSFER_SUCCESS - const renderSwapState = useMemo(() => { - if (isSwapFirstStep) { - if (state === "TRANSFER_PENDING") { - return ; - } - if (state === "TRANSFER_SUCCESS") { - return ; - } - if (state === "TRANSFER_FAILURE") { - return ; - } - - return ; - } - switch (state) { - case "TRANSFER_RECEIVED": - return ; - case "TRANSFER_SUCCESS": - return ; - case "TRANSFER_FAILURE": - return ; - default: - return ; - } - }, [isSwapFirstStep, state]); - - const dataTestValue = JSON.stringify({ - sourceChain: action.chain, - destinationChain: action.chain, - sourceAsset: action.sourceAsset, - destinationAsset: action.destinationAsset, - bridgeOrVenue: action.venue, - type: action.type, - }); - - if (!assetIn && assetOut) { - return ( -
-
{renderSwapState}
-
- - Swap to - - {assetOut.name - {assetOut.recommendedSymbol} - - on - - {action.venue.name} - {venue?.prettyName} - - - {explorerLink && ( - - {explorerLink.shorthand} - - )} -
-
- ); - } - - if (assetIn && !assetOut) { - return ( -
-
{renderSwapState}
-
- - Swap - - {assetIn.name - {assetIn.recommendedSymbol} - - on - - {action.venue.name} - {venue?.prettyName} - - - {explorerLink && ( - - {explorerLink.shorthand} - - )} -
-
- ); - } - - if (!assetIn || !assetOut) { - return null; - } - - return ( -
-
{renderSwapState}
-
- - Swap - - {assetIn.name - - {assetIn.recommendedSymbol} - - for - - {assetOut.name - {assetOut.recommendedSymbol} - - - on - {action.venue.name} - {venue?.prettyName} - - - {explorerLink && ( - - {explorerLink.shorthand} - - )} -
-
- ); -}; diff --git a/src/components/RouteDisplay/TransferStep.tsx b/src/components/RouteDisplay/TransferStep.tsx deleted file mode 100644 index 940c3764..00000000 --- a/src/components/RouteDisplay/TransferStep.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { BridgeType } from "@skip-router/core"; -import Image from "next/image"; -import { useMemo } from "react"; - -import { useAssets } from "@/context/assets"; -import { useBridgeByID } from "@/hooks/useBridges"; -import { useChainByID } from "@/hooks/useChains"; -import { useBroadcastedTxsStatus } from "@/solve"; -import { onImageError } from "@/utils/image"; - -import { AdaptiveLink } from "../AdaptiveLink"; -import { Gap } from "../common/Gap"; -import { Action } from "./make-actions"; -import { makeStepState } from "./make-step-state"; -import { Step } from "./Step"; - -export interface TransferAction { - type: "TRANSFER"; - asset: string; - sourceChain: string; - destinationChain: string; - id: string; - bridgeID: BridgeType; -} - -interface TransferStepProps { - actions: Action[]; - action: TransferAction; - statusData?: ReturnType["data"]; -} - -export const TransferStep = ({ action, actions, statusData }: TransferStepProps) => { - const { getAsset } = useAssets(); - const { data: bridge } = useBridgeByID(action.bridgeID); - const { data: sourceChain } = useChainByID(action.sourceChain); - const { data: destinationChain } = useChainByID(action.destinationChain); - - const { explorerLink, state, operationIndex } = makeStepState({ actions, action, statusData }); - - const isFirstOpSwap = actions[0]?.type === "SWAP"; - - const renderTransferState = useMemo(() => { - // We don't show loading state if first operation is swap operation, loading will be in swap operation - if (isFirstOpSwap) { - if (state === "TRANSFER_FAILURE") { - return ; - } - if (state === "TRANSFER_SUCCESS") { - return ; - } - return ; - } - // We can assume that the transfer operation is successful when the state is TRANSFER_SUCCESS or TRANSFER_RECEIVED - switch (state) { - case "TRANSFER_SUCCESS": - return ; - case "TRANSFER_RECEIVED": - return ; - case "TRANSFER_FAILURE": - return ; - case "TRANSFER_PENDING": - return ; - - default: - return
; - } - }, [isFirstOpSwap, state]); - - const asset = (() => { - const currentAsset = getAsset(action.asset, action.sourceChain); - if (currentAsset) return currentAsset; - const prevAction = actions[operationIndex - 1]; - if (!prevAction || prevAction.type !== "TRANSFER") return; - const prevAsset = getAsset(prevAction.asset, prevAction.sourceChain); - return prevAsset; - })(); - - const dataTestValue = JSON.stringify({ - sourceChain: action.sourceChain, - destinationChain: action.destinationChain, - sourceAsset: action.asset, - destinationAsset: action.asset, - bridgeOrVenue: action.bridgeID, - type: action.type, - }); - - if (!sourceChain || !destinationChain) { - // this should be unreachable - return null; - } - - if (!asset) { - return ( -
-
{renderTransferState}
-
- - Transfer - from - - {sourceChain.prettyName} - {sourceChain.prettyName} - - - - to - - {destinationChain.prettyName} - {destinationChain.prettyName} - - {bridge && ( - <> - with - - {bridge.name.toLowerCase() !== "ibc" && ( - {bridge.name} - )} - - {bridge.name} - - - )} - - {explorerLink && ( - - {explorerLink.shorthand} - - )} -
-
- ); - } - - return ( -
-
{renderTransferState}
-
- - Transfer - - {asset.name - {asset.recommendedSymbol} - - from - - {sourceChain.prettyName} - {sourceChain.prettyName} - - - - to - - {destinationChain.prettyName} - {destinationChain.prettyName} - - {bridge && ( - <> - with - - {bridge.name.toLowerCase() !== "ibc" && ( - {bridge.name} - )} - - {bridge.name} - - - )} - - {explorerLink && ( - - {explorerLink.shorthand} - - )} -
-
- ); -}; diff --git a/src/components/RouteDisplay/__test__/make-actions.test.tsx b/src/components/RouteDisplay/__test__/make-actions.test.tsx deleted file mode 100644 index 3c818529..00000000 --- a/src/components/RouteDisplay/__test__/make-actions.test.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { makeActions } from "../make-actions"; -import { - cosmosHubAtomToAkashAKT, - cosmoshubATOMToAkashATOM, - cosmoshubATOMToArbitrumARB, - nobleUSDCToEthereumUSDC, - RouteArgs, -} from "./route-to-test"; -import { createRoute } from "./utils"; - -const makeActionsTest = async (_route: RouteArgs) => { - const { direction, amount, sourceAsset, sourceAssetChainID, destinationAsset, destinationAssetChainID, swapVenue } = - _route; - - const route = await createRoute( - direction === "swap-in" - ? { - amountIn: amount, - sourceAssetDenom: sourceAsset, - sourceAssetChainID: sourceAssetChainID, - destAssetDenom: destinationAsset, - destAssetChainID: destinationAssetChainID, - swapVenue, - allowMultiTx: true, - allowUnsafe: true, - experimentalFeatures: ["cctp"], - } - : { - amountOut: amount, - sourceAssetDenom: sourceAsset, - sourceAssetChainID: sourceAssetChainID, - destAssetDenom: destinationAsset, - destAssetChainID: destinationAssetChainID, - swapVenue, - allowMultiTx: true, - allowUnsafe: true, - experimentalFeatures: ["cctp"], - }, - ); - const actions = makeActions({ route }); - - expect(actions).toBeTruthy(); - expect(actions.length).toBeGreaterThan(0); - - const totalActions = actions.length; - - actions.forEach((currentAction, i) => { - const nextAction = i === totalActions - 1 ? undefined : actions[i + 1]; - const prevAction = i === 0 ? undefined : actions[i - 1]; - const bridgeId = (() => { - if ("transfer" in route.operations[i]) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - return route.operations[i].transfer.bridgeID; - } - if ("axelarTransfer" in route.operations[i]) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - return route.operations[i].axelarTransfer.bridgeID; - } - if ("cctpTransfer" in route.operations[i]) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - return route.operations[i].cctpTransfer.bridgeID; - } - return undefined; - })(); - if (currentAction.type === "TRANSFER") { - expect(currentAction.bridgeID).toBe(bridgeId); - if (actions.length === 1) { - expect(currentAction.sourceChain).toBe(_route.sourceAssetChainID); - expect(currentAction.asset).toBe(_route.sourceAsset); - expect(currentAction.destinationChain).toBe(_route.destinationAssetChainID); - return; - } - if (i === 0) { - expect(currentAction.sourceChain).toBe(_route.sourceAssetChainID); - expect(currentAction.asset).toBe(_route.sourceAsset); - return; - } - if (nextAction) { - if (nextAction.type === "SWAP") { - expect(currentAction.destinationChain).toBe(nextAction.chain); - return; - } - if (nextAction.type === "TRANSFER") { - expect(currentAction.destinationChain).toBe(nextAction.sourceChain); - return; - } - } - } - if (currentAction.type === "SWAP") { - if (prevAction) { - if (prevAction.type === "TRANSFER") { - expect(currentAction.chain).toBe(prevAction.destinationChain); - return; - } - } - } - }); - return actions; -}; - -test("make-actions: Cosmoshub ATOM -> Akash AKT", async () => { - await makeActionsTest(cosmosHubAtomToAkashAKT); -}, 30000); - -test("make-actions: Cosmoshub ATOM to Akash ATOM", async () => { - await makeActionsTest(cosmoshubATOMToAkashATOM); -}, 30000); - -test("make-actions: Noble USDC to Ethereum USDC", async () => { - await makeActionsTest(nobleUSDCToEthereumUSDC); -}, 30000); - -test("make-actions: Cosmoshub ATOM to Arbitrum ARB", async () => { - await makeActionsTest(cosmoshubATOMToArbitrumARB); -}, 30000); diff --git a/src/components/RouteDisplay/__test__/make-step.test.tsx b/src/components/RouteDisplay/__test__/make-step.test.tsx deleted file mode 100644 index 6131c93b..00000000 --- a/src/components/RouteDisplay/__test__/make-step.test.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { useBroadcastedTxsStatus } from "@/solve"; -import { AllTheProviders, renderHook, waitFor } from "@/test"; - -import { makeActions } from "../make-actions"; -import { makeStepState } from "../make-step-state"; -import { cosmoshubATOMToAkashATOM } from "./route-to-test"; -import { createRoute } from "./utils"; - -test("make-step: cosmoshub ATOM to Akash ATOM", async () => { - const { direction, amount, sourceAsset, sourceAssetChainID, destinationAsset, destinationAssetChainID, swapVenue } = - cosmoshubATOMToAkashATOM; - - const route = await createRoute( - direction === "swap-in" - ? { - amountIn: amount, - sourceAssetDenom: sourceAsset, - sourceAssetChainID: sourceAssetChainID, - destAssetDenom: destinationAsset, - destAssetChainID: destinationAssetChainID, - swapVenue, - allowMultiTx: true, - allowUnsafe: true, - experimentalFeatures: ["cctp"], - } - : { - amountOut: amount, - sourceAssetDenom: sourceAsset, - sourceAssetChainID: sourceAssetChainID, - destAssetDenom: destinationAsset, - destAssetChainID: destinationAssetChainID, - swapVenue, - allowMultiTx: true, - allowUnsafe: true, - experimentalFeatures: ["cctp"], - }, - ); - const actions = makeActions({ route }); - const { result } = renderHook( - () => - useBroadcastedTxsStatus({ - txsRequired: route.txsRequired, - txs: [ - { - chainID: "cosmoshub-4", - txHash: "F793B9F1ABCA715FF4706004AA4E220E6F0E5BE79CA97D5FD799BF6FD27BE036", - }, - ], - enabled: true, - }), - { - wrapper: AllTheProviders, - }, - ); - await waitFor(() => expect(result.current.isLoading).toBeFalsy(), { - timeout: 120000, - }); - - actions.forEach((action, i) => { - const { explorerLink, operationIndex, operationTypeIndex, state } = makeStepState({ - action, - actions, - statusData: result.current.data, - }); - expect(operationIndex).toEqual(i); - expect(state).toBeDefined(); - expect(operationTypeIndex).toBeDefined(); - expect(explorerLink).toBeDefined(); - }); -}, 120000); diff --git a/src/components/RouteDisplay/__test__/route-to-test.ts b/src/components/RouteDisplay/__test__/route-to-test.ts deleted file mode 100644 index a1d1c843..00000000 --- a/src/components/RouteDisplay/__test__/route-to-test.ts +++ /dev/null @@ -1,59 +0,0 @@ -export interface RouteArgs { - direction: string; - amount: string; - sourceAsset: string; - sourceAssetChainID: string; - destinationAsset: string; - destinationAssetChainID: string; - swapVenue: undefined; -} - -export const cosmosHubAtomToAkashAKT = { - direction: "swap-in", - amount: "1000000", - sourceAsset: "uatom", - sourceAssetChainID: "cosmoshub-4", - destinationAsset: "uakt", - destinationAssetChainID: "akashnet-2", - swapVenue: undefined, -}; - -export const cosmoshubATOMToAkashATOM = { - direction: "swap-in", - amount: "1000000", - sourceAsset: "uatom", - sourceAssetChainID: "cosmoshub-4", - destinationAsset: "ibc/2E5D0AC026AC1AFA65A23023BA4F24BB8DDF94F118EDC0BAD6F625BFC557CDED", - destinationAssetChainID: "akashnet-2", - swapVenue: undefined, -}; - -export const nobleUSDCToEthereumUSDC = { - direction: "swap-in", - amount: "11000000", - sourceAsset: "uusdc", - sourceAssetChainID: "noble-1", - destinationAsset: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - destinationAssetChainID: "1", - swapVenue: undefined, -}; - -export const cosmoshubATOMToArbitrumARB = { - direction: "swap-in", - amount: "11000000", - sourceAsset: "uatom", - sourceAssetChainID: "cosmoshub-4", - destinationAsset: "0x912CE59144191C1204E64559FE8253a0e49E6548", - destinationAssetChainID: "42161", - swapVenue: undefined, -}; - -export const nobleUSDCtoInjectiveINJ = { - direction: "swap-in", - amount: "1000000", - sourceAsset: "uusdc", - sourceAssetChainID: "noble-1", - destinationAsset: "inj", - destinationAssetChainID: "injective-1", - swapVenue: undefined, -}; diff --git a/src/components/RouteDisplay/__test__/utils.ts b/src/components/RouteDisplay/__test__/utils.ts deleted file mode 100644 index 9b8943db..00000000 --- a/src/components/RouteDisplay/__test__/utils.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { RouteRequest, SkipRouter } from "@skip-router/core"; -import { waitFor } from "@testing-library/react"; - -import { API_URL, APP_URL } from "@/constants/api"; - -export const createRoute = async (options: RouteRequest) => { - const skipClient = new SkipRouter({ - clientID: process.env.NEXT_PUBLIC_CLIENT_ID, - apiURL: API_URL, - endpointOptions: { - getRpcEndpointForChain: async (chainID) => { - return `${APP_URL}/api/rpc/${chainID}`; - }, - getRestEndpointForChain: async (chainID) => { - return `${APP_URL}/api/rest/${chainID}`; - }, - }, - }); - const route = await skipClient.route(options); - await waitFor(() => expect(route).toBeTruthy(), { - timeout: 10000, - }); - - if (!route) { - throw new Error("useRoute hook returned no data"); - } - return route; -}; diff --git a/src/components/RouteDisplay/index.tsx b/src/components/RouteDisplay/index.tsx deleted file mode 100644 index 2b72c804..00000000 --- a/src/components/RouteDisplay/index.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { RouteResponse } from "@skip-router/core"; -import { Dispatch, Fragment, SetStateAction, useMemo } from "react"; -import { formatUnits } from "viem"; - -import { useAssets } from "@/context/assets"; -import { useChainByID } from "@/hooks/useChains"; -import { useBroadcastedTxsStatus } from "@/solve"; - -import { ExpandArrow } from "../Icons/ExpandArrow"; -import { BroadcastedTx } from "../TransactionDialog/TransactionDialogContent"; -import { makeActions } from "./make-actions"; -import { RouteEnd } from "./RouteEnd"; -import { SwapStep } from "./SwapStep"; -import { TransferStep } from "./TransferStep"; - -interface RouteDisplayProps { - route: RouteResponse; - isRouteExpanded: boolean; - setIsRouteExpanded: Dispatch>; - broadcastedTxs?: BroadcastedTx[]; -} - -export const RouteDisplay = ({ route, isRouteExpanded, setIsRouteExpanded, broadcastedTxs }: RouteDisplayProps) => { - const { getAsset } = useAssets(); - - const sourceAsset = getAsset(route.sourceAssetDenom, route.sourceAssetChainID); - - const destinationAsset = getAsset(route.destAssetDenom, route.destAssetChainID); - - const { data: sourceChain } = useChainByID(route.sourceAssetChainID); - const { data: destinationChain } = useChainByID(route.destAssetChainID); - - const amountIn = useMemo(() => { - try { - return formatUnits(BigInt(route.amountIn), sourceAsset?.decimals ?? 6); - } catch { - return "0"; - } - }, [route.amountIn, sourceAsset?.decimals]); - - const amountOut = useMemo(() => { - try { - return formatUnits(BigInt(route.amountOut), destinationAsset?.decimals ?? 6); - } catch { - return "0"; - } - }, [route.amountOut, destinationAsset?.decimals]); - - const actions = useMemo(() => makeActions({ route }), [route]); - const { data: statusData } = useBroadcastedTxsStatus({ txsRequired: route.txsRequired, txs: broadcastedTxs }); - - return ( -
-
-
-
-
-
- - {isRouteExpanded && ( - - )} -
- {isRouteExpanded && - actions.map((action, i) => ( - - {action.type === "SWAP" && ( - - )} - {action.type === "TRANSFER" && ( - - )} - - ))} - {!isRouteExpanded && ( -
- -
- )} - -
-
- ); -}; diff --git a/src/components/RouteDisplay/make-actions.ts b/src/components/RouteDisplay/make-actions.ts deleted file mode 100644 index 13a311f1..00000000 --- a/src/components/RouteDisplay/make-actions.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { RouteResponse } from "@skip-router/core"; - -import { SwapAction } from "./SwapStep"; -import { TransferAction } from "./TransferStep"; - -export type Action = TransferAction | SwapAction; - -export const makeActions = ({ route }: { route: RouteResponse }): Action[] => { - const _actions: Action[] = []; - - let swapCount = 0; - let transferCount = 0; - let asset = route.sourceAssetDenom; - - route.operations.forEach((operation, i) => { - if ("swap" in operation) { - if ("swapIn" in operation.swap) { - _actions.push({ - type: "SWAP", - sourceAsset: operation.swap.swapIn.swapOperations[0].denomIn, - destinationAsset: - operation.swap.swapIn.swapOperations[operation.swap.swapIn.swapOperations.length - 1].denomOut, - chain: operation.swap.swapIn.swapVenue.chainID, - venue: operation.swap.swapIn.swapVenue, - id: `SWAP-${swapCount}-${i}`, - }); - - asset = operation.swap.swapIn.swapOperations[operation.swap.swapIn.swapOperations.length - 1].denomOut; - } - - if ("swapOut" in operation.swap) { - _actions.push({ - type: "SWAP", - sourceAsset: operation.swap.swapOut.swapOperations[0].denomIn, - destinationAsset: - operation.swap.swapOut.swapOperations[operation.swap.swapOut.swapOperations.length - 1].denomOut, - chain: operation.swap.swapOut.swapVenue.chainID, - venue: operation.swap.swapOut.swapVenue, - id: `SWAP-${swapCount}-${i}`, - }); - - asset = operation.swap.swapOut.swapOperations[operation.swap.swapOut.swapOperations.length - 1].denomOut; - } - swapCount++; - return; - } - - if ("axelarTransfer" in operation) { - _actions.push({ - type: "TRANSFER", - asset, - sourceChain: operation.axelarTransfer.fromChainID, - destinationChain: operation.axelarTransfer.toChainID, - id: `TRANSFER-${transferCount}-${i}`, - bridgeID: operation.axelarTransfer.bridgeID, - }); - - asset = operation.axelarTransfer.asset; - transferCount++; - return; - } - - if ("cctpTransfer" in operation) { - _actions.push({ - type: "TRANSFER", - asset, - sourceChain: operation.cctpTransfer.fromChainID, - destinationChain: operation.cctpTransfer.toChainID, - id: `TRANSFER-${transferCount}-${i}`, - bridgeID: operation.cctpTransfer.bridgeID, - }); - - asset = operation.cctpTransfer.burnToken; - transferCount++; - return; - } - - if ("hyperlaneTransfer" in operation) { - _actions.push({ - type: "TRANSFER", - asset, - sourceChain: operation.hyperlaneTransfer.fromChainID, - destinationChain: operation.hyperlaneTransfer.toChainID, - id: `transfer-${transferCount}-${i}`, - bridgeID: operation.hyperlaneTransfer.bridgeID, - }); - - asset = operation.hyperlaneTransfer.denomIn; - transferCount++; - return; - } - - if ("bankSend" in operation) { - _actions.push({ - type: "TRANSFER", - asset, - sourceChain: operation.bankSend.chainID, - destinationChain: operation.bankSend.chainID, - id: `transfer-${transferCount}-${i}`, - bridgeID: "IBC", - }); - - asset = operation.bankSend.denom; - transferCount++; - return; - } - - const sourceChain = operation.transfer.chainID; - - let destinationChain = ""; - if (i === route.operations.length - 1) { - destinationChain = route.destAssetChainID; - } else { - const nextOperation = route.operations[i + 1]; - if ("swap" in nextOperation) { - if ("swapIn" in nextOperation.swap) { - destinationChain = nextOperation.swap.swapIn.swapVenue.chainID; - } - - if ("swapOut" in nextOperation.swap) { - destinationChain = nextOperation.swap.swapOut.swapVenue.chainID; - } - } else if ("axelarTransfer" in nextOperation) { - destinationChain = nextOperation.axelarTransfer.fromChainID; - } else if ("cctpTransfer" in nextOperation) { - destinationChain = nextOperation.cctpTransfer.fromChainID; - } else if ("hyperlaneTransfer" in nextOperation) { - destinationChain = nextOperation.hyperlaneTransfer.fromChainID; - } else if ("bankSend" in nextOperation) { - destinationChain = nextOperation.bankSend.chainID; - } else { - destinationChain = nextOperation.transfer.chainID; - } - } - - _actions.push({ - type: "TRANSFER", - asset, - sourceChain, - destinationChain, - id: `TRANSFER-${transferCount}-${i}`, - bridgeID: operation.transfer.bridgeID, - }); - - asset = operation.transfer.destDenom; - transferCount++; - }); - - return _actions; -}; diff --git a/src/components/RouteDisplay/make-step-state.ts b/src/components/RouteDisplay/make-step-state.ts deleted file mode 100644 index 3832fd95..00000000 --- a/src/components/RouteDisplay/make-step-state.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { useBroadcastedTxsStatus } from "@/solve"; -import { makeExplorerLink } from "@/utils/link"; - -import { Action } from "./make-actions"; - -export const makeStepState = ({ - actions, - action, - statusData, -}: { - actions: Action[]; - action: Action; - statusData?: ReturnType["data"]; -}) => { - // operations from router and tx/status response are not one to one - // tx/status only tracks transfer operations - - // format: -- - const _id = action.id.split("-"); - const operationType = action.type; - const operationTypeIndex = Number(_id[1]); - const operationIndex = Number(_id[2]); - - // swap operation - if (operationType === "SWAP") { - // Swap operation is not tracked by tx/status - // so we got the state from the previous transfer operation - // or next transfer operation if swap is the first operation - // ┌───────────┐ ┌───────────┐ - // │ Transfer │◀─┐ │ Swap │──┐ (first operation) - // └───────────┘ │ └───────────┘ │ - // │ state │ state - // ┌───────────┐ │ ┌───────────┐ │ - // │ Swap │──┘ │ Transfer │◀─┘ - // └───────────┘ └───────────┘ - const isSwapFirstStep = operationIndex === 0 && operationTypeIndex === 0; - const prevTransferOpIndex = Number( - actions.find((x) => Number(x.id.split("-")[2]) === operationIndex - 1)?.id.split("-")[1], - ); - const swapSequence = statusData?.transferSequence[isSwapFirstStep ? 0 : prevTransferOpIndex]; - const explorerLink = (() => { - const tx = isSwapFirstStep ? swapSequence?.txs.sendTx : swapSequence?.txs.receiveTx; - if (!tx) return; - if (swapSequence?.state !== "TRANSFER_SUCCESS") return; - return makeExplorerLink(tx.explorerLink); - })(); - return { - state: swapSequence?.state, - explorerLink, - operationIndex, - operationTypeIndex, - }; - } - - // transfer operation - const isNextOpSwap = - actions.find((x) => Number(x.id.split("-")[2]) === operationIndex + 1)?.id.split("-")[0] === "SWAP"; - const isPrevOpTransfer = actions[operationIndex - 1]?.type === "TRANSFER"; - const transferSequence = statusData?.transferSequence[operationTypeIndex]; - const explorerLink = (() => { - const packetTx = (() => { - if (operationIndex === 0) return transferSequence?.txs.sendTx; - if (isNextOpSwap) return transferSequence?.txs.sendTx; - if (isPrevOpTransfer) return transferSequence?.txs.sendTx; - return transferSequence?.txs.receiveTx; - })(); - if (!packetTx?.explorerLink) { - return null; - } - return makeExplorerLink(packetTx.explorerLink); - })(); - - return { - state: transferSequence?.state, - explorerLink, - operationIndex, - operationTypeIndex, - }; -}; diff --git a/src/components/RouteLoadingBanner.tsx b/src/components/RouteLoadingBanner.tsx deleted file mode 100644 index 27750be7..00000000 --- a/src/components/RouteLoadingBanner.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { SpinnerIcon } from "./SpinnerIcon"; - -export default function RouteLoadingBanner() { - return ( -
-

Finding best route...

- -
- ); -} diff --git a/src/components/RouteTransactionCountBanner.tsx b/src/components/RouteTransactionCountBanner.tsx deleted file mode 100644 index f6efb00d..00000000 --- a/src/components/RouteTransactionCountBanner.tsx +++ /dev/null @@ -1,15 +0,0 @@ -interface Props { - numberOfTransactions: number; -} - -export default function RouteTransactionCountBanner({ numberOfTransactions }: Props) { - return ( -
-

- This route requires {numberOfTransactions === 1 && 1 Transaction} - {numberOfTransactions > 1 && {numberOfTransactions} Transactions} to - complete -

-
- ); -} diff --git a/src/components/SwapWidget/SwapWidget.tsx b/src/components/SwapWidget/SwapWidget.tsx index 57c45c45..22484e02 100644 --- a/src/components/SwapWidget/SwapWidget.tsx +++ b/src/components/SwapWidget/SwapWidget.tsx @@ -1,4 +1,4 @@ -import { ArrowsUpDownIcon } from "@heroicons/react/20/solid"; +import { ArrowsUpDownIcon, FingerPrintIcon } from "@heroicons/react/20/solid"; import * as Tooltip from "@radix-ui/react-tooltip"; import { ElementRef, useEffect, useRef } from "react"; import type {} from "typed-query-selector"; @@ -13,9 +13,8 @@ import AssetInput from "../AssetInput"; import { ConnectedWalletButton } from "../ConnectedWalletButton"; import { HistoryButton } from "../HistoryButton"; import { HistoryDialog } from "../HistoryDialog"; +import { Spinner } from "../Icons/Spinner"; import { JsonDialog } from "../JsonDialog"; -import RouteLoadingBanner from "../RouteLoadingBanner"; -import RouteTransactionCountBanner from "../RouteTransactionCountBanner"; import { SettingsButton } from "../SettingsButton"; import { SettingsDialog } from "../SettingsDialog"; import { SimpleTooltip } from "../SimpleTooltip"; @@ -65,10 +64,9 @@ export function SwapWidget() { usdDiffPercent, } = useSwapWidget(); - const srcAccount = useAccount("source"); - const destAccount = useAccount("destination"); + const srcAccount = useAccount(sourceChain?.chainID); - const isWalletConnected = srcAccount?.isWalletConnected && destAccount?.isWalletConnected; + const isWalletConnected = srcAccount?.isWalletConnected; function promptDestAsset() { document.querySelector("[data-testid='destination'] button")?.click(); @@ -87,9 +85,7 @@ export function SwapWidget() { return () => ref.removeEventListener("animationend", listener); }, []); - const accountStateKey = `${ - srcAccount?.isWalletConnected ? "src" : "no-src" - }-${destAccount?.isWalletConnected ? "dest" : "no-dest"}`; + const accountStateKey = `${srcAccount?.isWalletConnected ? "src" : "no-src"}`; return ( @@ -108,7 +104,7 @@ export function SwapWidget() { sourceChain?.chainID && openWalletModal(sourceChain.chainID, "source")} + onClick={() => sourceChain?.chainID && openWalletModal(sourceChain.chainID)} walletName={srcAccount.wallet?.walletPrettyName} walletLogo={ srcAccount.wallet.walletInfo @@ -161,28 +157,6 @@ export function SwapWidget() {

To

-
- {destAccount?.address && destAccount?.wallet ? ( - - { - destinationChain?.chainID && openWalletModal(destinationChain.chainID, "destination"); - }} - walletName={destAccount.wallet?.walletPrettyName} - walletLogo={ - destAccount.wallet.walletInfo - ? typeof destAccount.wallet.walletInfo.logo === "string" - ? destAccount.wallet.walletInfo.logo - : destAccount.wallet.walletInfo.logo?.major || destAccount.wallet.walletInfo.logo?.minor - : "" - } - className="animate-slide-left-and-fade" - key={destAccount.address} - /> - - ) : null} -
)} - {routeLoading && } - {route && !routeLoading && } + {routeLoading && ( +
+

Finding best route...

+ +
+ )} + {route && !routeLoading && numberOfTransactions > 1 && ( +
+
+
+ +
+

{numberOfTransactions} Signature Required

+
+ )} {!!routeError && (

{routeError}

@@ -243,26 +230,20 @@ export function SwapWidget() { disabled={!sourceChain} onClick={async () => { if (sourceChain && !srcAccount?.isWalletConnected) { - openWalletModal(sourceChain.chainID, "source"); + openWalletModal(sourceChain.chainID); return; } if (!destinationChain) { promptDestAsset(); return; } - if (destinationChain && !destAccount?.isWalletConnected) { - openWalletModal(destinationChain.chainID, "destination"); - return; - } }} >
- {!srcAccount?.isWalletConnected && !destAccount?.isWalletConnected && "Connect Wallet"} - {!srcAccount?.isWalletConnected && destAccount?.isWalletConnected && "Connect Source Wallet"} - {srcAccount?.isWalletConnected && !destAccount?.isWalletConnected && "Connect Destination Wallet"} + {!srcAccount?.isWalletConnected && "Connect Wallet"}
)} @@ -271,7 +252,6 @@ export function SwapWidget() { { return getAmountWei(amountIn, srcAsset?.decimals); }, [amountIn, srcAsset?.decimals]); @@ -605,18 +606,15 @@ export function useSwapWidget() { return useSwapWidgetStore.subscribe( (state) => state.sourceChain, async (srcChain) => { - const { source: srcTrack, destination: dstTrack } = trackWallet.get(); + const { cosmos, svm } = trackWallet.get(); if (srcChain && srcChain.chainType === "cosmos") { const { wallets } = getWalletRepo(srcChain.chainName); let wallet: (typeof wallets)[number] | undefined; - if (srcTrack?.chainType === "cosmos") { - wallet = wallets.find((w) => { - return w.walletName === srcTrack.walletName; - }); - } else if (dstTrack?.chainType === "cosmos") { + + if (cosmos?.chainType === "cosmos") { wallet = wallets.find((w) => { - return w.walletName === dstTrack.walletName; + return w.walletName === cosmos.walletName; }); } else { wallet = wallets.find((w) => { @@ -626,12 +624,12 @@ export function useSwapWidget() { if (wallet) { try { await gracefullyConnect(wallet); - trackWallet.track("source", srcChain.chainID, wallet.walletName, srcChain.chainType); + trackWallet.track("cosmos", wallet.walletName, srcChain.chainType); } catch (error) { console.error(error); } } else { - trackWallet.untrack("source"); + trackWallet.untrack("cosmos"); } } if (srcChain && srcChain.chainType === "evm") { @@ -640,32 +638,30 @@ export function useSwapWidget() { if (switchNetworkAsync && evmChain.id !== +srcChain.chainID) { await switchNetworkAsync({ chainId: +srcChain.chainID }); } - trackWallet.track("source", srcChain.chainID, connector.id, srcChain.chainType); + trackWallet.track("evm", connector.id, srcChain.chainType); } catch (error) { console.error(error); - trackWallet.untrack("source"); + trackWallet.untrack("evm"); disconnect(); } } else { - trackWallet.untrack("source"); + trackWallet.untrack("evm"); disconnect(); } } if (srcChain && srcChain.chainType === "svm") { let wallet: (typeof wallets)[number] | undefined; - if (srcTrack?.chainType === "svm") { - wallet = wallets.find((w) => w.adapter.name === srcTrack?.walletName); - } else if (dstTrack?.chainType === "svm") { - wallet = wallets.find((w) => w.adapter.name === dstTrack?.walletName); + if (svm?.chainType === "svm") { + wallet = wallets.find((w) => w.adapter.name === svm?.walletName); } else { wallet = wallets.find((w) => w.adapter.connected); } if (wallet) { wallet.adapter.connect(); - trackWallet.track("source", srcChain.chainID, wallet.adapter.name, srcChain.chainType); + trackWallet.track("svm", wallet.adapter.name, srcChain.chainType); } else { - trackWallet.untrack("source"); + trackWallet.untrack("svm"); } } }, @@ -676,133 +672,13 @@ export function useSwapWidget() { ); }, [connector, disconnect, evmChain, getWalletRepo, switchNetworkAsync, wallets]); - /** - * sync destination chain wallet connections - * @see {dstChain} - */ - useEffect(() => { - return useSwapWidgetStore.subscribe( - (state) => state.destinationChain, - async (dstChain) => { - const { source: srcTrack, destination: dstTrack } = trackWallet.get(); - - if (dstChain && dstChain.chainType === "cosmos") { - const { wallets } = getWalletRepo(dstChain.chainName); - let wallet: (typeof wallets)[number] | undefined; - if (dstTrack?.chainType === "cosmos") { - wallet = wallets.find((w) => { - return w.walletName === dstTrack.walletName; - }); - } else if (srcTrack?.chainType === "cosmos") { - wallet = wallets.find((w) => { - return w.walletName === srcTrack.walletName; - }); - } else { - wallet = wallets.find((w) => { - return w.isWalletConnected && !w.isWalletDisconnected; - }); - } - if (wallet) { - try { - await gracefullyConnect(wallet); - trackWallet.track("destination", dstChain.chainID, wallet.walletName, dstChain.chainType); - } catch (error) { - console.error(error); - } - } else { - trackWallet.untrack("destination"); - } - } - if (dstChain && dstChain.chainType === "evm") { - if (evmChain && connector) { - try { - if (switchNetworkAsync && evmChain.id !== +dstChain.chainID && srcChain && srcChain.chainType !== "evm") { - await switchNetworkAsync({ chainId: +dstChain.chainID }); - } - trackWallet.track("destination", dstChain.chainID, connector.id, dstChain.chainType); - } catch (error) { - console.error(error); - trackWallet.untrack("destination"); - disconnect(); - } - } else { - trackWallet.untrack("destination"); - disconnect(); - } - } - if (dstChain && dstChain.chainType === "svm") { - let wallet: (typeof wallets)[number] | undefined; - if (dstTrack?.chainType === "svm") { - wallet = wallets.find((w) => w.adapter.name === dstTrack?.walletName); - } else if (srcTrack?.chainType === "svm") { - wallet = wallets.find((w) => w.adapter.name === srcTrack?.walletName); - } else { - wallet = wallets.find((w) => w.adapter.connected); - } - - if (wallet) { - wallet.adapter.connect(); - trackWallet.track("destination", dstChain.chainID, wallet.adapter.name, dstChain.chainType); - } else { - trackWallet.untrack("destination"); - } - } - }, - { - equalityFn: shallow, - fireImmediately: true, - }, - ); - }, [connector, disconnect, evmChain, getWalletRepo, srcChain, switchNetworkAsync, wallets]); + // #endregion - /** - * sync destination chain wallet connections on track wallet level - * @see {trackWallet} - */ useEffect(() => { - return trackWallet.subscribe( - (state) => state.source, - async (srcTrack) => { - const { sourceChain: srcChain, destinationChain: dstChain } = useSwapWidgetStore.getState(); - const { destination: dstTrack } = trackWallet.get(); - if ( - srcChain?.chainType === "cosmos" && - srcTrack?.chainType === "cosmos" && - dstChain?.chainType === "cosmos" && - dstTrack?.chainType !== "cosmos" - ) { - const { wallets } = getWalletRepo(dstChain.chainName); - const wallet = wallets.find((w) => { - return w.walletName === srcTrack.walletName; - }); - if (wallet) { - try { - await gracefullyConnect(wallet); - trackWallet.track("destination", dstChain.chainID, wallet.walletName, dstChain.chainType); - } catch (error) { - console.error(error); - } - } - } - if (!srcTrack && dstChain?.chainType === "cosmos" && dstTrack?.chainType === "cosmos") { - const { wallets } = getWalletRepo(dstChain.chainName); - const wallet = wallets.find((w) => { - return w.walletName === dstTrack.walletName; - }); - if (wallet) { - wallet.disconnect(); - trackWallet.untrack("destination"); - } - } - }, - { - equalityFn: shallow, - fireImmediately: true, - }, - ); - }, [getWalletRepo]); - - // #endregion + if (route?.chainIDs) { + chainAddresses.init(route?.chainIDs); + } + }, [route?.chainIDs]); ///////////////////////////////////////////////////////////////////////////// diff --git a/src/components/TransactionDialog/index.tsx b/src/components/TransactionDialog.tsx similarity index 77% rename from src/components/TransactionDialog/index.tsx rename to src/components/TransactionDialog.tsx index 7a6aeb16..7f0d0ddb 100644 --- a/src/components/TransactionDialog/index.tsx +++ b/src/components/TransactionDialog.tsx @@ -5,15 +5,14 @@ import toast from "react-hot-toast"; import { useDisclosureKey } from "@/context/disclosures"; import { cn } from "@/utils/ui"; -import { PriceImpactWarning } from "../PriceImpactWarning"; -import TransactionDialogContent from "./TransactionDialogContent"; +import { PreviewRoute } from "./PreviewRoute"; +import { PriceImpactWarning } from "./PriceImpactWarning"; export type ActionType = "NONE" | "TRANSFER" | "SWAP"; interface Props { isLoading?: boolean; route?: RouteResponse; - transactionCount: number; isAmountError?: boolean | string; shouldShowPriceImpactWarning?: boolean; routeWarningMessage?: string; @@ -25,15 +24,13 @@ function TransactionDialog({ isLoading, route, isAmountError, - transactionCount, shouldShowPriceImpactWarning, routeWarningMessage, routeWarningTitle, - onAllTransactionComplete, }: Props) { const [hasDisplayedWarning, setHasDisplayedWarning] = useState(false); - const [isOpen, confirmControl] = useDisclosureKey("confirmSwapDialog"); - + const confirmDisclosure = useDisclosureKey("confirmSwapDialog"); + const [isOpen, confirmControl] = confirmDisclosure; const [, priceImpactControl] = useDisclosureKey("priceImpactDialog"); useEffect(() => { @@ -82,15 +79,11 @@ function TransactionDialog({ Preview Route {isOpen && route && ( -
- -
+ )}
void; - onAllTransactionComplete?: () => void; -} - -export interface BroadcastedTx { - chainID: string; - txHash: string; - explorerLink: string; -} - -function TransactionDialogContent({ route, onClose, isAmountError, transactionCount }: Props) { - const skipClient = useSkipClient(); - - const [isOngoing, setOngoing] = useState(false); - - const [isTxComplete, setTxComplete] = useState(false); - const [isExpanded, setIsExpanded] = useState(false); - const [broadcastedTxs, setBroadcastedTxs] = useState([]); - - const txStatus = useBroadcastedTxsStatus({ - txs: broadcastedTxs, - txsRequired: route.txsRequired, - }); - - const srcAccount = useAccount("source"); - const dstAccount = useAccount("destination"); - - const showCCTPLedgerWarning = isCCTPLedgerBrokenInOperation(route) && srcAccount?.wallet?.isLedger === true; - const showEthermintLikeLedgerWarning = isEthermintLedgerInOperation(route) && srcAccount?.wallet?.isLedger === true; - - const showLedgerWarning = showCCTPLedgerWarning || showEthermintLikeLedgerWarning; - - const { data: userAddresses } = useWalletAddresses(route.chainIDs); - - async function onSubmit() { - if (!userAddresses) return; - setOngoing(true); - setIsExpanded(true); - const historyId = randomId(); - try { - await skipClient.executeRoute({ - route, - userAddresses, - validateGasBalance: route.txsRequired === 1, - slippageTolerancePercent: useSettingsStore.getState().slippage, - onTransactionTracked: async (txStatus) => { - const makeExplorerUrl = await getExplorerUrl(txStatus.chainID); - const explorerLink = makeExplorerUrl?.(txStatus.txHash); - - txHistory.addStatus(historyId, route, { - chainId: txStatus.chainID, - txHash: txStatus.txHash, - explorerLink: explorerLink || "#", - }); - - setBroadcastedTxs((v) => { - const txs = [ - ...v, - { - chainID: txStatus.chainID, - txHash: txStatus.txHash, - explorerLink: explorerLink || "#", - }, - ]; - if (route.txsRequired === txs.length) { - toast.success(

You can safely navigate away from this page while your transaction is pending

, { - icon: , - }); - } - return txs; - }); - }, - }); - - setTxComplete(true); - } catch (err: unknown) { - console.error(err); - if (isUserRejectedRequestError(err)) { - return; - } - Sentry.withScope((scope) => { - scope.setUser({ - id: srcAccount?.address, - }); - scope.setTransactionName("Swap.onSubmit"); - scope.setTags({ - sourceChain: route.sourceAssetChainID, - destinationChain: route.destAssetChainID, - sourceAssetDenom: route.sourceAssetDenom, - destinationAssetDenom: route.destAssetDenom, - doesSwap: route.doesSwap, - }); - scope.setExtras({ - sourceAddress: srcAccount?.address, - destinationAddress: dstAccount?.address, - sourceChain: route.sourceAssetChainID, - destinationChain: route.destAssetChainID, - sourceAssetDenom: route.sourceAssetDenom, - destinationAssetDenom: route.destAssetDenom, - amountIn: route.amountIn, - amountOut: route.amountOut, - }); - Sentry.captureException(err); - }); - toast( - ({ createdAt, id }) => ( -
-

Swap Failed!

-
-              {err instanceof Error ? `${err.name}: ${err.message}` : String(err)}
-              
-
- {new Date(createdAt).toISOString()} -
- -
- ), - { - ariaProps: { - "aria-live": "assertive", - role: "alert", - }, - duration: Infinity, - }, - ); - } finally { - setOngoing(false); - } - } - - const estimatedFinalityTime = useFinalityTimeEstimate(route); - if (isTxComplete && txStatus.data?.isSuccess) { - return ( - - ); - } - - return ( -
-
-
- -

Transaction Preview

-
-
-
- -
- -
- {broadcastedTxs.map(({ txHash }, i) => ( -
- {txStatus.data?.states?.[i] === "STATE_COMPLETED_SUCCESS" ? ( - - ) : txStatus.data?.states?.[i] === "STATE_COMPLETED_ERROR" ? ( - - ) : ( - - )} -
-

Transaction {i + 1}

-
- -
- ))} -
-
- {estimatedFinalityTime !== "" && ( - - EVM bridging finality time is {estimatedFinalityTime} - -

- This swap contains at least one EVM chain, so it might take longer. Read more about{" "} - - common finality times - - . -

-
-
- )} - {showCCTPLedgerWarning && ( - - -

- WARNING: - ibc.fun does not support signing with Ledger when transferring over CCTP to the Ethereum ecosystem. - We're actively working on fixing this with the Noble/Circle teams. We apologize for the - inconvenience -

-
-
- )} - {showEthermintLikeLedgerWarning && ( - - -

- WARNING: - ibc.fun does not support signing with Ledger on Ethermint-like chains (e.g. Injective, Dymension, EVMOS, - etc...). We're actively working on fixing this with the Ledger team. We apologize for the - inconvenience. -

-
-
- )} - {isAmountError && !isOngoing && !isTxComplete && ( -

- {typeof isAmountError === "string" ? isAmountError : "Insufficient balance."} -

- )} -
-

- This route requires{" "} - - {transactionCount} Transaction - {transactionCount > 1 ? "s" : ""} - {" "} - to complete -

-
- {isOngoing ? ( - - ) : ( - - )} -
-
- ); -} - -const HREF_COMMON_FINALITY_TIMES = `https://docs.axelar.dev/learn/txduration#common-finality-time-for-interchain-transactions`; - -export default TransactionDialogContent; diff --git a/src/components/TransactionDialog/TransactionDialogTrigger.tsx b/src/components/TransactionDialog/TransactionDialogTrigger.tsx deleted file mode 100644 index 66c76368..00000000 --- a/src/components/TransactionDialog/TransactionDialogTrigger.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { ForwardedRef, forwardRef } from "react"; - -interface Props { - disabled?: boolean; -} - -const TransactionDialogTrigger = forwardRef(function TransactionDialogTrigger( - { disabled, ...props }: Props, - ref: ForwardedRef, -) { - return ( - - ); -}); - -export default TransactionDialogTrigger; diff --git a/src/components/TransactionSuccessView.tsx b/src/components/TransactionSuccessView.tsx deleted file mode 100644 index 49d34e29..00000000 --- a/src/components/TransactionSuccessView.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { CheckCircleIcon } from "@heroicons/react/20/solid"; -import { RouteResponse } from "@skip-router/core"; -import { FC } from "react"; - -import { useAssets } from "@/context/assets"; -import { Chain, useChains } from "@/hooks/useChains"; - -import { BroadcastedTx } from "./TransactionDialog/TransactionDialogContent"; - -const TransactionSuccessView: FC<{ - route: RouteResponse; - onClose: () => void; - transactions: BroadcastedTx[]; -}> = ({ route, onClose, transactions }) => { - const { getAsset } = useAssets(); - const { data: chains } = useChains(); - - const sourceAsset = getAsset(route.sourceAssetDenom, route.sourceAssetChainID); - const destinationAsset = getAsset(route.destAssetDenom, route.destAssetChainID); - - if (!chains) { - return null; - } - - const sourceChain = chains.find((c) => c.chainID === route.sourceAssetChainID) as Chain; - const destinationChain = chains.find((c) => c.chainID === route.destAssetChainID) as Chain; - - return ( -
-
- - - -
-
-

{route.doesSwap ? "Swap" : "Transfer"} Successful

-
-

- {route.doesSwap && - `Successfully swapped ${ - sourceAsset?.recommendedSymbol ?? route.sourceAssetDenom - } for ${destinationAsset?.recommendedSymbol ?? route.destAssetDenom}`} - {!route.doesSwap && - `Successfully transfered ${ - sourceAsset?.recommendedSymbol ?? route.sourceAssetDenom - } from ${sourceChain.prettyName} to ${destinationChain.prettyName}`} -

-
- {transactions.map(({ explorerLink, txHash }, i) => ( -
- -
-

Transaction {i + 1}

-
- -
- ))} -
-
- -
-
- ); -}; - -export default TransactionSuccessView; diff --git a/src/components/WalletModal/WalletModal.tsx b/src/components/WalletModal/WalletModal.tsx index 7b6ac0b9..cc450161 100644 --- a/src/components/WalletModal/WalletModal.tsx +++ b/src/components/WalletModal/WalletModal.tsx @@ -1,44 +1,26 @@ -import { useManager } from "@cosmos-kit/react"; import { ArrowTopRightOnSquareIcon } from "@heroicons/react/16/solid"; import { ArrowLeftIcon, FaceFrownIcon } from "@heroicons/react/20/solid"; import * as ScrollArea from "@radix-ui/react-scroll-area"; -import { useWallet } from "@solana/wallet-adapter-react"; import Image from "next/image"; -import toast from "react-hot-toast"; -import { useAccount, useConnect, useDisconnect } from "wagmi"; -import { chainIdToName } from "@/chains/types"; import { DialogContent } from "@/components/Dialog"; -import { trackWallet } from "@/context/track-wallet"; +import { trackWallet, TrackWalletCtx } from "@/context/track-wallet"; import { useChainByID } from "@/hooks/useChains"; +import { MinimalWallet, useMakeWallets } from "@/hooks/useMakeWallets"; import { cn } from "@/utils/ui"; -import { gracefullyConnect } from "@/utils/wallet"; import { AdaptiveLink } from "../AdaptiveLink"; import { useWalletModal } from "./context"; import { useTotalWallets, WalletListItem } from "./WalletListItem"; -export interface MinimalWallet { - walletName: string; - walletPrettyName: string; - walletInfo: { - logo?: string | { major: string; minor: string }; - }; - connect: () => Promise; - disconnect: () => Promise; - isWalletConnected: boolean; - isAvailable?: boolean; -} - interface Props { chainType: string; wallets: MinimalWallet[]; onClose: () => void; + chainID: string; } export function WalletModal({ chainType, onClose, wallets }: Props) { - const { context } = useWalletModal(); - async function onWalletConnect(wallet: MinimalWallet) { await wallet.connect(); onClose(); @@ -58,9 +40,7 @@ export function WalletModal({ chainType, onClose, wallets }: Props) { > -

- Connect {context && {context}} Wallet -

+

Connect Wallet

{totalWallets < 1 && (
@@ -138,7 +118,7 @@ export function WalletModal({ chainType, onClose, wallets }: Props) { onClick={async (event) => { event.stopPropagation(); await wallet.disconnect(); - context && trackWallet.untrack(context); + trackWallet.untrack(chainType as TrackWalletCtx); onClose(); }} > @@ -167,131 +147,26 @@ export function WalletModal({ chainType, onClose, wallets }: Props) { } function WalletModalWithContext() { - const { connector: currentConnector } = useAccount(); - const { chainID, context } = useWalletModal(); - const { disconnectAsync } = useDisconnect(); - // evm - const { connectors, connectAsync } = useConnect({ - mutation: { - onError: (err) => { - toast.error( -

- Failed to connect! -
- {err.name}: {err.message} -

, - ); - }, - }, - }); - // cosmos - const { getWalletRepo } = useManager(); - // solana - const { wallets: solanaWallets } = useWallet(); - + const { chainID } = useWalletModal(); const { setIsOpen } = useWalletModal(); - const { data: chain } = useChainByID(chainID); + const { makeWallets } = useMakeWallets(); + const wallets = makeWallets(chainID); + if (!chain) { return null; } const { chainType } = chain; - let wallets: MinimalWallet[] = []; - - if (chainType === "cosmos") { - const chainName = chainIdToName[chainID]; - const walletRepo = getWalletRepo(chainName); - wallets = walletRepo.wallets.map((wallet) => ({ - walletName: wallet.walletName, - walletPrettyName: wallet.walletPrettyName, - walletInfo: { - logo: wallet.walletInfo.logo, - }, - connect: async () => { - try { - await gracefullyConnect(wallet); - context && trackWallet.track(context, chainID, wallet.walletName, chainType); - } catch (error) { - console.error(error); - context && trackWallet.untrack(context); - } - }, - disconnect: async () => { - await wallet.disconnect(); - context && trackWallet.untrack(context); - }, - isWalletConnected: wallet.isWalletConnected, - })); - } - - if (chainType === "evm") { - for (const connector of connectors) { - if (wallets.findIndex((wallet) => wallet.walletName === connector.id) !== -1) { - continue; - } - - const minimalWallet: MinimalWallet = { - walletName: connector.id, - walletPrettyName: connector.name, - walletInfo: { - logo: connector.icon, - }, - connect: async () => { - if (connector.id === currentConnector?.id) return; - try { - await connectAsync({ connector, chainId: Number(chainID) }); - context && trackWallet.track(context, chainID, connector.id, chainType); - } catch (error) { - console.error(error); - } - }, - disconnect: async () => { - await disconnectAsync(); - context && trackWallet.untrack(context); - }, - isWalletConnected: connector.id === currentConnector?.id, - }; - - wallets.push(minimalWallet); - } - } - - if (chainType === "svm") { - for (const wallet of solanaWallets) { - const minimalWallet: MinimalWallet = { - walletName: wallet.adapter.name, - walletPrettyName: wallet.adapter.name, - walletInfo: { - logo: wallet.adapter.icon, - }, - connect: async () => { - try { - await wallet.adapter.connect(); - context && trackWallet.track(context, chainID, wallet.adapter.name, chainType); - } catch (error) { - console.error(error); - } - }, - disconnect: async () => { - await wallet.adapter.disconnect(); - context && trackWallet.untrack(context); - }, - isWalletConnected: wallet.adapter.connected, - isAvailable: wallet.readyState === "Installed", - }; - wallets.push(minimalWallet); - } - } - return ( setIsOpen(false)} + chainID={chainID} /> ); diff --git a/src/components/WalletModal/context.tsx b/src/components/WalletModal/context.tsx index 8257e698..6d7eddd3 100644 --- a/src/components/WalletModal/context.tsx +++ b/src/components/WalletModal/context.tsx @@ -6,8 +6,7 @@ interface WalletModalContext { chainID: string; isOpen: boolean; setIsOpen: (isOpen: boolean) => void; - openWalletModal: (chainID: string, context: NonNullable) => void; - context?: "source" | "destination"; + openWalletModal: (chainID: string) => void; } const WalletModalContext = createContext(undefined); @@ -23,19 +22,16 @@ export function useWalletModal() { export function WalletModalProvider({ children }: { children: ReactNode }) { const [chainID, setChainID] = useState(""); const [isOpen, setIsOpen] = useState(false); - const [context, setContext] = useState(); return ( { + openWalletModal: (_chainID) => { setIsOpen(true); setChainID(_chainID); - setContext(context); }, - context, }} > = {}; + +export const useChainAddressesStore = create(subscribeWithSelector(() => defaultValues)); + +export const chainAddresses = { + reset: () => { + useChainAddressesStore.setState(defaultValues); + }, + init: (chainIDs: string[]) => { + useChainAddressesStore.setState(defaultValues); + useChainAddressesStore.setState(() => { + const newState: Record = {}; + chainIDs.forEach((chainID) => { + newState[chainIDs.indexOf(chainID)] = { + chainID, + }; + }); + return newState; + }); + }, + set: ({ + index, + address, + chainID, + chainType, + source, + }: { + index: number; + chainID: string; + chainType: TrackWalletCtx; + address: string; + source: "input" | Wallet; + }) => { + const current = useChainAddressesStore.getState()[index]; + if (current) { + useChainAddressesStore.setState((state) => { + return { + ...state, + [index]: { + ...current, + chainID, + chainType, + address, + source, + }, + }; + }); + } else { + useChainAddressesStore.setState((state) => { + return { + ...state, + [index]: { + chainID, + chainType, + address, + source, + }, + }; + }); + } + }, + get: (index: number) => { + return useChainAddressesStore.getState()?.[index]; + }, +}; diff --git a/src/context/disclosures.ts b/src/context/disclosures.ts index 0ecef7be..e577f9f2 100644 --- a/src/context/disclosures.ts +++ b/src/context/disclosures.ts @@ -6,7 +6,7 @@ const defaultValues = { historyDialog: false, priceImpactDialog: false, settingsDialog: false, - + destinationDialog: false, // TODO: port dialogs to new system // assetSelect: false, // chainSelect: false, diff --git a/src/context/track-wallet.ts b/src/context/track-wallet.ts index 75a04ae7..90936b1b 100644 --- a/src/context/track-wallet.ts +++ b/src/context/track-wallet.ts @@ -1,40 +1,39 @@ import { create } from "zustand"; import { createJSONStorage, persist, subscribeWithSelector } from "zustand/middleware"; -export type TrackWalletCtx = "source" | "destination"; +export type TrackWalletCtx = "evm" | "cosmos" | "svm"; + +interface WalletState { + walletName: string; + chainType: string; +} interface TrackWalletStore { - source?: { - chainID: string; - walletName: string; - chainType: string; - }; - destination?: { - chainID: string; - walletName: string; - chainType: string; - }; + evm?: WalletState; + cosmos?: WalletState; + svm?: WalletState; } const defaultValues: TrackWalletStore = { - source: undefined, - destination: undefined, + evm: undefined, + cosmos: undefined, + svm: undefined, }; const useStore = create( subscribeWithSelector( persist(() => defaultValues, { name: "TrackWalletState", - version: 1, + version: 2, storage: createJSONStorage(() => window.sessionStorage), }), ), ); export const trackWallet = { - track: (ctx: TrackWalletCtx, chainID: string, walletName: string, chainType: string) => { + track: (ctx: TrackWalletCtx, walletName: string, chainType: string) => { useStore.setState({ - [ctx]: { chainID, walletName, chainType }, + [ctx]: { walletName, chainType }, }); }, untrack: (ctx: TrackWalletCtx) => { @@ -46,6 +45,6 @@ export const trackWallet = { subscribe: useStore.subscribe, }; -export function useTrackWallet(ctx: TrackWalletCtx) { - return useStore((state) => state[ctx]); +export function useTrackWallet(ctx?: TrackWalletCtx) { + return useStore((state) => ctx && state[ctx]); } diff --git a/src/hooks/useAccount.ts b/src/hooks/useAccount.ts index 8164ae21..534754d5 100644 --- a/src/hooks/useAccount.ts +++ b/src/hooks/useAccount.ts @@ -9,10 +9,10 @@ import { trackWallet, TrackWalletCtx, useTrackWallet } from "@/context/track-wal import { useChainByID } from "@/hooks/useChains"; import { isReadyToCheckLedger, isWalletClientUsingLedger } from "@/utils/wallet"; -export function useAccount(context: TrackWalletCtx) { - const trackedWallet = useTrackWallet(context); +export function useAccount(chainID?: string) { + const { data: chain } = useChainByID(chainID); + const trackedWallet = useTrackWallet(chain?.chainType as TrackWalletCtx); - const { data: chain } = useChainByID(trackedWallet?.chainID); const { getWalletRepo } = useCosmosManager(); const cosmosWallet = useMemo(() => { @@ -39,19 +39,13 @@ export function useAccount(context: TrackWalletCtx) { // eslint-disable-next-line @tanstack/query/exhaustive-deps queryKey: [ "cosmosWallet", - { context, cosmosWallet: cosmosWallet?.walletName, address: cosmosWallet?.address, chainID: chain?.chainID }, + { cosmosWallet: cosmosWallet?.walletName, address: cosmosWallet?.address, chainID: chain?.chainID }, ], queryFn: () => { if (!cosmosWallet?.client || !chain) return null; return getIsLedger(cosmosWallet.client, chain.chainID); }, - enabled: - chain && - chain.chainType === "cosmos" && - !!cosmosWallet && - context === "source" && - readyToCheckLedger && - !!cosmosWallet?.address, + enabled: chain && chain.chainType === "cosmos" && !!cosmosWallet && readyToCheckLedger && !!cosmosWallet?.address, }); const account = useMemo(() => { @@ -74,12 +68,12 @@ export function useAccount(context: TrackWalletCtx) { chainType: chain.chainType, connect: () => { return cosmosWallet.connect().then(() => { - trackWallet.track(context, chain.chainID, cosmosWallet.walletName, chain.chainType); + trackWallet.track("cosmos", cosmosWallet.walletName, chain.chainType); }); }, disconnect: () => { return cosmosWallet.disconnect().then(() => { - trackWallet.untrack(context); + trackWallet.untrack("cosmos"); }); }, }; @@ -100,12 +94,12 @@ export function useAccount(context: TrackWalletCtx) { chainType: chain.chainType, connect: () => { return wagmiAccount.connector?.connect().then(() => { - trackWallet.track(context, chain.chainID, wagmiAccount.connector!.id, chain.chainType); + trackWallet.track("evm", wagmiAccount.connector!.id, chain.chainType); }); }, disconnect: () => { return wagmiAccount.connector?.disconnect().then(() => { - trackWallet.untrack(context); + trackWallet.untrack("evm"); }); }, }; @@ -127,12 +121,12 @@ export function useAccount(context: TrackWalletCtx) { chainType: chain.chainType, connect: () => { return solanaWallet?.adapter.connect().then(() => { - trackWallet.track(context, chain.chainID, solanaWallet.adapter.name, chain.chainType); + trackWallet.track("svm", solanaWallet.adapter.name, chain.chainType); }); }, disconnect: () => { return solanaWallet?.adapter.disconnect().then(() => { - trackWallet.untrack(context); + trackWallet.untrack("svm"); }); }, }; @@ -142,7 +136,6 @@ export function useAccount(context: TrackWalletCtx) { chain, cosmosWallet, cosmosWalletIsLedgerQuery.data, - context, wagmiAccount.address, wagmiAccount.isConnected, wagmiAccount.connector, diff --git a/src/hooks/useAutoSetAddress.ts b/src/hooks/useAutoSetAddress.ts new file mode 100644 index 00000000..1cdbb7d1 --- /dev/null +++ b/src/hooks/useAutoSetAddress.ts @@ -0,0 +1,189 @@ +import { Chain } from "@skip-router/core"; +import { useQuery } from "@tanstack/react-query"; + +import { chainAddresses, useChainAddressesStore } from "@/context/chainAddresses"; +import { trackWallet, TrackWalletCtx, useTrackWallet } from "@/context/track-wallet"; + +import { useMakeWallets } from "./useMakeWallets"; + +export const useAutoSetAddress = ({ + chain, + chainID, + index, + enabled, + signRequired, +}: { + chain?: Chain; + chainID: string; + index: number; + enabled?: boolean; + signRequired?: boolean; +}) => { + const trackedWallets = useTrackWallet(chain?.chainType as TrackWalletCtx); + const addresses = useChainAddressesStore(); + const source = addresses?.[0]; + const destination = addresses?.[Object.values(addresses).length - 1]; + const current = addresses?.[index]; + const currentAcdress = current?.address; + const isSameAsDestination = + current?.source !== "input" && + destination?.source !== "input" && + destination?.source?.walletName === current?.source?.walletName; + const isSameAsSource = + current?.source !== "input" && + source?.source !== "input" && + source?.source?.walletName === current?.source?.walletName; + + const { makeWallets } = useMakeWallets(); + + return useQuery({ + queryKey: [ + "auto-set-address", + { chainID, chainType: chain?.chainType, trackedWallets, index, destination, currentSource: current?.source }, + ], + queryFn: async () => { + if (current?.source === "input") { + return null; + } + const wallets = makeWallets(chainID); + const { cosmos, evm, svm } = trackWallet.get(); + if (chain?.chainType === "cosmos") { + // intermediary chain need to be signed and the source chain is same as the current chain + if (index !== 0 && signRequired && source?.chainType === "cosmos" && cosmos) { + const walletSelected = wallets.find((wallet) => wallet.walletName === cosmos?.walletName); + const address = await walletSelected?.getAddress?.({ signRequired }); + if (walletSelected && address) { + chainAddresses.set({ + index, + chainID, + chainType: chain.chainType as TrackWalletCtx, + address, + source: walletSelected, + }); + } + } + // destination chain is cosmos and the source is not input + if ( + Boolean(destination?.address) && + destination?.chainType === "cosmos" && + destination?.source !== "input" && + index !== 0 && + !signRequired + ) { + const walletName = destination.source?.walletName; + const walletSelected = wallets.find((wallet) => wallet.walletName === walletName); + const address = await walletSelected?.getAddress?.({}); + if (walletSelected && address) { + chainAddresses.set({ + index, + chainID, + chainType: chain.chainType as TrackWalletCtx, + address, + source: walletSelected, + }); + } + } else { + const walletSelected = wallets.find((wallet) => wallet.walletName === cosmos?.walletName); + const address = await walletSelected?.getAddress?.({}); + if (walletSelected && address) { + chainAddresses.set({ + index, + chainID, + chainType: chain.chainType as TrackWalletCtx, + address, + source: walletSelected, + }); + } + } + } + if (chain?.chainType === "evm") { + // intermediary chain need to be signed and the source chain is same as the current chain + if (index !== 0 && signRequired && source?.chainType === "evm" && evm) { + const walletSelected = wallets.find((wallet) => wallet.walletName === evm?.walletName); + const address = await walletSelected?.getAddress?.({ signRequired }); + if (walletSelected && address) { + chainAddresses.set({ + index, + chainID, + chainType: chain.chainType as TrackWalletCtx, + address, + source: walletSelected, + }); + } + } + // destination chain is evm and the source is not input + if ( + Boolean(destination?.address) && + destination?.chainType === "evm" && + destination?.source !== "input" && + index !== 0 + ) { + const walletName = destination.source?.walletName; + const walletSelected = wallets.find((wallet) => wallet.walletName === walletName); + const address = await walletSelected?.getAddress?.({}); + if (walletSelected && address) { + chainAddresses.set({ + index, + chainID, + chainType: chain.chainType as TrackWalletCtx, + address, + source: walletSelected, + }); + } + } else { + const walletSelected = wallets.find((wallet) => wallet.walletName === evm?.walletName); + const address = await walletSelected?.getAddress?.({}); + if (walletSelected && address) { + chainAddresses.set({ + index, + chainID, + chainType: chain.chainType as TrackWalletCtx, + address, + source: walletSelected, + }); + } + } + } + if (chain?.chainType === "svm") { + if ( + Boolean(destination?.address) && + destination?.chainType === "svm" && + destination?.source !== "input" && + index !== 0 + ) { + const walletName = destination.source?.walletName; + const walletSelected = wallets.find((wallet) => wallet.walletName === walletName); + const address = await walletSelected?.getAddress?.({}); + if (walletSelected && address) { + chainAddresses.set({ + index, + chainID, + chainType: chain.chainType as TrackWalletCtx, + address, + source: walletSelected, + }); + } + } else { + const walletSelected = wallets.find((wallet) => wallet.walletName === svm?.walletName); + const address = await walletSelected?.getAddress?.({}); + if (walletSelected && address) { + chainAddresses.set({ + index, + chainID, + chainType: chain.chainType as TrackWalletCtx, + address, + source: walletSelected, + }); + } + } + } + return null; + }, + enabled: + enabled && !!chain?.chainType && !!trackedWallets && (!currentAcdress || !isSameAsDestination || !isSameAsSource), + retry: false, + refetchOnMount: true, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }); +}; diff --git a/src/hooks/useMakeWallets.tsx b/src/hooks/useMakeWallets.tsx new file mode 100644 index 00000000..b998752b --- /dev/null +++ b/src/hooks/useMakeWallets.tsx @@ -0,0 +1,241 @@ +import { useManager } from "@cosmos-kit/react"; +import { useWallet } from "@solana/wallet-adapter-react"; +import toast from "react-hot-toast"; +import { useAccount, useConnect, useDisconnect } from "wagmi"; + +import { chainIdToName } from "@/chains/types"; +import { trackWallet } from "@/context/track-wallet"; +import { useChains } from "@/hooks/useChains"; +import { gracefullyConnect } from "@/utils/wallet"; + +export interface MinimalWallet { + walletName: string; + walletPrettyName: string; + walletInfo: { + logo?: string | { major: string; minor: string }; + }; + connect: () => Promise; + disconnect: () => Promise; + isWalletConnected: boolean; + isAvailable?: boolean; + getAddress?: (props: { signRequired?: boolean; context?: "recovery" | "destination" }) => Promise; +} + +export const useMakeWallets = () => { + const { data: chains } = useChains(); + const getChain = (_chainID: string) => chains?.find((chain) => chain.chainID === _chainID); + + const { connector: currentConnector, address: evmAddress, isConnected: isEvmConnected } = useAccount(); + const { disconnectAsync } = useDisconnect(); + // evm + const { connectors, connectAsync } = useConnect({ + mutation: { + onError: (err) => { + toast.error( +

+ Failed to connect! +
+ {err.name}: {err.message} +

, + ); + }, + }, + }); + // cosmos + const { getWalletRepo } = useManager(); + // solana + const { wallets: solanaWallets } = useWallet(); + + const makeWallets = (chainID: string) => { + const chainType = getChain(chainID)?.chainType; + + let wallets: MinimalWallet[] = []; + + if (chainType === "cosmos") { + const chainName = chainIdToName[chainID]; + const walletRepo = getWalletRepo(chainName); + wallets = walletRepo.wallets.map((wallet) => ({ + walletName: wallet.walletName, + walletPrettyName: wallet.walletPrettyName, + walletInfo: { + logo: wallet.walletInfo.logo, + }, + connect: async () => { + try { + await gracefullyConnect(wallet); + trackWallet.track("cosmos", wallet.walletName, chainType); + } catch (error) { + console.error(error); + toast.error( +

+ Failed to connect! +

, + ); + trackWallet.untrack("cosmos"); + } + }, + getAddress: async ({ signRequired, context }) => { + try { + if (trackWallet.get().cosmos && wallet.isWalletConnected && !wallet.isWalletDisconnected) { + if (signRequired) { + trackWallet.track("cosmos", wallet.walletName, chainType); + } + if (context && wallet.address) { + toast.success(`Successfully retrieve ${context} address from ${wallet.walletName}`); + } + return wallet.address; + } + await gracefullyConnect(wallet); + if (!trackWallet.get().cosmos) { + trackWallet.track("cosmos", wallet.walletName, chainType); + } + if (signRequired) { + trackWallet.track("cosmos", wallet.walletName, chainType); + } + if (context && wallet.address) { + toast.success(`Successfully retrieve ${context} address from ${wallet.walletName}`); + } + return wallet.address; + } catch (error) { + console.log(error); + toast.error( +

+ Failed to get address! +

, + ); + } + }, + disconnect: async () => { + await wallet.disconnect(); + trackWallet.untrack("cosmos"); + }, + isWalletConnected: wallet.isWalletConnected, + })); + } + + if (chainType === "evm") { + for (const connector of connectors) { + if (wallets.findIndex((wallet) => wallet.walletName === connector.id) !== -1) { + continue; + } + + const minimalWallet: MinimalWallet = { + walletName: connector.id, + walletPrettyName: connector.name, + walletInfo: { + logo: connector.icon, + }, + connect: async () => { + if (connector.id === currentConnector?.id) return; + try { + await connectAsync({ connector, chainId: Number(chainID) }); + trackWallet.track("evm", connector.id, chainType); + } catch (error) { + console.error(error); + throw error; + } + }, + getAddress: async ({ signRequired, context }) => { + try { + if (connector.id === currentConnector?.id) { + if (isEvmConnected && evmAddress) { + if (signRequired) { + trackWallet.track("evm", connector.id, chainType); + } + if (context && evmAddress) { + toast.success(`Successfully retrieve ${context} address from ${connector.name}`); + } + return evmAddress; + } + } else { + await connectAsync({ connector, chainId: Number(chainID) }); + trackWallet.track("evm", connector.id, chainType); + if (context && evmAddress) { + toast.success(`Successfully retrieve ${context} address from ${connector.name}`); + } + return evmAddress; + } + } catch (error) { + console.error(error); + toast.error( +

+ Failed to get address! +

, + ); + } + }, + disconnect: async () => { + await disconnectAsync(); + trackWallet.untrack("evm"); + }, + isWalletConnected: connector.id === currentConnector?.id, + }; + + wallets.push(minimalWallet); + } + } + + if (chainType === "svm") { + for (const wallet of solanaWallets) { + const minimalWallet: MinimalWallet = { + walletName: wallet.adapter.name, + walletPrettyName: wallet.adapter.name, + walletInfo: { + logo: wallet.adapter.icon, + }, + connect: async () => { + try { + await wallet.adapter.connect(); + trackWallet.track("svm", wallet.adapter.name, chainType); + } catch (error) { + console.error(error); + toast.error( +

+ Failed to connect! +

, + ); + } + }, + getAddress: async ({ signRequired, context }) => { + try { + const isConnected = wallet.adapter.connected; + if (!isConnected) { + await wallet.adapter.connect(); + trackWallet.track("svm", wallet.adapter.name, chainType); + } + const address = wallet.adapter.publicKey; + if (!address) throw new Error("No address found"); + if (signRequired) { + trackWallet.track("svm", wallet.adapter.name, chainType); + } + if (context && address) { + toast.success(`Successfully retrieve ${context} address from ${wallet.adapter.name}`); + } + return address.toBase58(); + } catch (error) { + console.error(error); + toast.error( +

+ Failed to get address! +

, + ); + } + }, + disconnect: async () => { + await wallet.adapter.disconnect(); + trackWallet.untrack("svm"); + }, + isWalletConnected: wallet.adapter.connected, + isAvailable: wallet.readyState === "Installed", + }; + wallets.push(minimalWallet); + } + } + + return wallets; + }; + + return { + makeWallets, + }; +}; diff --git a/src/hooks/useWalletAddresses.ts b/src/hooks/useWalletAddresses.ts index 75c8d06d..cdd3a388 100644 --- a/src/hooks/useWalletAddresses.ts +++ b/src/hooks/useWalletAddresses.ts @@ -14,8 +14,8 @@ export function useWalletAddresses(chainIDs: string[]) { const { getWalletRepo } = useManager(); const { wallets } = useWallet(); - const srcAccount = useAccount("source"); - const dstAccount = useAccount("destination"); + const cosmos = useAccount("cosmos"); + const svm = useAccount("svm"); const queryKey = useMemo(() => ["USE_WALLET_ADDRESSES", chainIDs] as const, [chainIDs]); @@ -27,9 +27,6 @@ export function useWalletAddresses(chainIDs: string[]) { const srcChain = chains.find(({ chainID }) => { return chainID === chainIDs.at(0); }); - const dstChain = chains.find(({ chainID }) => { - return chainID === chainIDs.at(-1); - }); for (const currentChainID of chainIDs) { const chain = chains.find(({ chainID }) => chainID === currentChainID); @@ -43,18 +40,12 @@ export function useWalletAddresses(chainIDs: string[]) { const currentWalletName = (() => { // if `chainID` is the source or destination chain if (srcChain?.chainID === currentChainID) { - return srcAccount?.wallet?.walletName; - } - if (dstChain?.chainID === currentChainID) { - return dstAccount?.wallet?.walletName; + return cosmos?.wallet?.walletName; } // if `chainID` isn't the source or destination chain if (srcChain?.chainType === "cosmos") { - return srcAccount?.wallet?.walletName; - } - if (dstChain?.chainType === "cosmos") { - return dstAccount?.wallet?.walletName; + return cosmos?.wallet?.walletName; } })(); @@ -83,10 +74,7 @@ export function useWalletAddresses(chainIDs: string[]) { } if (chain.chainType === "svm") { - const solanaWallet = wallets.find( - (w) => - w.adapter.name === srcAccount?.wallet?.walletName || w.adapter.name === dstAccount?.wallet?.walletName, - ); + const solanaWallet = wallets.find((w) => w.adapter.name === svm?.wallet?.walletName); if (!solanaWallet?.adapter.publicKey) { throw new Error(`useWalletAddresses error: svm wallet not connected`); diff --git a/src/solve/context.tsx b/src/solve/context.tsx index e835608c..869b6f6d 100644 --- a/src/solve/context.tsx +++ b/src/solve/context.tsx @@ -27,9 +27,8 @@ export function SkipProvider({ children }: { children: ReactNode }) { } const walletName = (() => { - const { source, destination } = trackWallet.get(); - if (source?.chainType === "cosmos") return source.walletName; - if (destination?.chainType === "cosmos") return destination.walletName; + const { cosmos } = trackWallet.get(); + if (cosmos?.chainType === "cosmos") return cosmos.walletName; })(); const wallet = getWalletRepo(chainName).wallets.find((w) => { @@ -70,9 +69,8 @@ export function SkipProvider({ children }: { children: ReactNode }) { }, getSVMSigner: async () => { const walletName = (() => { - const { source, destination } = trackWallet.get(); - if (source?.chainType === "svm") return source.walletName; - if (destination?.chainType === "svm") return destination.walletName; + const { svm } = trackWallet.get(); + if (svm?.chainType === "svm") return svm.walletName; })(); const solanaWallet = wallets.find((w) => w.adapter.name === walletName); diff --git a/src/solve/queries.ts b/src/solve/queries.ts index dced9076..cb406ecb 100644 --- a/src/solve/queries.ts +++ b/src/solve/queries.ts @@ -210,7 +210,6 @@ export const useBroadcastedTxsStatus = ({ } | undefined >(undefined); - const queryKey = useMemo(() => ["solve-txs-status", txsRequired, txs] as const, [txs, txsRequired]); return useQuery({ @@ -223,7 +222,6 @@ export const useBroadcastedTxsStatus = ({ chainID: tx.chainID, txHash: tx.txHash, }); - const cleanTransferSequence = _res.transferSequence.map((transfer) => { if ("ibcTransfer" in transfer) { return { @@ -252,7 +250,7 @@ export const useBroadcastedTxsStatus = ({ } })(); return { - srcChainID: transfer.cctpTransfer.dstChainID, + srcChainID: transfer.cctpTransfer.srcChainID, destChainID: transfer.cctpTransfer.dstChainID, txs: { sendTx: transfer.cctpTransfer.txs.sendTx, @@ -321,7 +319,6 @@ export const useBroadcastedTxsStatus = ({ state: axelarState, }; }); - return { state: _res.state, transferSequence: cleanTransferSequence, diff --git a/tailwind.config.js b/tailwind.config.js index fa4579ab..e71fbf0f 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -32,6 +32,9 @@ module.exports = { "slide-down-and-fade": `slide-down-and-fade 300ms cubic-bezier(0.16, 1, 0.3, 1)`, "slide-left-and-fade": `slide-left-and-fade 300ms cubic-bezier(0.16, 1, 0.3, 1)`, "spin-swap": `spin 0.5s cubic-bezier(0.18, 0.89, 0.32, 1.27)`, + "gradient-x": "gradient-x 3s ease infinite", + "gradient-y": "gradient-y 3s ease infinite", + "gradient-xy": "gradient-xy 3s ease infinite", }, colors: { ...blackA, @@ -80,6 +83,36 @@ module.exports = { from: { opacity: 0, transform: "translateX(2px)" }, to: { opacity: 1, transform: "translateX(0)" }, }, + "gradient-y": { + "0%, 100%": { + "background-size": "600% 140%", + "background-position": "top bottom", + }, + "50%": { + "background-size": "200% 200%", + "background-position": "center center", + }, + }, + "gradient-x": { + "0%, 100%": { + "background-size": "200% 200%", + "background-position": "left center", + }, + "50%": { + "background-size": "200% 200%", + "background-position": "right center", + }, + }, + "gradient-xy": { + "0%, 100%": { + "background-size": "400% 400%", + "background-position": "left center", + }, + "50%": { + "background-size": "200% 200%", + "background-position": "right center", + }, + }, }, }, },