Skip to main content

bal_archive/
lib.rs

1#![doc = include_str!("../README.md")]
2//!
3//! `bal-archive`: accumulate storage changes from verified BALs, serve
4//! versioned reads, backfill older blocks, handle reorgs. Knows blocks and slots;
5//! knows nothing about Solidity.
6//!
7//! Every value in the store carries its [`Provenance`]. Every miss is a typed
8//! [`NotAvailable`]. There is no code path that returns a zero for "unknown".
9//!
10//! All methods take `&self`: redb serialises writers itself, so an
11//! [`Archive`] can be shared (e.g. in an `Arc`) and read while [`Archive::sync`]
12//! is running. [`Archive::watch`] and the sync loop coordinate through a
13//! small gate so that a watch added mid-sync is never silently skipped.
14
15mod backfill;
16mod keys;
17mod reads;
18mod sync;
19mod writes;
20
21pub use backfill::{BackfillOpts, BackfillReport, BackfillStop};
22pub use keys::{BootState, Provenance, OLDEST_UPGRADABLE, SCHEMA_VERSION};
23pub use sync::{SyncReport, REORG_HORIZON_FALLBACK, TOUCHED_CAP};
24
25use alloy_primitives::{Address, B256};
26use bal_codec::BlockAccessIndex;
27use keys::*;
28use redb::{Database, ReadableTable, ReadableTableMetadata, TableDefinition};
29use std::ops::{Bound, Range, RangeBounds};
30use std::path::Path;
31use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
32use std::sync::Mutex;
33
34pub(crate) const SLOTS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("slots");
35/// addr || block -> the slots written in that block (v3). One entry per
36/// address and block; `diff`, rendering and rollback read it.
37pub(crate) const BLOCKIDX: TableDefinition<&[u8], &[u8]> = TableDefinition::new("blockslots");
38/// v1/v2 block index (one key per slot), migrated on open and dropped.
39const LEGACY_BLOCKIDX: TableDefinition<&[u8], ()> = TableDefinition::new("blockidx");
40pub(crate) const META: TableDefinition<&str, &[u8]> = TableDefinition::new("meta");
41pub(crate) const WATCH: TableDefinition<&[u8], u64> = TableDefinition::new("watch");
42/// block -> hash(32) || state_root(32)
43const HASHES: TableDefinition<u64, &[u8]> = TableDefinition::new("blockhashes");
44pub(crate) const BOOT: TableDefinition<&[u8], &[u8]> = TableDefinition::new("bootstrap");
45/// addr || slot -> first_seen, for slots whose bootstrap is pending. Lets
46/// the retry path scan only what is pending instead of every slot.
47pub(crate) const PENDING: TableDefinition<&[u8], u64> = TableDefinition::new("pending");
48/// addr -> block in which the contract was created, when a verified BAL
49/// showed the creation. Before that block the account had no storage, so
50/// every slot's pre-value is zero by protocol rule (EIP-7610) — no proof
51/// needed, ever, for such an address.
52pub(crate) const CREATED: TableDefinition<&[u8], u64> = TableDefinition::new("created");
53
54const META_SCHEMA: &str = "schema_version";
55/// `anchor:<addr>` -> hash of the address's current start block, written by
56/// backfill so the next backward step can check the parent link.
57const META_ANCHOR: &str = "anchor:";
58const META_HEAD: &str = "head";
59const META_FULL_DETAIL: &str = "full_detail";
60
61/// Failures of the archive itself (storage, source, verification). Reads
62/// use [`NotAvailable`] instead: a missing value is an answer, not a failure.
63#[derive(Debug, thiserror::Error)]
64pub enum ArchiveError {
65    /// Embedded database error.
66    #[error("db: {0}")]
67    Db(Box<redb::Error>),
68    /// The file was written by a build with a different key layout.
69    #[error("schema version {found} on disk, this build speaks {expected}")]
70    SchemaMismatch {
71        /// Version found in the file.
72        found: u32,
73        /// Version this build writes.
74        expected: u32,
75    },
76    /// `from_block` must be at least 1 (the pre-value record lives at `from_block - 1`).
77    #[error("from_block must be >= 1 (got {0})")]
78    InvalidStart(u64),
79    /// Watching from a block the archive has already passed (or is applying
80    /// right now) is backfill, not `watch`.
81    #[error("watch from block {from_block} is in the past (head {head}); use backfill for history before the head")]
82    StartInPast {
83        /// Requested start.
84        from_block: u64,
85        /// Current archive head (or the block being applied).
86        head: u64,
87    },
88    /// The address is not on the watchlist.
89    #[error("address {0} is not watched")]
90    NotWatched(Address),
91    /// The address is already watched with a different start.
92    #[error(
93        "address {address} is already watched from block {from_block}; unwatch first to change it"
94    )]
95    AlreadyWatched {
96        /// The address.
97        address: Address,
98        /// Its existing start block.
99        from_block: u64,
100    },
101    /// Another `sync` pass is running on this archive.
102    #[error("a sync pass is already running on this archive")]
103    SyncInProgress,
104    /// The file was created with a different value of a creation-time option.
105    #[error("archive was created with {option} = {on_disk}, opened with {requested}")]
106    ConfigMismatch {
107        /// Option name.
108        option: &'static str,
109        /// Value stored in the file.
110        on_disk: String,
111        /// Value requested now.
112        requested: String,
113    },
114    /// A bootstrap was requested before the archive reached the watch start.
115    #[error("archive head {head} is below watch start {start}; nothing to bootstrap yet")]
116    HeadBelowStart {
117        /// Current head.
118        head: u64,
119        /// Watch start of the address.
120        start: u64,
121    },
122    /// The BAL / state source failed.
123    #[error("source: {0}")]
124    Source(#[from] bal_source::SourceError),
125    /// `keccak(rlp(bal))` did not match the header. Sync stops here.
126    #[error("block {block} failed BAL verification: {err}")]
127    Verification {
128        /// Offending block.
129        block: u64,
130        /// What the codec found.
131        err: bal_codec::CodecError,
132    },
133    /// The header has no BAL hash and `allow_unverified` is off.
134    #[error(
135        "block {0} header carries no block_access_list_hash; refusing to apply unverifiable data"
136    )]
137    NoBalHash(u64),
138    /// A reorg reached below the retained block hashes; the archive cannot
139    /// find the fork point and must not guess.
140    #[error("reorg deeper than retained block hashes (fork below block {0})")]
141    ReorgBeyondHorizon(u64),
142    /// The source keeps serving a block whose parent is not the block it
143    /// serves for `number - 1` (pooled upstreams on different forks).
144    #[error(
145        "source is inconsistent around block {0}: parent hash does not match its own block {0}-1"
146    )]
147    InconsistentSource(u64),
148    /// Backfill found that the node's block at the watch start is not the
149    /// block the archive holds: the start was reorged. A forward sync
150    /// resolves that; backfill will not guess which branch to extend.
151    #[error("block {0} on the node is not the block the archive holds; run sync first")]
152    StartReplaced(u64),
153    /// A Merkle proof did not verify against the header's `state_root`.
154    #[error("proof: {0}")]
155    Proof(#[from] bal_source::ProofError),
156    /// A stored record has an unexpected shape.
157    #[error("corrupt record: {0}")]
158    Corrupt(&'static str),
159}
160
161macro_rules! from_redb {
162    ($($t:ty),*) => {$(
163        impl From<$t> for ArchiveError {
164            fn from(e: $t) -> Self { ArchiveError::Db(Box::new(e.into())) }
165        }
166    )*};
167}
168from_redb!(
169    redb::Error,
170    redb::DatabaseError,
171    redb::TransactionError,
172    redb::TableError,
173    redb::StorageError,
174    redb::CommitError,
175    redb::CompactionError
176);
177
178/// Result of archive operations.
179pub type Result<T> = std::result::Result<T, ArchiveError>;
180
181/// Slots seen for the first time in a block: `(addr, watch_start, slots)`.
182pub(crate) type FreshSlots = Vec<(Address, u64, Vec<B256>)>;
183
184/// Why a read has no answer. Promise #3 lives here: a caller always learns
185/// *which* boundary it hit, and never receives a zero in place of "unknown".
186#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
187pub enum NotAvailable {
188    /// The address is not on the watchlist.
189    #[error("address {0} is not watched")]
190    NotWatched(Address),
191    /// The block precedes the address's watch start.
192    #[error("block {requested} is before watch start {start}")]
193    BeforeStart {
194        /// Requested block.
195        requested: u64,
196        /// Watch start for this address.
197        start: u64,
198    },
199    /// The block is beyond what the archive has applied.
200    #[error("block {requested} is after archive head {head}")]
201    AfterHead {
202        /// Requested block.
203        requested: u64,
204        /// Current archive head.
205        head: u64,
206    },
207    /// No block has been applied yet.
208    #[error("archive has not synced any block yet")]
209    NotSynced,
210    /// `start >= end`: a caller error, reported rather than answered with nothing.
211    #[error("invalid block range {start}..{end}")]
212    InvalidRange {
213        /// Range start.
214        start: u64,
215        /// Range end (exclusive).
216        end: u64,
217    },
218    /// No change to the slot has been recorded since `start`, and the address
219    /// was not seen being created: its value is not known. Backfill to the
220    /// contract's creation ([`Archive::backfill`]) or prove it at the head
221    /// ([`Archive::bootstrap_slot`]).
222    #[error("no change to this slot is recorded since the watch start; backfill to the contract's creation, or prove it at the head")]
223    NeverRecorded,
224    /// The slot's earliest recorded change is at `first_seen`; nothing is
225    /// known before it yet. Backfill further back (or prove it while the
226    /// node's state window still allows).
227    #[error("no record before block {first_seen} (the slot's earliest recorded change); backfill further back")]
228    UnknownBefore {
229        /// Block of the earliest recorded change.
230        first_seen: u64,
231    },
232    /// Storage failure surfaced through a read.
233    #[error("internal: {0}")]
234    Internal(String),
235}
236
237impl From<ArchiveError> for NotAvailable {
238    fn from(e: ArchiveError) -> Self {
239        NotAvailable::Internal(e.to_string())
240    }
241}
242
243/// A stored word with where it came from and when it was set.
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245pub struct StorageValue {
246    /// The 32-byte word.
247    pub value: B256,
248    /// BAL, proof, import, or unverified.
249    pub provenance: Provenance,
250    /// Block at which this value was set; `watch start - 1` for a proven
251    /// pre-value.
252    pub set_at: u64,
253    /// Position within `set_at` (`u32::MAX` for proven pre-values).
254    pub index: BlockAccessIndex,
255}
256
257/// One recorded change, as returned by [`Archive::history`].
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub struct HistoryEntry {
260    /// Block of the change.
261    pub block: u64,
262    /// Position within the block.
263    pub index: BlockAccessIndex,
264    /// Post-value.
265    pub value: B256,
266    /// [`Provenance::Bal`], or [`Provenance::Unverified`] under `allow_unverified`.
267    pub provenance: Provenance,
268}
269
270/// What [`Archive::stats`] reports.
271#[derive(Debug, Clone)]
272pub struct ArchiveStats {
273    /// Last applied block and hash.
274    pub head: Option<(u64, B256)>,
275    /// Watched addresses with start blocks.
276    pub watches: Vec<(Address, u64)>,
277    /// Addresses whose creation was seen, with the creation block. Their
278    /// history is complete: no pre-value is ever unknown.
279    pub created: Vec<(Address, u64)>,
280    /// Slot records in the primary index.
281    pub slot_records: u64,
282    /// Slots whose pre-value is proven.
283    pub slots_done: u64,
284    /// Slots whose pre-value is still awaited.
285    pub slots_pending: u64,
286    /// Slots whose pre-value was lost.
287    pub slots_lost: u64,
288    /// Block hashes kept for reorg detection.
289    pub retained_headers: u64,
290    /// Size of the archive file on disk.
291    pub file_bytes: u64,
292}
293
294/// Tunables fixed at [`Archive::open_with`].
295#[derive(Debug, Clone)]
296pub struct ArchiveConfig {
297    /// How many blocks back the node can still serve `eth_getProof`.
298    /// Pending bootstraps older than this are marked lost.
299    pub bootstrap_window: u64,
300    /// Store every intra-block change rather than only the last one.
301    /// Decided at creation; changing it later means a new archive.
302    pub full_detail: bool,
303    /// Apply blocks whose header has no BAL hash. Debug only; such values
304    /// are stored with [`Provenance::Unverified`].
305    pub allow_unverified: bool,
306}
307
308impl Default for ArchiveConfig {
309    fn default() -> Self {
310        Self {
311            bootstrap_window: 120,
312            full_detail: false,
313            allow_unverified: false,
314        }
315    }
316}
317
318/// The store. One file, one process, any number of readers alongside the
319/// syncing writer.
320pub struct Archive {
321    db: Database,
322    path: std::path::PathBuf,
323    config: ArchiveConfig,
324    /// Serialises `watch()`/`unwatch()` against the sync loop's per-block
325    /// watchlist read.
326    watch_gate: Mutex<()>,
327    /// Block the sync loop is about to apply (0 when idle). `watch()` refuses
328    /// starts at or below it, so a watch is never added for a block whose
329    /// watchlist snapshot has already been taken.
330    in_flight: AtomicU64,
331    /// Set while a `sync` pass runs; a second concurrent pass is refused.
332    syncing: AtomicBool,
333}
334
335impl Archive {
336    /// Open or create `path` with default configuration.
337    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
338        Self::open_with(path, ArchiveConfig::default())
339    }
340
341    /// Open or create `path`. Refuses files written with another
342    /// [`SCHEMA_VERSION`].
343    pub fn open_with(path: impl AsRef<Path>, config: ArchiveConfig) -> Result<Self> {
344        let path_buf = path.as_ref().to_path_buf();
345        let db = Database::create(&path_buf)?;
346        let txn = db.begin_write()?;
347        {
348            txn.open_table(SLOTS)?;
349            txn.open_table(BLOCKIDX)?;
350            txn.open_table(WATCH)?;
351            txn.open_table(HASHES)?;
352            txn.open_table(CREATED)?;
353            let boot = txn.open_table(BOOT)?;
354            let mut pending = txn.open_table(PENDING)?;
355            let mut meta = txn.open_table(META)?;
356            let found: Option<Vec<u8>> = meta.get(META_SCHEMA)?.map(|v| v.value().to_vec());
357            match found {
358                Some(v) => {
359                    let found = u32::from_be_bytes(
360                        v.as_slice()
361                            .try_into()
362                            .map_err(|_| ArchiveError::Corrupt("schema_version"))?,
363                    );
364                    if (OLDEST_UPGRADABLE..SCHEMA_VERSION).contains(&found) {
365                        if found < 3 {
366                            migrate_block_index(&txn)?;
367                        }
368                        // Stamp the new version so an older build refuses
369                        // the file cleanly from now on.
370                        meta.insert(META_SCHEMA, SCHEMA_VERSION.to_be_bytes().as_slice())?;
371                    } else if found != SCHEMA_VERSION {
372                        return Err(ArchiveError::SchemaMismatch {
373                            found,
374                            expected: SCHEMA_VERSION,
375                        });
376                    }
377                }
378                None => {
379                    meta.insert(META_SCHEMA, SCHEMA_VERSION.to_be_bytes().as_slice())?;
380                }
381            }
382            // `full_detail` decides the key set on disk; it cannot change later.
383            let stored_detail = meta.get(META_FULL_DETAIL)?.map(|v| v.value().to_vec());
384            match stored_detail {
385                Some(v) => {
386                    let on_disk = v.first().copied().unwrap_or(0) != 0;
387                    if on_disk != config.full_detail {
388                        return Err(ArchiveError::ConfigMismatch {
389                            option: "full_detail",
390                            on_disk: on_disk.to_string(),
391                            requested: config.full_detail.to_string(),
392                        });
393                    }
394                }
395                None => {
396                    meta.insert(META_FULL_DETAIL, [config.full_detail as u8].as_slice())?;
397                }
398            }
399            // Files written before the pending index existed: rebuild it once.
400            if pending.is_empty()? {
401                let mut rebuilt = Vec::new();
402                for item in boot.iter()? {
403                    let (k, v) = item?;
404                    if let Some(BootState::Pending { first_seen }) = decode_boot(v.value()) {
405                        rebuilt.push((k.value().to_vec(), first_seen));
406                    }
407                }
408                for (k, f) in rebuilt {
409                    pending.insert(k.as_slice(), f)?;
410                }
411            }
412        }
413        txn.commit()?;
414        Ok(Self {
415            db,
416            path: path_buf,
417            config,
418            watch_gate: Mutex::new(()),
419            in_flight: AtomicU64::new(0),
420            syncing: AtomicBool::new(false),
421        })
422    }
423
424    /// Claim the sync slot; `false` if another pass is already running.
425    pub(crate) fn begin_sync(&self) -> bool {
426        self.syncing
427            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
428            .is_ok()
429    }
430
431    /// Configuration this archive was opened with.
432    pub fn config(&self) -> &ArchiveConfig {
433        &self.config
434    }
435
436    // ---- watchlist ------------------------------------------------------
437
438    /// Start accumulating `addr` from `from_block` (inclusive). `from_block`
439    /// must be above the current head and above any block the sync loop is
440    /// currently applying: history before now is [`Archive::backfill`], an
441    /// explicit call. An address can be watched once; change
442    /// its start with [`Archive::unwatch`] first (which drops its data).
443    pub fn watch(&self, addr: Address, from_block: u64) -> Result<()> {
444        if from_block == 0 {
445            return Err(ArchiveError::InvalidStart(from_block));
446        }
447        let _gate = self.watch_gate.lock().unwrap_or_else(|p| p.into_inner());
448        if let Some(existing) = self.start_of(addr)? {
449            if existing == from_block {
450                return Ok(());
451            }
452            return Err(ArchiveError::AlreadyWatched {
453                address: addr,
454                from_block: existing,
455            });
456        }
457        let head = self.head()?.map(|(h, _)| h).unwrap_or(0);
458        let floor = head.max(self.in_flight.load(Ordering::SeqCst));
459        if from_block <= floor {
460            return Err(ArchiveError::StartInPast {
461                from_block,
462                head: floor,
463            });
464        }
465        let txn = self.db.begin_write()?;
466        txn.open_table(WATCH)?.insert(addr.as_slice(), from_block)?;
467        txn.commit()?;
468        Ok(())
469    }
470
471    /// Stop watching and delete everything stored for `addr`. Taken under
472    /// the watch gate; a block being applied concurrently re-checks the
473    /// watchlist inside its transaction, so nothing is written for `addr`
474    /// after this returns.
475    pub fn unwatch(&self, addr: Address) -> Result<()> {
476        let _gate = self.watch_gate.lock().unwrap_or_else(|p| p.into_inner());
477        let txn = self.db.begin_write()?;
478        {
479            txn.open_table(WATCH)?.remove(addr.as_slice())?;
480            let mut slots = txn.open_table(SLOTS)?;
481            for k in collect_prefix_keys(&slots, addr.as_slice())? {
482                slots.remove(k.as_slice())?;
483            }
484            let mut boot = txn.open_table(BOOT)?;
485            for k in collect_prefix_keys(&boot, addr.as_slice())? {
486                boot.remove(k.as_slice())?;
487            }
488            let mut pending = txn.open_table(PENDING)?;
489            for k in collect_prefix_keys(&pending, addr.as_slice())? {
490                pending.remove(k.as_slice())?;
491            }
492            let mut idx = txn.open_table(BLOCKIDX)?;
493            for k in collect_prefix_keys(&idx, addr.as_slice())? {
494                idx.remove(k.as_slice())?;
495            }
496            txn.open_table(CREATED)?.remove(addr.as_slice())?;
497            txn.open_table(META)?.remove(anchor_key(addr).as_str())?;
498        }
499        txn.commit()?;
500        Ok(())
501    }
502
503    /// Rewrite the file without free pages. redb frees pages as records are
504    /// overwritten and transactions retire, but never shrinks the file on
505    /// its own; after a long backfill the difference is large. Needs the
506    /// file to itself: call with no [`Archive`] open on it. Returns whether
507    /// anything changed.
508    pub fn compact_file(path: impl AsRef<Path>) -> Result<bool> {
509        let mut db = Database::open(path.as_ref())?;
510        Ok(db.compact()?)
511    }
512
513    /// Block in which `addr` was created, if a verified BAL showed it.
514    pub fn created_at(&self, addr: Address) -> Result<Option<u64>> {
515        let rtx = self.db.begin_read()?;
516        let t = rtx.open_table(CREATED)?;
517        Ok(t.get(addr.as_slice())?.map(|v| v.value()))
518    }
519
520    /// Watched addresses with their start blocks.
521    pub fn watchlist(&self) -> Result<Vec<(Address, u64)>> {
522        let rtx = self.db.begin_read()?;
523        let t = rtx.open_table(WATCH)?;
524        let mut out = Vec::new();
525        for item in t.iter()? {
526            let (k, v) = item?;
527            out.push((Address::from_slice(k.value()), v.value()));
528        }
529        Ok(out)
530    }
531
532    /// Watchlist snapshot for applying `block`, taken under the watch gate
533    /// so that no `watch()` can slip in between the snapshot and the apply.
534    pub(crate) fn watchlist_for(&self, block: u64) -> Result<Vec<(Address, u64)>> {
535        let _gate = self.watch_gate.lock().unwrap_or_else(|p| p.into_inner());
536        self.in_flight.store(block, Ordering::SeqCst);
537        self.watchlist()
538    }
539
540    /// Decide where a sync pass starts, under the watch gate, and publish it
541    /// as the in-flight block *before* the pass awaits anything. Without
542    /// this, a `watch()` with a start below the pass's first block could be
543    /// accepted while the pass is fetching, and its early blocks skipped.
544    /// Returns `None` if nothing is watched.
545    pub(crate) fn claim_start(&self) -> Result<Option<u64>> {
546        let _gate = self.watch_gate.lock().unwrap_or_else(|p| p.into_inner());
547        let earliest = self.watchlist()?.iter().map(|(_, s)| *s).min();
548        let Some(earliest) = earliest else {
549            return Ok(None);
550        };
551        let next = match self.head()? {
552            Some((h, _)) => h + 1,
553            None => earliest,
554        };
555        self.in_flight.store(next, Ordering::SeqCst);
556        Ok(Some(next))
557    }
558
559    /// Release the sync slot and the in-flight marker. Always called when a
560    /// pass ends, successfully or not.
561    pub(crate) fn sync_idle(&self) {
562        self.in_flight.store(0, Ordering::SeqCst);
563        self.syncing.store(false, Ordering::SeqCst);
564    }
565
566    pub(crate) fn start_of(&self, addr: Address) -> Result<Option<u64>> {
567        let rtx = self.db.begin_read()?;
568        let t = rtx.open_table(WATCH)?;
569        Ok(t.get(addr.as_slice())?.map(|v| v.value()))
570    }
571
572    // ---- head -----------------------------------------------------------
573
574    /// Counts and sizes for `status`-style reporting. Scans the bootstrap
575    /// table, so it is proportional to the number of distinct slots seen —
576    /// fine for a command, not for a hot path.
577    pub fn stats(&self) -> Result<ArchiveStats> {
578        let rtx = self.db.begin_read()?;
579        let slot_records = rtx.open_table(SLOTS)?.len()?;
580        let pending = rtx.open_table(PENDING)?.len()?;
581        let boot = rtx.open_table(BOOT)?;
582        let (mut done, mut lost) = (0u64, 0u64);
583        for item in boot.iter()? {
584            let (_, v) = item?;
585            match decode_boot(v.value()) {
586                Some(BootState::Done) => done += 1,
587                Some(BootState::Lost { .. }) => lost += 1,
588                _ => {}
589            }
590        }
591        let retained_headers = rtx.open_table(HASHES)?.len()?;
592        let file_bytes = std::fs::metadata(&self.path).map(|m| m.len()).unwrap_or(0);
593        let mut created = Vec::new();
594        for item in rtx.open_table(CREATED)?.iter()? {
595            let (k, v) = item?;
596            created.push((Address::from_slice(k.value()), v.value()));
597        }
598        Ok(ArchiveStats {
599            head: self.head()?,
600            watches: self.watchlist()?,
601            created,
602            slot_records,
603            slots_done: done,
604            slots_pending: pending,
605            slots_lost: lost,
606            retained_headers,
607            file_bytes,
608        })
609    }
610
611    /// Last applied block and its hash, if any block was applied.
612    pub fn head(&self) -> Result<Option<(u64, B256)>> {
613        let rtx = self.db.begin_read()?;
614        let t = rtx.open_table(META)?;
615        match t.get(META_HEAD)? {
616            None => Ok(None),
617            Some(v) => {
618                let b = v.value();
619                if b.len() != 40 {
620                    return Err(ArchiveError::Corrupt("head"));
621                }
622                let num: [u8; 8] = b[..8]
623                    .try_into()
624                    .map_err(|_| ArchiveError::Corrupt("head"))?;
625                Ok(Some((u64::from_be_bytes(num), B256::from_slice(&b[8..]))))
626            }
627        }
628    }
629
630    /// Stored `(hash, state_root)` of `block`, if retained.
631    pub(crate) fn header_at(&self, block: u64) -> Result<Option<(B256, B256)>> {
632        let rtx = self.db.begin_read()?;
633        let t = rtx.open_table(HASHES)?;
634        match t.get(block)? {
635            None => Ok(None),
636            Some(v) => {
637                let b = v.value();
638                if b.len() != 64 {
639                    return Err(ArchiveError::Corrupt("blockhashes"));
640                }
641                Ok(Some((
642                    B256::from_slice(&b[..32]),
643                    B256::from_slice(&b[32..]),
644                )))
645            }
646        }
647    }
648
649    /// Remember a header fetched outside `apply_block` (e.g. the parent of
650    /// the first watched block) so bootstrap retries can find its root.
651    pub(crate) fn remember_header(&self, block: u64, hash: B256, state_root: B256) -> Result<()> {
652        let txn = self.db.begin_write()?;
653        {
654            let mut hashes = txn.open_table(HASHES)?;
655            if hashes.get(block)?.is_none() {
656                hashes.insert(block, header_bytes(hash, state_root).as_slice())?;
657            }
658        }
659        txn.commit()?;
660        Ok(())
661    }
662}
663
664/// `true` if this account's changes show a contract being created: a code
665/// change to non-empty code that is not an EIP-7702 delegation designator
666/// (`0xef0100 || address`, which an EOA can set and clear while keeping its
667/// storage). Contract creation at an address with non-empty storage is
668/// impossible (EIP-7610), so this implies "no storage before this block".
669pub(crate) fn creation_in(acc: &bal_codec::AccountChanges) -> bool {
670    acc.code_changes
671        .iter()
672        .any(|c| !c.new_code.is_empty() && !c.new_code.starts_with(&[0xef, 0x01, 0x00]))
673}
674
675/// Once an address is known to be created, every slot's pre-value is zero:
676/// mark whatever was pending or lost as done and drop the retry entries.
677pub(crate) fn settle_created(
678    boot: &mut redb::Table<'_, &[u8], &[u8]>,
679    pending: &mut redb::Table<'_, &[u8], u64>,
680    addr: Address,
681) -> Result<()> {
682    for k in collect_prefix_keys(boot, addr.as_slice())? {
683        boot.insert(k.as_slice(), encode_boot(BootState::Done).as_slice())?;
684    }
685    for k in collect_prefix_keys(pending, addr.as_slice())? {
686        pending.remove(k.as_slice())?;
687    }
688    Ok(())
689}
690
691/// v1/v2 -> v3: regroup the per-slot block index into one entry per
692/// (address, block), then drop the old table. Runs inside the open
693/// transaction, so a crash midway leaves the old layout and version intact.
694fn migrate_block_index(txn: &redb::WriteTransaction) -> Result<()> {
695    let mut grouped: std::collections::BTreeMap<(Address, u64), Vec<B256>> =
696        std::collections::BTreeMap::new();
697    {
698        let old = txn.open_table(LEGACY_BLOCKIDX)?;
699        for item in old.iter()? {
700            let (k, _) = item?;
701            let (a, b, s) = parse_legacy_blockidx_key(k.value())
702                .ok_or(ArchiveError::Corrupt("legacy blockidx"))?;
703            grouped.entry((a, b)).or_default().push(s);
704        }
705    }
706    {
707        let mut new = txn.open_table(BLOCKIDX)?;
708        for ((a, b), slots) in &grouped {
709            new.insert(
710                blockidx_key(*a, *b).as_slice(),
711                encode_slots(slots).as_slice(),
712            )?;
713        }
714    }
715    txn.delete_table(LEGACY_BLOCKIDX)?;
716    Ok(())
717}
718
719pub(crate) fn anchor_key(addr: Address) -> String {
720    format!("{META_ANCHOR}{addr}")
721}
722
723fn head_bytes(block: u64, hash: B256) -> [u8; 40] {
724    let mut b = [0u8; 40];
725    b[..8].copy_from_slice(&block.to_be_bytes());
726    b[8..].copy_from_slice(hash.as_slice());
727    b
728}
729
730fn header_bytes(hash: B256, state_root: B256) -> [u8; 64] {
731    let mut b = [0u8; 64];
732    b[..32].copy_from_slice(hash.as_slice());
733    b[32..].copy_from_slice(state_root.as_slice());
734    b
735}
736
737/// `lo..hi`, or `lo..` when the prefix is all `0xFF` and has no successor.
738fn bounds<'a>(lo: &'a [u8], hi: Option<&'a [u8]>) -> impl RangeBounds<&'a [u8]> {
739    (
740        Bound::Included(lo),
741        match hi {
742            Some(h) => Bound::Excluded(h),
743            None => Bound::Unbounded,
744        },
745    )
746}
747
748/// Every key starting with `prefix`, in order.
749pub(crate) fn collect_prefix_keys<V: redb::Value + 'static>(
750    t: &impl ReadableTable<&'static [u8], V>,
751    prefix: &[u8],
752) -> Result<Vec<Vec<u8>>> {
753    let hi = prefix_end(prefix);
754    let mut out = Vec::new();
755    for item in t.range::<&[u8]>(bounds(prefix, hi.as_deref()))? {
756        let (k, _) = item?;
757        out.push(k.value().to_vec());
758    }
759    Ok(out)
760}