diff --git a/src/components/RouteDisplay.tsx b/src/components/RouteDisplay.tsx index 49914c20..434518af 100644 --- a/src/components/RouteDisplay.tsx +++ b/src/components/RouteDisplay.tsx @@ -1,10 +1,15 @@ /* eslint-disable @next/next/no-img-element */ +import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid"; import { RouteResponse } from "@skip-router/core"; import { ethers } from "ethers"; import { Dispatch, FC, Fragment, SetStateAction, useMemo } from "react"; import { useAssets } from "@/context/assets"; import { useChainByID } from "@/hooks/useChains"; +import { useBroadcastedTxsStatus } from "@/solve"; + +import { AdaptiveLink } from "./AdaptiveLink"; +import { BroadcastedTx } from "./TransactionDialog/TransactionDialogContent"; export interface SwapVenueConfig { name: string; @@ -38,6 +43,7 @@ interface TransferAction { asset: string; sourceChain: string; destinationChain: string; + id: string; } interface SwapAction { @@ -46,6 +52,7 @@ interface SwapAction { destinationAsset: string; chain: string; venue: string; + id: string; } type Action = TransferAction | SwapAction; @@ -75,10 +82,66 @@ const RouteEnd: FC<{ ); }; -const TransferStep: FC<{ action: TransferAction }> = ({ action }) => { +const TransferStep: FC<{ + action: TransferAction; + id: string; + statusData?: ReturnType["data"]; +}> = ({ action, id, statusData }) => { const { data: sourceChain } = useChainByID(action.sourceChain); const { data: destinationChain } = useChainByID(action.destinationChain); + // format: operationType-- + const operationCount = Number(id.split("-")[1]); + const transfer = statusData?.transferSequence[operationCount]; + + // We can assume that the transfer is successful when the state is TRANSFER_SUCCESS or TRANSFER_RECEIVED + const transferState = useMemo(() => { + switch (transfer?.state) { + case "TRANSFER_SUCCESS": + return ( +
+ +
+ ); + case "TRANSFER_RECEIVED": + return ( +
+ +
+ ); + case "TRANSFER_FAILURE": + return ( +
+ +
+ ); + case "TRANSFER_PENDING": + return ( +
+ +
+ ); + + default: + return
; + } + }, [transfer?.state]); + + const renderExplorerLink = () => { + if (!transfer?.explorerLink) return null; + return ( + + + {transfer.explorerLink.split("/").at(-1)?.slice(0, 6)}… + {transfer.explorerLink.split("/").at(-1)?.slice(-6)} + + + ); + }; + const { getAsset } = useAssets(); const asset = getAsset(action.asset, action.sourceChain); @@ -92,7 +155,7 @@ const TransferStep: FC<{ action: TransferAction }> = ({ action }) => { return (
-
+ {transferState}

@@ -106,6 +169,7 @@ const TransferStep: FC<{ action: TransferAction }> = ({ action }) => { {destinationChain.prettyName}

+ {renderExplorerLink()}
); @@ -114,7 +178,7 @@ const TransferStep: FC<{ action: TransferAction }> = ({ action }) => { return (
-
+ {transferState}

@@ -152,12 +216,18 @@ const TransferStep: FC<{ action: TransferAction }> = ({ action }) => { {destinationChain.prettyName}

+ {renderExplorerLink()}
); }; -const SwapStep: FC<{ action: SwapAction }> = ({ action }) => { +const SwapStep: FC<{ + action: SwapAction; + actions: Action[]; + id: string; + statusData?: ReturnType["data"]; +}> = ({ action, actions, id, statusData }) => { const { getAsset } = useAssets(); const assetIn = getAsset(action.sourceAsset, action.chain); @@ -166,11 +236,64 @@ const SwapStep: FC<{ action: SwapAction }> = ({ action }) => { const venue = SWAP_VENUES[action.venue]; + // format: operationType-- + const operationIndex = Number(id.split("-")[2]); + const operationCount = 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 swap = statusData?.transferSequence[operationCount]; + + // as for swap operations, we can assume that the swap is successful if the previous transfer state is TRANSFER_SUCCESS + const swapState = useMemo(() => { + switch (swap?.state) { + case "TRANSFER_RECEIVED": + return ( +
+ +
+ ); + case "TRANSFER_SUCCESS": + return ( +
+ +
+ ); + case "TRANSFER_FAILURE": + return ( +
+ +
+ ); + + default: + return
; + } + }, [swap?.state]); + + const renderExplorerLink = () => { + if (!swap?.explorerLink) return null; + if (swap?.state !== "TRANSFER_SUCCESS") return null; + return ( + + + {swap.explorerLink.split("/").at(-1)?.slice(0, 6)}… + {swap.explorerLink.split("/").at(-1)?.slice(-6)} + + + ); + }; + if (!assetIn && assetOut) { return (
-
+ {swapState}

@@ -199,6 +322,7 @@ const SwapStep: FC<{ action: SwapAction }> = ({ action }) => { />{" "} {venue.name}

+ {renderExplorerLink()}
); @@ -208,7 +332,7 @@ const SwapStep: FC<{ action: SwapAction }> = ({ action }) => { return (
-
+ {swapState}

@@ -229,6 +353,7 @@ const SwapStep: FC<{ action: SwapAction }> = ({ action }) => { />{" "} {venue.name}

+ {renderExplorerLink()}
); @@ -241,7 +366,7 @@ const SwapStep: FC<{ action: SwapAction }> = ({ action }) => { return (
-
+ {swapState}

@@ -271,6 +396,7 @@ const SwapStep: FC<{ action: SwapAction }> = ({ action }) => { />{" "} {venue.name}

+ {renderExplorerLink()}
); @@ -280,12 +406,14 @@ interface Props { route: RouteResponse; isRouteExpanded: boolean; setIsRouteExpanded: Dispatch>; + broadcastedTxs?: BroadcastedTx[]; } const RouteDisplay: FC = ({ route, isRouteExpanded, setIsRouteExpanded, + broadcastedTxs, }) => { const { getAsset } = useAssets(); @@ -324,6 +452,8 @@ const RouteDisplay: FC = ({ const actions = useMemo(() => { const _actions: Action[] = []; + let swapCount = 0; + let transferCount = 0; let asset = route.sourceAssetDenom; route.operations.forEach((operation, i) => { @@ -338,6 +468,7 @@ const RouteDisplay: FC = ({ ].denomOut, chain: operation.swap.swapIn.swapVenue.chainID, venue: operation.swap.swapIn.swapVenue.name, + id: `swap-${swapCount}-${i}`, }); asset = @@ -356,6 +487,7 @@ const RouteDisplay: FC = ({ ].denomOut, chain: operation.swap.swapOut.swapVenue.chainID, venue: operation.swap.swapOut.swapVenue.name, + id: `swap-${swapCount}-${i}`, }); asset = @@ -363,7 +495,7 @@ const RouteDisplay: FC = ({ operation.swap.swapOut.swapOperations.length - 1 ].denomOut; } - + swapCount++; return; } @@ -373,10 +505,11 @@ const RouteDisplay: FC = ({ asset, sourceChain: operation.axelarTransfer.fromChainID, destinationChain: operation.axelarTransfer.toChainID, + id: `transfer-${transferCount}-${i}`, }); asset = operation.axelarTransfer.asset; - + transferCount++; return; } @@ -407,14 +540,21 @@ const RouteDisplay: FC = ({ asset, sourceChain, destinationChain, + id: `transfer-${transferCount}-${i}`, }); asset = operation.transfer.destDenom; + transferCount++; }); return _actions; }, [route]); + const { data: statusData } = useBroadcastedTxsStatus( + route.txsRequired, + broadcastedTxs, + ); + return (
@@ -438,12 +578,27 @@ const RouteDisplay: FC = ({ )}
{isRouteExpanded && - actions.map((action, i) => ( - - {action.type === "SWAP" && } - {action.type === "TRANSFER" && } - - ))} + actions.map((action, i) => { + return ( + + {action.type === "SWAP" && ( + + )} + {action.type === "TRANSFER" && ( + + )} + + ); + })} {!isRouteExpanded && (
- {txStatuses.map(({ status, explorerLink, txHash }, i) => ( + {txStatuses.map(({ status }, i) => (
{status === "INIT" && ( @@ -356,20 +357,6 @@ function TransactionDialogContent({ )} - {txHash && explorerLink && ( - - - {txHash.slice(0, 6)} - ... - {txHash.slice(-6)} - - - )}
))} diff --git a/src/context/assets.tsx b/src/context/assets.tsx index ec8cf314..5152daf0 100644 --- a/src/context/assets.tsx +++ b/src/context/assets.tsx @@ -1,7 +1,8 @@ import { Asset } from "@skip-router/core"; import { createContext, - ReactNode, + FC, + PropsWithChildren, useCallback, useContext, useEffect, @@ -35,7 +36,7 @@ export const AssetsContext = createContext({ isReady: false, }); -export function AssetsProvider({ children }: { children: ReactNode }) { +export const AssetsProvider: FC = ({ children }) => { const { data: chains } = useChains(); const { data: solveAssets } = useSolveAssets(); @@ -113,7 +114,7 @@ export function AssetsProvider({ children }: { children: ReactNode }) { logoURI && load(logoURI); }); }); - }, [isReady]); + }, [assets, chains, isReady]); return ( ); -} +}; export function useAssets() { return useContext(AssetsContext); diff --git a/src/solve/queries.ts b/src/solve/queries.ts index 2817b9bf..59f73b19 100644 --- a/src/solve/queries.ts +++ b/src/solve/queries.ts @@ -1,9 +1,16 @@ -import { AssetsRequest, SwapVenue } from "@skip-router/core"; +import { AssetsRequest, SwapVenue, TransferState } from "@skip-router/core"; import { useQuery } from "@tanstack/react-query"; import { useEffect, useMemo, useState } from "react"; import { useSkipClient } from "./hooks"; +interface TransferSequence { + srcChainID: string; + destChainID: string; + explorerLink: string | undefined; + state: TransferState; +} + export function useAssets(options: AssetsRequest = {}) { const skipClient = useSkipClient(); @@ -144,3 +151,104 @@ export function useRoute({ return query; } + +export const useBroadcastedTxsStatus = ( + txsRequired: number, + txs: { chainID: string; txHash: string }[] | undefined, +) => { + const skipClient = useSkipClient(); + const [isSettled, setIsSettled] = useState(false); + const [prevData, setPrevData] = useState< + | { + transferSequence: TransferSequence[]; + } + | undefined + >(undefined); + + const queryKey = useMemo( + () => ["solve-tx-status", txsRequired, txs] as const, + [txs, txsRequired], + ); + + return useQuery({ + queryKey, + queryFn: async ({ queryKey: [, txsRequired, txs] }) => { + if (!txs) return; + const result = await Promise.all( + txs.map(async (tx) => { + const _res = await skipClient.transactionStatus({ + chainID: tx.chainID, + txHash: tx.txHash, + }); + + const cleanTransferSequence = _res.transferSequence.map( + (transfer) => { + if ("ibcTransfer" in transfer) { + return { + srcChainID: transfer.ibcTransfer.srcChainID, + destChainID: transfer.ibcTransfer.dstChainID, + explorerLink: + transfer.ibcTransfer.packetTXs.sendTx?.explorerLink, + state: transfer.ibcTransfer.state, + }; + } + const axelarState: TransferState = (() => { + switch (transfer.axelarTransfer.state) { + case "AXELAR_TRANSFER_PENDING_RECEIPT": + return "TRANSFER_PENDING"; + case "AXELAR_TRANSFER_PENDING_CONFIRMATION": + return "TRANSFER_PENDING"; + case "AXELAR_TRANSFER_FAILURE": + return "TRANSFER_FAILURE"; + case "AXELAR_TRANSFER_SUCCESS": + return "TRANSFER_SUCCESS"; + default: + return "TRANSFER_UNKNOWN"; + } + })(); + + return { + srcChainID: transfer.axelarTransfer.srcChainID, + destChainID: transfer.axelarTransfer.dstChainID, + explorerLink: transfer.axelarTransfer.axelarScanLink, + state: axelarState, + }; + }, + ); + + return { + state: _res.state, + transferSequence: cleanTransferSequence, + }; + }), + ); + const _isSettled = result.every((tx) => { + return ( + tx.state === "STATE_COMPLETED_SUCCESS" || + tx.state === "STATE_COMPLETED_ERROR" || + tx.state === "STATE_ABANDONED" + ); + }); + if (result.length > 0 && txsRequired === result.length && _isSettled) { + setIsSettled(true); + } + + const mergedTransferSequence = result.reduce( + (acc, tx) => { + return acc.concat(...tx.transferSequence); + }, + [], + ); + + const resData = { + transferSequence: mergedTransferSequence, + }; + setPrevData(resData); + return resData; + }, + enabled: !isSettled && !!txs && txs.length > 0, + refetchInterval: 1000 * 2, + // to make the data persist when query key changed + initialData: prevData, + }); +};