diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/tx/[txHash]/bridge-status.stories.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/tx/[txHash]/bridge-status.stories.tsx new file mode 100644 index 00000000000..5fd50ea3e1e --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/tx/[txHash]/bridge-status.stories.tsx @@ -0,0 +1,106 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import type { Status } from "thirdweb/bridge"; +import { storybookThirdwebClient } from "@/storybook/utils"; +import { BridgeStatus } from "./bridge-status"; + +const meta = { + component: BridgeStatus, + title: "chain/tx/bridge-status", + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const completedStatus: Status = { + status: "COMPLETED", + paymentId: + "0x8e0a0d9c0254a75a3004a28e0fb69bae7f2d659938ddd461753627270930db60", + originAmount: 129935n, + destinationAmount: 100000n, + transactions: [ + { + chainId: 42161, + transactionHash: + "0x37daf698878e73590adb2d6f833e810304a9981829ad983fb355245af1183107", + }, + { + chainId: 8453, + transactionHash: + "0xc72abba2761bb48f605125dc12c17b15088303db960816fa12316f0c90796417", + }, + ], + originChainId: 42161, + originTokenAddress: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + originToken: { + chainId: 42161, + address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + symbol: "USDC", + name: "USD Coin", + decimals: 6, + iconUri: "https://ethereum-optimism.github.io/data/USDC/logo.png", + }, + destinationChainId: 8453, + destinationTokenAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + destinationToken: { + chainId: 8453, + address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + symbol: "USDC", + name: "USD Coin", + decimals: 6, + iconUri: + "https://coin-images.coingecko.com/coins/images/6319/large/usdc.png?1696506694", + }, + sender: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709", + receiver: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709", +}; + +const pendingStatus: Status = { + ...completedStatus, + status: "PENDING", +}; + +const failedStatus: Status = { + ...completedStatus, + status: "FAILED", +}; + +const notFoundStatus: Status = { + ...completedStatus, + status: "NOT_FOUND", + transactions: [], +}; + +export const Completed: Story = { + args: { + bridgeStatus: completedStatus, + client: storybookThirdwebClient, + }, +}; + +export const Pending: Story = { + args: { + bridgeStatus: pendingStatus, + client: storybookThirdwebClient, + }, +}; + +export const Failed: Story = { + args: { + bridgeStatus: failedStatus, + client: storybookThirdwebClient, + }, +}; + +export const NotFound: Story = { + args: { + bridgeStatus: notFoundStatus, + client: storybookThirdwebClient, + }, +}; diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/tx/[txHash]/bridge-status.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/tx/[txHash]/bridge-status.tsx new file mode 100644 index 00000000000..5d4ed8b5cd4 --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/tx/[txHash]/bridge-status.tsx @@ -0,0 +1,372 @@ +"use client"; +import { useQuery } from "@tanstack/react-query"; +import { Img } from "@workspace/ui/components/img"; +import { Spinner } from "@workspace/ui/components/spinner"; +import { ArrowRightIcon, CircleCheckIcon, CircleXIcon } from "lucide-react"; +import Link from "next/link"; +import type { ThirdwebClient } from "thirdweb"; +import type { Status, Token } from "thirdweb/bridge"; +import { status } from "thirdweb/bridge"; +import { toTokens } from "thirdweb/utils"; +import { WalletAddress } from "@/components/blocks/wallet-address"; +import { CopyTextButton } from "@/components/ui/CopyTextButton"; +import { SkeletonContainer } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; +import { fetchChain } from "@/utils/fetchChain"; +import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler"; + +export function BridgeStatus(props: { + bridgeStatus: Status; + client: ThirdwebClient; +}) { + const { bridgeStatus } = props; + + return ( +
+ {bridgeStatus.status === "FAILED" && + bridgeStatus.transactions.length > 0 && ( + + )} + + {(bridgeStatus.status === "COMPLETED" || + bridgeStatus.status === "PENDING") && ( + + )} + +
+
+

Status

+ +
+ {bridgeStatus.status === "COMPLETED" && ( + + )} + {bridgeStatus.status === "PENDING" && ( + + )} + {bridgeStatus.status === "FAILED" && ( + + )} + {bridgeStatus.status === "NOT_FOUND" && ( + + )} + + {bridgeStatus.status.toLowerCase().replace("_", " ")} +
+
+ +
+

Payment ID

+ +
+
+
+ ); +} + +function TokenInfo(props: { + label: string; + addressLabel: string; + token: Token; + amountWei: bigint | undefined; + walletAddress: string; + client: ThirdwebClient; + txHash: string | undefined; +}) { + const chainQuery = useChainQuery(props.token.chainId); + + return ( +
+
+

+ {props.label} +

+
+ +
+
+
+ + + {props.token.name} + } + /> + +
+ {props.token.name} + } + /> +
+
+
+

+ {props.amountWei ? ( + <> + {toTokens(props.amountWei, props.token.decimals)}{" "} + {props.token.symbol} + + ) : ( + "N/A" + )} +

+ ( + + {data} + + )} + /> +
+
+
+ +
+ +
+
+

{props.addressLabel}

+ +
+ +
+

Token Address

+ +
+ +
+

Transaction Hash

+ {props.txHash ? ( + + ) : ( +

N/A

+ )} +
+
+
+ ); +} + +function BridgeStatusContent(props: { + bridgeStatus: Exclude; + client: ThirdwebClient; +}) { + const { bridgeStatus } = props; + + const fromTxHash = bridgeStatus.transactions.find( + (tx) => tx.chainId === bridgeStatus.originChainId, + )?.transactionHash; + const toTxHash = bridgeStatus.transactions.find( + (tx) => tx.chainId === bridgeStatus.destinationChainId, + )?.transactionHash; + + return ( +
+ + +
+
+ +
+ +
+
+ ); +} + +function FailedBridgeStatusContent(props: { + transactions: { chainId: number; transactionHash: string }[]; + client: ThirdwebClient; +}) { + return ( +
+

+ Transactions +

+
+ {props.transactions.map((tx) => { + return ( + + ); + })} +
+
+ ); +} + +function useChainQuery(chainId: number | undefined) { + return useQuery({ + enabled: !!chainId, + queryKey: ["chain-metadata", chainId], + queryFn: async () => { + if (!chainId) { + return null; + } + return fetchChain(chainId); + }, + }); +} + +function TxHashRow(props: { + client: ThirdwebClient; + chainId: number; + txHash: string; +}) { + const chainQuery = useChainQuery(props.chainId); + + return ( +
+
+ {chainQuery.data?.name} + } + /> + ( +

{data}

+ )} + /> +
+ +
+ ); +} + +export function BridgeStatusWithPolling(props: { + bridgeStatus: Status; + client: ThirdwebClient; + chainId: number; + transactionHash: string; +}) { + const bridgeStatusQuery = useQuery({ + enabled: + props.bridgeStatus.status === "PENDING" || + props.bridgeStatus.status === "NOT_FOUND", + queryKey: ["bridge-status", props.transactionHash, props.chainId], + queryFn: () => + status({ + chainId: props.chainId, + transactionHash: props.transactionHash as `0x${string}`, + client: props.client, + }), + refetchInterval(query) { + const status = query.state.data?.status; + if (status === "COMPLETED" || status === "FAILED") { + return false; + } + return 5000; + }, + }); + + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/tx/[txHash]/page.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/tx/[txHash]/page.tsx index 051758360e4..b67d14b1c66 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/tx/[txHash]/page.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/tx/[txHash]/page.tsx @@ -8,13 +8,17 @@ import { Clock4Icon, InfoIcon, } from "lucide-react"; +import { notFound } from "next/navigation"; import { toTokens } from "thirdweb"; +import { status } from "thirdweb/bridge"; +import type { ChainMetadata } from "thirdweb/chains"; import { eth_getBlockByHash, eth_getTransactionByHash, eth_getTransactionReceipt, getRpcClient, } from "thirdweb/rpc"; +import type { TransactionReceipt } from "thirdweb/transaction"; import { hexToNumber, toEther } from "thirdweb/utils"; import { Badge } from "@/components/ui/badge"; import { @@ -26,9 +30,14 @@ import { BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; import { CopyTextButton } from "@/components/ui/CopyTextButton"; +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; import { serverThirdwebClient } from "@/constants/thirdweb-client.server"; import { mapV4ChainToV5Chain } from "@/utils/map-chains"; import { getChain } from "../../../utils"; +import { BridgeStatusWithPolling } from "./bridge-status"; +import { BridgeAndOverviewTabs } from "./tabs"; + +type Transaction = Awaited>; export default async function Page(props: { params: Promise<{ chain_id: string; txHash: `0x${string}` }>; @@ -43,27 +52,41 @@ export default async function Page(props: { client: serverThirdwebClient, }); - const [transaction, receipt] = await Promise.all([ + const [transaction, receipt, bridgeStatus] = await Promise.all([ eth_getTransactionByHash(rpcRequest, { hash: params.txHash, }), eth_getTransactionReceipt(rpcRequest, { hash: params.txHash, }), + status({ + chainId: chain.chainId, + transactionHash: params.txHash, + client: serverThirdwebClient, + }).catch(() => undefined), ]); + if (!transaction.blockHash) { - return
no tx found
; + notFound(); } + const block = await eth_getBlockByHash(rpcRequest, { blockHash: transaction.blockHash, }); - const timestamp = getDatefromTimestamp(block.timestamp); + const overviewContent = ( + + ); return (
- + Chainlist @@ -82,219 +105,244 @@ export default async function Page(props: {
-
+

Transaction Details

-
-
- {/* section 1 */} -
- - + {bridgeStatus ? ( + - - - - - {receipt.status === "success" ? ( - - ) : ( - - )} - {receipt.status} - - + } + overview={overviewContent} + /> + ) : ( + overviewContent + )} +
+
+ ); +} - {transaction.blockNumber && ( - -

{Number(transaction.blockNumber)}

-
- )} +function GeneericTxDetails(props: { + transaction: Transaction; + receipt: TransactionReceipt; + block: { + timestamp: bigint; + baseFeePerGas: bigint | null; + }; + chain: ChainMetadata; +}) { + const { transaction, receipt, chain, block } = props; - {timestamp && ( - -
- -

- {formatDistanceToNowStrict(timestamp, { - addSuffix: true, - })} -

-

({formatDate(timestamp, "PP pp z")})

-
-
- )} - + const timestamp = getDatefromTimestamp(block.timestamp); - {/* section 2 */} -
- - - + return ( +
+ {/* section 1 */} +
+ + + - {transaction.to && ( - - - + + + {receipt.status === "success" ? ( + + ) : ( + )} -
+ {receipt.status} + + - {/* section 3 */} -
- -

- {toEther(transaction.value)} {chain.nativeCurrency?.symbol} -

-
+ {transaction.blockNumber && ( + +

{Number(transaction.blockNumber)}

+
+ )} - + {timestamp && ( + +
+

- {toEther((transaction.gasPrice || 0n) * receipt.gasUsed)}{" "} - {chain.nativeCurrency?.symbol} + {formatDistanceToNowStrict(timestamp, { + addSuffix: true, + })}

- +

({formatDate(timestamp, "PP pp z")})

+
+
+ )} +
- -

- {toEther(transaction.gasPrice || 0n)}{" "} - {chain.nativeCurrency?.symbol} -

-
+ {/* section 2 */} +
+ + + - -

{receipt.gasUsed.toString()}

-
+ {transaction.to && ( + + + + )} +
- -

- Base: {toTokens(block.baseFeePerGas || 0n, 9)} Gwei + {/* section 3 */} +

+ +

+ {toEther(transaction.value)} {chain.nativeCurrency?.symbol} +

+
- {transaction.maxFeePerGas && ( - <> - | - - Max: {toTokens(transaction.maxFeePerGas || 0n, 9)} Gwei - - - )} + +

+ {toEther((transaction.gasPrice || 0n) * receipt.gasUsed)}{" "} + {chain.nativeCurrency?.symbol} +

+
- {transaction.maxPriorityFeePerGas && ( - <> - | - - Max priority:{" "} - {transaction.maxPriorityFeePerGas?.toString()} Gwei - - - )} -

- + +

+ {toEther(transaction.gasPrice || 0n)} {chain.nativeCurrency?.symbol} +

+
- -

{toEther(block.baseFeePerGas || 0n * receipt.gasUsed)}

-
-
+ +

{receipt.gasUsed.toString()}

+
- {/* section 4 */} -
- -
- - Txn type: - - {hexToNumber(transaction.typeHex || "0x0")} ( - {transaction.type}) - - - - Nonce: - {transaction.nonce} - - - Position: - {transaction.transactionIndex} - -
-
+ +

+ Base: {toTokens(block.baseFeePerGas || 0n, 9)} Gwei - - - -

-
-
- + {transaction.maxFeePerGas && ( + <> + | + + Max: {toTokens(transaction.maxFeePerGas || 0n, 9)} Gwei + + + )} + + {transaction.maxPriorityFeePerGas && ( + <> + | + + Max priority: {transaction.maxPriorityFeePerGas?.toString()}{" "} + Gwei + + + )} +

+ + + +

{toEther(block.baseFeePerGas || 0n * receipt.gasUsed)}

+
+ + + {/* section 4 */} +
+ +
+ + Txn type: + + {hexToNumber(transaction.typeHex || "0x0")} ({transaction.type}) + + + + Nonce: + {transaction.nonce} + + + Position: + {transaction.transactionIndex} + +
+
+ + + + +
+ ); } diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/tx/[txHash]/tabs.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/tx/[txHash]/tabs.tsx new file mode 100644 index 00000000000..f00cd5db1fb --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/tx/[txHash]/tabs.tsx @@ -0,0 +1,31 @@ +"use client"; +import { useState } from "react"; +import { TabButtons } from "@/components/ui/tabs"; + +export function BridgeAndOverviewTabs(props: { + bridgeStatus: React.ReactNode; + overview: React.ReactNode; +}) { + const [tab, setTab] = useState<"bridge" | "overview">("bridge"); + return ( +
+ setTab("bridge"), + isActive: tab === "bridge", + }, + { + name: "Overview", + onClick: () => setTab("overview"), + isActive: tab === "overview", + }, + ]} + /> +
+ {tab === "bridge" && props.bridgeStatus} + {tab === "overview" && props.overview} +
+ ); +}