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}
-
+