#![doc = include_str!("../README.md")]
mod backfill;
mod keys;
mod reads;
mod sync;
mod writes;
pub use backfill::{BackfillOpts, BackfillReport, BackfillStop};
pub use keys::{BootState, Provenance, OLDEST_UPGRADABLE, SCHEMA_VERSION};
pub use sync::{SyncReport, REORG_HORIZON_FALLBACK, TOUCHED_CAP};
use alloy_primitives::{Address, B256};
use bal_codec::BlockAccessIndex;
use keys::*;
use redb::{Database, ReadableTable, ReadableTableMetadata, TableDefinition};
use std::ops::{Bound, Range, RangeBounds};
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Mutex;
pub(crate) const SLOTS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("slots");
pub(crate) const BLOCKIDX: TableDefinition<&[u8], &[u8]> = TableDefinition::new("blockslots");
const LEGACY_BLOCKIDX: TableDefinition<&[u8], ()> = TableDefinition::new("blockidx");
pub(crate) const META: TableDefinition<&str, &[u8]> = TableDefinition::new("meta");
pub(crate) const WATCH: TableDefinition<&[u8], u64> = TableDefinition::new("watch");
const HASHES: TableDefinition<u64, &[u8]> = TableDefinition::new("blockhashes");
pub(crate) const BOOT: TableDefinition<&[u8], &[u8]> = TableDefinition::new("bootstrap");
pub(crate) const PENDING: TableDefinition<&[u8], u64> = TableDefinition::new("pending");
pub(crate) const CREATED: TableDefinition<&[u8], u64> = TableDefinition::new("created");
const META_SCHEMA: &str = "schema_version";
const META_ANCHOR: &str = "anchor:";
const META_HEAD: &str = "head";
const META_FULL_DETAIL: &str = "full_detail";
#[derive(Debug, thiserror::Error)]
pub enum ArchiveError {
#[error("db: {0}")]
Db(Box<redb::Error>),
#[error("schema version {found} on disk, this build speaks {expected}")]
SchemaMismatch {
found: u32,
expected: u32,
},
#[error("from_block must be >= 1 (got {0})")]
InvalidStart(u64),
#[error("watch from block {from_block} is in the past (head {head}); use backfill for history before the head")]
StartInPast {
from_block: u64,
head: u64,
},
#[error("address {0} is not watched")]
NotWatched(Address),
#[error(
"address {address} is already watched from block {from_block}; unwatch first to change it"
)]
AlreadyWatched {
address: Address,
from_block: u64,
},
#[error("a sync pass is already running on this archive")]
SyncInProgress,
#[error("archive was created with {option} = {on_disk}, opened with {requested}")]
ConfigMismatch {
option: &'static str,
on_disk: String,
requested: String,
},
#[error("archive head {head} is below watch start {start}; nothing to bootstrap yet")]
HeadBelowStart {
head: u64,
start: u64,
},
#[error("source: {0}")]
Source(#[from] bal_source::SourceError),
#[error("block {block} failed BAL verification: {err}")]
Verification {
block: u64,
err: bal_codec::CodecError,
},
#[error(
"block {0} header carries no block_access_list_hash; refusing to apply unverifiable data"
)]
NoBalHash(u64),
#[error("reorg deeper than retained block hashes (fork below block {0})")]
ReorgBeyondHorizon(u64),
#[error(
"source is inconsistent around block {0}: parent hash does not match its own block {0}-1"
)]
InconsistentSource(u64),
#[error("block {0} on the node is not the block the archive holds; run sync first")]
StartReplaced(u64),
#[error("proof: {0}")]
Proof(#[from] bal_source::ProofError),
#[error("corrupt record: {0}")]
Corrupt(&'static str),
}
macro_rules! from_redb {
($($t:ty),*) => {$(
impl From<$t> for ArchiveError {
fn from(e: $t) -> Self { ArchiveError::Db(Box::new(e.into())) }
}
)*};
}
from_redb!(
redb::Error,
redb::DatabaseError,
redb::TransactionError,
redb::TableError,
redb::StorageError,
redb::CommitError,
redb::CompactionError
);
pub type Result<T> = std::result::Result<T, ArchiveError>;
pub(crate) type FreshSlots = Vec<(Address, u64, Vec<B256>)>;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum NotAvailable {
#[error("address {0} is not watched")]
NotWatched(Address),
#[error("block {requested} is before watch start {start}")]
BeforeStart {
requested: u64,
start: u64,
},
#[error("block {requested} is after archive head {head}")]
AfterHead {
requested: u64,
head: u64,
},
#[error("archive has not synced any block yet")]
NotSynced,
#[error("invalid block range {start}..{end}")]
InvalidRange {
start: u64,
end: u64,
},
#[error("no change to this slot is recorded since the watch start; backfill to the contract's creation, or prove it at the head")]
NeverRecorded,
#[error("no record before block {first_seen} (the slot's earliest recorded change); backfill further back")]
UnknownBefore {
first_seen: u64,
},
#[error("internal: {0}")]
Internal(String),
}
impl From<ArchiveError> for NotAvailable {
fn from(e: ArchiveError) -> Self {
NotAvailable::Internal(e.to_string())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StorageValue {
pub value: B256,
pub provenance: Provenance,
pub set_at: u64,
pub index: BlockAccessIndex,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HistoryEntry {
pub block: u64,
pub index: BlockAccessIndex,
pub value: B256,
pub provenance: Provenance,
}
#[derive(Debug, Clone)]
pub struct ArchiveStats {
pub head: Option<(u64, B256)>,
pub watches: Vec<(Address, u64)>,
pub created: Vec<(Address, u64)>,
pub slot_records: u64,
pub slots_done: u64,
pub slots_pending: u64,
pub slots_lost: u64,
pub retained_headers: u64,
pub file_bytes: u64,
}
#[derive(Debug, Clone)]
pub struct ArchiveConfig {
pub bootstrap_window: u64,
pub full_detail: bool,
pub allow_unverified: bool,
}
impl Default for ArchiveConfig {
fn default() -> Self {
Self {
bootstrap_window: 120,
full_detail: false,
allow_unverified: false,
}
}
}
pub struct Archive {
db: Database,
path: std::path::PathBuf,
config: ArchiveConfig,
watch_gate: Mutex<()>,
in_flight: AtomicU64,
syncing: AtomicBool,
}
impl Archive {
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
Self::open_with(path, ArchiveConfig::default())
}
pub fn open_with(path: impl AsRef<Path>, config: ArchiveConfig) -> Result<Self> {
let path_buf = path.as_ref().to_path_buf();
let db = Database::create(&path_buf)?;
let txn = db.begin_write()?;
{
txn.open_table(SLOTS)?;
txn.open_table(BLOCKIDX)?;
txn.open_table(WATCH)?;
txn.open_table(HASHES)?;
txn.open_table(CREATED)?;
let boot = txn.open_table(BOOT)?;
let mut pending = txn.open_table(PENDING)?;
let mut meta = txn.open_table(META)?;
let found: Option<Vec<u8>> = meta.get(META_SCHEMA)?.map(|v| v.value().to_vec());
match found {
Some(v) => {
let found = u32::from_be_bytes(
v.as_slice()
.try_into()
.map_err(|_| ArchiveError::Corrupt("schema_version"))?,
);
if (OLDEST_UPGRADABLE..SCHEMA_VERSION).contains(&found) {
if found < 3 {
migrate_block_index(&txn)?;
}
meta.insert(META_SCHEMA, SCHEMA_VERSION.to_be_bytes().as_slice())?;
} else if found != SCHEMA_VERSION {
return Err(ArchiveError::SchemaMismatch {
found,
expected: SCHEMA_VERSION,
});
}
}
None => {
meta.insert(META_SCHEMA, SCHEMA_VERSION.to_be_bytes().as_slice())?;
}
}
let stored_detail = meta.get(META_FULL_DETAIL)?.map(|v| v.value().to_vec());
match stored_detail {
Some(v) => {
let on_disk = v.first().copied().unwrap_or(0) != 0;
if on_disk != config.full_detail {
return Err(ArchiveError::ConfigMismatch {
option: "full_detail",
on_disk: on_disk.to_string(),
requested: config.full_detail.to_string(),
});
}
}
None => {
meta.insert(META_FULL_DETAIL, [config.full_detail as u8].as_slice())?;
}
}
if pending.is_empty()? {
let mut rebuilt = Vec::new();
for item in boot.iter()? {
let (k, v) = item?;
if let Some(BootState::Pending { first_seen }) = decode_boot(v.value()) {
rebuilt.push((k.value().to_vec(), first_seen));
}
}
for (k, f) in rebuilt {
pending.insert(k.as_slice(), f)?;
}
}
}
txn.commit()?;
Ok(Self {
db,
path: path_buf,
config,
watch_gate: Mutex::new(()),
in_flight: AtomicU64::new(0),
syncing: AtomicBool::new(false),
})
}
pub(crate) fn begin_sync(&self) -> bool {
self.syncing
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
}
pub fn config(&self) -> &ArchiveConfig {
&self.config
}
pub fn watch(&self, addr: Address, from_block: u64) -> Result<()> {
if from_block == 0 {
return Err(ArchiveError::InvalidStart(from_block));
}
let _gate = self.watch_gate.lock().unwrap_or_else(|p| p.into_inner());
if let Some(existing) = self.start_of(addr)? {
if existing == from_block {
return Ok(());
}
return Err(ArchiveError::AlreadyWatched {
address: addr,
from_block: existing,
});
}
let head = self.head()?.map(|(h, _)| h).unwrap_or(0);
let floor = head.max(self.in_flight.load(Ordering::SeqCst));
if from_block <= floor {
return Err(ArchiveError::StartInPast {
from_block,
head: floor,
});
}
let txn = self.db.begin_write()?;
txn.open_table(WATCH)?.insert(addr.as_slice(), from_block)?;
txn.commit()?;
Ok(())
}
pub fn unwatch(&self, addr: Address) -> Result<()> {
let _gate = self.watch_gate.lock().unwrap_or_else(|p| p.into_inner());
let txn = self.db.begin_write()?;
{
txn.open_table(WATCH)?.remove(addr.as_slice())?;
let mut slots = txn.open_table(SLOTS)?;
for k in collect_prefix_keys(&slots, addr.as_slice())? {
slots.remove(k.as_slice())?;
}
let mut boot = txn.open_table(BOOT)?;
for k in collect_prefix_keys(&boot, addr.as_slice())? {
boot.remove(k.as_slice())?;
}
let mut pending = txn.open_table(PENDING)?;
for k in collect_prefix_keys(&pending, addr.as_slice())? {
pending.remove(k.as_slice())?;
}
let mut idx = txn.open_table(BLOCKIDX)?;
for k in collect_prefix_keys(&idx, addr.as_slice())? {
idx.remove(k.as_slice())?;
}
txn.open_table(CREATED)?.remove(addr.as_slice())?;
txn.open_table(META)?.remove(anchor_key(addr).as_str())?;
}
txn.commit()?;
Ok(())
}
pub fn compact_file(path: impl AsRef<Path>) -> Result<bool> {
let mut db = Database::open(path.as_ref())?;
Ok(db.compact()?)
}
pub fn created_at(&self, addr: Address) -> Result<Option<u64>> {
let rtx = self.db.begin_read()?;
let t = rtx.open_table(CREATED)?;
Ok(t.get(addr.as_slice())?.map(|v| v.value()))
}
pub fn watchlist(&self) -> Result<Vec<(Address, u64)>> {
let rtx = self.db.begin_read()?;
let t = rtx.open_table(WATCH)?;
let mut out = Vec::new();
for item in t.iter()? {
let (k, v) = item?;
out.push((Address::from_slice(k.value()), v.value()));
}
Ok(out)
}
pub(crate) fn watchlist_for(&self, block: u64) -> Result<Vec<(Address, u64)>> {
let _gate = self.watch_gate.lock().unwrap_or_else(|p| p.into_inner());
self.in_flight.store(block, Ordering::SeqCst);
self.watchlist()
}
pub(crate) fn claim_start(&self) -> Result<Option<u64>> {
let _gate = self.watch_gate.lock().unwrap_or_else(|p| p.into_inner());
let earliest = self.watchlist()?.iter().map(|(_, s)| *s).min();
let Some(earliest) = earliest else {
return Ok(None);
};
let next = match self.head()? {
Some((h, _)) => h + 1,
None => earliest,
};
self.in_flight.store(next, Ordering::SeqCst);
Ok(Some(next))
}
pub(crate) fn sync_idle(&self) {
self.in_flight.store(0, Ordering::SeqCst);
self.syncing.store(false, Ordering::SeqCst);
}
pub(crate) fn start_of(&self, addr: Address) -> Result<Option<u64>> {
let rtx = self.db.begin_read()?;
let t = rtx.open_table(WATCH)?;
Ok(t.get(addr.as_slice())?.map(|v| v.value()))
}
pub fn stats(&self) -> Result<ArchiveStats> {
let rtx = self.db.begin_read()?;
let slot_records = rtx.open_table(SLOTS)?.len()?;
let pending = rtx.open_table(PENDING)?.len()?;
let boot = rtx.open_table(BOOT)?;
let (mut done, mut lost) = (0u64, 0u64);
for item in boot.iter()? {
let (_, v) = item?;
match decode_boot(v.value()) {
Some(BootState::Done) => done += 1,
Some(BootState::Lost { .. }) => lost += 1,
_ => {}
}
}
let retained_headers = rtx.open_table(HASHES)?.len()?;
let file_bytes = std::fs::metadata(&self.path).map(|m| m.len()).unwrap_or(0);
let mut created = Vec::new();
for item in rtx.open_table(CREATED)?.iter()? {
let (k, v) = item?;
created.push((Address::from_slice(k.value()), v.value()));
}
Ok(ArchiveStats {
head: self.head()?,
watches: self.watchlist()?,
created,
slot_records,
slots_done: done,
slots_pending: pending,
slots_lost: lost,
retained_headers,
file_bytes,
})
}
pub fn head(&self) -> Result<Option<(u64, B256)>> {
let rtx = self.db.begin_read()?;
let t = rtx.open_table(META)?;
match t.get(META_HEAD)? {
None => Ok(None),
Some(v) => {
let b = v.value();
if b.len() != 40 {
return Err(ArchiveError::Corrupt("head"));
}
let num: [u8; 8] = b[..8]
.try_into()
.map_err(|_| ArchiveError::Corrupt("head"))?;
Ok(Some((u64::from_be_bytes(num), B256::from_slice(&b[8..]))))
}
}
}
pub(crate) fn header_at(&self, block: u64) -> Result<Option<(B256, B256)>> {
let rtx = self.db.begin_read()?;
let t = rtx.open_table(HASHES)?;
match t.get(block)? {
None => Ok(None),
Some(v) => {
let b = v.value();
if b.len() != 64 {
return Err(ArchiveError::Corrupt("blockhashes"));
}
Ok(Some((
B256::from_slice(&b[..32]),
B256::from_slice(&b[32..]),
)))
}
}
}
pub(crate) fn remember_header(&self, block: u64, hash: B256, state_root: B256) -> Result<()> {
let txn = self.db.begin_write()?;
{
let mut hashes = txn.open_table(HASHES)?;
if hashes.get(block)?.is_none() {
hashes.insert(block, header_bytes(hash, state_root).as_slice())?;
}
}
txn.commit()?;
Ok(())
}
}
pub(crate) fn creation_in(acc: &bal_codec::AccountChanges) -> bool {
acc.code_changes
.iter()
.any(|c| !c.new_code.is_empty() && !c.new_code.starts_with(&[0xef, 0x01, 0x00]))
}
pub(crate) fn settle_created(
boot: &mut redb::Table<'_, &[u8], &[u8]>,
pending: &mut redb::Table<'_, &[u8], u64>,
addr: Address,
) -> Result<()> {
for k in collect_prefix_keys(boot, addr.as_slice())? {
boot.insert(k.as_slice(), encode_boot(BootState::Done).as_slice())?;
}
for k in collect_prefix_keys(pending, addr.as_slice())? {
pending.remove(k.as_slice())?;
}
Ok(())
}
fn migrate_block_index(txn: &redb::WriteTransaction) -> Result<()> {
let mut grouped: std::collections::BTreeMap<(Address, u64), Vec<B256>> =
std::collections::BTreeMap::new();
{
let old = txn.open_table(LEGACY_BLOCKIDX)?;
for item in old.iter()? {
let (k, _) = item?;
let (a, b, s) = parse_legacy_blockidx_key(k.value())
.ok_or(ArchiveError::Corrupt("legacy blockidx"))?;
grouped.entry((a, b)).or_default().push(s);
}
}
{
let mut new = txn.open_table(BLOCKIDX)?;
for ((a, b), slots) in &grouped {
new.insert(
blockidx_key(*a, *b).as_slice(),
encode_slots(slots).as_slice(),
)?;
}
}
txn.delete_table(LEGACY_BLOCKIDX)?;
Ok(())
}
pub(crate) fn anchor_key(addr: Address) -> String {
format!("{META_ANCHOR}{addr}")
}
fn head_bytes(block: u64, hash: B256) -> [u8; 40] {
let mut b = [0u8; 40];
b[..8].copy_from_slice(&block.to_be_bytes());
b[8..].copy_from_slice(hash.as_slice());
b
}
fn header_bytes(hash: B256, state_root: B256) -> [u8; 64] {
let mut b = [0u8; 64];
b[..32].copy_from_slice(hash.as_slice());
b[32..].copy_from_slice(state_root.as_slice());
b
}
fn bounds<'a>(lo: &'a [u8], hi: Option<&'a [u8]>) -> impl RangeBounds<&'a [u8]> {
(
Bound::Included(lo),
match hi {
Some(h) => Bound::Excluded(h),
None => Bound::Unbounded,
},
)
}
pub(crate) fn collect_prefix_keys<V: redb::Value + 'static>(
t: &impl ReadableTable<&'static [u8], V>,
prefix: &[u8],
) -> Result<Vec<Vec<u8>>> {
let hi = prefix_end(prefix);
let mut out = Vec::new();
for item in t.range::<&[u8]>(bounds(prefix, hi.as_deref()))? {
let (k, _) = item?;
out.push(k.value().to_vec());
}
Ok(out)
}