diff --git a/README.md b/README.md
index 80a7a4ac..e552d274 100644
--- a/README.md
+++ b/README.md
@@ -42,10 +42,15 @@ Read more on all available environment variables in [`.env.example`](.env.exampl
- build production bundle: `npm run build`
- run production server: `npm run start` (must run `build` first)
+## Testing
+
+`npm run test` will run the unit tests.
+`npm run test:e2e` will run the automated end-to-end tests. Make sure you have `WORD_PHRASE_KEY=""`(12 word phrase key) in your `.env` file. This will be used to importing the wallet and perform the tests.
+
## Contributing
Feel free to open an issue or submit a pull request for any bugs and/or improvements.
## Contact
-Reach out to our [support email](mailto:support@skip.money), or join our [Discord](https://skip.money/discord) server.
+Reach out by joining our [Discord](https://skip.money/discord) server.
diff --git a/chain-registry b/chain-registry
index 2eaab04a..84b6ffcb 160000
--- a/chain-registry
+++ b/chain-registry
@@ -1 +1 @@
-Subproject commit 2eaab04a72bc0273121d71a597aca4ef31d12420
+Subproject commit 84b6ffcbf6838dd2ab102c097ca3132e62724e2b
diff --git a/env.d.ts b/env.d.ts
index 5fc94372..aca3e5bc 100644
--- a/env.d.ts
+++ b/env.d.ts
@@ -11,6 +11,7 @@ declare namespace NodeJS {
readonly NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID?: string;
readonly RESEND_API_KEY?: string;
readonly WALLETCONNECT_VERIFY_KEY?: string;
+ readonly WORD_PHRASE_KEY?: string;
}
}
diff --git a/package-lock.json b/package-lock.json
index d6abf7a2..2052dcf0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -38,7 +38,7 @@
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@sentry/nextjs": "^7.99.0",
- "@skip-router/core": "^1.3.5",
+ "@skip-router/core": "^1.3.12",
"@tailwindcss/forms": "^0.5.7",
"@tanstack/query-sync-storage-persister": "^5.18.1",
"@tanstack/react-query": "^5.18.1",
@@ -53,6 +53,7 @@
"clsx": "^2.1.0",
"cosmjs-types": "0.8.x",
"date-fns": "^3.3.1",
+ "dotenv": "^16.4.5",
"download": "^8.0.0",
"match-sorter": "^6.3.3",
"next": "^14.1.0",
@@ -7680,9 +7681,9 @@
}
},
"node_modules/@skip-router/core": {
- "version": "1.3.5",
- "resolved": "https://registry.npmjs.org/@skip-router/core/-/core-1.3.5.tgz",
- "integrity": "sha512-nkNwsBCgwnLHTzbXOS78Q77ckHHMxrp7LbT4v5oMaI1ZJuwhtCRP48LprNDLiGy91tDiYeW1aBOuhoLARQu1Pw==",
+ "version": "1.3.12",
+ "resolved": "https://registry.npmjs.org/@skip-router/core/-/core-1.3.12.tgz",
+ "integrity": "sha512-G/xGTzrYClh66kBguytluMY9+Fi2WuVZIA3iV0JBxZ4dRmzCx2EX/7E+vCN8nPr9l8Z+AiVcy7j/815/xxIPhg==",
"dependencies": {
"@cosmjs/amino": "0.31.x",
"@cosmjs/cosmwasm-stargate": "0.31.x",
@@ -12631,6 +12632,17 @@
"tslib": "^2.0.3"
}
},
+ "node_modules/dotenv": {
+ "version": "16.4.5",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
+ "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
"node_modules/download": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/download/-/download-8.0.0.tgz",
diff --git a/package.json b/package.json
index ac60eb1c..432b513f 100644
--- a/package.json
+++ b/package.json
@@ -56,7 +56,7 @@
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@sentry/nextjs": "^7.99.0",
- "@skip-router/core": "^1.3.5",
+ "@skip-router/core": "^1.3.12",
"@tailwindcss/forms": "^0.5.7",
"@tanstack/query-sync-storage-persister": "^5.18.1",
"@tanstack/react-query": "^5.18.1",
@@ -71,6 +71,7 @@
"clsx": "^2.1.0",
"cosmjs-types": "0.8.x",
"date-fns": "^3.3.1",
+ "dotenv": "^16.4.5",
"download": "^8.0.0",
"match-sorter": "^6.3.3",
"next": "^14.1.0",
diff --git a/playwright.config.ts b/playwright.config.ts
index af40ce00..133fe25b 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -2,9 +2,11 @@ import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
+ globalSetup: "./tests/lib/globalSetup.ts",
+ timeout: 600000,
fullyParallel: true,
forbidOnly: !!process.env.CI,
- retries: process.env.CI ? 2 : 0,
+ retries: process.env.CI ? 2 : 2,
workers: 1,
reporter: "html",
use: {
@@ -17,4 +19,9 @@ export default defineConfig({
use: { ...devices["Desktop Chrome"] },
},
],
+ webServer: {
+ command: "npm run dev",
+ url: "http://localhost:3000",
+ reuseExistingServer: !process.env.CI,
+ },
});
diff --git a/src/components/AssetSelect/AssetSelectContent.tsx b/src/components/AssetSelect/AssetSelectContent.tsx
index a7de78af..44a2f0ba 100644
--- a/src/components/AssetSelect/AssetSelectContent.tsx
+++ b/src/components/AssetSelect/AssetSelectContent.tsx
@@ -89,6 +89,7 @@ function AssetSelectContent({ assets = [], balances, onChange, onClose, showChai
{filteredAssets.map((asset) => (
(onClose(), onChange?.(asset))}
>
diff --git a/src/components/AssetSelect/index.tsx b/src/components/AssetSelect/index.tsx
index 07905869..b7ca3716 100644
--- a/src/components/AssetSelect/index.tsx
+++ b/src/components/AssetSelect/index.tsx
@@ -32,6 +32,7 @@ function AssetSelect({ asset, assets, balances, onChange, showChainInfo, isBalan
"disabled:cursor-not-allowed disabled:opacity-50",
)}
disabled={!assets || assets.length === 0}
+ data-testid="select-asset"
>
{asset && (
onChange(chain)}
+ data-testid="chain-item"
>
(
"border border-neutral-200 hover:border-neutral-300",
)}
ref={ref}
+ data-testid={"select-chain"}
{...props}
>
{chain ? chain.prettyName : "Select Chain"}
diff --git a/src/components/Icons/ExpandArrow.tsx b/src/components/Icons/ExpandArrow.tsx
new file mode 100644
index 00000000..99fae931
--- /dev/null
+++ b/src/components/Icons/ExpandArrow.tsx
@@ -0,0 +1,17 @@
+import { ComponentProps } from "react";
+
+export const ExpandArrow = ({ className, ...props }: ComponentProps<"svg">) => (
+
+
+
+);
diff --git a/src/components/Icons/Spinner.tsx b/src/components/Icons/Spinner.tsx
new file mode 100644
index 00000000..d4a53a35
--- /dev/null
+++ b/src/components/Icons/Spinner.tsx
@@ -0,0 +1,27 @@
+import { ComponentProps } from "react";
+
+import { cn } from "@/utils/ui";
+
+export const Spinner = ({ className, ...props }: ComponentProps<"svg">) => (
+
+
+
+
+);
diff --git a/src/components/RouteDisplay.tsx b/src/components/RouteDisplay.tsx
deleted file mode 100644
index 4a1c7b2f..00000000
--- a/src/components/RouteDisplay.tsx
+++ /dev/null
@@ -1,835 +0,0 @@
-import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid";
-import { BridgeType, RouteResponse } from "@skip-router/core";
-import { ComponentProps, Dispatch, Fragment, SetStateAction, SyntheticEvent, useMemo } from "react";
-import { formatUnits } from "viem";
-
-import { useAssets } from "@/context/assets";
-import { useBridgeByID } from "@/hooks/useBridges";
-import { useChainByID } from "@/hooks/useChains";
-import { useBroadcastedTxsStatus } from "@/solve";
-import { cn } from "@/utils/ui";
-
-import { AdaptiveLink } from "./AdaptiveLink";
-import { SimpleTooltip } from "./SimpleTooltip";
-import { BroadcastedTx } from "./TransactionDialog/TransactionDialogContent";
-
-export interface SwapVenueConfig {
- name: string;
- imageURL: string;
-}
-
-export const SWAP_VENUES: Record = {
- "neutron-astroport": {
- name: "Neutron Astroport",
- imageURL: "https://avatars.githubusercontent.com/u/87135340",
- },
- "terra-astroport": {
- name: "Terra Astroport",
- imageURL: "https://avatars.githubusercontent.com/u/87135340",
- },
- "injective-astroport": {
- name: "Injective Astroport",
- imageURL: "https://avatars.githubusercontent.com/u/87135340",
- },
- "sei-astroport": {
- name: "Sei Astroport",
- imageURL: "https://avatars.githubusercontent.com/u/87135340",
- },
- "osmosis-poolmanager": {
- name: "Osmosis",
- imageURL: "https://raw.githubusercontent.com/cosmostation/chainlist/main/chain/osmosis/dappImg/app.png",
- },
- "neutron-lido-satellite": {
- name: "Neutron Lido Satellite",
- imageURL: "https://raw.githubusercontent.com/cosmos/chain-registry/master/_non-cosmos/ethereum/images/wsteth.svg",
- },
- "migaloo-white-whale": {
- name: "Migaloo White Whale",
- imageURL: "https://whitewhale.money/logo.svg",
- },
- "chihuahua-white-whale": {
- name: "Chihuahua White Whale",
- imageURL: "https://whitewhale.money/logo.svg",
- },
- "terra-white-whale": {
- name: "Terra White Whale",
- imageURL: "https://whitewhale.money/logo.svg",
- },
-};
-
-interface TransferAction {
- type: "TRANSFER";
- asset: string;
- sourceChain: string;
- destinationChain: string;
- id: string;
- bridgeID: BridgeType;
-}
-
-interface SwapAction {
- type: "SWAP";
- sourceAsset: string;
- destinationAsset: string;
- chain: string;
- venue: string;
- id: string;
-}
-
-type Action = TransferAction | SwapAction;
-
-interface RouteEndProps {
- amount: string;
- symbol: string;
- chain: string;
- logo: string;
-}
-
-function RouteEnd({ amount, symbol, logo, chain }: RouteEndProps) {
- return (
-
-
-
-
-
-
-
- {parseFloat(amount).toLocaleString("en-US", { maximumFractionDigits: 8 })} {symbol}
-
-
-
On {chain}
-
-
- );
-}
-
-interface TransferStepProps {
- actions: Action[];
- action: TransferAction;
- id: string;
- statusData?: ReturnType["data"];
-}
-
-function TransferStep({ action, actions, id, statusData }: TransferStepProps) {
- const { data: bridge } = useBridgeByID(action.bridgeID);
- const { data: sourceChain } = useChainByID(action.sourceChain);
- const { data: destinationChain } = useChainByID(action.destinationChain);
-
- // format: operationType--
- const operationTypeCount = Number(id.split("-")[1]);
- const operationIndex = Number(id.split("-")[2]);
- const isFirstOpSwap = actions[0]?.type === "SWAP";
- const transferStatus = statusData?.transferSequence[operationTypeCount];
- const isNextOpSwap =
- actions
- // We can assume that the swap operation by the previous transfer
- .find((x) => Number(x.id.split("-")[2]) === operationIndex + 1)
- ?.id.split("-")[0] === "swap";
- const isPrevOpTransfer = actions[operationIndex - 1]?.type === "TRANSFER";
-
- // We can assume that the transfer is successful when the state is TRANSFER_SUCCESS or TRANSFER_RECEIVED
- const renderTransferState = useMemo(() => {
- if (isFirstOpSwap) {
- if (transferStatus?.state === "TRANSFER_FAILURE") {
- return (
-
-
-
- );
- }
- if (transferStatus?.state === "TRANSFER_SUCCESS") {
- return (
-
-
-
- );
- }
-
- return
;
- }
- switch (transferStatus?.state) {
- case "TRANSFER_SUCCESS":
- return (
-
-
-
- );
- case "TRANSFER_RECEIVED":
- return (
-
-
-
- );
- case "TRANSFER_FAILURE":
- return (
-
-
-
- );
- case "TRANSFER_PENDING":
- return (
-
-
-
- );
-
- default:
- return
;
- }
- }, [isFirstOpSwap, transferStatus?.state]);
-
- const explorerLink = useMemo(() => {
- const packetTx = (() => {
- if (operationIndex === 0) return transferStatus?.txs.sendTx;
- if (isNextOpSwap) return transferStatus?.txs.sendTx;
- if (isPrevOpTransfer) return transferStatus?.txs.sendTx;
- return transferStatus?.txs.receiveTx;
- })();
- if (!packetTx?.explorerLink) {
- return null;
- }
- return makeExplorerLink(packetTx.explorerLink);
- }, [isNextOpSwap, isPrevOpTransfer, operationIndex, transferStatus?.txs.receiveTx, transferStatus?.txs.sendTx]);
-
- const { getAsset } = useAssets();
-
- 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;
- })();
-
- if (!sourceChain || !destinationChain) {
- // this should be unreachable
- return null;
- }
-
- if (!asset) {
- return (
-
-
{renderTransferState}
-
-
- Transfer
- from
-
-
- {sourceChain.prettyName}
-
-
-
- to
-
-
- {destinationChain.prettyName}
-
- {bridge && (
- <>
- with
-
- {bridge.name.toLowerCase() !== "ibc" && (
-
- )}
-
- {bridge.name}
-
- >
- )}
-
- {explorerLink && (
-
- {explorerLink.shorthand}
-
- )}
-
-
- );
- }
-
- return (
-
-
{renderTransferState}
-
-
- Transfer
-
-
- {asset.recommendedSymbol}
-
- from
-
-
- {sourceChain.prettyName}
-
-
-
- to
-
-
- {destinationChain.prettyName}
-
- {bridge && (
- <>
- with
-
- {bridge.name.toLowerCase() !== "ibc" && (
-
- )}
-
- {bridge.name}
-
- >
- )}
-
- {explorerLink && (
-
- {explorerLink.shorthand}
-
- )}
-
-
- );
-}
-
-interface SwapStepProps {
- action: SwapAction;
- actions: Action[];
- id: string;
- statusData?: ReturnType["data"];
-}
-
-function SwapStep({ action, actions, id, 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]);
-
- const venue = SWAP_VENUES[action.venue];
-
- // format: operationType--
- const operationIndex = Number(id.split("-")[2]);
- const operationTypeCount = Number(id.split("-")[1]);
- const isSwapFirstStep = operationIndex === 0 && operationTypeCount === 0;
-
- const sequenceIndex = Number(
- actions
- // We can assume that the swap operation by the previous transfer
- .find((x) => Number(x.id.split("-")[2]) === operationIndex - 1)
- ?.id.split("-")[1],
- );
- const swapStatus = statusData?.transferSequence[isSwapFirstStep ? 0 : sequenceIndex];
-
- // 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 (swapStatus?.state === "TRANSFER_PENDING") {
- return (
-
-
-
- );
- }
- if (swapStatus?.state === "TRANSFER_SUCCESS") {
- return (
-
-
-
- );
- }
- if (swapStatus?.state === "TRANSFER_FAILURE") {
- return (
-
-
-
- );
- }
-
- return
;
- }
- switch (swapStatus?.state) {
- case "TRANSFER_RECEIVED":
- return (
-
-
-
- );
- case "TRANSFER_SUCCESS":
- return (
-
-
-
- );
- case "TRANSFER_FAILURE":
- return (
-
-
-
- );
-
- default:
- return
;
- }
- }, [isSwapFirstStep, swapStatus?.state]);
-
- const explorerLink = useMemo(() => {
- const tx = isSwapFirstStep ? swapStatus?.txs.sendTx : swapStatus?.txs.receiveTx;
- if (!tx) return;
- if (swapStatus?.state !== "TRANSFER_SUCCESS") return;
- return makeExplorerLink(tx.explorerLink);
- }, [isSwapFirstStep, swapStatus?.state, swapStatus?.txs.receiveTx, swapStatus?.txs.sendTx]);
-
- if (!assetIn && assetOut) {
- return (
-
-
{renderSwapState}
-
-
- Swap to
-
-
- {assetOut.recommendedSymbol}
-
- on
-
-
- {venue.name}
-
-
- {explorerLink && (
-
- {explorerLink.shorthand}
-
- )}
-
-
- );
- }
-
- if (assetIn && !assetOut) {
- return (
-
-
{renderSwapState}
-
-
- Swap
-
-
- {assetIn.recommendedSymbol}
-
- on
-
-
- {venue.name}
-
-
- {explorerLink && (
-
- {explorerLink.shorthand}
-
- )}
-
-
- );
- }
-
- if (!assetIn || !assetOut) {
- return null;
- }
-
- return (
-
-
{renderSwapState}
-
-
- Swap
-
-
- {assetIn.recommendedSymbol}
-
- for
-
-
- {assetOut.recommendedSymbol}
-
-
- on
-
- {venue.name}
-
-
- {explorerLink && (
-
- {explorerLink.shorthand}
-
- )}
-
-
- );
-}
-
-interface RouteDisplayProps {
- route: RouteResponse;
- isRouteExpanded: boolean;
- setIsRouteExpanded: Dispatch>;
- broadcastedTxs?: BroadcastedTx[];
-}
-
-function 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(() => {
- 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.name,
- 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.name,
- 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;
- }
-
- 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 {
- 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;
- }, [route]);
-
- const { data: statusData } = useBroadcastedTxsStatus({ txsRequired: route.txsRequired, txs: broadcastedTxs });
-
- return (
-
-
-
-
-
- {isRouteExpanded && (
- setIsRouteExpanded(false)}
- >
- Hide Details
-
- )}
-
- {isRouteExpanded &&
- actions.map((action, i) => (
-
- {action.type === "SWAP" && (
-
- )}
- {action.type === "TRANSFER" && (
-
- )}
-
- ))}
- {!isRouteExpanded && (
-
-
setIsRouteExpanded(true)}
- >
-
-
-
-
-
- )}
-
-
-
- );
-}
-
-function Spinner() {
- return (
-
-
-
-
- );
-}
-
-export default RouteDisplay;
-
-const Gap = {
- Parent({ className, ...props }: ComponentProps<"div">) {
- return (
-
- );
- },
- Child({ className, ...props }: ComponentProps<"div">) {
- return (
-
- );
- },
-};
-
-function makeExplorerLink(link: string) {
- return {
- link,
- shorthand: `${link.split("/").at(-1)?.slice(0, 6)}…${link.split("/").at(-1)?.slice(-6)}`,
- };
-}
-
-function onImageError(event: SyntheticEvent) {
- event.currentTarget.src = "https://api.dicebear.com/6.x/shapes/svg";
-}
diff --git a/src/components/RouteDisplay/RouteEnd.tsx b/src/components/RouteDisplay/RouteEnd.tsx
new file mode 100644
index 00000000..5c71c856
--- /dev/null
+++ b/src/components/RouteDisplay/RouteEnd.tsx
@@ -0,0 +1,30 @@
+import { SimpleTooltip } from "../SimpleTooltip";
+
+export interface RouteEndProps {
+ amount: string;
+ symbol: string;
+ chain: string;
+ logo: string;
+}
+
+export const RouteEnd = ({ amount, symbol, logo, chain }: RouteEndProps) => {
+ return (
+
+
+
+
+
+
+
+ {parseFloat(amount).toLocaleString("en-US", { maximumFractionDigits: 8 })} {symbol}
+
+
+
On {chain}
+
+
+ );
+};
diff --git a/src/components/RouteDisplay/Step.tsx b/src/components/RouteDisplay/Step.tsx
new file mode 100644
index 00000000..e401e5ae
--- /dev/null
+++ b/src/components/RouteDisplay/Step.tsx
@@ -0,0 +1,36 @@
+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
new file mode 100644
index 00000000..1f977ae8
--- /dev/null
+++ b/src/components/RouteDisplay/SwapStep.tsx
@@ -0,0 +1,224 @@
+import { SwapVenue } from "@skip-router/core";
+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.recommendedSymbol}
+
+ on
+
+
+ {venue.prettyName}
+
+
+ {explorerLink && (
+
+ {explorerLink.shorthand}
+
+ )}
+
+
+ );
+ }
+
+ if (assetIn && !assetOut) {
+ return (
+
+
{renderSwapState}
+
+
+ Swap
+
+
+ {assetIn.recommendedSymbol}
+
+ on
+
+
+ {venue.prettyName}
+
+
+ {explorerLink && (
+
+ {explorerLink.shorthand}
+
+ )}
+
+
+ );
+ }
+
+ if (!assetIn || !assetOut) {
+ return null;
+ }
+
+ return (
+
+
{renderSwapState}
+
+
+ Swap
+
+
+ {assetIn.recommendedSymbol}
+
+ for
+
+
+ {assetOut.recommendedSymbol}
+
+
+ on
+
+ {venue.prettyName}
+
+
+ {explorerLink && (
+
+ {explorerLink.shorthand}
+
+ )}
+
+
+ );
+};
diff --git a/src/components/RouteDisplay/TransferStep.tsx b/src/components/RouteDisplay/TransferStep.tsx
new file mode 100644
index 00000000..ceeb1197
--- /dev/null
+++ b/src/components/RouteDisplay/TransferStep.tsx
@@ -0,0 +1,226 @@
+import { BridgeType } from "@skip-router/core";
+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}
+
+
+
+ to
+
+
+ {destinationChain.prettyName}
+
+ {bridge && (
+ <>
+ with
+
+ {bridge.name.toLowerCase() !== "ibc" && (
+
+ )}
+
+ {bridge.name}
+
+ >
+ )}
+
+ {explorerLink && (
+
+ {explorerLink.shorthand}
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+
{renderTransferState}
+
+
+ Transfer
+
+
+ {asset.recommendedSymbol}
+
+ from
+
+
+ {sourceChain.prettyName}
+
+
+
+ to
+
+
+ {destinationChain.prettyName}
+
+ {bridge && (
+ <>
+ with
+
+ {bridge.name.toLowerCase() !== "ibc" && (
+
+ )}
+
+ {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
new file mode 100644
index 00000000..57b7647f
--- /dev/null
+++ b/src/components/RouteDisplay/__test__/make-actions.test.tsx
@@ -0,0 +1,118 @@
+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);
+});
+
+test("make-actions: Cosmoshub ATOM to Akash ATOM", async () => {
+ await makeActionsTest(cosmoshubATOMToAkashATOM);
+});
+
+test("make-actions: Noble USDC to Ethereum USDC", async () => {
+ await makeActionsTest(nobleUSDCToEthereumUSDC);
+});
+
+test("make-actions: Cosmoshub ATOM to Arbitrum ARB", async () => {
+ await makeActionsTest(cosmoshubATOMToArbitrumARB);
+});
diff --git a/src/components/RouteDisplay/__test__/make-step.test.tsx b/src/components/RouteDisplay/__test__/make-step.test.tsx
new file mode 100644
index 00000000..09aa37ba
--- /dev/null
+++ b/src/components/RouteDisplay/__test__/make-step.test.tsx
@@ -0,0 +1,71 @@
+import { useBroadcastedTxsStatus } from "@/solve";
+import { AllTheProviders, renderHook, waitFor } from "@/test";
+
+import { makeActions } from "../make-actions";
+import { makeStepState } from "../make-step-state";
+import { nobleUSDCtoInjectiveINJ } from "./route-to-test";
+import { createRoute } from "./utils";
+
+test("make-step: Noble USDC to Injective INJ", async () => {
+ const { direction, amount, sourceAsset, sourceAssetChainID, destinationAsset, destinationAssetChainID, swapVenue } =
+ nobleUSDCtoInjectiveINJ;
+
+ 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: "noble-1",
+ txHash: "40C220E06B22435842A1DDA80ED2D38917228D8A77419A9F4B885C9E48D6B228",
+ },
+ ],
+ enabled: true,
+ }),
+ {
+ wrapper: AllTheProviders,
+ },
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBeFalsy(), {
+ timeout: 10000,
+ });
+
+ 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();
+ });
+});
diff --git a/src/components/RouteDisplay/__test__/route-to-test.ts b/src/components/RouteDisplay/__test__/route-to-test.ts
new file mode 100644
index 00000000..a1d1c843
--- /dev/null
+++ b/src/components/RouteDisplay/__test__/route-to-test.ts
@@ -0,0 +1,59 @@
+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
new file mode 100644
index 00000000..9b8943db
--- /dev/null
+++ b/src/components/RouteDisplay/__test__/utils.ts
@@ -0,0 +1,28 @@
+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
new file mode 100644
index 00000000..2b72c804
--- /dev/null
+++ b/src/components/RouteDisplay/index.tsx
@@ -0,0 +1,116 @@
+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 && (
+ setIsRouteExpanded(false)}
+ >
+ Hide Details
+
+ )}
+
+ {isRouteExpanded &&
+ actions.map((action, i) => (
+
+ {action.type === "SWAP" && (
+
+ )}
+ {action.type === "TRANSFER" && (
+
+ )}
+
+ ))}
+ {!isRouteExpanded && (
+
+ setIsRouteExpanded(true)}
+ >
+
+
+
+ )}
+
+
+
+ );
+};
diff --git a/src/components/RouteDisplay/make-actions.ts b/src/components/RouteDisplay/make-actions.ts
new file mode 100644
index 00000000..13a311f1
--- /dev/null
+++ b/src/components/RouteDisplay/make-actions.ts
@@ -0,0 +1,150 @@
+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
new file mode 100644
index 00000000..3832fd95
--- /dev/null
+++ b/src/components/RouteDisplay/make-step-state.ts
@@ -0,0 +1,79 @@
+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/SkipBanner.tsx b/src/components/SkipBanner.tsx
index e3590847..3c210c97 100644
--- a/src/components/SkipBanner.tsx
+++ b/src/components/SkipBanner.tsx
@@ -7,7 +7,7 @@ import { AdaptiveLink, AdaptiveLinkProps } from "./AdaptiveLink";
function SkipBanner({ className, ...props }: Omit) {
return (
diff --git a/src/components/SwapWidget/SwapDetails.tsx b/src/components/SwapWidget/SwapDetails.tsx
index 222f8be5..b192fb11 100644
--- a/src/components/SwapWidget/SwapDetails.tsx
+++ b/src/components/SwapWidget/SwapDetails.tsx
@@ -5,7 +5,7 @@ import { useMemo } from "react";
import { disclosure, useDisclosureKey } from "@/context/disclosures";
import { useSettingsStore } from "@/context/settings";
-import { formatPercent } from "@/utils/intl";
+import { formatPercent, formatUSD } from "@/utils/intl";
import { cn } from "@/utils/ui";
import { ConversionRate } from "../ConversionRate";
@@ -48,11 +48,12 @@ export const SwapDetails = ({
const bridgingFee = useMemo(() => {
if (!axelarTransferOperation) return;
- const { feeAmount, asset } = axelarTransferOperation.axelarTransfer;
- const computed = (+feeAmount / Math.pow(10, 18)).toLocaleString("en-US", {
+ const { feeAmount, feeAsset, usdFeeAmount } = axelarTransferOperation.axelarTransfer;
+ const computed = (+feeAmount / Math.pow(10, feeAsset.decimals || 18)).toLocaleString("en-US", {
maximumFractionDigits: 6,
});
- return `${computed} ${asset}`;
+
+ return { inAsset: `${computed} ${feeAsset.symbol}`, inUSD: `${formatUSD(usdFeeAmount)}` };
}, [axelarTransferOperation]);
if (!(sourceChain && sourceAsset && destinationChain && destinationAsset)) {
@@ -159,7 +160,7 @@ export const SwapDetails = ({
{sourceFeeAsset && (
<>
- Estimated Fee
+ Estimated Transaction Fee
{gasRequired ?? "-"} {sourceFeeAsset.recommendedSymbol}
@@ -182,7 +183,10 @@ export const SwapDetails = ({
{parseFloat(gasAmount).toLocaleString()}
*/}
Bridging Fee
- {bridgingFee ?? "-"}
+
+ {bridgingFee?.inAsset ?? "-"}{" "}
+ {bridgingFee?.inUSD ?? "-"}
+
diff --git a/src/components/SwapWidget/SwapWidget.tsx b/src/components/SwapWidget/SwapWidget.tsx
index 93247ad8..85d44c23 100644
--- a/src/components/SwapWidget/SwapWidget.tsx
+++ b/src/components/SwapWidget/SwapWidget.tsx
@@ -74,6 +74,7 @@ export function SwapWidget() {
const destAccount = useAccount("destination");
const isWalletConnected = srcAccount?.isWalletConnected && destAccount?.isWalletConnected;
+ const isEvmtoEvm = srcAccount?.chainType === "evm" && destAccount?.chainType === "evm";
function promptDestAsset() {
document.querySelector("[data-testid='destination'] button")?.click();
@@ -238,6 +239,16 @@ export function SwapWidget() {
)}
+ {isEvmtoEvm && (
+
+
+ WARNING:
+ ibc.fun only supports swapping/transferring to, from, and within the Cosmos ecosystem at this time. If
+ you're not transferring to or from a Cosmos chain, we recommend{" "}
+ jumper.exchange
+
+
+ )}
{!isWalletConnected && (
{
+ toast.error(`Network switch error: ${error.message}`);
+ },
+ });
+ const { disconnect } = useWagmiDisconnect();
const [userTouchedDstAsset, setUserTouchedDstAsset] = useState(false);
@@ -630,9 +636,12 @@ export function useSwapWidget() {
trackWallet.track("source", srcChain.chainID, connector.id, srcChain.chainType);
} catch (error) {
console.error(error);
+ trackWallet.untrack("source");
+ disconnect();
}
} else {
trackWallet.untrack("source");
+ disconnect();
}
}
},
@@ -683,15 +692,18 @@ export function useSwapWidget() {
if (dstChain && dstChain.chainType === "evm") {
if (evmChain && connector) {
try {
- if (switchNetworkAsync && evmChain.id !== +dstChain.chainID) {
+ if (switchNetworkAsync && evmChain.id !== +dstChain.chainID && srcChain && srcChain.chainType !== "evm") {
await switchNetworkAsync(+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();
}
}
},
@@ -700,7 +712,7 @@ export function useSwapWidget() {
fireImmediately: true,
},
);
- }, [connector, evmChain, getWalletRepo, switchNetworkAsync]);
+ }, [connector, evmChain, getWalletRepo, srcChain, switchNetworkAsync]);
/**
* sync destination chain wallet connections on track wallet level
@@ -842,5 +854,5 @@ function getRouteErrorMessage({ message }: { message: string }) {
if (message.includes("evm native destination tokens are currently not supported")) {
return "EVM native destination tokens are currently not supported";
}
- return "Route not found";
+ return message;
}
diff --git a/src/components/TransactionDialog/TransactionDialogContent.tsx b/src/components/TransactionDialog/TransactionDialogContent.tsx
index 88fdb265..7786be2a 100644
--- a/src/components/TransactionDialog/TransactionDialogContent.tsx
+++ b/src/components/TransactionDialog/TransactionDialogContent.tsx
@@ -17,7 +17,7 @@ import { isCCTPLedgerBrokenInOperation, isEthermintLedgerInOperation } from "@/u
import { randomId } from "@/utils/random";
import { cn } from "@/utils/ui";
-import RouteDisplay from "../RouteDisplay";
+import { RouteDisplay } from "../RouteDisplay";
import { SpinnerIcon } from "../SpinnerIcon";
import TransactionSuccessView from "../TransactionSuccessView";
import * as AlertCollapse from "./AlertCollapse";
@@ -53,10 +53,11 @@ function TransactionDialogContent({ route, onClose, isAmountError, transactionCo
const srcAccount = useAccount("source");
const dstAccount = useAccount("destination");
- const showCCTPLedgerWarning = isCCTPLedgerBrokenInOperation(route) && srcAccount?.wallet?.isLedger;
- const showEthermintLikeLedgerWarning = isEthermintLedgerInOperation(route) && srcAccount?.wallet?.isLedger;
+ const showCCTPLedgerWarning = isCCTPLedgerBrokenInOperation(route) && srcAccount?.wallet?.isLedger === true;
+ const showEthermintLikeLedgerWarning = isEthermintLedgerInOperation(route) && srcAccount?.wallet?.isLedger === true;
const showLedgerWarning = showCCTPLedgerWarning || showEthermintLikeLedgerWarning;
+ const isEvmtoEvm = srcAccount?.chainType === "evm" && dstAccount?.chainType === "evm";
const { data: userAddresses } = useWalletAddresses(route.chainIDs);
@@ -196,7 +197,10 @@ function TransactionDialogContent({ route, onClose, isAmountError, transactionCo
/>
-
+
{broadcastedTxs.map(({ txHash }, i) => (
{txHash.slice(0, 6)}
@@ -281,6 +287,20 @@ function TransactionDialogContent({ route, onClose, isAmountError, transactionCo
)}
+ {isEvmtoEvm && (
+
+
+
+ WARNING:
+ ibc.fun only supports swapping/transferring to, from, and within the Cosmos ecosystem at this time. If
+ you're not transferring to or from a Cosmos chain, we recommend satelite.money
+
+
+
+ )}
{isAmountError && !isOngoing && !isTxComplete && (
{typeof isAmountError === "string" ? isAmountError : "Insufficient balance."}
@@ -289,7 +309,11 @@ function TransactionDialogContent({ route, onClose, isAmountError, transactionCo
This route requires{" "}
-
+
{transactionCount} Transaction
{transactionCount > 1 ? "s" : ""}
{" "}
diff --git a/src/components/TransactionSuccessView.tsx b/src/components/TransactionSuccessView.tsx
index 5033641a..49d34e29 100644
--- a/src/components/TransactionSuccessView.tsx
+++ b/src/components/TransactionSuccessView.tsx
@@ -41,7 +41,7 @@ const TransactionSuccessView: FC<{
/>
-
+
{route.doesSwap ? "Swap" : "Transfer"} Successful
diff --git a/src/components/WalletModal/WalletModal.tsx b/src/components/WalletModal/WalletModal.tsx
index db9b61d0..c120d9ec 100644
--- a/src/components/WalletModal/WalletModal.tsx
+++ b/src/components/WalletModal/WalletModal.tsx
@@ -2,6 +2,7 @@ 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 toast from "react-hot-toast";
import { useAccount, useConnect, useDisconnect } from "wagmi";
import { chainIdToName } from "@/chains/types";
@@ -151,7 +152,17 @@ function WalletModalWithContext() {
const { connector: currentConnector } = useAccount();
const { chainID, context } = useWalletModal();
const { disconnectAsync } = useDisconnect();
- const { connectors, connectAsync } = useConnect();
+ const { connectors, connectAsync } = useConnect({
+ onError: (err) => {
+ toast.error(
+
+ Failed to connect!
+
+ {err.name}: {err.message}
+
,
+ );
+ },
+ });
const { getWalletRepo } = useManager();
const { setIsOpen } = useWalletModal();
@@ -209,8 +220,12 @@ function WalletModalWithContext() {
},
connect: async () => {
if (connector.id === currentConnector?.id) return;
- await connectAsync({ connector, chainId: Number(chainID) });
- context && trackWallet.track(context, chainID, connector.id, chainType);
+ try {
+ await connectAsync({ connector, chainId: Number(chainID) });
+ context && trackWallet.track(context, chainID, connector.id, chainType);
+ } catch (error) {
+ console.error(error);
+ }
},
disconnect: async () => {
await disconnectAsync();
diff --git a/src/components/__tests__/ConnectWalletButtonSmall.test.tsx b/src/components/__tests__/ConnectWalletButtonSmall.test.tsx
deleted file mode 100644
index dc181503..00000000
--- a/src/components/__tests__/ConnectWalletButtonSmall.test.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { jest } from "@jest/globals";
-import userEvent from "@testing-library/user-event";
-import { act } from "react-dom/test-utils";
-
-import { render, screen } from "@/test";
-
-import { ConnectWalletButtonSmall } from "../ConnectWalletButtonSmall";
-
-describe("ConnectWalletButtonSmall", () => {
- it("handles clicks", async () => {
- const fn = jest.fn();
-
- await act(async () => {
- render(
);
- });
-
- await userEvent.click(screen.getByRole("button"));
-
- expect(fn).toHaveBeenCalled();
- });
-});
diff --git a/src/components/__tests__/SwapWidget.test.tsx b/src/components/__tests__/SwapWidget.test.tsx
deleted file mode 100644
index cafcb6ba..00000000
--- a/src/components/__tests__/SwapWidget.test.tsx
+++ /dev/null
@@ -1,406 +0,0 @@
-import { rest } from "msw";
-import { setupServer } from "msw/node";
-
-import { API_URL } from "@/constants/api";
-import { act, fireEvent, render, screen, waitFor, within } from "@/test";
-
-import { ASSETS_RESPONSE } from "../../../fixtures/assets";
-import { CHAINS_RESPONSE } from "../../../fixtures/chains";
-import { SwapWidget } from "../SwapWidget";
-
-const handlers = [
- rest.get(`${API_URL}/v1/info/chains`, (_, res, ctx) => {
- return res(ctx.status(200), ctx.json(CHAINS_RESPONSE));
- }),
- rest.get(`${API_URL}/v1/fungible/assets`, (_, res, ctx) => {
- return res(ctx.status(200), ctx.json(ASSETS_RESPONSE));
- }),
- rest.post(`${API_URL}/v2/fungible/route`, (_, res, ctx) => {
- return res(
- ctx.status(200),
- ctx.json({
- source_asset_denom: "uatom",
- source_asset_chain_id: "cosmoshub-4",
- dest_asset_denom: "untrn",
- dest_asset_chain_id: "neutron-1",
- amount_in: "1000000",
- operations: [
- {
- transfer: {
- port: "transfer",
- channel: "channel-569",
- chain_id: "cosmoshub-4",
- pfm_enabled: true,
- dest_denom: "ibc/C4CFF46FD6DE35CA4CF4CE031E643C8FDC9BA4B99AE598E9B0ED98FE3A2319F9",
- supports_memo: true,
- },
- },
- {
- swap: {
- swap_in: {
- swap_venue: {
- name: "neutron-astroport",
- chain_id: "neutron-1",
- },
- swap_operations: [
- {
- pool: "neutron1e22zh5p8meddxjclevuhjmfj69jxfsa8uu3jvht72rv9d8lkhves6t8veq",
- denom_in: "ibc/C4CFF46FD6DE35CA4CF4CE031E643C8FDC9BA4B99AE598E9B0ED98FE3A2319F9",
- denom_out: "untrn",
- },
- ],
- swap_amount_in: "1000000",
- },
- estimated_affiliate_fee: "0untrn",
- },
- },
- ],
- chain_ids: ["cosmoshub-4", "neutron-1"],
- does_swap: true,
- estimated_amount_out: "25329854",
- amount_out: "25329854",
- swap_venue: {
- name: "neutron-astroport",
- chain_id: "neutron-1",
- },
- }),
- );
- }),
-];
-
-const server = setupServer(...handlers);
-
-describe.skip("SwapWidget", () => {
- // Establish API mocking before all tests.
- beforeAll(() => server.listen());
-
- // Clean up after the tests are finished.
- afterAll(() => server.close());
-
- beforeEach(() => {
- localStorage.clear();
- });
-
- // Reset any request handlers that we may add during the tests,
- // so they don't affect other tests.
- afterEach(() => server.resetHandlers());
-
- it("can select source chain", async () => {
- await act(async () => {
- render(
);
- });
-
- const sourceAssetSection = await screen.findByTestId("source");
-
- // Source chain should be Cosmos Hub by default
- const sourceChainButton = await within(sourceAssetSection).findByText("Cosmos Hub");
- expect(sourceChainButton).toBeInTheDocument();
-
- // Source asset should be selected
- const sourceAssetButton = await within(sourceAssetSection).findByText("ATOM");
- expect(sourceAssetButton).toBeInTheDocument();
-
- // Update amount in
- const inputAmountElement = within(sourceAssetSection).getByRole("textbox");
- fireEvent.change(inputAmountElement, { target: { value: "1" } });
-
- // Select new source chain
- fireEvent.click(sourceChainButton);
- fireEvent.click(await within(sourceAssetSection).findByText("Osmosis"));
-
- // Source chain is now Osmosis
- expect(sourceChainButton).toHaveTextContent("Osmosis");
-
- // Source chain is stored in local storage
- // expect(localStorage.getItem(LAST_SOURCE_CHAIN_KEY)).toEqual("osmosis-1");
-
- // Source asset is now OSMO
- expect(await within(sourceAssetSection).findByText("OSMO")).toBeInTheDocument();
-
- // Amount in is now empty
- expect(inputAmountElement).toHaveValue("");
- });
-
- it("can select source asset", async () => {
- await act(async () => {
- render(
);
- });
-
- const sourceAssetSection = await screen.findByTestId("source");
-
- // Source asset should be ATOM initially
- const sourceAssetButton = await within(sourceAssetSection).findByText("ATOM");
- expect(sourceAssetButton).toBeInTheDocument();
-
- // Select new asset
- fireEvent.click(sourceAssetButton);
- fireEvent.click(await within(sourceAssetSection).findByText("NTRN"));
-
- // Source asset is now NTRN
- await waitFor(() => expect(sourceAssetButton).toHaveTextContent("NTRN"));
- });
-
- it("can select destination chain", async () => {
- await act(async () => {
- render(
);
- });
-
- const destinationAssetSection = await screen.findByTestId("destination");
-
- // Destination chain should be undefined initially
- const destinationChainButton = await within(destinationAssetSection).findByText("Select Chain");
- expect(destinationChainButton).toBeInTheDocument();
-
- // Select new destination chain
- fireEvent.click(destinationChainButton);
- fireEvent.click(await within(destinationAssetSection).findByText("Neutron"));
-
- // Destination chain is now Neutron
- expect(destinationChainButton).toHaveTextContent("Neutron");
-
- // Destination asset should be selected
- const destinationAssetButton = await within(destinationAssetSection).findByText("NTRN");
- expect(destinationAssetButton).toBeInTheDocument();
-
- // Select new destination chain
- fireEvent.click(destinationChainButton);
- fireEvent.click(await within(destinationAssetSection).findByText("Osmosis"));
-
- // Destination chain is now Osmosis
- expect(destinationChainButton).toHaveTextContent("Osmosis");
-
- // Destination asset should now be OSMO
- await waitFor(async () => expect(await within(destinationAssetSection).findByText("OSMO")).toBeInTheDocument());
- });
-
- it("can select destination asset", async () => {
- await act(async () => {
- render(
);
- });
-
- const destinationAssetSection = await screen.findByTestId("destination");
-
- const destinationChainButton = await within(destinationAssetSection).findByText("Select Chain");
- expect(destinationChainButton).toBeInTheDocument();
-
- // Select Osmosis as destination chain
- fireEvent.click(destinationChainButton);
- fireEvent.click(await within(destinationAssetSection).findByText("Osmosis"));
-
- // Destination asset should be OSMO initially
- const destinationAssetButton = await within(destinationAssetSection).findByText("OSMO");
- expect(destinationAssetButton).toBeInTheDocument();
-
- // Select new asset
- fireEvent.click(destinationAssetButton);
- fireEvent.click(await within(destinationAssetSection).findByText("CMDX"));
-
- // Destination asset is now CMDX
- await waitFor(() => expect(destinationAssetButton).toHaveTextContent("CMDX"));
- });
-
- it("can select destination asset before selecting destination chain", async () => {
- await act(async () => {
- render(
);
- });
-
- const destinationAssetSection = await screen.findByTestId("destination");
-
- // Destination chain should be undefined
- const destinationChainButton = await within(destinationAssetSection).findByText("Select Chain");
- expect(destinationChainButton).toBeInTheDocument();
-
- const destinationAssetButton = await within(destinationAssetSection).findByText("Select Token");
- expect(destinationAssetButton).toBeInTheDocument();
-
- // Select destination asset
- fireEvent.click(destinationAssetButton);
- fireEvent.click(await within(destinationAssetSection).findByText("ATOM"));
-
- // Destination chain is now Cosmos Hub
- await waitFor(() => expect(destinationChainButton).toHaveTextContent("Cosmos Hub"));
-
- // Destination asset is now ATOM
- await waitFor(() => within(destinationAssetSection).findByText("ATOM"));
-
- // Select new destination chain
- fireEvent.click(destinationChainButton);
- fireEvent.click(await within(destinationAssetSection).findByText("Osmosis"));
-
- // Destination chain is now Osmosis
- expect(destinationChainButton).toHaveTextContent("Osmosis");
-
- // Destination asset should still be ATOM
- await waitFor(() => within(destinationAssetSection).findByText("ATOM"));
- });
-
- it("can swap source and destination", async () => {
- await act(async () => {
- render(
);
- });
-
- const sourceAssetSection = await screen.findByTestId("source");
- const destinationAssetSection = await screen.findByTestId("destination");
-
- // Source chain should be selected
- const sourceChainButton = await within(sourceAssetSection).findByText("Cosmos Hub");
- expect(sourceChainButton).toBeInTheDocument();
-
- // Source asset should be selected
- const sourceAssetButton = await within(sourceAssetSection).findByText("ATOM");
- expect(sourceAssetButton).toBeInTheDocument();
-
- const destinationChainButton = await within(destinationAssetSection).findByText("Select Chain");
-
- fireEvent.click(destinationChainButton);
- fireEvent.click(await within(destinationAssetSection).findByText("Neutron"));
-
- // Destination chain should be selected
- expect(destinationChainButton).toHaveTextContent("Neutron");
-
- // Destination asset should be selected
- const destinationAssetButton = await within(destinationAssetSection).findByText("NTRN");
- expect(destinationAssetButton).toBeInTheDocument();
-
- const swapButton = await screen.findByTestId("swap-button");
- fireEvent.click(swapButton);
-
- // Source chain should be Neutron
- expect(sourceChainButton).toHaveTextContent("Neutron");
-
- // Source asset should be NTRN
- expect(sourceAssetButton).toHaveTextContent("NTRN");
-
- // Destination chain should be Cosmos Hub
- expect(destinationChainButton).toHaveTextContent("Cosmos Hub");
-
- // Destination asset should be ATOM
- expect(destinationAssetButton).toHaveTextContent("ATOM");
- });
-
- it("fetches a route and displays an amount out", async () => {
- await act(async () => {
- render(
);
- });
-
- const sourceAssetSection = await screen.findByTestId("source");
- const destinationAssetSection = await screen.findByTestId("destination");
-
- // Source chain should be selected
- const sourceChainButton = await within(sourceAssetSection).findByText("Cosmos Hub");
- expect(sourceChainButton).toBeInTheDocument();
-
- // Source asset should be selected
- const sourceAssetButton = await within(sourceAssetSection).findByText("ATOM");
- expect(sourceAssetButton).toBeInTheDocument();
-
- const destinationChainButton = await within(destinationAssetSection).findByText("Select Chain");
-
- fireEvent.click(destinationChainButton);
- fireEvent.click(await within(destinationAssetSection).findByText("Neutron"));
-
- // Destination chain should be selected
- expect(destinationChainButton).toHaveTextContent("Neutron");
-
- // Destination asset should be selected
- const destinationAssetButton = await within(destinationAssetSection).findByText("NTRN");
- expect(destinationAssetButton).toBeInTheDocument();
-
- // Update amount in
- const inputAmountElement = within(sourceAssetSection).getByRole("textbox");
- fireEvent.change(inputAmountElement, { target: { value: "1" } });
-
- const outputAmountElement = within(destinationAssetSection).getByTestId("amount");
-
- await waitFor(() => expect(outputAmountElement).toHaveTextContent("25.329854"));
- });
-
- it("does not show the connect destination wallet button if the source chain and destination chain are the same chain type", async () => {
- await act(async () => {
- render(
);
- });
-
- const sourceAssetSection = await screen.findByTestId("source");
- const destinationAssetSection = await screen.findByTestId("destination");
-
- const sourceChainButton = within(sourceAssetSection).getByText("Cosmos Hub");
-
- const destinationChainButton = within(destinationAssetSection).getByText("Select Chain");
-
- // select Cosmos Hub and Neutron
- await act(() => {
- fireEvent.click(sourceChainButton);
- });
- fireEvent.click(
- await within(sourceAssetSection).findByRole("button", {
- name: /cosmos hub/i,
- }),
- );
-
- await act(() => {
- fireEvent.click(destinationChainButton);
- });
- fireEvent.click(
- await within(destinationAssetSection).findByRole("button", {
- name: /neutron/i,
- }),
- );
-
- expect(screen.queryByTestId("destination-wallet-btn")).not.toBeInTheDocument();
- });
-
- it("displays the connect destination wallet button if the source chain and destination chain are not the same chain type", async () => {
- await act(async () => {
- render(
);
- });
-
- const sourceAssetSection = await screen.findByTestId("source");
- const destinationAssetSection = await screen.findByTestId("destination");
-
- const sourceChainButton = within(sourceAssetSection).getByText("Cosmos Hub");
-
- const destinationChainButton = within(destinationAssetSection).getByText("Select Chain");
-
- // select Arbitrum and Osmosis
- await act(() => {
- fireEvent.click(sourceChainButton);
- });
- fireEvent.click(
- await within(sourceAssetSection).findByRole("button", {
- name: /arbitrum/i,
- }),
- );
-
- await act(() => {
- fireEvent.click(destinationChainButton);
- });
- fireEvent.click(
- await within(destinationAssetSection).findByRole("button", {
- name: /osmosis/i,
- }),
- );
-
- expect(screen.queryByTestId("destination-wallet-btn")).toBeInTheDocument();
-
- // select Osmosis and Polygon
- await act(() => {
- fireEvent.click(sourceChainButton);
- });
- fireEvent.click(
- await within(sourceAssetSection).findByRole("button", {
- name: /osmosis/i,
- }),
- );
-
- await act(() => {
- fireEvent.click(destinationChainButton);
- });
- fireEvent.click(
- await within(destinationAssetSection).findByRole("button", {
- name: /polygon/i,
- }),
- );
-
- expect(screen.queryByTestId("destination-wallet-btn")).toBeInTheDocument();
- }, 10000);
-});
diff --git a/src/components/__tests__/WalletModal.test.tsx b/src/components/__tests__/WalletModal.test.tsx
deleted file mode 100644
index 676c37ab..00000000
--- a/src/components/__tests__/WalletModal.test.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-import { jest } from "@jest/globals";
-import userEvent from "@testing-library/user-event";
-
-import { act, render, screen } from "@/test";
-
-import { MinimalWallet, WalletModal } from "../WalletModal/WalletModal";
-
-describe("WalletModal", () => {
- it("closes when the back arrow is clicked", async () => {
- const onClose = jest.fn();
-
- const wallets: MinimalWallet[] = [];
-
- await act(async () => {
- render(
-
,
- );
- });
- });
-
- it("can connect a wallet", async () => {
- const onClose = jest.fn();
- const keplrConnectFn = jest.fn();
-
- const keplr = {
- walletName: "keplr-extension",
- walletPrettyName: "Keplr",
- walletInfo: {
- logo: "https://keplr.logo",
- },
- connect: keplrConnectFn,
- disconnect: jest.fn(),
- isWalletConnected: false,
- };
-
- // @ts-expect-error -- mocked functions
- const wallets: MinimalWallet[] = [keplr];
-
- await act(async () => {
- render(
-
,
- );
- });
-
- await userEvent.click(
- screen.getByRole("button", {
- name: /keplr/i,
- }),
- );
-
- expect(keplrConnectFn).toHaveBeenCalled();
- expect(onClose).toHaveBeenCalled();
- });
-
- it("can disconnect a wallet", async () => {
- const onClose = jest.fn();
- const keplrConnectFn = jest.fn();
-
- const keplr = {
- walletName: "keplr-extension",
- walletPrettyName: "Keplr",
- walletInfo: {
- logo: "https://keplr.logo",
- },
- connect: keplrConnectFn,
- disconnect: jest.fn(),
- isWalletConnected: true,
- };
-
- // @ts-expect-error -- mocked functions
- const wallets: MinimalWallet[] = [keplr];
-
- await act(async () => {
- render(
-
,
- );
- });
-
- await userEvent.click(
- screen.getByRole("button", {
- name: /disconnect keplr/i,
- }),
- );
-
- expect(keplr.disconnect).toHaveBeenCalled();
- expect(onClose).toHaveBeenCalled();
- });
-});
diff --git a/src/components/common/Gap.tsx b/src/components/common/Gap.tsx
new file mode 100644
index 00000000..e21db2ae
--- /dev/null
+++ b/src/components/common/Gap.tsx
@@ -0,0 +1,22 @@
+import { ComponentProps } from "react";
+
+import { cn } from "@/utils/ui";
+
+export const Gap = {
+ Parent({ className, ...props }: ComponentProps<"div">) {
+ return (
+
+ );
+ },
+ Child({ className, ...props }: ComponentProps<"div">) {
+ return (
+
+ );
+ },
+};
diff --git a/src/constants/api.ts b/src/constants/api.ts
index 26cece44..7b486736 100644
--- a/src/constants/api.ts
+++ b/src/constants/api.ts
@@ -2,6 +2,13 @@ export const API_URL = process.env.NEXT_PUBLIC_API_URL || "https://api.skip.mone
export const APP_URL = process.env.APP_URL;
+export const appUrl =
+ process.env.NEXT_PUBLIC_VERCEL_ENV === "preview"
+ ? typeof window !== "undefined"
+ ? `https://${window.location.hostname}`
+ : process.env.NEXT_PUBLIC_VERCEL_URL
+ : APP_URL;
+
export const APP_DOMAIN =
process.env.APP_URL?.replace(/https?:\/\//, "").split("/")[0] ||
process.env.NEXT_PUBLIC_VERCEL_URL ||
diff --git a/src/constants/swap-venues.ts b/src/constants/swap-venues.ts
new file mode 100644
index 00000000..2eca2fb2
--- /dev/null
+++ b/src/constants/swap-venues.ts
@@ -0,0 +1,33 @@
+export interface SwapVenueConfig {
+ prettyName: string;
+}
+
+export const SWAP_VENUES: Record
= {
+ "neutron-astroport": {
+ prettyName: "Neutron Astroport",
+ },
+ "terra-astroport": {
+ prettyName: "Terra Astroport",
+ },
+ "injective-astroport": {
+ prettyName: "Injective Astroport",
+ },
+ "sei-astroport": {
+ prettyName: "Sei Astroport",
+ },
+ "osmosis-poolmanager": {
+ prettyName: "Osmosis",
+ },
+ "neutron-lido-satellite": {
+ prettyName: "Neutron Lido Satellite",
+ },
+ "migaloo-white-whale": {
+ prettyName: "Migaloo White Whale",
+ },
+ "chihuahua-white-whale": {
+ prettyName: "Chihuahua White Whale",
+ },
+ "terra-white-whale": {
+ prettyName: "Terra White Whale",
+ },
+};
diff --git a/src/constants/wagmi.ts b/src/constants/wagmi.ts
index 7e74f905..0b08b057 100644
--- a/src/constants/wagmi.ts
+++ b/src/constants/wagmi.ts
@@ -10,6 +10,7 @@ import {
kava,
linea,
mainnet,
+ manta,
moonbeam,
optimism,
polygon,
@@ -44,4 +45,5 @@ export const EVM_CHAINS: Chain[] = [
optimism,
fantom,
kava,
+ manta,
];
diff --git a/src/hooks/useAccount.ts b/src/hooks/useAccount.ts
index 89b6227d..6e16f871 100644
--- a/src/hooks/useAccount.ts
+++ b/src/hooks/useAccount.ts
@@ -7,7 +7,7 @@ import { useAccount as useWagmiAccount } from "wagmi";
import { EVM_WALLET_LOGOS, INJECTED_EVM_WALLET_LOGOS } from "@/constants/wagmi";
import { trackWallet, TrackWalletCtx, useTrackWallet } from "@/context/track-wallet";
import { useChainByID } from "@/hooks/useChains";
-import { isWalletClientUsingLedger } from "@/utils/wallet";
+import { isReadyToCheckLedger, isWalletClientUsingLedger } from "@/utils/wallet";
export function useAccount(context: TrackWalletCtx) {
const trackedWallet = useTrackWallet(context);
@@ -28,13 +28,28 @@ export function useAccount(context: TrackWalletCtx) {
return isLedger;
};
+ const readyToCheckLedger = useMemo(() => {
+ if (!cosmosWallet?.client) return false;
+ return isReadyToCheckLedger(cosmosWallet?.client);
+ }, [cosmosWallet?.client]);
+
const cosmosWalletIsLedgerQuery = useQuery({
- queryKey: ["cosmosWallet", cosmosWallet, chain, cosmosWallet?.client, chain?.chainID],
+ // eslint-disable-next-line @tanstack/query/exhaustive-deps
+ queryKey: [
+ "cosmosWallet",
+ { context, cosmosWallet: cosmosWallet?.walletName, address: cosmosWallet?.address, chainID: chain?.chainID },
+ ],
queryFn: () => {
- if (!cosmosWallet || !chain) return;
+ if (!cosmosWallet?.client || !chain) return;
return getIsLedger(cosmosWallet.client, chain.chainID);
},
- enabled: chain && chain.chainType === "cosmos" && !!cosmosWallet,
+ enabled:
+ chain &&
+ chain.chainType === "cosmos" &&
+ !!cosmosWallet &&
+ context === "source" &&
+ readyToCheckLedger &&
+ !!cosmosWallet?.address,
});
const account = useMemo(() => {
@@ -51,7 +66,7 @@ export function useAccount(context: TrackWalletCtx) {
walletInfo: {
logo: cosmosWallet.walletInfo.logo,
},
- isLedger: !!cosmosWalletIsLedgerQuery.data,
+ isLedger: cosmosWalletIsLedgerQuery.data,
}
: undefined,
chainType: chain.chainType,
diff --git a/src/solve/context.tsx b/src/solve/context.tsx
index d7ab6755..13523bb5 100644
--- a/src/solve/context.tsx
+++ b/src/solve/context.tsx
@@ -4,7 +4,7 @@ import { getWalletClient } from "@wagmi/core";
import { createContext, ReactNode } from "react";
import { chainIdToName } from "@/chains/types";
-import { API_URL, APP_URL } from "@/constants/api";
+import { API_URL, appUrl } from "@/constants/api";
import { trackWallet } from "@/context/track-wallet";
import { gracefullyConnect, isWalletClientUsingLedger } from "@/utils/wallet";
@@ -66,10 +66,10 @@ export function SkipProvider({ children }: { children: ReactNode }) {
},
endpointOptions: {
getRpcEndpointForChain: async (chainID) => {
- return `${APP_URL}/api/rpc/${chainID}`;
+ return `${appUrl}/api/rpc/${chainID}`;
},
getRestEndpointForChain: async (chainID) => {
- return `${APP_URL}/api/rest/${chainID}`;
+ return `${appUrl}/api/rest/${chainID}`;
},
},
});
diff --git a/src/solve/queries.ts b/src/solve/queries.ts
index 6865bd9c..87088018 100644
--- a/src/solve/queries.ts
+++ b/src/solve/queries.ts
@@ -233,6 +233,31 @@ export const useBroadcastedTxsStatus = ({
state: cctpState,
};
}
+ if ("hyperlaneTransfer" in transfer) {
+ const hyperlaneState: TransferState = (() => {
+ switch (transfer.hyperlaneTransfer.state) {
+ case "HYPERLANE_TRANSFER_SENT":
+ return "TRANSFER_PENDING";
+ case "HYPERLANE_TRANSFER_FAILED":
+ return "TRANSFER_FAILURE";
+ case "HYPERLANE_TRANSFER_RECEIVED":
+ return "TRANSFER_SUCCESS";
+ case "HYPERLANE_TRANSFER_UNKNOWN":
+ return "TRANSFER_UNKNOWN";
+ default:
+ return "TRANSFER_UNKNOWN";
+ }
+ })();
+ return {
+ srcChainID: transfer.hyperlaneTransfer.fromChainID,
+ destChainID: transfer.hyperlaneTransfer.toChainID,
+ txs: {
+ sendTx: transfer.hyperlaneTransfer.txs.sendTx,
+ receiveTx: transfer.hyperlaneTransfer.txs.receiveTx,
+ },
+ state: hyperlaneState,
+ };
+ }
const axelarState: TransferState = (() => {
switch (transfer.axelarTransfer.state) {
case "AXELAR_TRANSFER_PENDING_RECEIPT":
diff --git a/src/utils/clients.ts b/src/utils/clients.ts
index 6356afd9..1e5140a7 100644
--- a/src/utils/clients.ts
+++ b/src/utils/clients.ts
@@ -2,7 +2,7 @@ import { CosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { StargateClient } from "@cosmjs/stargate";
import { accountParser } from "@skip-router/core/parser";
-import { APP_URL } from "@/constants/api";
+import { appUrl } from "@/constants/api";
const STARGATE_CLIENTS: Record = {};
@@ -11,7 +11,7 @@ export async function getStargateClientForChainID(chainID: string) {
return STARGATE_CLIENTS[chainID];
}
- const endpoint = `${APP_URL}/api/rpc/${chainID}`;
+ const endpoint = `${appUrl}/api/rpc/${chainID}`;
const client = await StargateClient.connect(endpoint, {
accountParser,
});
@@ -26,7 +26,7 @@ export async function getCosmWasmClientForChainID(chainID: string) {
return COSMWASM_CLIENTS[chainID];
}
- const endpoint = `${APP_URL}/api/rpc/${chainID}`;
+ const endpoint = `${appUrl}/api/rpc/${chainID}`;
const client = await CosmWasmClient.connect(endpoint);
return (COSMWASM_CLIENTS[chainID] = client), client;
diff --git a/src/utils/image.ts b/src/utils/image.ts
new file mode 100644
index 00000000..966a1d52
--- /dev/null
+++ b/src/utils/image.ts
@@ -0,0 +1,5 @@
+import { SyntheticEvent } from "react";
+
+export const onImageError = (event: SyntheticEvent) => {
+ event.currentTarget.src = "https://api.dicebear.com/6.x/shapes/svg";
+};
diff --git a/src/utils/link.ts b/src/utils/link.ts
new file mode 100644
index 00000000..72983644
--- /dev/null
+++ b/src/utils/link.ts
@@ -0,0 +1,6 @@
+export const makeExplorerLink = (link: string) => {
+ return {
+ link,
+ shorthand: `${link.split("/").at(-1)?.slice(0, 6)}…${link.split("/").at(-1)?.slice(-6)}`,
+ };
+};
diff --git a/src/utils/wallet.ts b/src/utils/wallet.ts
index f68aa59c..9ef34eee 100644
--- a/src/utils/wallet.ts
+++ b/src/utils/wallet.ts
@@ -65,3 +65,26 @@ export async function isWalletClientUsingLedger(wa
return false;
}
+
+export function isReadyToCheckLedger(walletClient: T) {
+ if (!("client" in walletClient)) {
+ return false;
+ }
+
+ // Keplr | Leap | Okxwallet | Vectis | XDEFI
+ if ("getKey" in walletClient.client) {
+ return true;
+ }
+
+ // Station
+ if ("keplr" in walletClient.client) {
+ return true;
+ }
+
+ // Cosmostation
+ if ("cosmos" in walletClient.client) {
+ return true;
+ }
+
+ return false;
+}
diff --git a/tests/example.spec.ts b/tests/example.spec.ts
deleted file mode 100644
index 25c37957..00000000
--- a/tests/example.spec.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { expect, test } from "./lib/fixtures";
-
-test("has title", async ({ page }) => {
- await page.goto("https://playwright.dev/");
-
- // Expect a title "to contain" a substring.
- await expect(page).toHaveTitle(/Playwright/);
-});
-
-test("get started link", async ({ page }) => {
- await page.goto("https://playwright.dev/");
-
- // Click the get started link.
- await page.getByRole("link", { name: "Get started" }).click();
-
- // Expects page to have a heading with the name of Installation.
- await expect(page.getByRole("heading", { name: "Installation" })).toBeVisible();
-});
diff --git a/tests/lib/commands/keplr.ts b/tests/lib/commands/keplr.ts
index 8a986cbd..511a5fa2 100644
--- a/tests/lib/commands/keplr.ts
+++ b/tests/lib/commands/keplr.ts
@@ -11,8 +11,8 @@ export async function initialSetup(playwrightInstance: BrowserType) {
await playwright.assignWindows();
await playwright.assignActiveTabName("keplr");
-
- await importWallet("test test test test test test test test test test test junk", "Tester@1234");
+ const phrase = process.env.WORD_PHRASE_KEY || "test test test test test test test test test test test junk";
+ await importWallet(phrase, "Tester@1234");
// keplrWindow
}
diff --git a/tests/lib/commands/playwright.ts b/tests/lib/commands/playwright.ts
index 0e08ffc9..9bb8cc60 100644
--- a/tests/lib/commands/playwright.ts
+++ b/tests/lib/commands/playwright.ts
@@ -5,7 +5,7 @@ let browser: Browser;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let _mainWindow: Page;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
-let _keplrPopupWindow: Page;
+let _keplrPopupWindow: Page | undefined;
let _keplrWindow: Page;
// let metamaskNotificationWindow;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -23,6 +23,16 @@ export function keplrWindow() {
return _keplrWindow;
}
+export function keplrPopupWindow() {
+ return _keplrPopupWindow;
+}
+
+export function close() {
+ if (browser) {
+ browser.close();
+ }
+}
+
export async function init(playwrightInstance?: BrowserType) {
const chromiumInstance = playwrightInstance ? playwrightInstance : chromium;
const debuggerDetails = await fetch("http://127.0.0.1:9222/json/version");
@@ -44,6 +54,18 @@ export async function init(playwrightInstance?: BrowserType) {
return browser.isConnected();
}
+export async function watchKeplrPopupApproveWindow() {
+ while (browser) {
+ assignWindows();
+ if (_keplrPopupWindow) {
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ await _keplrPopupWindow?.getByRole("button", { name: "Approve" }).click();
+ _keplrPopupWindow = undefined;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ }
+}
+
export async function assignActiveTabName(tabName: string) {
_activeTabName = tabName;
}
@@ -53,7 +75,8 @@ export async function assignWindows() {
const keplrExtensionData = extensionsData.keplr;
- const pages = await browser.contexts()[0].pages();
+ const pages = await browser.contexts()[0]?.pages();
+ if (!pages) return;
for (const page of pages) {
if (page.url().includes("specs/runner")) {
diff --git a/tests/lib/globalSetup.ts b/tests/lib/globalSetup.ts
new file mode 100644
index 00000000..83e14200
--- /dev/null
+++ b/tests/lib/globalSetup.ts
@@ -0,0 +1,14 @@
+// eslint-disable-next-line @typescript-eslint/no-var-requires
+const dotenv = require("dotenv");
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-expect-error
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+async function globalSetup(config) {
+ dotenv.config({
+ path: ".env",
+ override: true,
+ });
+}
+
+module.exports = globalSetup;
diff --git a/tests/transactions.spec.ts b/tests/transactions.spec.ts
new file mode 100644
index 00000000..177745fd
--- /dev/null
+++ b/tests/transactions.spec.ts
@@ -0,0 +1,59 @@
+import { test } from "./lib/fixtures";
+import {
+ connectDestination,
+ connectSource,
+ e2eTest,
+ expectPageLoaded,
+ fillAmount,
+ initKeplr,
+ selectDestination,
+ selectSource,
+} from "./utils";
+
+test("Noble USDC -> Injective INJ", async ({ page }) => {
+ await initKeplr();
+ await expectPageLoaded(page);
+
+ await selectSource(page, "noble", "usdc");
+ await selectDestination(page, "injective", "inj");
+
+ await connectSource(page);
+
+ await fillAmount(page, "5");
+
+ await connectDestination(page);
+
+ await e2eTest(page);
+});
+
+test("Injective INJ -> Cosmoshub ATOM", async ({ page }) => {
+ await initKeplr();
+ await expectPageLoaded(page);
+
+ await selectSource(page, "injective", "inj");
+ await selectDestination(page, "cosmos hub", "atom");
+
+ await connectSource(page);
+
+ await fillAmount(page, "0.13");
+
+ await connectDestination(page);
+
+ await e2eTest(page);
+});
+
+test("Cosmoshub ATOM -> Noble USDC", async ({ page }) => {
+ await initKeplr();
+ await expectPageLoaded(page);
+
+ await selectSource(page, "cosmos hub", "atom");
+ await selectDestination(page, "noble", "usdc");
+
+ await connectSource(page);
+
+ await fillAmount(page, "0.45");
+
+ await connectDestination(page);
+
+ await e2eTest(page);
+});
diff --git a/tests/utils.ts b/tests/utils.ts
new file mode 100644
index 00000000..2246c814
--- /dev/null
+++ b/tests/utils.ts
@@ -0,0 +1,134 @@
+import { Page } from "@playwright/test";
+
+import * as playwright from "./lib/commands/playwright";
+import { test } from "./lib/fixtures";
+
+export async function connectDestination(page: Page) {
+ await page.getByRole("button", { name: "Connect Destination Wallet" }).click();
+ await page.getByRole("button", { name: "Keplr" }).click();
+}
+export async function connectSource(page: Page) {
+ await page.getByRole("button", { name: "Connect Wallet" }).click();
+ await page.getByRole("button", { name: "Keplr" }).click();
+}
+export async function selectDestination(page: Page, chain: string, asset: string) {
+ await page.getByTestId("destination").getByTestId("select-chain").click({
+ force: true,
+ });
+ await page.getByPlaceholder("Search for a chain").fill(chain);
+ await page.getByTestId("destination").getByTestId("chain-item").first().click({
+ force: true,
+ });
+ await page.getByTestId("destination").getByTestId("select-asset").click({
+ force: true,
+ });
+ await page.getByPlaceholder("Search name or paste address").click();
+ await page.getByPlaceholder("Search name or paste address").fill(asset);
+ await page.getByTestId("destination").getByTestId("asset-item").first().click({
+ force: true,
+ });
+}
+export async function selectSource(page: Page, chain: string, asset: string) {
+ await page.getByTestId("source").getByTestId("select-chain").click({
+ force: true,
+ });
+ await page.getByPlaceholder("Search for a chain").fill(chain);
+ await page.getByTestId("source").getByTestId("chain-item").first().click({
+ force: true,
+ });
+ await page.getByTestId("source").getByTestId("select-asset").click({
+ force: true,
+ });
+ await page.getByPlaceholder("Search name or paste address").click();
+ await page.getByPlaceholder("Search name or paste address").fill(asset);
+ await page.getByTestId("source").getByTestId("asset-item").first().click({
+ force: true,
+ });
+}
+export async function initKeplr() {
+ await playwright.init();
+ playwright.watchKeplrPopupApproveWindow();
+}
+export async function expectPageLoaded(page: Page) {
+ page.setViewportSize({
+ height: 1080,
+ width: 1920,
+ });
+ await page.goto("http://localhost:3000");
+ await test.expect(page.getByRole("button", { name: "Cosmos Hub" })).toBeVisible({
+ timeout: 5000,
+ });
+ await test.expect(page.getByRole("button", { name: "ATOM ATOM" })).toBeVisible({
+ timeout: 120000,
+ });
+}
+
+export async function e2eTest(page: Page) {
+ await test.expect(page.getByTestId("destination").getByTestId("amount")).not.toBeEmpty({
+ timeout: 10000,
+ });
+
+ await test.expect(page.getByRole("button", { name: "Preview Route" })).toBeEnabled();
+ await page.getByRole("button", { name: "Preview Route" }).click();
+ await Promise.resolve(setTimeout(() => {}, 5000));
+ await test.expect(page.getByRole("button", { name: "Submit" })).toBeVisible({
+ timeout: 10000,
+ });
+ await page.getByRole("button", { name: "Submit" }).click({
+ force: true,
+ clickCount: 2,
+ delay: 3000,
+ });
+
+ const txCount = await page.getByTestId("transactions-count").getAttribute("data-test-value");
+ const trackedTxHashes = [];
+ for (let i = 0; i < Number(txCount); i++) {
+ await test.expect(page.getByText(`Transaction ${i + 1}`)).toBeVisible({
+ timeout: 300000,
+ });
+ await test.expect(page.getByTestId(`tx-hash-${i + 1}`)).toBeVisible({
+ timeout: 300000,
+ });
+ const txSignedHash = await page.getByTestId(`tx-hash-${i + 1}`).getAttribute("data-test-value");
+ trackedTxHashes.push(txSignedHash);
+ }
+ console.log("Tracked tx hashes", trackedTxHashes);
+
+ const operations = [];
+ await test.expect(page.getByTestId("operations")).toBeVisible({
+ timeout: 5000,
+ });
+ const operationsCount = await page.getByTestId("operations").getAttribute("data-test-value");
+ for (let i = 0; i < Number(operationsCount); i++) {
+ await test.expect(page.getByTestId(`operation-step-${i}`)).toBeVisible({
+ timeout: 5000,
+ });
+ const data = await page.getByTestId(`operation-step-${i}`).getAttribute("data-test-value");
+ await test.expect(page.getByTestId(`operation-step-${i}`).getByTestId("explorer-link")).toBeVisible({
+ timeout: 30000,
+ });
+ const _explorerLink = await page
+ .getByTestId(`operation-step-${i}`)
+ .getByTestId("explorer-link")
+ .getAttribute("href");
+ if (data) {
+ operations.push({ ...JSON.parse(data), explorerLink: _explorerLink });
+ }
+
+ await test.expect(page.getByTestId(`operation-step-${i}`).getByTestId("state-success")).toBeVisible({
+ timeout: 120000,
+ });
+ }
+ console.log(operations);
+
+ await test.expect(page.getByTestId("transaction-success")).toBeVisible({
+ timeout: 120000,
+ });
+ page.close();
+ playwright.close();
+}
+
+export async function fillAmount(page: Page, amount: string) {
+ await page.getByTestId("source").getByTestId("amount").click();
+ await page.getByTestId("source").getByTestId("amount").fill(amount);
+}