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) => ( - )} - - {isRouteExpanded && - actions.map((action, i) => ( - - {action.type === "SWAP" && ( - - )} - {action.type === "TRANSFER" && ( - - )} - - ))} - {!isRouteExpanded && ( -
- -
- )} - - - - ); -} - -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 ( +
+
+ {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 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.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 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} + {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 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 && ( + + )} +
+ {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 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 && (
-
+
{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); +}