Skip to content

Commit

Permalink
feat(explorer): global transactions listener (#3285)
Browse files Browse the repository at this point in the history
Co-authored-by: Kevin Ingersoll <[email protected]>
  • Loading branch information
karooolis and holic authored Oct 16, 2024
1 parent 8b2fda7 commit 4b46409
Show file tree
Hide file tree
Showing 9 changed files with 238 additions and 204 deletions.
5 changes: 5 additions & 0 deletions .changeset/tall-penguins-promise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@latticexyz/explorer": patch
---

Transactions are now monitored across all tabs while the World Explorer is open.
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Providers>
<Navigation />
<TransactionsWatcher />
{children}
</Providers>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ 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,
status,
children,
}: {
label: string;
status: WatchedTransaction["status"];
status: ObservedTransaction["status"];
children: React.ReactNode;
}) {
return (
Expand All @@ -30,7 +30,7 @@ function TransactionTableRowDataCell({
);
}

export function TransactionTableRow({ row }: { row: Row<WatchedTransaction> }) {
export function TransactionTableRow({ row }: { row: Row<ObservedTransaction> }) {
const data = row?.original;
const status = data.status;
const logs = data?.logs;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<WatchedTransaction>();
const columnHelper = createColumnHelper<ObservedTransaction>();
export const columns = [
columnHelper.accessor("receipt.blockNumber", {
header: "Block",
Expand Down Expand Up @@ -94,7 +94,7 @@ export const columns = [
];

export function TransactionsTable() {
const transactions = useTransactionWatcher();
const transactions = useObservedTransactions();
const [expanded, setExpanded] = useState<ExpandedState>({});

const table = useReactTable({
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<string | undefined, ObservedTransaction>();

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;
}
Loading

0 comments on commit 4b46409

Please sign in to comment.