Skip to main content

bal_archive/
sync.rs

1//! The sync loop: fetch → verify → detect reorg → apply → early bootstrap.
2//! Memory: one block's BAL at a time.
3
4use crate::backfill::FETCH_AHEAD;
5use crate::{Archive, ArchiveError, Result};
6use alloy_primitives::{Address, B256};
7use bal_source::{
8    check_requested, verify_account_proof, BalSource, SourceError, SourcedBlock, StateSource,
9};
10use futures::future::join_all;
11use std::collections::{BTreeMap, VecDeque};
12use tracing::{debug, info, warn};
13
14/// Block hashes retained behind the head when the source has no
15/// `finalized` tag, and the deepest reorg `find_fork` will walk. Deeper
16/// reorgs are refused as [`ArchiveError::ReorgBeyondHorizon`] rather than
17/// guessed or walked one RPC call at a time forever.
18pub const REORG_HORIZON_FALLBACK: u64 = 4096;
19
20/// Slots per `eth_getProof` call. Nodes refuse very large requests; a block
21/// touching thousands of fresh slots must not turn all of them into
22/// `Pending` because one request was too big.
23const PROOF_CHUNK: usize = 256;
24
25/// What one [`Archive::sync`] pass did.
26#[derive(Debug, Default, Clone)]
27pub struct SyncReport {
28    /// First block this pass tried to apply, if any.
29    pub from: Option<u64>,
30    /// Last block applied, if any.
31    pub to: Option<u64>,
32    /// Blocks applied in this pass.
33    pub blocks_applied: u64,
34    /// Fork point, if a reorg was rolled back.
35    pub reorged_to: Option<u64>,
36    /// Slot records written (one per changed slot, or per change with `full_detail`).
37    pub slots_written: usize,
38    /// Pre-values proven and stored in this pass.
39    pub bootstrapped: usize,
40    /// Slots whose pre-value is still awaiting a proof.
41    pub bootstrap_pending: usize,
42    /// Slots whose pre-value became unobtainable in this pass.
43    pub bootstrap_lost: usize,
44    /// Blocks applied without a BAL hash (only with `allow_unverified`).
45    pub unverified_blocks: u64,
46    /// The source's head when the pass started; `to == source_head` means
47    /// the archive caught up.
48    pub source_head: Option<u64>,
49    /// Every account that appeared in an applied block's BAL, deduplicated
50    /// and capped at [`TOUCHED_CAP`]. Useful as candidate mapping keys when
51    /// naming what changed (senders and recipients are in the BAL).
52    pub touched: Vec<Address>,
53}
54
55/// Bound on [`SyncReport::touched`]; beyond it the list stops growing.
56pub const TOUCHED_CAP: usize = 4096;
57
58/// Releases the sync slot when the pass ends — by return, error, or the
59/// future being dropped.
60struct SyncGuard<'a>(&'a Archive);
61
62impl Drop for SyncGuard<'_> {
63    fn drop(&mut self) {
64        self.0.sync_idle();
65    }
66}
67
68impl Archive {
69    /// Sync from the archive head (or the earliest watch start) to the
70    /// source head. `state` enables early bootstrap; without it, first-seen
71    /// slots are left `Pending` and will be picked up by a later sync that
72    /// has a state source — if the window has not passed by then.
73    ///
74    /// A block the source reports as missing near its head ends the pass
75    /// quietly (pooled gateways lag); the next pass picks it up.
76    pub async fn sync<S: BalSource + ?Sized>(
77        &self,
78        source: &S,
79        state: Option<&dyn StateSource>,
80    ) -> Result<SyncReport> {
81        self.sync_step(source, state, None).await
82    }
83
84    /// [`Archive::sync`] that applies at most `max_blocks` and returns, so a
85    /// caller can show progress and keep reading between steps. The pass is
86    /// complete when `blocks_applied` is 0 or `to == source_head`.
87    pub async fn sync_step<S: BalSource + ?Sized>(
88        &self,
89        source: &S,
90        state: Option<&dyn StateSource>,
91        max_blocks: Option<u64>,
92    ) -> Result<SyncReport> {
93        if !self.begin_sync() {
94            return Err(ArchiveError::SyncInProgress);
95        }
96        let _guard = SyncGuard(self);
97        self.sync_inner(source, state, max_blocks).await
98    }
99
100    async fn sync_inner<S: BalSource + ?Sized>(
101        &self,
102        source: &S,
103        state: Option<&dyn StateSource>,
104        max_blocks: Option<u64>,
105    ) -> Result<SyncReport> {
106        let mut report = SyncReport::default();
107        // Claim the start block before the first await so that no `watch()`
108        // below it can be accepted while we are fetching.
109        let Some(mut next) = self.claim_start()? else {
110            return Ok(report);
111        };
112        let src_head = source.head().await?;
113        report.source_head = Some(src_head);
114        // Reorg horizon: the node's `finalized` tag, clamped to its head
115        // (a node cannot prune our head by claiming a finalized block above
116        // it) and never further back than the fallback horizon (so the
117        // header table cannot grow without bound if `finalized` stalls).
118        let horizon_floor = src_head.saturating_sub(REORG_HORIZON_FALLBACK);
119        let finalized = match source.finalized().await {
120            Ok(f) => f.min(src_head).max(horizon_floor),
121            Err(e) => {
122                debug!(%e, "no finalized tag; using fixed reorg horizon");
123                horizon_floor
124            }
125        };
126
127        if let Some((h, hash)) = self.head()? {
128            // Head still canonical? Header only — the BAL is not needed.
129            let cur = match source.header(h).await {
130                Ok(hdr) => hdr,
131                Err(SourceError::BlockNotFound(_)) => {
132                    debug!(head = h, "head not served by upstream yet; skipping pass");
133                    return Ok(report);
134                }
135                Err(e) => return Err(e.into()),
136            };
137            if cur.hash != hash {
138                let fork = self.find_fork(source, h).await?;
139                warn!(head = h, fork, "reorg detected at start of sync");
140                self.rollback_to(fork)?;
141                report.reorged_to = Some(fork);
142                next = fork + 1;
143            }
144        }
145        report.from = Some(next);
146
147        // Guards against a source that keeps contradicting itself about the
148        // same parent link (pooled upstreams on different forks).
149        let mut last_fork: Option<u64> = None;
150        let mut touched: std::collections::BTreeSet<Address> = std::collections::BTreeSet::new();
151
152        // Blocks are fetched FETCH_AHEAD at a time and applied in order; a
153        // reorg discards what was prefetched past the fork.
154        let mut queue: VecDeque<(u64, bal_source::Result<SourcedBlock>)> = VecDeque::new();
155        while next <= src_head {
156            if max_blocks.is_some_and(|m| report.blocks_applied >= m) {
157                break;
158            }
159            if queue.front().map(|(n, _)| *n) != Some(next) {
160                queue.clear();
161                let mut n = FETCH_AHEAD.min(src_head - next + 1);
162                if let Some(m) = max_blocks {
163                    n = n.min(m.saturating_sub(report.blocks_applied)).max(1);
164                }
165                let numbers: Vec<u64> = (0..n).map(|i| next + i).collect();
166                let fetched = join_all(numbers.iter().map(|&b| source.block(b))).await;
167                queue.extend(numbers.into_iter().zip(fetched));
168            }
169            let Some((_, res)) = queue.pop_front() else {
170                break;
171            };
172            let blk = match res {
173                Ok(b) => b,
174                Err(SourceError::BlockNotFound(n)) if n >= src_head.saturating_sub(2) => {
175                    debug!(block = n, "not yet available upstream; stopping this pass");
176                    break;
177                }
178                Err(e) => return Err(e.into()),
179            };
180            let header = blk.header.clone();
181            if header.number != next {
182                return Err(ArchiveError::Source(SourceError::Malformed(format!(
183                    "asked for block {next}, source answered with block {}",
184                    header.number
185                ))));
186            }
187
188            // Parent linkage against what we stored.
189            if let Some((stored_hash, _)) = self.header_at(next - 1)? {
190                if stored_hash != header.parent_hash {
191                    let fork = self.find_fork(source, next - 1).await?;
192                    if last_fork == Some(fork) {
193                        return Err(ArchiveError::InconsistentSource(next));
194                    }
195                    last_fork = Some(fork);
196                    warn!(block = next, fork, "reorg detected mid-sync");
197                    self.rollback_to(fork)?;
198                    report.reorged_to = Some(fork);
199                    next = fork + 1;
200                    continue;
201                }
202            }
203
204            let verified = match header.block_access_list_hash {
205                Some(expected) => {
206                    blk.bal
207                        .verify(expected)
208                        .map_err(|err| ArchiveError::Verification { block: next, err })?;
209                    true
210                }
211                None if self.config.allow_unverified => {
212                    report.unverified_blocks += 1;
213                    warn!(
214                        block = next,
215                        "applying block without BAL hash (allow_unverified)"
216                    );
217                    false
218                }
219                None => return Err(ArchiveError::NoBalHash(next)),
220            };
221
222            // Snapshot the watchlist for this block under the watch gate:
223            // a `watch()` racing with us either lands in this snapshot or is
224            // refused for this block.
225            let watches = self.watchlist_for(next)?;
226            let prune_below =
227                Some(finalized.min(src_head.saturating_sub(self.config.bootstrap_window + 1)));
228            if touched.len() < TOUCHED_CAP {
229                touched.extend(blk.bal.accounts.iter().map(|a| a.address));
230            }
231            let (fresh, written) =
232                self.apply_block(&header, &blk.bal, &watches, verified, prune_below)?;
233            report.slots_written += written;
234            report.blocks_applied += 1;
235            report.to = Some(next);
236            debug!(block = next, written, fresh = fresh.len(), "applied");
237
238            // Early bootstrap: prove pre-values at `next - 1` while the node
239            // still has that state. Failure leaves the slot Pending.
240            if !fresh.is_empty() {
241                let prev = match state {
242                    None => None,
243                    Some(_) => self.header_of(source, next - 1).await,
244                };
245                for (addr, start, slots) in &fresh {
246                    match (state, prev) {
247                        (Some(st), Some((hash, root))) => {
248                            match self
249                                .bootstrap_at(st, root, hash, *addr, *start, slots, next - 1)
250                                .await
251                            {
252                                Ok(n) => {
253                                    report.bootstrapped += n;
254                                    report.bootstrap_pending += slots.len() - n;
255                                }
256                                Err(e) => {
257                                    warn!(%addr, block = next, %e, "early bootstrap failed; left pending");
258                                    report.bootstrap_pending += slots.len();
259                                }
260                            }
261                        }
262                        _ => report.bootstrap_pending += slots.len(),
263                    }
264                }
265            }
266
267            next += 1;
268        }
269
270        // Retry pending bootstraps; expire those the window has passed.
271        if let Some(st) = state {
272            let (ok, pending, lost) = self.retry_pending(source, st, src_head).await?;
273            report.bootstrapped += ok;
274            report.bootstrap_pending = pending;
275            report.bootstrap_lost = lost;
276        }
277
278        report.touched = touched.into_iter().take(TOUCHED_CAP).collect();
279        info!(?report, "sync done");
280        Ok(report)
281    }
282
283    /// `(hash, state_root)` of `block`: from the stored headers, or fetched
284    /// from the source and remembered. `None` if neither works.
285    async fn header_of<S: BalSource + ?Sized>(
286        &self,
287        source: &S,
288        block: u64,
289    ) -> Option<(B256, B256)> {
290        match self.header_at(block) {
291            Ok(Some(h)) => return Some(h),
292            Ok(None) => {}
293            Err(e) => {
294                warn!(block, %e, "cannot read stored header");
295                return None;
296            }
297        }
298        match source.header(block).await {
299            Ok(h) => {
300                if let Err(e) = self.remember_header(block, h.hash, h.state_root) {
301                    warn!(block, %e, "cannot remember header");
302                }
303                Some((h.hash, h.state_root))
304            }
305            Err(e) => {
306                warn!(block, %e, "cannot fetch header for proof root");
307                None
308            }
309        }
310    }
311
312    /// Walk back from `from` until a stored hash matches the source, at most
313    /// [`REORG_HORIZON_FALLBACK`] blocks.
314    async fn find_fork<S: BalSource + ?Sized>(&self, source: &S, from: u64) -> Result<u64> {
315        let floor = from.saturating_sub(REORG_HORIZON_FALLBACK);
316        let mut b = from;
317        loop {
318            let Some((stored, _)) = self.header_at(b)? else {
319                return Err(ArchiveError::ReorgBeyondHorizon(b));
320            };
321            let live = source.header(b).await?.hash;
322            if live == stored {
323                return Ok(b);
324            }
325            if b == 0 || b <= floor {
326                return Err(ArchiveError::ReorgBeyondHorizon(b));
327            }
328            b -= 1;
329        }
330    }
331
332    /// Fetch and verify proofs for exactly `slots` at `block` (in chunks),
333    /// store the ones that are genuine pre-values. Returns how many were
334    /// stored.
335    #[allow(clippy::too_many_arguments)]
336    async fn bootstrap_at(
337        &self,
338        state: &dyn StateSource,
339        state_root: B256,
340        block_hash: B256,
341        addr: Address,
342        start: u64,
343        slots: &[B256],
344        block: u64,
345    ) -> Result<usize> {
346        let mut stored = 0;
347        for chunk in slots.chunks(PROOF_CHUNK) {
348            let proof = state.proof(addr, chunk, block).await?;
349            check_requested(chunk, &proof)?;
350            let values = verify_account_proof(state_root, &proof)?;
351            let values: Vec<(B256, B256)> = values
352                .into_iter()
353                .map(|(k, v)| (k, B256::from(v.to_be_bytes::<32>())))
354                .collect();
355            stored += self.put_bootstrap(addr, start, block, block_hash, &values)?;
356        }
357        Ok(stored)
358    }
359
360    /// Returns `(proven, still pending, newly lost)`.
361    async fn retry_pending<S: BalSource + ?Sized>(
362        &self,
363        source: &S,
364        state: &dyn StateSource,
365        src_head: u64,
366    ) -> Result<(usize, usize, usize)> {
367        let pending = self.pending_bootstraps()?;
368        if pending.is_empty() {
369            return Ok((0, 0, 0));
370        }
371        let watches = self.watchlist()?;
372        let start_of = |a: Address| watches.iter().find(|(x, _)| *x == a).map(|(_, s)| *s);
373        let (mut ok, mut still, mut lost) = (0, 0, 0);
374        // Group by (addr, first_seen) → one proof call each.
375        let mut groups: BTreeMap<(Address, u64), Vec<B256>> = BTreeMap::new();
376        for (addr, slot, first_seen) in pending {
377            groups.entry((addr, first_seen)).or_default().push(slot);
378        }
379        for ((addr, first_seen), slots) in groups {
380            let Some(start) = start_of(addr) else {
381                continue;
382            };
383            // `first_seen` is written by `apply_block` for blocks >= start >= 1;
384            // a zero can only come from a tampered file.
385            let Some(at) = first_seen.checked_sub(1) else {
386                return Err(ArchiveError::Corrupt("pending first_seen"));
387            };
388            if src_head.saturating_sub(at) > self.config.bootstrap_window {
389                self.mark_lost(addr, &slots, first_seen)?;
390                lost += slots.len();
391                continue;
392            }
393            let Some((hash, root)) = self.header_of(source, at).await else {
394                still += slots.len();
395                continue;
396            };
397            match self
398                .bootstrap_at(state, root, hash, addr, start, &slots, at)
399                .await
400            {
401                Ok(n) => {
402                    ok += n;
403                    still += slots.len() - n;
404                }
405                Err(e) => {
406                    debug!(%addr, first_seen, %e, "pending bootstrap retry failed");
407                    still += slots.len();
408                }
409            }
410        }
411        Ok((ok, still, lost))
412    }
413
414    /// Lazy bootstrap for a slot that never changed since watch start: prove
415    /// its value at the archive head (which equals its value at `start`, by
416    /// BAL completeness) and store it as the pre-value.
417    ///
418    /// The head is read *before* the slot's state is checked, and the write
419    /// is skipped if a change at or before that head has been recorded, if
420    /// the watch changed, or if the head block was replaced in the meantime —
421    /// a sync running concurrently cannot turn a post-value into a stored
422    /// pre-value.
423    pub async fn bootstrap_slot(
424        &self,
425        state: &dyn StateSource,
426        addr: Address,
427        slot: B256,
428    ) -> Result<()> {
429        let start = self.start_of(addr)?.ok_or(ArchiveError::NotWatched(addr))?;
430        let (head, _) = self
431            .head()?
432            .ok_or(ArchiveError::HeadBelowStart { head: 0, start })?;
433        if head < start {
434            return Err(ArchiveError::HeadBelowStart { head, start });
435        }
436        if self.boot_state(addr, slot)?.is_some() {
437            return Ok(()); // seen or proven already; nothing to do
438        }
439        let (hash, root) = self
440            .header_at(head)?
441            .ok_or(ArchiveError::ReorgBeyondHorizon(head))?;
442        self.bootstrap_at(state, root, hash, addr, start, &[slot], head)
443            .await?;
444        Ok(())
445    }
446}