Skip to content

Commit 0592406

Browse files
karooolisfrolic
andauthored
feat(explorer): bundled transactions support (#3313)
Co-authored-by: Kevin Ingersoll <kingersoll@gmail.com>
1 parent c93df2e commit 0592406

14 files changed

Lines changed: 383 additions & 82 deletions

File tree

.changeset/funny-peas-care.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@latticexyz/explorer": patch
3+
---
4+
5+
Added support for ERC-4337 bundled transactions, monitoring them by either listening to chain blocks or using the `observer` transport wrapper. Each user operation within a bundled transaction is displayed as an individual transaction in the Observe tab.

packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TimingRowExpanded.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ export function TimingRowExpanded(write: Write) {
1919
className={cn(`h-1`, {
2020
"bg-[#5c9af6]": timing.type === "write",
2121
"bg-[#4d7cc0]": timing.type === "waitForTransaction",
22-
"bg-[#3d5c8a]": timing.type === "waitForTransactionReceipt",
22+
"bg-[#3d5c8a]":
23+
timing.type === "waitForTransactionReceipt" || timing.type === "waitForUserOperationReceipt",
2324
})}
2425
style={{
2526
width: `${timing.widthPercentage}%`,

packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TimingRowHeader.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ export function TimingRowHeader(write: Write) {
1313
className={cn(`h-1`, {
1414
"bg-[#5c9af6]": timing.type === "write",
1515
"mt-0.5 bg-[#4d7cc0]": timing.type === "waitForTransaction",
16-
"mt-0.5 bg-[#3d5c8a]": timing.type === "waitForTransactionReceipt",
16+
"mt-0.5 bg-[#3d5c8a]":
17+
timing.type === "waitForTransactionReceipt" || timing.type === "waitForUserOperationReceipt",
1718
})}
1819
style={{
1920
width: `${timing.widthPercentage}%`,

packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionTableRow.tsx

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -81,14 +81,24 @@ export function TransactionTableRow({ row }: { row: Row<ObservedTransaction> })
8181
<Separator className="my-5" />
8282
<div className="flex items-start gap-x-4">
8383
<h3 className="w-[45px] flex-shrink-0 text-2xs font-bold uppercase">Inputs</h3>
84-
{Array.isArray(data.functionData?.args) && data.functionData?.args.length > 0 ? (
85-
<div className="min-w-0 flex-grow border border-white/20 p-2">
86-
{data.functionData?.args?.map((arg, idx) => (
87-
<div key={idx} className="flex">
88-
<span className="flex-shrink-0 text-xs text-white/60">arg {idx + 1}:</span>
89-
<span className="ml-2 break-all text-xs">
90-
{typeof arg === "object" && arg !== null ? JSON.stringify(arg, null, 2) : String(arg)}
91-
</span>
84+
85+
{data.calls.length > 0 ? (
86+
<div className="flex w-full flex-col gap-y-4">
87+
{data.calls.map((call, idx) => (
88+
<div key={idx} className="min-w-0 flex-grow border border-white/20 p-2 pt-1">
89+
<span className="text-xs">{call.functionName}:</span>
90+
{call.args?.map((arg, argIdx) => (
91+
<div key={argIdx} className="flex">
92+
<span className="flex-shrink-0 text-xs text-white/60">arg {argIdx + 1}:</span>
93+
<span className="ml-2 break-all text-xs">
94+
{typeof arg === "object" && arg !== null ? JSON.stringify(arg, null, 2) : String(arg)}
95+
</span>
96+
</div>
97+
))}
98+
99+
{call.value && call.value > 0n ? (
100+
<div className="text-xs text-white/60">value: {formatEther(call.value)} ETH</div>
101+
) : null}
92102
</div>
93103
))}
94104
</div>
@@ -128,7 +138,7 @@ export function TransactionTableRow({ row }: { row: Row<ObservedTransaction> })
128138
{Object.entries(args).map(([key, value]) => (
129139
<li key={key} className="mt-1 flex">
130140
<span className="flex-shrink-0 text-xs text-white/60">{key}: </span>
131-
<span className="ml-2 break-all text-xs">{value as never}</span>
141+
<span className="ml-2 break-all text-xs">{String(value)}</span>
132142
</li>
133143
))}
134144
</ul>

packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionsTable.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,21 @@ export const columns = [
4545
);
4646
},
4747
}),
48-
columnHelper.accessor("functionData.functionName", {
49-
header: "Function",
48+
columnHelper.accessor("calls", {
49+
header: "Function(s)",
5050
cell: (row) => {
51-
const functionName = row.getValue();
51+
const calls = row.getValue();
5252
const status = row.row.original.status;
5353
return (
5454
<div className="flex items-center">
55-
<Badge variant="secondary">{functionName}</Badge>
55+
<div className="flex gap-2">
56+
{calls.map(({ functionName }, idx) => (
57+
<Badge variant="secondary" key={idx}>
58+
{functionName}
59+
</Badge>
60+
))}
61+
</div>
62+
5663
{status === "pending" && <CheckCheckIcon className="ml-2 h-4 w-4 text-white/60" />}
5764
{status === "success" && <CheckCheckIcon className="ml-2 h-4 w-4 text-green-400" />}
5865
{(status === "reverted" || status === "rejected") && <XIcon className="ml-2 h-4 w-4 text-red-400" />}

packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionsWatcher.tsx

Lines changed: 128 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
import { useParams } from "next/navigation";
2-
import { BaseError, Hex, TransactionReceipt, decodeFunctionData, parseEventLogs } from "viem";
2+
import {
3+
Address,
4+
BaseError,
5+
Hash,
6+
Transaction,
7+
TransactionReceipt,
8+
decodeFunctionData,
9+
getAddress,
10+
parseAbi,
11+
parseEventLogs,
12+
} from "viem";
13+
import { UserOperation, entryPoint07Abi, entryPoint07Address } from "viem/account-abstraction";
314
import { useConfig, useWatchBlocks } from "wagmi";
415
import { getTransaction, simulateContract, waitForTransactionReceipt } from "wagmi/actions";
516
import { useStore } from "zustand";
@@ -8,22 +19,106 @@ import { store as observerStore } from "../../../../../../observer/store";
819
import { useChain } from "../../../../hooks/useChain";
920
import { useWorldAbiQuery } from "../../../../queries/useWorldAbiQuery";
1021
import { store as worldStore } from "../store";
22+
import { userOperationEventAbi } from "./abis/userOperationEventAbi";
23+
import { getDecodedUserOperationCalls } from "./utils/getDecodedUserOperationCalls";
1124

1225
export function TransactionsWatcher() {
1326
const { id: chainId } = useChain();
14-
const { worldAddress } = useParams<{ worldAddress: string }>();
27+
const { worldAddress } = useParams<{ worldAddress: Address }>();
1528
const wagmiConfig = useConfig();
1629
const { data: worldAbiData } = useWorldAbiQuery();
1730
const abi = worldAbiData?.abi;
1831
const { transactions, setTransaction, updateTransaction } = useStore(worldStore);
1932
const observerWrites = useStore(observerStore, (state) => state.writes);
2033

21-
const handleTransaction = useCallback(
22-
async (hash: Hex, timestamp: bigint) => {
34+
const handleUserOperation = useCallback(
35+
({
36+
hash,
37+
writeId,
38+
timestamp,
39+
receipt,
40+
transaction,
41+
userOperation,
42+
}: {
43+
hash: Hash;
44+
writeId?: string;
45+
timestamp: bigint;
46+
receipt: TransactionReceipt;
47+
transaction: Transaction;
48+
userOperation: UserOperation<"0.7">;
49+
}) => {
2350
if (!abi) return;
2451

25-
const transaction = await getTransaction(wagmiConfig, { hash });
26-
if (transaction.to !== worldAddress) return;
52+
const decodedSmartAccountCall = decodeFunctionData({
53+
abi: parseAbi([
54+
"function execute(address target, uint256 value, bytes calldata data)",
55+
"function executeBatch((address target,uint256 value,bytes data)[])",
56+
]),
57+
data: userOperation.callData,
58+
});
59+
60+
const { functionName: decodedFunctionName, args: decodedArgs } = decodedSmartAccountCall;
61+
const calls = getDecodedUserOperationCalls({
62+
abi,
63+
functionName: decodedFunctionName,
64+
decodedArgs,
65+
}).filter(({ to }) => to && getAddress(to) === getAddress(worldAddress));
66+
if (calls.length === 0) return;
67+
68+
const logs = parseEventLogs({
69+
abi: [...abi, userOperationEventAbi],
70+
logs: receipt.logs,
71+
});
72+
const userOperationEvent = logs.find(({ eventName }) => eventName === "UserOperationEvent");
73+
74+
setTransaction({
75+
hash,
76+
writeId: writeId ?? hash,
77+
from: transaction.from,
78+
timestamp,
79+
transaction,
80+
calls,
81+
receipt,
82+
logs,
83+
value: transaction.value,
84+
status: userOperationEvent?.args.success ? "success" : "reverted",
85+
});
86+
},
87+
[abi, setTransaction, worldAddress],
88+
);
89+
90+
const handleUserOperations = useCallback(
91+
async ({ writeId, timestamp, transaction }: { writeId?: string; timestamp: bigint; transaction: Transaction }) => {
92+
if (!abi) return;
93+
94+
const hash = transaction.hash;
95+
const receipt = await waitForTransactionReceipt(wagmiConfig, { hash: transaction.hash });
96+
const decodedEntryPointCall = decodeFunctionData({
97+
abi: entryPoint07Abi,
98+
data: transaction.input,
99+
});
100+
101+
const userOperations = decodedEntryPointCall.args[0] as never as UserOperation<"0.7">[];
102+
for (const userOperation of userOperations) {
103+
handleUserOperation({ hash, writeId, timestamp, receipt, transaction, userOperation });
104+
}
105+
},
106+
[abi, handleUserOperation, wagmiConfig],
107+
);
108+
109+
const handleAuthenticTransaction = useCallback(
110+
async ({
111+
writeId,
112+
hash,
113+
timestamp,
114+
transaction,
115+
}: {
116+
hash: Hash;
117+
writeId?: string;
118+
timestamp: bigint;
119+
transaction: Transaction;
120+
}) => {
121+
if (!abi || !transaction.to) return;
27122

28123
let functionName: string | undefined;
29124
let args: readonly unknown[] | undefined;
@@ -38,18 +133,20 @@ export function TransactionsWatcher() {
38133
functionName = transaction.input.length > 10 ? transaction.input.slice(0, 10) : "unknown";
39134
}
40135

41-
const write = Object.values(observerWrites).find((write) => write.hash === hash);
42136
setTransaction({
43137
hash,
44-
writeId: write?.writeId ?? hash,
138+
writeId: writeId ?? hash,
45139
from: transaction.from,
46140
timestamp,
47141
transaction,
48142
status: "pending",
49-
functionData: {
50-
functionName,
51-
args,
52-
},
143+
calls: [
144+
{
145+
to: transaction.to,
146+
functionName,
147+
args,
148+
},
149+
],
53150
value: transaction.value,
54151
});
55152

@@ -92,16 +189,29 @@ export function TransactionsWatcher() {
92189
error: transactionError as BaseError,
93190
});
94191
},
95-
[abi, wagmiConfig, worldAddress, observerWrites, setTransaction, updateTransaction],
192+
[abi, wagmiConfig, worldAddress, setTransaction, updateTransaction],
193+
);
194+
195+
const handleTransaction = useCallback(
196+
async ({ hash, writeId, timestamp }: { hash: Hash; timestamp: bigint; writeId?: string }) => {
197+
if (!abi) return;
198+
199+
const transaction = await getTransaction(wagmiConfig, { hash });
200+
if (transaction.to && getAddress(transaction.to) === getAddress(entryPoint07Address)) {
201+
handleUserOperations({ writeId, timestamp, transaction });
202+
} else if (transaction.to && getAddress(transaction.to) === getAddress(worldAddress)) {
203+
handleAuthenticTransaction({ hash, writeId, timestamp, transaction });
204+
}
205+
},
206+
[abi, wagmiConfig, worldAddress, handleUserOperations, handleAuthenticTransaction],
96207
);
97208

98209
useEffect(() => {
99-
for (const write of Object.values(observerWrites)) {
100-
const hash = write.hash;
101-
if (hash && write.address.toLowerCase() === worldAddress.toLowerCase()) {
210+
for (const { hash, writeId, time } of Object.values(observerWrites)) {
211+
if (hash) {
102212
const transaction = transactions.find((transaction) => transaction.hash === hash);
103213
if (!transaction) {
104-
handleTransaction(hash, BigInt(write.time) / 1000n);
214+
handleTransaction({ hash, writeId, timestamp: BigInt(time) / 1000n });
105215
}
106216
}
107217
}
@@ -111,7 +221,7 @@ export function TransactionsWatcher() {
111221
onBlock(block) {
112222
for (const hash of block.transactions) {
113223
if (transactions.find((transaction) => transaction.hash === hash)) continue;
114-
handleTransaction(hash, block.timestamp);
224+
handleTransaction({ hash, timestamp: block.timestamp });
115225
}
116226
},
117227
chainId,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
export const userOperationEventAbi = {
2+
type: "event",
3+
name: "UserOperationEvent",
4+
inputs: [
5+
{
6+
type: "bytes32",
7+
name: "userOpHash",
8+
indexed: true,
9+
},
10+
{
11+
type: "address",
12+
name: "sender",
13+
indexed: true,
14+
},
15+
{
16+
type: "address",
17+
name: "paymaster",
18+
indexed: true,
19+
},
20+
{
21+
type: "uint256",
22+
name: "nonce",
23+
indexed: false,
24+
},
25+
{
26+
type: "bool",
27+
name: "success",
28+
indexed: false,
29+
},
30+
{
31+
type: "uint256",
32+
name: "actualGasCost",
33+
indexed: false,
34+
},
35+
{
36+
type: "uint256",
37+
name: "actualGasUsed",
38+
indexed: false,
39+
},
40+
],
41+
} as const;

0 commit comments

Comments
 (0)