From 4b4640913d014fb3a0a5a417b84c91b247e08ffc Mon Sep 17 00:00:00 2001 From: Karolis Ramanauskas Date: Wed, 16 Oct 2024 14:26:10 +0300 Subject: [PATCH] feat(explorer): global transactions listener (#3285) Co-authored-by: Kevin Ingersoll --- .changeset/tall-penguins-promise.md | 5 + .../worlds/[worldAddress]/layout.tsx | 2 + .../observe/TransactionTableRow.tsx | 6 +- .../observe/TransactionsTable.tsx | 6 +- .../observe/TransactionsWatcher.tsx | 122 +++++++++++ .../observe/useObservedTransactions.ts | 77 +++++++ .../observe/useTransactionWatcher.ts | 196 ------------------ .../worlds/[worldAddress]/store.ts | 20 ++ .../explorer/src/app/(explorer)/layout.tsx | 8 +- 9 files changed, 238 insertions(+), 204 deletions(-) create mode 100644 .changeset/tall-penguins-promise.md create mode 100644 packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionsWatcher.tsx create mode 100644 packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/useObservedTransactions.ts delete mode 100644 packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/useTransactionWatcher.ts create mode 100644 packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/store.ts diff --git a/.changeset/tall-penguins-promise.md b/.changeset/tall-penguins-promise.md new file mode 100644 index 0000000000..59adfb99e5 --- /dev/null +++ b/.changeset/tall-penguins-promise.md @@ -0,0 +1,5 @@ +--- +"@latticexyz/explorer": patch +--- + +Transactions are now monitored across all tabs while the World Explorer is open. diff --git a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/layout.tsx b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/layout.tsx index 5b4c354560..2c8b5a4811 100644 --- a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/layout.tsx +++ b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/layout.tsx @@ -2,11 +2,13 @@ import { Navigation } from "../../../../../components/Navigation"; import { Providers } from "./Providers"; +import { TransactionsWatcher } from "./observe/TransactionsWatcher"; export default function WorldLayout({ children }: { children: React.ReactNode }) { return ( + {children} ); diff --git a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionTableRow.tsx b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionTableRow.tsx index 9b7d4af1ef..d828a5436f 100644 --- a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionTableRow.tsx +++ b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionTableRow.tsx @@ -8,7 +8,7 @@ import { cn } from "../../../../../../utils"; import { Confirmations } from "./Confirmations"; import { TimingRowExpanded } from "./TimingRowExpanded"; import { columns } from "./TransactionsTable"; -import { WatchedTransaction } from "./useTransactionWatcher"; +import { ObservedTransaction } from "./useObservedTransactions"; function TransactionTableRowDataCell({ label, @@ -16,7 +16,7 @@ function TransactionTableRowDataCell({ children, }: { label: string; - status: WatchedTransaction["status"]; + status: ObservedTransaction["status"]; children: React.ReactNode; }) { return ( @@ -30,7 +30,7 @@ function TransactionTableRowDataCell({ ); } -export function TransactionTableRow({ row }: { row: Row }) { +export function TransactionTableRow({ row }: { row: Row }) { const data = row?.original; const status = data.status; const logs = data?.logs; diff --git a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionsTable.tsx b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionsTable.tsx index c3b5eacea0..06fcbda1a8 100644 --- a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionsTable.tsx +++ b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionsTable.tsx @@ -12,9 +12,9 @@ import { BlockExplorerLink } from "./BlockExplorerLink"; import { TimeAgo } from "./TimeAgo"; import { TimingRowHeader } from "./TimingRowHeader"; import { TransactionTableRow } from "./TransactionTableRow"; -import { WatchedTransaction, useTransactionWatcher } from "./useTransactionWatcher"; +import { ObservedTransaction, useObservedTransactions } from "./useObservedTransactions"; -const columnHelper = createColumnHelper(); +const columnHelper = createColumnHelper(); export const columns = [ columnHelper.accessor("receipt.blockNumber", { header: "Block", @@ -94,7 +94,7 @@ export const columns = [ ]; export function TransactionsTable() { - const transactions = useTransactionWatcher(); + const transactions = useObservedTransactions(); const [expanded, setExpanded] = useState({}); const table = useReactTable({ diff --git a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionsWatcher.tsx b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionsWatcher.tsx new file mode 100644 index 0000000000..8cb52a2657 --- /dev/null +++ b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionsWatcher.tsx @@ -0,0 +1,122 @@ +import { useParams } from "next/navigation"; +import { BaseError, Hex, TransactionReceipt, decodeFunctionData, parseEventLogs } from "viem"; +import { useConfig, useWatchBlocks } from "wagmi"; +import { getTransaction, simulateContract, waitForTransactionReceipt } from "wagmi/actions"; +import { useStore } from "zustand"; +import { useCallback, useEffect } from "react"; +import { store as observerStore } from "../../../../../../observer/store"; +import { useChain } from "../../../../hooks/useChain"; +import { useWorldAbiQuery } from "../../../../queries/useWorldAbiQuery"; +import { store as worldStore } from "../store"; + +export function TransactionsWatcher() { + const { id: chainId } = useChain(); + const { worldAddress } = useParams(); + const wagmiConfig = useConfig(); + const { data: worldAbiData } = useWorldAbiQuery(); + const abi = worldAbiData?.abi; + const { transactions, setTransaction, updateTransaction } = useStore(worldStore); + const observerWrites = useStore(observerStore, (state) => state.writes); + + const handleTransaction = useCallback( + async (hash: Hex, timestamp: bigint) => { + if (!abi) return; + + const transaction = await getTransaction(wagmiConfig, { hash }); + if (transaction.to !== worldAddress) return; + + let functionName: string | undefined; + let args: readonly unknown[] | undefined; + let transactionError: BaseError | undefined; + + try { + const functionData = decodeFunctionData({ abi, data: transaction.input }); + functionName = functionData.functionName; + args = functionData.args; + } catch (error) { + transactionError = error as BaseError; + functionName = transaction.input.length > 10 ? transaction.input.slice(0, 10) : "unknown"; + } + + const write = Object.values(observerWrites).find((write) => write.hash === hash); + setTransaction({ + hash, + writeId: write?.writeId ?? hash, + from: transaction.from, + timestamp, + transaction, + status: "pending", + functionData: { + functionName, + args, + }, + value: transaction.value, + }); + + let receipt: TransactionReceipt | undefined; + try { + receipt = await waitForTransactionReceipt(wagmiConfig, { hash }); + } catch { + console.error(`Failed to fetch transaction receipt. Transaction hash: ${hash}`); + } + + if (receipt && receipt.status === "reverted" && functionName) { + try { + // Simulate the failed transaction to retrieve the revert reason + // Note, it only works for functions that are declared in the ABI + // See: https://github.com/wevm/viem/discussions/462 + await simulateContract(wagmiConfig, { + account: transaction.from, + address: worldAddress, + abi, + value: transaction.value, + blockNumber: receipt.blockNumber, + functionName, + args, + }); + } catch (error) { + transactionError = error as BaseError; + } + } + + const status = receipt ? receipt.status : "unknown"; + const logs = parseEventLogs({ + abi, + logs: receipt?.logs || [], + }); + + updateTransaction(hash, { + receipt, + logs, + status, + error: transactionError as BaseError, + }); + }, + [abi, wagmiConfig, worldAddress, observerWrites, setTransaction, updateTransaction], + ); + + useEffect(() => { + for (const write of Object.values(observerWrites)) { + const hash = write.hash; + if (hash && write.address === worldAddress) { + const transaction = transactions.find((transaction) => transaction.hash === hash); + if (!transaction) { + handleTransaction(hash, BigInt(write.time) / 1000n); + } + } + } + }, [handleTransaction, observerWrites, transactions, worldAddress]); + + useWatchBlocks({ + onBlock(block) { + for (const hash of block.transactions) { + if (transactions.find((transaction) => transaction.hash === hash)) continue; + handleTransaction(hash, block.timestamp); + } + }, + chainId, + pollingInterval: 500, + }); + + return null; +} diff --git a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/useObservedTransactions.ts b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/useObservedTransactions.ts new file mode 100644 index 0000000000..ff49086f28 --- /dev/null +++ b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/useObservedTransactions.ts @@ -0,0 +1,77 @@ +import { useParams } from "next/navigation"; +import { + AbiFunction, + Address, + BaseError, + DecodeFunctionDataReturnType, + Hex, + Log, + Transaction, + TransactionReceipt, + parseAbiItem, +} from "viem"; +import { useStore } from "zustand"; +import { useMemo } from "react"; +import { Message } from "../../../../../../observer/messages"; +import { type Write, store as observerStore } from "../../../../../../observer/store"; +import { store as worldStore } from "../store"; + +export type ObservedTransaction = { + writeId: string; + hash?: Hex; + from?: Address; + timestamp?: bigint; + transaction?: Transaction; + functionData?: DecodeFunctionDataReturnType; + value?: bigint; + receipt?: TransactionReceipt; + status: "pending" | "success" | "reverted" | "rejected" | "unknown"; + write?: Write; + logs?: Log[]; + error?: BaseError; +}; + +export function useObservedTransactions() { + const { worldAddress } = useParams(); + const transactions = useStore(worldStore, (state) => state.transactions); + const observerWrites = useStore(observerStore, (state) => state.writes); + + const mergedTransactions = useMemo((): ObservedTransaction[] => { + const mergedMap = new Map(); + + for (const write of Object.values(observerWrites)) { + if (write.address !== worldAddress) continue; + + const parsedAbiItem = parseAbiItem(`function ${write.functionSignature}`) as AbiFunction; + const writeResult = write.events.find((event): event is Message<"write:result"> => event.type === "write:result"); + + mergedMap.set(write.hash || write.writeId, { + hash: write.hash, + writeId: write.writeId, + from: write.from, + status: writeResult?.status === "rejected" ? "rejected" : "pending", + timestamp: BigInt(write.time) / 1000n, + functionData: { + functionName: parsedAbiItem.name, + args: write.args, + }, + value: write.value, + error: writeResult && "reason" in writeResult ? (writeResult.reason as BaseError) : undefined, + write, + }); + } + + for (const transaction of transactions) { + const existing = mergedMap.get(transaction.hash); + if (existing) { + mergedMap.set(transaction.hash, { ...transaction, write: existing.write }); + } else { + mergedMap.set(transaction.hash, { ...transaction }); + } + } + + return Array.from(mergedMap.values()).sort((a, b) => Number(b.timestamp ?? 0n) - Number(a.timestamp ?? 0n)); + }, [observerWrites, worldAddress, transactions]); + + return mergedTransactions; +} diff --git a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/useTransactionWatcher.ts b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/useTransactionWatcher.ts deleted file mode 100644 index 58d56c2a9e..0000000000 --- a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/useTransactionWatcher.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { useParams } from "next/navigation"; -import { - AbiFunction, - Address, - BaseError, - DecodeFunctionDataReturnType, - Hex, - Log, - Transaction, - TransactionReceipt, - decodeFunctionData, - parseAbiItem, - parseEventLogs, -} from "viem"; -import { useConfig, useWatchBlocks } from "wagmi"; -import { getTransaction, simulateContract, waitForTransactionReceipt } from "wagmi/actions"; -import { useStore } from "zustand"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Message } from "../../../../../../observer/messages"; -import { type Write, store } from "../../../../../../observer/store"; -import { useChain } from "../../../../hooks/useChain"; -import { useWorldAbiQuery } from "../../../../queries/useWorldAbiQuery"; - -export type WatchedTransaction = { - writeId: string; - hash?: Hex; - from?: Address; - timestamp?: bigint; - transaction?: Transaction; - functionData?: DecodeFunctionDataReturnType; - value?: bigint; - receipt?: TransactionReceipt; - status: "pending" | "success" | "reverted" | "rejected" | "unknown"; - write?: Write; - logs?: Log[]; - error?: BaseError; -}; - -export function useTransactionWatcher() { - const { id: chainId } = useChain(); - const { worldAddress } = useParams(); - const wagmiConfig = useConfig(); - const { data: worldAbiData } = useWorldAbiQuery(); - const abi = worldAbiData?.abi; - const [transactions, setTransactions] = useState([]); - const observerWrites = useStore(store, (state) => state.writes); - - const handleTransaction = useCallback( - async (hash: Hex, timestamp: bigint) => { - if (!abi) return; - - const transaction = await getTransaction(wagmiConfig, { hash }); - if (transaction.to !== worldAddress) return; - - let functionName: string | undefined; - let args: readonly unknown[] | undefined; - let transactionError: BaseError | undefined; - - try { - const functionData = decodeFunctionData({ abi, data: transaction.input }); - functionName = functionData.functionName; - args = functionData.args; - } catch (error) { - transactionError = error as BaseError; - functionName = transaction.input.length > 10 ? transaction.input.slice(0, 10) : "unknown"; - } - - const write = Object.values(observerWrites).find((write) => write.hash === hash); - setTransactions((prevTransactions) => [ - { - hash, - writeId: write?.writeId ?? hash, - from: transaction.from, - timestamp, - transaction, - status: "pending", - functionData: { - functionName, - args, - }, - value: transaction.value, - }, - ...prevTransactions, - ]); - - let receipt: TransactionReceipt | undefined; - try { - receipt = await waitForTransactionReceipt(wagmiConfig, { hash }); - } catch { - console.error(`Failed to fetch transaction receipt. Transaction hash: ${hash}`); - } - - if (receipt && receipt.status === "reverted" && functionName) { - try { - // Simulate the failed transaction to retrieve the revert reason - // Note, it only works for functions that are declared in the ABI - // See: https://github.com/wevm/viem/discussions/462 - await simulateContract(wagmiConfig, { - account: transaction.from, - address: worldAddress, - abi, - value: transaction.value, - blockNumber: receipt.blockNumber, - functionName, - args, - }); - } catch (error) { - transactionError = error as BaseError; - } - } - - const status = receipt ? receipt.status : "unknown"; - const logs = parseEventLogs({ - abi, - logs: receipt?.logs || [], - }); - - setTransactions((prevTransactions) => - prevTransactions.map((transaction) => - transaction.hash === hash - ? { - ...transaction, - receipt, - logs, - status, - error: transactionError as BaseError, - } - : transaction, - ), - ); - }, - [abi, observerWrites, wagmiConfig, worldAddress], - ); - - useEffect(() => { - for (const write of Object.values(observerWrites)) { - const hash = write.hash; - if (write.type === "waitForTransactionReceipt" && hash && write.address === worldAddress) { - const transaction = transactions.find((transaction) => transaction.hash === hash); - if (!transaction) { - handleTransaction(hash, BigInt(write.time) / 1000n); - } - } - } - }, [handleTransaction, observerWrites, transactions, worldAddress]); - - useWatchBlocks({ - onBlock(block) { - for (const hash of block.transactions) { - if (transactions.find((transaction) => transaction.hash === hash)) continue; - handleTransaction(hash, block.timestamp); - } - }, - chainId, - pollingInterval: 500, - }); - - const mergedTransactions = useMemo((): WatchedTransaction[] => { - const mergedMap = new Map(); - - for (const write of Object.values(observerWrites)) { - if (write.address !== worldAddress) continue; - - const parsedAbiItem = parseAbiItem(`function ${write.functionSignature}`) as AbiFunction; - const writeResult = write.events.find((event): event is Message<"write:result"> => event.type === "write:result"); - - mergedMap.set(write.hash || write.writeId, { - hash: write.hash, - writeId: write.writeId, - from: write.from, - status: writeResult?.status === "rejected" ? "rejected" : "pending", - timestamp: BigInt(write.time) / 1000n, - functionData: { - functionName: parsedAbiItem.name, - args: write.args, - }, - value: write.value, - error: writeResult && "reason" in writeResult ? (writeResult.reason as BaseError) : undefined, - write, - }); - } - - for (const transaction of transactions) { - const existing = mergedMap.get(transaction.hash); - if (existing) { - mergedMap.set(transaction.hash, { ...transaction, write: existing.write }); - } else { - mergedMap.set(transaction.hash, { ...transaction }); - } - } - - return Array.from(mergedMap.values()).sort((a, b) => Number(b.timestamp ?? 0n) - Number(a.timestamp ?? 0n)); - }, [observerWrites, worldAddress, transactions]); - - return mergedTransactions; -} diff --git a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/store.ts b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/store.ts new file mode 100644 index 0000000000..007159fcf1 --- /dev/null +++ b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/store.ts @@ -0,0 +1,20 @@ +import { createStore } from "zustand"; +import { ObservedTransaction } from "./observe/useObservedTransactions"; + +export type State = { + transactions: ObservedTransaction[]; + setTransaction: (transaction: ObservedTransaction) => void; + updateTransaction: (hash: string, updatedTransaction: Partial) => void; +}; + +export const store = createStore()((set) => ({ + transactions: [], + setTransaction: (transaction) => + set((state) => ({ + transactions: [...state.transactions, transaction], + })), + updateTransaction: (hash: string, updatedTransaction: Partial) => + set((state) => ({ + transactions: state.transactions.map((tx) => (tx.hash === hash ? { ...tx, ...updatedTransaction } : tx)), + })), +})); diff --git a/packages/explorer/src/app/(explorer)/layout.tsx b/packages/explorer/src/app/(explorer)/layout.tsx index 3ee4268150..8c630fc884 100644 --- a/packages/explorer/src/app/(explorer)/layout.tsx +++ b/packages/explorer/src/app/(explorer)/layout.tsx @@ -3,8 +3,12 @@ import { Inter, JetBrains_Mono } from "next/font/google"; import { Toaster } from "sonner"; import { Theme } from "@radix-ui/themes"; import "@radix-ui/themes/styles.css"; +import { store } from "../../observer/store"; import "./globals.css"; +// Reference to imported store observer so the listener is established when this component is used. +store; + const inter = Inter({ subsets: ["latin"], display: "swap", @@ -29,9 +33,9 @@ export default function RootLayout({ return ( - +
{children}
- +