Skip to content

Commit 40b4ce1

Browse files
authored
Merge pull request #108 from ethereum-optimism/harry/attribution_spike
feat: smart wallet attribution support
2 parents 757d263 + c6f4674 commit 40b4ce1

File tree

11 files changed

+1187
-24
lines changed

11 files changed

+1187
-24
lines changed

packages/demo/backend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
3232
"dev:local": "LOCAL_DEV=true pnpm dev",
3333
"start": "node dist/index.js",
3434
"test": "vitest --run",
35-
"typecheck": "tsc --noEmit"
35+
"typecheck": "tsc --noEmit",
36+
"attribution": "tsx scripts/attribution.ts"
3637
},
3738
"dependencies": {
3839
"@clerk/backend": "^2.12.0",
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/*
2+
Purpose: Find transactions containing a 16-byte attribution suffix appended to ERC-4337 UserOperations.
3+
4+
What it does:
5+
- Scans recent blocks for EntryPoint UserOperationEvent logs in CHUNK_SIZE windows.
6+
- Dedupes bundle transaction hashes (newest first), fetches each tx once, and decodes handleOps.
7+
- For each user operation, compares the last 16 bytes of callData and initCode to TARGET_SUFFIX.
8+
- Prints the first match (block, txHash, opIndex, sender) or reports none within BLOCKS_BACK.
9+
10+
Config (env):
11+
- RPC_URL JSON-RPC endpoint.
12+
- TARGET_SUFFIX Required 16-byte hex (0x + 32 chars) to match.
13+
- BLOCKS_BACK Max blocks to scan backwards (default 5000).
14+
- CHUNK_SIZE Log query window size in blocks (default 1000).
15+
- ENTRYPOINT EntryPoint address (hardcoded below for convenience).
16+
*/
17+
18+
import 'dotenv/config'
19+
20+
import type { Address, GetTransactionReturnType, Hex } from 'viem'
21+
import {
22+
createPublicClient,
23+
decodeFunctionData,
24+
http,
25+
parseAbiItem,
26+
} from 'viem'
27+
import { baseSepolia } from 'viem/chains'
28+
29+
import { entryPointAbi } from './entrypointAbi.js'
30+
31+
type DecodedUserOp = {
32+
sender: Address
33+
nonce: bigint
34+
initCode: Hex
35+
callData: Hex
36+
callGasLimit: bigint
37+
verificationGasLimit: bigint
38+
preVerificationGas: bigint
39+
maxFeePerGas: bigint
40+
maxPriorityFeePerGas: bigint
41+
paymasterAndData: Hex
42+
signature: Hex
43+
}
44+
45+
const RPC_URL = process.env.RPC_URL
46+
if (!RPC_URL) {
47+
throw new Error('RPC_URL is not set')
48+
}
49+
const BLOCKS_BACK = Number(process.env.BLOCKS_BACK ?? 5000)
50+
const TARGET_SUFFIX: Hex = process.env.TARGET_SUFFIX as Hex
51+
if (!TARGET_SUFFIX) {
52+
throw new Error('TARGET_SUFFIX is not set')
53+
}
54+
const ENTRYPOINT = '0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789'
55+
const CHUNK_SIZE = Number(process.env.CHUNK_SIZE ?? 1000)
56+
57+
const client = createPublicClient({
58+
chain: baseSepolia,
59+
transport: http(RPC_URL),
60+
})
61+
62+
function extract16ByteSuffix(hex: Hex): Hex | null {
63+
const raw = hex.slice(2)
64+
if (raw.length < 32) return null // need at least 16 bytes
65+
const suffix = `0x${raw.slice(-32)}` as Hex // last 16 bytes (32 hex chars)
66+
return /^0x[0-9a-fA-F]{32}$/.test(suffix) ? suffix : null
67+
}
68+
69+
function findMatchingOpIndex(
70+
operations: readonly DecodedUserOp[],
71+
tx: GetTransactionReturnType<typeof baseSepolia>,
72+
): boolean {
73+
for (let i = 0; i < operations.length; i++) {
74+
const op = operations[i]
75+
const callDataSuffix = extract16ByteSuffix(op.callData)
76+
const initCodeSuffix = extract16ByteSuffix(op.initCode)
77+
if (callDataSuffix === TARGET_SUFFIX || initCodeSuffix === TARGET_SUFFIX) {
78+
console.log(
79+
`MATCH block=${tx.blockNumber} tx=${tx.hash} opIndex=${i} sender=${op.sender}`,
80+
)
81+
return true
82+
}
83+
}
84+
return false
85+
}
86+
87+
async function main() {
88+
const latest = await client.getBlockNumber()
89+
const from = latest - BigInt(BLOCKS_BACK)
90+
console.log(
91+
`Scanning logs ${from} -> ${latest} in ${CHUNK_SIZE}-block chunks for UserOperationEvent at ${ENTRYPOINT} (target suffix ${TARGET_SUFFIX})`,
92+
)
93+
94+
let found = false
95+
96+
for (
97+
let chunkEnd = latest;
98+
chunkEnd >= from && !found;
99+
chunkEnd -= BigInt(CHUNK_SIZE)
100+
) {
101+
const tentativeStart = chunkEnd - BigInt(CHUNK_SIZE - 1)
102+
const chunkStart = tentativeStart > from ? tentativeStart : from
103+
const userOperationEvent =
104+
'event UserOperationEvent(bytes32 indexed userOpHash, address indexed sender, address indexed paymaster, uint256 nonce, bool success, uint256 actualGasCost, uint256 actualGasUsed)'
105+
106+
const logs = await client.getLogs({
107+
address: ENTRYPOINT,
108+
event: parseAbiItem(userOperationEvent),
109+
fromBlock: chunkStart,
110+
toBlock: chunkEnd,
111+
})
112+
113+
// Deduplicate tx hashes (newest-first) and iterate once
114+
const seen = new Set<string>()
115+
const uniqueTxs: Hex[] = []
116+
for (let idx = logs.length - 1; idx >= 0; idx--) {
117+
const h = logs[idx].transactionHash
118+
if (!h) continue
119+
if (!seen.has(h)) {
120+
seen.add(h)
121+
uniqueTxs.push(h)
122+
}
123+
}
124+
console.log(`Found ${uniqueTxs.length} unique txs`)
125+
126+
for (const txHash of uniqueTxs) {
127+
if (found) break
128+
const tx = await client.getTransaction({ hash: txHash })
129+
130+
const input = tx.input
131+
try {
132+
const decoded = decodeFunctionData({ abi: entryPointAbi, data: input })
133+
if (decoded.functionName !== 'handleOps') continue
134+
const [ops] = decoded.args
135+
136+
if (findMatchingOpIndex(ops, tx)) {
137+
found = true
138+
break
139+
}
140+
} catch {
141+
continue
142+
}
143+
}
144+
}
145+
146+
if (!found) {
147+
console.log('\nNo matches found in the specified block range.')
148+
}
149+
}
150+
151+
main().catch((e) => {
152+
console.error(e)
153+
process.exit(1)
154+
})

0 commit comments

Comments
 (0)