@@ -7,74 +7,150 @@ let time = 0
77let count = 0
88
99const 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
1349export 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+
21122export 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 */
79155export 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+
158214export 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
189242let lastSave = 0
190243export 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