Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(explorer): global transactions listener #3285

Merged
merged 16 commits into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -12,6 +12,7 @@ import { Button } from "../../../../components/ui/Button";
import { Command, CommandGroup, CommandItem, CommandList } from "../../../../components/ui/Command";
import { Form, FormControl, FormField, FormItem, FormMessage } from "../../../../components/ui/Form";
import { Input } from "../../../../components/ui/Input";
import { store } from "../../../../observer/store";
import mudLogo from "../../icon.svg";
import { getWorldUrl } from "../../utils/getWorldUrl";

Expand All @@ -25,6 +26,9 @@ const formSchema = z.object({
});

export function WorldsForm({ worlds }: { worlds: Address[] }) {
// Initialize the observer store to start fetching transactions
store;
karooolis marked this conversation as resolved.
Show resolved Hide resolved

const router = useRouter();
const { chainName } = useParams();
const [open, setOpen] = useState(false);
Expand Down
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
Loading