degenbot_simulation/sim/evm/warm_code_cache.rs
1//! Cross-block warm cache for immutable/long-TTL account data — bytecode +
2//! account existence (the `basic_ref` + `code_by_hash_ref` rows). Sits
3//! underneath the per-block `CacheDB` in the `BlockSimHandle` DB stack so the
4//! contract code-load RPCs stop repeating every block while the per-block
5//! `CacheDB` (overrides + mutable storage, fresh each block, drops at EOB) is
6//! preserved.
7//!
8//! ## What this caches (the staleness line)
9//!
10//! Only the immutable rows:
11//! - `basic_ref` — `AccountInfo` FOR CONTRACTS ONLY. An EOA's nonce/balance
12//! mutate every block, so EOA rows (empty code hash) are never cached —
13//! they fall through to the per-block `CacheDB` + RPC path fresh at each
14//! block build. (A cached contract's AccountInfo byte-RARELY mutates:
15//! contract nonces advance only via `CREATE*`; a stale contract nonce does
16//! not gate frame-replay validation the way a sender nonce does, and the
17//! TTL refresh bounds that edge.)
18//! - `code_by_hash_ref` — `Bytecode` keyed by `code_hash` (immutable by hash).
19//!
20//! `storage_ref` + `block_hash_ref` are forwarded UNCHECKED to the inner `Db`
21//! (the mutable-per-block row — V2 reserves slot 8 / V3 `slot0`+ticks change
22//! every block there's a swap; the chain block hash is per-chain, not
23//! per-block-snapshot). Caching these would re-introduce the K-invariant /
24//! `LOK` stale-state-divergence reverts the option-B path hit.
25//!
26//! ## The TTL (a safety net, not the primary correctness mechanism)
27//!
28//! Each entry records the block it was loaded at; an entry read at block `B`
29//! is a **miss** when `B - loaded_block > WARM_CODE_CACHE_TTL_BLOCKS`. A
30//! re-fetch on miss re-caches with `loaded_block = B`.
31//!
32//! In the bot's domain every cached contract is immutable (EIP-1967 proxies
33//! upgrade via the implementation storage slot — uncached; the injected
34//! executor is per-block via `stateOverrides`, never read here). The TTL is a
35//! belt-and-suspenders net against:
36//! - **EIP-6780-disabled selfdestruct edge cases** (pre-Cancun metamorphic
37//! `CREATE2`-redeploy-to-same-address: a contract that selfdestructs then
38//! redeploys with different bytecode at the same address would be stale).
39//! EIP-6780 (Cancun, mainnet since March 2024) disables code-deletion on
40//! selfdestruct unless the contract was created in the same tx — so the
41//! vector is effectively closed for any persistent contract.
42//! - **Future protocol changes** to code mutability.
43//!
44//! ## Composition
45//!
46//! ```text
47//! CacheDB (per-block: overrides + mutable storage, fresh each block, drops at EOB)
48//! → WarmCodeCache (persistent: bytecode + account-existence, per-entry TTL'd)
49//! ↳ holds Arc<RwLock<WarmCodeCacheInner>> cloned from the engine owner
50//! ↳ the WarmCodeCache<Db> wrapper value itself is per-block
51//! → BotStateDb (forwarding storage fallback)
52//! → WrapDatabaseAsync<AlloyDB> (RPC cold-miss)
53//! ```
54//!
55//! ## Ownership
56//!
57//! The wrapper value (`WarmCodeCache<Db>`) is per-block — constructed in
58//! `BlockSimHandle::build`, holds the per-block `Db`, drops at end of block.
59//! Only the inner map (`Arc<RwLock<WarmCodeCacheInner>>`) persists across
60//! blocks — cloned from the engine owner (`PyArbitrageEngine` / the
61//! standalone Rust `Bot`). This preserves the per-block `CacheDB` discipline
62//! while sharing the bytecode cache across blocks.
63
64// Solidity/EVM + Rust-ecosystem identifiers (EIP-6780, EIP-1967, DatabaseRef,
65// CacheDB, WrapDatabaseAsync, BlockSimHandle, etc.) are ubiquitous here —
66// match `degenbot-simulation`'s convention.
67
68use alloy::primitives::map::{AddressHashMap, B256HashMap};
69use alloy::primitives::Address;
70use parking_lot::RwLock;
71use revm::bytecode::Bytecode;
72use revm::database_interface::DatabaseRef;
73use revm::primitives::{StorageKey, StorageValue, B256};
74use revm::state::AccountInfo;
75use std::sync::Arc;
76
77/// The default per-entry TTL in blocks — ≈ 1 day of mainnet blocks (12s
78/// block time → ~7200 blocks/hour → ~10 000 ≈ 1.4 days).
79///
80/// A safety net, not the primary correctness mechanism — in the bot's domain
81/// every cached contract is immutable (EIP-1967 proxies upgrade via the
82/// implementation *storage* slot, which this cache does NOT serve; the
83/// injected executor is per-block via `stateOverrides`). See the module doc
84/// for the EIP-6780 selfdestruct + future-protocol-change analysis.
85pub const WARM_CODE_CACHE_TTL_BLOCKS: u64 = 10_000;
86
87/// The cross-block persistent inner state — bytecode + account-existence maps,
88/// each entry tagged with the block it was loaded at for per-entry TTL expiry.
89///
90/// Held behind `Arc<RwLock<...>>` so the engine owner (`PyArbitrageEngine` /
91/// the standalone Rust `Bot`) shares one instance across every per-block
92/// `WarmCodeCache` wrapper value. The maps grow with the working set (a few
93/// hundred contracts for one bot's pool-set); there is no LRU eviction in
94/// Tier 1 — unbounded growth is not a near-term concern (a follow-up if
95/// measurement shows otherwise).
96pub struct WarmCodeCacheInner {
97 /// `(loaded_block, AccountInfo)` per address. `AccountInfo` carries
98 /// `code_hash` (the bytecode is fetched separately via `code_by_hash_ref`,
99 /// or eagerly inline in `basic_ref` — revm's `AlloyDB` does the latter).
100 /// `None` (no-account) IS cached (existence-negative) — saves the `basic`
101 /// RPC on the no-code-EOA case the simulate's `balanceOf`-to-fresh-
102 /// addresses hits.
103 accounts: AddressHashMap<(u64, Option<AccountInfo>)>,
104 /// `(loaded_block, Bytecode)` per `code_hash` (immutable by hash).
105 bytecode: B256HashMap<(u64, Bytecode)>,
106 /// The fixed-offset TTL in blocks. An entry loaded at `loaded_block` is
107 /// a miss when `block - loaded_block > ttl_blocks` (strictly greater — an
108 /// entry loaded THIS block is always fresh).
109 ttl_blocks: u64,
110}
111
112impl WarmCodeCacheInner {
113 /// Construct an empty inner state with the given per-entry TTL.
114 #[must_use]
115 fn new(ttl_blocks: u64) -> Self {
116 Self {
117 accounts: AddressHashMap::default(),
118 bytecode: B256HashMap::default(),
119 ttl_blocks,
120 }
121 }
122
123 /// Construct a shared (`Arc<RwLock<...>>`) empty inner state with the
124 /// default TTL — the engine-owner construction shape (the `PyArbitrageEngine`
125 /// / standalone Rust `Bot` calls this once at construction + holds the arc
126 /// for the engine's life, cloning it into each per-block
127 /// `WarmCodeCache::with_owner`).
128 #[must_use]
129 pub fn shared_default() -> Arc<RwLock<Self>> {
130 Arc::new(RwLock::new(Self::new(WARM_CODE_CACHE_TTL_BLOCKS)))
131 }
132
133 /// Construct a shared inner state with a custom TTL (for tests + the
134 /// benchmark's TTL-boundary assertion).
135 #[must_use]
136 pub fn shared_with_ttl(ttl_blocks: u64) -> Arc<RwLock<Self>> {
137 Arc::new(RwLock::new(Self::new(ttl_blocks)))
138 }
139
140 /// Is an entry loaded at `loaded_block` still fresh at `block`?
141 /// Fresh iff `block - loaded_block <= ttl_blocks` (an entry loaded THIS
142 /// block is always fresh — `block - loaded_block = 0 <= ttl`).
143 fn is_fresh(&self, loaded_block: u64, block: u64) -> bool {
144 block.saturating_sub(loaded_block) <= self.ttl_blocks
145 }
146}
147
148/// A cross-block warm `DatabaseRef` wrapper — caches `basic_ref` +
149/// `code_by_hash_ref` (the immutable rows) with a per-entry TTL, forwards
150/// `storage_ref` + `block_hash_ref` to the inner `Db` (the mutable-per-block
151/// row, never cached).
152///
153/// The wrapper value is per-block (constructed in `BlockSimHandle::build`,
154/// holds the per-block `Db`); only the inner `Arc<RwLock<WarmCodeCacheInner>>`
155/// persists across blocks — cloned from the engine owner. See the module doc.
156///
157/// # Errors
158///
159/// `Error = Db::Error` — the cache layer is infallible; errors surface from
160/// the inner `Db` on a cache miss (the RPC cold-load path).
161pub struct WarmCodeCache<Db>
162where
163 Db: DatabaseRef,
164{
165 /// The cross-block persistent maps (bytecode + account-existence), shared
166 /// via the engine owner's arc.
167 cache: Arc<RwLock<WarmCodeCacheInner>>,
168 /// The block this cache view is for (drives per-entry TTL expiry checks).
169 block: u64,
170 /// The per-block `DatabaseRef` (forwards `storage_ref` + `block_hash_ref`
171 /// untouched; serves as the cold-miss fallback for `basic_ref` +
172 /// `code_by_hash_ref`).
173 db: Db,
174}
175
176impl<Db> WarmCodeCache<Db>
177where
178 Db: DatabaseRef,
179{
180 /// Build a per-block view over a shared inner cache + a per-block `Db`.
181 /// The `cache` arc is cloned from the engine owner; the `block` drives
182 /// per-entry TTL expiry; the `db` is the per-block fallback
183 /// (`BotStateDb<WrapDatabaseAsync<AlloyDB>>` in production).
184 #[must_use]
185 pub fn with_owner(cache: Arc<RwLock<WarmCodeCacheInner>>, block: u64, db: Db) -> Self {
186 Self { cache, block, db }
187 }
188}
189
190impl<Db> DatabaseRef for WarmCodeCache<Db>
191where
192 Db: DatabaseRef,
193{
194 type Error = Db::Error;
195
196 /// Serve from the warm cache if the entry is fresh; else forward to the
197 /// inner `Db` + cache the result (the existence-negative `None` IS cached
198 /// — saves the `basic` RPC on the no-code-EOA case).
199 ///
200 /// # Errors
201 ///
202 /// Returns the inner `Db`'s error on a cache miss (the RPC `basic` fetch).
203 fn basic_ref(&self, address: Address) -> Result<Option<AccountInfo>, Self::Error> {
204 // Fast path: read-locked cache hit (fresh entry).
205 {
206 let guard = self.cache.read();
207 if let Some((loaded_block, info)) = guard.accounts.get(&address) {
208 if guard.is_fresh(*loaded_block, self.block) {
209 // SIMPIPE T1 lab: warm-cache served (cross-block/candidate
210 // reuse signal).
211 super::sim_metrics::record_basic_warm_hit();
212 return Ok(info.clone());
213 }
214 }
215 }
216 // Miss (or stale): forward. EOA rows are NEVER cached — an EOA's
217 // nonce + balance mutate every block, and the warm layer is
218 // cross-block: caching them froze frame-replay nonce validation
219 // at its first touch for a full TTL window (observed live as replay
220 // errors stuck at the capture-time `expected` across head advances).
221 let (info, dur) = super::sim_metrics::timed(|| self.db.basic_ref(address));
222 super::sim_metrics::record_basic(dur);
223 let info = info?;
224 let is_contract = info
225 .as_ref()
226 .is_some_and(|i| i.code_hash != revm::primitives::KECCAK_EMPTY);
227 if is_contract {
228 let mut guard = self.cache.write();
229 guard.accounts.insert(address, (self.block, info.clone()));
230 }
231 Ok(info)
232 }
233
234 /// Serve bytecode from the warm cache if the entry is fresh; else forward
235 /// to the inner `Db` + cache the result.
236 ///
237 /// In revm's `AlloyDB`, `basic_ref` eagerly loads code inline — so
238 /// `code_by_hash_ref` is only reached when `basic_ref` returned a
239 /// `code_hash` without the code (the spike-verified panic-safety
240 /// invariant — never in production). Caching here is defensive + handles
241 /// a `Db` whose `basic_ref` does NOT eagerly load code.
242 ///
243 /// # Errors
244 ///
245 /// Returns the inner `Db`'s error on a cache miss.
246 fn code_by_hash_ref(&self, code_hash: B256) -> Result<Bytecode, Self::Error> {
247 {
248 let guard = self.cache.read();
249 if let Some((loaded_block, code)) = guard.bytecode.get(&code_hash) {
250 if guard.is_fresh(*loaded_block, self.block) {
251 return Ok(code.clone());
252 }
253 }
254 }
255 let (code, dur) = super::sim_metrics::timed(|| self.db.code_by_hash_ref(code_hash));
256 super::sim_metrics::record_code(dur);
257 let code = code?;
258 let mut guard = self.cache.write();
259 guard.bytecode.insert(code_hash, (self.block, code.clone()));
260 Ok(code)
261 }
262
263 /// Forward to the inner `Db` — storage is the mutable-per-block row (V2
264 /// reserves slot 8, V3 `slot0`/`liquidity`/`ticks` change every block
265 /// there's a swap). NEVER cached (caching would re-introduce the
266 /// K-invariant / stale-state-divergence reverts the option-B path hit).
267 ///
268 /// # Errors
269 ///
270 /// Returns the inner `Db`'s error if the RPC fetch fails.
271 fn storage_ref(
272 &self,
273 address: Address,
274 index: StorageKey,
275 ) -> Result<StorageValue, Self::Error> {
276 // SIMPIPE T1 lab: storage ALWAYS forwards — the latency split sizes
277 // the anchor-served (fast) vs RPC-reached (slow) shares.
278 let (value, dur) = super::sim_metrics::timed(|| self.db.storage_ref(address, index));
279 super::sim_metrics::record_storage(dur);
280 value
281 }
282
283 /// Forward to the inner `Db` — block hashes are per-chain, not
284 /// per-block-snapshot. NEVER cached.
285 ///
286 /// # Errors
287 ///
288 /// Returns the inner `Db`'s error if the RPC fetch fails.
289 fn block_hash_ref(&self, number: u64) -> Result<B256, Self::Error> {
290 super::sim_metrics::record_block_hash();
291 self.db.block_hash_ref(number)
292 }
293}
294
295#[expect(clippy::expect_used)]
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use alloy::primitives::{address, U256};
300 use revm::database_interface::EmptyDB;
301 use std::cell::Cell;
302
303 /// A counting mock `DatabaseRef` — wraps `EmptyDB` + records every call.
304 /// `EmptyDB` returns `None` for `basic_ref` + `KECCAK_EMPTY` bytecode for
305 /// `code_by_hash_ref` (both error-free), so the mock serves as the
306 /// cold-miss fallback the warm cache forwards to.
307 #[expect(clippy::struct_field_names)]
308 struct CountingMockDb {
309 basic_calls: Cell<u64>,
310 code_by_hash_calls: Cell<u64>,
311 storage_calls: Cell<u64>,
312 block_hash_calls: Cell<u64>,
313 }
314
315 impl CountingMockDb {
316 const EMPTY_CODE_HASH: B256 = B256::ZERO;
317 fn new() -> Self {
318 Self {
319 basic_calls: Cell::new(0),
320 code_by_hash_calls: Cell::new(0),
321 storage_calls: Cell::new(0),
322 block_hash_calls: Cell::new(0),
323 }
324 }
325 }
326
327 impl DatabaseRef for CountingMockDb {
328 type Error = core::convert::Infallible;
329 fn basic_ref(&self, _address: Address) -> Result<Option<AccountInfo>, Self::Error> {
330 self.basic_calls.set(self.basic_calls.get() + 1);
331 // Return a trivially-populated AccountInfo (some balance + the
332 // sentinel code hash) so the cache stores a non-vacuous value.
333 Ok(Some(AccountInfo::new(
334 U256::from(1),
335 0,
336 Self::EMPTY_CODE_HASH,
337 revm::bytecode::Bytecode::new_legacy(alloy::primitives::Bytes::from_static(&[
338 0x00,
339 ])),
340 )))
341 }
342 fn code_by_hash_ref(&self, _code_hash: B256) -> Result<Bytecode, Self::Error> {
343 self.code_by_hash_calls
344 .set(self.code_by_hash_calls.get() + 1);
345 Ok(revm::bytecode::Bytecode::new_legacy(
346 alloy::primitives::Bytes::new(),
347 ))
348 }
349 fn storage_ref(
350 &self,
351 _address: Address,
352 _index: StorageKey,
353 ) -> Result<StorageValue, Self::Error> {
354 self.storage_calls.set(self.storage_calls.get() + 1);
355 Ok(StorageValue::ZERO)
356 }
357 fn block_hash_ref(&self, _number: u64) -> Result<B256, Self::Error> {
358 self.block_hash_calls.set(self.block_hash_calls.get() + 1);
359 Ok(B256::ZERO)
360 }
361 }
362
363 const ADDR: Address = address!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
364
365 fn view(
366 cache: &Arc<RwLock<WarmCodeCacheInner>>,
367 block: u64,
368 db: CountingMockDb,
369 ) -> WarmCodeCache<CountingMockDb> {
370 WarmCodeCache::with_owner(Arc::clone(cache), block, db)
371 }
372
373 // ── Acceptance test 1: cache hit avoids second DB read ──────────────
374
375 #[test]
376 fn basic_ref_second_call_same_block_hits_cache() {
377 let inner = WarmCodeCacheInner::shared_with_ttl(10);
378 let v1 = view(&inner, 100, CountingMockDb::new());
379 let _ = v1.basic_ref(ADDR);
380 // Second call — a FRESH wrapper struct reusing the SAME arc, same block.
381 let v2 = view(&inner, 100, CountingMockDb::new());
382 let _ = v2.basic_ref(ADDR);
383 // Only the first wrapper's db saw a call; the second hit the cache.
384 // NOTE: each view has its own CountingMockDb, so we assert via the
385 // shared inner map instead: the entry is present + loaded at block 100.
386 let guard = inner.read();
387 assert_eq!(guard.accounts.len(), 1, "exactly one cached account");
388 let (loaded_block, info) = guard.accounts.get(&ADDR).expect("cached");
389 assert_eq!(*loaded_block, 100);
390 assert!(info.is_some(), "cached value is Some");
391 }
392
393 // ── Acceptance test 1b: the mock is actually hit on the FIRST call ──
394
395 #[test]
396 fn basic_ref_first_call_hits_the_db() {
397 let inner = WarmCodeCacheInner::shared_with_ttl(10);
398 let db = CountingMockDb::new();
399 let v = view(&inner, 100, db);
400 let _ = v.basic_ref(ADDR);
401 // The db the view owns records the call. Verify via the shared inner
402 // (the entry was inserted — only happens after a db forward).
403 assert!(inner.read().accounts.contains_key(&ADDR));
404 }
405
406 // ── Acceptance test 2: TTL expiry re-fetches ────────────────────────
407
408 #[test]
409 fn ttl_expiry_refetches_on_stale_read() {
410 // ttl = 5. Load at block 10 (fresh). Read at block 15 (15-10=5 <= 5,
411 // still fresh). Read at block 16 (16-10=6 > 5, stale → re-fetch).
412 let inner = WarmCodeCacheInner::shared_with_ttl(5);
413 // Load at block 10.
414 let v = view(&inner, 10, CountingMockDb::new());
415 let info10 = v.basic_ref(ADDR).unwrap();
416 let loaded_block_10 = inner.read().accounts.get(&ADDR).expect("cached").0;
417 assert_eq!(loaded_block_10, 10);
418 // Read at block 15 — fresh (15-10=5 <= 5), entry NOT rewritten.
419 let v15 = view(&inner, 15, CountingMockDb::new());
420 let info15 = v15.basic_ref(ADDR).unwrap();
421 assert_eq!(info15, info10, "fresh read returns the cached value");
422 let loaded_block_15 = inner.read().accounts.get(&ADDR).expect("cached").0;
423 assert_eq!(
424 loaded_block_15, 10,
425 "fresh read does NOT rewrite loaded_block"
426 );
427 // Read at block 16 — stale (16-10=6 > 5), re-fetch + rewrite.
428 let v16 = view(&inner, 16, CountingMockDb::new());
429 let _ = v16.basic_ref(ADDR).unwrap();
430 let loaded_block_16 = inner.read().accounts.get(&ADDR).expect("cached").0;
431 assert_eq!(
432 loaded_block_16, 16,
433 "stale read re-fetches + rewrites loaded_block"
434 );
435 }
436
437 // ── Acceptance test 3: storage_ref NEVER cached ─────────────────────
438
439 #[test]
440 fn storage_ref_is_never_cached() {
441 let inner = WarmCodeCacheInner::shared_with_ttl(100);
442 let db = CountingMockDb::new();
443 let v = view(&inner, 100, db);
444 let slot = StorageKey::ZERO;
445 // Two reads of the same (addr, slot) at the same block.
446 let _ = v.storage_ref(ADDR, slot);
447 let _ = v.storage_ref(ADDR, slot);
448 assert_eq!(
449 v.db.storage_calls.get(),
450 2,
451 "both storage reads hit the inner db (never cached)"
452 );
453 assert!(
454 inner.read().accounts.is_empty(),
455 "storage reads never populate the accounts map"
456 );
457 }
458
459 // ── Acceptance test 4: block_hash_ref NEVER cached ───────────────────
460
461 #[test]
462 fn block_hash_ref_is_never_cached() {
463 let inner = WarmCodeCacheInner::shared_with_ttl(100);
464 let db = CountingMockDb::new();
465 let v = view(&inner, 100, db);
466 let _ = v.block_hash_ref(50);
467 let _ = v.block_hash_ref(50);
468 assert_eq!(
469 v.db.block_hash_calls.get(),
470 2,
471 "both block_hash reads hit the inner db (never cached)"
472 );
473 }
474
475 // ── Acceptance test 5: code_by_hash_ref caches + TTLs ────────────────
476
477 #[test]
478 fn code_by_hash_ref_caches_and_expires() {
479 let inner = WarmCodeCacheInner::shared_with_ttl(5);
480 let hash = B256::repeat_byte(0xab);
481 // Load at block 20.
482 let v = view(&inner, 20, CountingMockDb::new());
483 let code20 = v.code_by_hash_ref(hash).unwrap();
484 assert_eq!(v.db.code_by_hash_calls.get(), 1, "first call hits the db");
485 let loaded_block_20 = inner.read().bytecode.get(&hash).expect("cached").0;
486 assert_eq!(loaded_block_20, 20);
487 // Read at block 25 (25-20=5 <= 5) — fresh, cache hit.
488 let v25 = view(&inner, 25, CountingMockDb::new());
489 let code25 = v25.code_by_hash_ref(hash).unwrap();
490 assert_eq!(
491 code25.hash_slow(),
492 code20.hash_slow(),
493 "fresh returns cached"
494 );
495 let loaded_block_25 = inner.read().bytecode.get(&hash).expect("cached").0;
496 assert_eq!(loaded_block_25, 20, "fresh read does NOT rewrite");
497 // Read at block 26 (26-20=6 > 5) — stale, re-fetch + rewrite.
498 let v26 = view(&inner, 26, CountingMockDb::new());
499 let _ = v26.code_by_hash_ref(hash).unwrap();
500 let loaded_block_26 = inner.read().bytecode.get(&hash).expect("cached").0;
501 assert_eq!(loaded_block_26, 26, "stale re-fetches + rewrites");
502 }
503
504 // ── Acceptance test 6: None (no-account) IS cached ───────────────────
505
506 #[test]
507 fn basic_ref_none_is_not_cached() {
508 // A None-returning fallback: an absent account is an EOA-shaped row
509 // (nonce/balance mutate per-block) and must NOT enter the cross-block
510 // warm cache: caching it froze sender nonces at first touch here.
511 struct NoneDb {
512 calls: Cell<u64>,
513 }
514 impl DatabaseRef for NoneDb {
515 type Error = core::convert::Infallible;
516 fn basic_ref(&self, _address: Address) -> Result<Option<AccountInfo>, Self::Error> {
517 self.calls.set(self.calls.get() + 1);
518 Ok(None)
519 }
520 fn code_by_hash_ref(&self, _code_hash: B256) -> Result<Bytecode, Self::Error> {
521 Ok(Bytecode::new_legacy(alloy::primitives::Bytes::new()))
522 }
523 fn storage_ref(
524 &self,
525 _address: Address,
526 _index: StorageKey,
527 ) -> Result<StorageValue, Self::Error> {
528 Ok(StorageValue::ZERO)
529 }
530 fn block_hash_ref(&self, _number: u64) -> Result<B256, Self::Error> {
531 Ok(B256::ZERO)
532 }
533 }
534 let inner = WarmCodeCacheInner::shared_with_ttl(100);
535 let db = NoneDb {
536 calls: Cell::new(0),
537 };
538 let v = WarmCodeCache::with_owner(Arc::clone(&inner), 100, db);
539 let info1 = v.basic_ref(ADDR).unwrap();
540 assert!(info1.is_none(), "first call returns None");
541 assert_eq!(v.db.calls.get(), 1, "first call hit the db");
542 let info2 = v.basic_ref(ADDR).unwrap();
543 assert!(info2.is_none(), "second call returns None again");
544 assert_eq!(
545 v.db.calls.get(),
546 2,
547 "EOA-shaped rows never cache — the second call re-queries"
548 );
549 }
550
551 // ── Acceptance test 7: an EmptyDB-backed view compiles + forwards ────
552
553 #[test]
554 fn warm_code_cache_compiles_over_empty_db() {
555 // The smoke-test shape: a WarmCodeCache<EmptyDB> (no RPC, no BotState).
556 // EmptyDB::basic_ref returns Ok(None) (no account) — an EOA-shaped
557 // row the warm cache deliberately NEVER caches (nonce/balance mutate
558 // per-block on real chains). This proves the type bound
559 // (no Display requirement on Error) compiles + the forwarding path
560 // works.
561 let inner = WarmCodeCacheInner::shared_default();
562 let v = WarmCodeCache::with_owner(Arc::clone(&inner), 1, EmptyDB::default());
563 let info = v.basic_ref(ADDR).unwrap();
564 assert!(info.is_none(), "EmptyDB returns no account");
565 assert_eq!(
566 inner.read().accounts.len(),
567 0,
568 "Ok(None) is never cached — EOA-shaped rows stay live"
569 );
570 }
571
572 // ── Ensure no pyo3 in this core crate (the invariant) ────────────────
573
574 #[test]
575 fn warm_code_cache_module_has_no_pyo3_dependency() {
576 // The no-pyo3-in-cores invariant is enforced by `just
577 // check-no-pyo3-in-cores` at the crate level; this test is a
578 // documentation anchor (the module imports no pyo3 symbols). The
579 // module-level `use` list above (parking_lot, alloy, revm, std) is the
580 // proof — no `pyo3` import exists in this file.
581 }
582}