Skip to content

Commit c8d5e3c

Browse files
committed
perf: cacheable isContract verification via block+target dual query with chain-aware windows
1 parent 1a91536 commit c8d5e3c

1 file changed

Lines changed: 146 additions & 102 deletions

File tree

src/utils/isContract.ts

Lines changed: 146 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -7,74 +7,150 @@ let time = 0
77
let count = 0
88

99
const localStoragePath = './data'
10-
const CACHE_TTL_MS = 10 * 60 * 1000 // 10 minutes wall-clock
11-
let cache: Map<string, { value: boolean; cachedAt: number; validFrom: number }>
10+
11+
/**
12+
* Width of the validity window for a single verification. On a cache miss
13+
* for block N, we query `eth_getCode(addr, N)` AND `eth_getCode(addr, target)`
14+
* where `target` is the next multiple of WINDOW above N. If both classify
15+
* the same way, the address's status was stable across [N, target] and we
16+
* cache the answer for that entire range — so the next ~WINDOW blocks of
17+
* sequential processing all hit the cache.
18+
*
19+
* Both calls are cacheable by `rpc-cache.ts` (they take a `0x…` block
20+
* number, not `'latest'`), and snapping `target` to a WINDOW boundary
21+
* makes the same target reused by every N in [target-WINDOW, target] —
22+
* so per-address we cache one `eth_getCode(addr, target)` entry per
23+
* WINDOW-wide bucket.
24+
*
25+
* Chain-aware widths target a ~14-day time horizon per verification so
26+
* faster chains (Base ~2 s/block, Sonic ~1 s/block, Arbitrum ~0.25 s/block)
27+
* get proportionally wider windows.
28+
*
29+
* Trade-off (accepted): a status transition that happens *after* the
30+
* verification target (e.g. CREATE2 deployment to a previously-empty
31+
* address at a block above `target`) won't be caught until the next
32+
* verification window. Rare in practice; `resetContractCache` is the
33+
* escape valve.
34+
*/
35+
const VERIFICATION_WINDOWS: Record<number, number> = {
36+
1: 100_000, // Ethereum mainnet (~14 days @ 12s)
37+
42161: 5_000_000, // Arbitrum (~14 days @ 0.25s)
38+
8453: 600_000, // Base (~14 days @ 2s)
39+
146: 1_200_000, // Sonic (~14 days @ 1s)
40+
}
41+
const DEFAULT_VERIFICATION_WINDOW = 100_000
42+
const verificationWindow = (chainId: number): number =>
43+
VERIFICATION_WINDOWS[chainId] ?? DEFAULT_VERIFICATION_WINDOW
44+
45+
type Entry = { value: boolean; validFrom: number; validUntil: number }
46+
47+
let cache: Map<string, Entry>
1248

1349
export const resetContractCache = async (ctx: Context) => {
1450
cache = new Map()
1551
await saveIsContractCache(ctx, true)
1652
}
1753

54+
const isEip7702 = (code: string): boolean =>
55+
code.length === 23 * 2 + 2 && code.startsWith('0xef0100')
56+
57+
const classify = (code: string, eip7702Check: boolean): boolean => {
58+
if (code === '0x') return false
59+
if (eip7702Check && isEip7702(code)) return false
60+
return true
61+
}
62+
63+
const blockHex = (h: number) => `0x${h.toString(16)}`
64+
65+
const nextWindowBoundary = (block: number, window: number): number =>
66+
Math.ceil((block + 1) / window) * window
67+
68+
const isHexCode = (x: unknown): x is string =>
69+
typeof x === 'string' && x.startsWith('0x')
70+
71+
const mergeEntry = (existing: Entry | undefined, fresh: Entry): Entry => {
72+
if (!existing || existing.value !== fresh.value) return fresh
73+
return {
74+
value: fresh.value,
75+
validFrom: Math.min(existing.validFrom, fresh.validFrom),
76+
validUntil: Math.max(existing.validUntil, fresh.validUntil),
77+
}
78+
}
79+
1880
/**
19-
* I've found we cannot cache this because it may change.
81+
* Fetch `eth_getCode` for each account at block `N` and, when `target` is
82+
* non-null, also at `target`. If the dual batch fails or returns malformed
83+
* results, falls back to a single-block batch. Returns `atTarget = null`
84+
* when the target read was skipped or invalid.
2085
*/
86+
async function fetchCodes(
87+
ctx: Context,
88+
accounts: string[],
89+
N: number,
90+
target: number | null,
91+
): Promise<Array<{ atBlock: string; atTarget: string | null }>> {
92+
if (target !== null) {
93+
const dual = accounts.flatMap((acc) => [
94+
{ method: 'eth_getCode', params: [acc, blockHex(N)] },
95+
{ method: 'eth_getCode', params: [acc, blockHex(target)] },
96+
])
97+
try {
98+
const r = (await ctx._chain.client.batchCall(dual)) as unknown[]
99+
const valid = accounts.every((_, i) => isHexCode(r[i * 2]) && isHexCode(r[i * 2 + 1]))
100+
if (valid) {
101+
return accounts.map((_, i) => ({
102+
atBlock: r[i * 2] as string,
103+
atTarget: r[i * 2 + 1] as string,
104+
}))
105+
}
106+
} catch {
107+
// fall through to single-block batch
108+
}
109+
}
110+
const single = accounts.map((acc) => ({
111+
method: 'eth_getCode',
112+
params: [acc, blockHex(N)],
113+
}))
114+
const r = (await ctx._chain.client.batchCall(single)) as unknown[]
115+
return accounts.map((acc, i) => {
116+
const code = r[i]
117+
if (!isHexCode(code)) throw new Error(`eth_getCode returned non-hex for ${acc} @ ${N}`)
118+
return { atBlock: code, atTarget: null }
119+
})
120+
}
121+
21122
export const isContract = async (
22123
ctx: Context,
23124
block: Block,
24125
account: string,
25126
eip7702Check: boolean = false,
26127
): Promise<boolean> => {
27128
if (account === '0x0000000000000000000000000000000000000000') return false
28-
if (cache.has(account)) {
29-
const entry = cache.get(account)!
30-
if (Date.now() - entry.cachedAt < CACHE_TTL_MS && entry.validFrom <= block.header.height) {
31-
return entry.value
32-
}
129+
const N = block.header.height
130+
const cached = cache.get(account)
131+
if (cached && cached.validFrom <= N && N <= cached.validUntil) {
132+
return cached.value
33133
}
34134
const start = Date.now()
135+
// Skip the forward verification when this batch contains the chain head —
136+
// `target` would be past head, RPC would error or return garbage.
137+
const target = ctx.isHead ? null : nextWindowBoundary(N, verificationWindow(ctx.chain.id))
35138

36-
let codeAtBlock
37-
let codeAtLatest
38-
39-
const batchResult = await ctx._chain.client.batchCall([
40-
{
41-
method: 'eth_getCode',
42-
params: [account, `latest`],
43-
},
44-
{
45-
method: 'eth_getCode',
46-
params: [account, `0x${block.header.height.toString(16)}`],
47-
},
48-
])
49-
codeAtLatest = batchResult[0]
50-
codeAtBlock = batchResult[1]
51-
52-
// EIP-7702: https://eips.ethereum.org/EIPS/eip-7702
53-
// Check if code length is 23 bytes and starts with 0xef0100
54-
const eip7702 = eip7702Check && codeAtBlock.length === 23 * 2 + 2 && codeAtBlock.startsWith('0xef0100')
55-
const isEOA = codeAtBlock === '0x' || eip7702
56-
if (codeAtBlock === codeAtLatest) {
57-
cache.set(account, {
58-
value: !isEOA,
59-
cachedAt: Date.now(),
60-
validFrom: block.header.height,
61-
})
62-
}
139+
const [{ atBlock, atTarget }] = await fetchCodes(ctx, [account], N, target)
140+
const valAtBlock = classify(atBlock, eip7702Check)
141+
const validUntil = atTarget !== null && classify(atTarget, eip7702Check) === valAtBlock ? target! : N
142+
cache.set(account, mergeEntry(cached, { value: valAtBlock, validFrom: N, validUntil }))
63143

64144
time += Date.now() - start
65145
count++
66146
if (process.env.DEBUG_PERF === 'true') {
67147
ctx.log.info(`isContract ${count} ${time / count}`)
68148
}
69-
return !isEOA
149+
return valAtBlock
70150
}
71151

72152
/**
73-
* Batch check if multiple accounts are contracts
74-
* @param ctx Context
75-
* @param block Block
76-
* @param accounts Array of account addresses to check
77-
* @returns Map of account addresses to boolean indicating if they are contracts
153+
* Batch check if multiple accounts are contracts.
78154
*/
79155
export const areContracts = async (
80156
ctx: Context,
@@ -84,64 +160,37 @@ export const areContracts = async (
84160
): Promise<Map<string, boolean>> => {
85161
if (!accounts.length) return new Map()
86162

163+
const N = block.header.height
87164
const result = new Map<string, boolean>()
88165
const accountsToCheck: string[] = []
89166

90-
// First check cache and filter out zero address
91167
for (const account of accounts) {
92168
if (account === '0x0000000000000000000000000000000000000000') {
93169
result.set(account, false)
94170
continue
95171
}
96-
97-
if (cache.has(account)) {
98-
const entry = cache.get(account)!
99-
if (Date.now() - entry.cachedAt < CACHE_TTL_MS && entry.validFrom <= block.header.height) {
100-
result.set(account, entry.value)
101-
continue
102-
}
172+
const cached = cache.get(account)
173+
if (cached && cached.validFrom <= N && N <= cached.validUntil) {
174+
result.set(account, cached.value)
175+
continue
103176
}
104-
105177
accountsToCheck.push(account)
106178
}
107179

108180
if (!accountsToCheck.length) return result
109181

110182
const start = Date.now()
111-
112-
// For historical blocks, we can check both latest and at block
113-
const batchCalls = accountsToCheck.flatMap((account) => [
114-
{
115-
method: 'eth_getCode',
116-
params: [account, 'latest'],
117-
},
118-
{
119-
method: 'eth_getCode',
120-
params: [account, `0x${block.header.height.toString(16)}`],
121-
},
122-
])
123-
124-
const batchResults = await ctx._chain.client.batchCall(batchCalls)
183+
const target = ctx.isHead ? null : nextWindowBoundary(N, verificationWindow(ctx.chain.id))
184+
const fetched = await fetchCodes(ctx, accountsToCheck, N, target)
125185

126186
for (let i = 0; i < accountsToCheck.length; i++) {
127187
const account = accountsToCheck[i]
128-
const codeAtLatest = batchResults[i * 2]
129-
const codeAtBlock = batchResults[i * 2 + 1]
130-
131-
// EIP-7702: https://eips.ethereum.org/EIPS/eip-7702
132-
// Check if code length is 23 bytes and starts with 0xef0100
133-
const eip7702 = eip7702Check && codeAtBlock.length === 23 * 2 + 2 && codeAtBlock.startsWith('0xef0100')
134-
const isEOA = codeAtBlock === '0x' || eip7702
135-
result.set(account, !isEOA)
136-
137-
// Update cache if the code is the same at latest and at block
138-
if (codeAtBlock === codeAtLatest) {
139-
cache.set(account, {
140-
value: !isEOA,
141-
cachedAt: Date.now(),
142-
validFrom: block.header.height,
143-
})
144-
}
188+
const { atBlock, atTarget } = fetched[i]
189+
const valAtBlock = classify(atBlock, eip7702Check)
190+
const validUntil = atTarget !== null && classify(atTarget, eip7702Check) === valAtBlock ? target! : N
191+
const cached = cache.get(account)
192+
cache.set(account, mergeEntry(cached, { value: valAtBlock, validFrom: N, validUntil }))
193+
result.set(account, valAtBlock)
145194
}
146195

147196
time += Date.now() - start
@@ -155,26 +204,31 @@ export const areContracts = async (
155204
return result
156205
}
157206

207+
const isEntry = (v: unknown): v is Entry =>
208+
typeof v === 'object' &&
209+
v !== null &&
210+
typeof (v as Entry).value === 'boolean' &&
211+
typeof (v as Entry).validFrom === 'number' &&
212+
typeof (v as Entry).validUntil === 'number'
213+
158214
export const loadIsContractCache = async (ctx: Context) => {
159215
if (cache) return
160216
const id = `${ctx.chain.id}-isContract`
217+
const fromJson = (data: Record<string, unknown>): Map<string, Entry> => {
218+
const m = new Map<string, Entry>()
219+
for (const [k, v] of Object.entries(data)) {
220+
if (isEntry(v)) m.set(k, { value: v.value, validFrom: v.validFrom, validUntil: v.validUntil })
221+
}
222+
return m
223+
}
161224
const entity = await ctx.store.get(UtilCache, id)
162225
if (entity) {
163-
const data = entity.data as Record<string, { value: boolean; cachedAt?: number; expiresAt?: number; validFrom?: number }>
164-
cache = new Map(
165-
Object.entries(data).map(([key, value]) => {
166-
return [key, { value: value.value, cachedAt: value.cachedAt ?? 0, validFrom: value.validFrom ?? 0 }]
167-
}),
168-
)
226+
cache = fromJson(entity.data as Record<string, unknown>)
169227
ctx.log.info('Loaded isContract cache from database: ' + cache.size + ' entries')
170228
} else if (existsSync(`${localStoragePath}/${id}.json`)) {
171229
try {
172230
const fileData = JSON.parse(readFileSync(`${localStoragePath}/${id}.json`, 'utf8'))
173-
cache = new Map(
174-
Object.entries(fileData).map(([key, value]: [string, any]) => {
175-
return [key, { value: value.value, cachedAt: value.cachedAt ?? 0, validFrom: value.validFrom ?? 0 }]
176-
}),
177-
)
231+
cache = fromJson(fileData)
178232
ctx.log.info('Loaded isContract cache from file: ' + cache.size + ' entries')
179233
} catch (e) {
180234
console.error('Error loading isContract cache from file:', e)
@@ -185,21 +239,11 @@ export const loadIsContractCache = async (ctx: Context) => {
185239
}
186240
}
187241

188-
// save once every ~5 minutes
189242
let lastSave = 0
190243
export const saveIsContractCache = async (ctx: Context, force: boolean = false) => {
191244
if (!cache) return
192245
if (Date.now() - lastSave < 5 * 60 * 1000 && !force) return
193246
const id = `${ctx.chain.id}-isContract`
194-
// if (process.env.NODE_ENV === 'development') {
195-
// writeFileSync(`${localStoragePath}/${id}.json`, JSON.stringify(Object.fromEntries(cache)))
196-
// }
197-
const now = Date.now()
198-
for (const [key, value] of cache) {
199-
if (now - value.cachedAt > CACHE_TTL_MS) {
200-
cache.delete(key)
201-
}
202-
}
203247
await ctx.store.save(
204248
new UtilCache({
205249
id,

0 commit comments

Comments
 (0)