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 9 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 @@ -4,6 +4,7 @@ import Image from "next/image";
import { useParams, useRouter } from "next/navigation";
import { Address, isAddress } from "viem";
import * as z from "zod";
import { useStore } from "zustand";
import { Command as CommandPrimitive } from "cmdk";
import { useState } from "react";
import { useForm } from "react-hook-form";
Expand All @@ -12,6 +13,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 +27,9 @@ const formSchema = z.object({
});

export function WorldsForm({ worlds }: { worlds: Address[] }) {
// Initialize the observer store to start fetching transactions
useStore(store);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this cause a rerender of this component every time the store changes?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if the zustand suggested approach of using a provider in next.js is for this reason 🙈 I assumed it was an SSR thing but maybe its a broad solution to this particular issue

Copy link
Contributor Author

@karooolis karooolis Oct 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, unfortunately causes re-renders, it's definitely a code smell to say the least. Using the provider does feel cleaner despite the all the boilerplate. But I think the Provider solution is specifically for SSR because I'm not sure how common of a scenario it is to initialize a store in such way that we're doing it now. Usually, you'd use one of store's getters/setters, and initialize the store implicitly when it's needed.

Perhaps a scenario where a store is initialized with props is more fitting for us https://zustand.docs.pmnd.rs/guides/initialize-state-with-props#store-creator-with-createstore but if we do that, then we're back to using providers.

Given that the current problem essentially is that the broadcast channel listener does not get started, I'm thinking what about moving the broadcast channel event listener into a hook, and initialize it somewhere higher-up the tree that way? Or alternatively, go back to the Providers solution from earlier, and include observer transactions into a common store too.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you know if the provider would solve for this?

if so I feel like maybe the provider approach is ~fine and solves for multiple problems (SSR, global mounting/initializing the store and observer)

but curious if we can generalize the store provider boilerplate to avoid repeating the pattern for every store

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd need to set it up to double-check but I'd think so. Given though that the store solution works, even if somewhat hacky, I'm included to stick with that

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,7 +8,7 @@ import { cn } from "../../../../../../utils";
import { Confirmations } from "./Confirmations";
import { TimingRowExpanded } from "./TimingRowExpanded";
import { columns } from "./TransactionsTable";
import { WatchedTransaction } from "./useTransactionWatcher";
import { WatchedTransaction } from "./useTransactionsWatcher";

function TransactionTableRowDataCell({
label,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { BlockExplorerLink } from "./BlockExplorerLink";
import { TimeAgo } from "./TimeAgo";
import { TimingRowHeader } from "./TimingRowHeader";
import { TransactionTableRow } from "./TransactionTableRow";
import { WatchedTransaction, useTransactionWatcher } from "./useTransactionWatcher";
import { WatchedTransaction, useTransactionsWatcher } from "./useTransactionsWatcher";

const columnHelper = createColumnHelper<WatchedTransaction>();
export const columns = [
Expand Down Expand Up @@ -94,7 +94,7 @@ export const columns = [
];

export function TransactionsTable() {
const transactions = useTransactionWatcher();
const transactions = useTransactionsWatcher();
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;
}

This file was deleted.

Loading
Loading