Skip to main content

reth_snap_sync/storage/
store.rs

1//! Persists contract storage response by response, ahead of the account range committing to it.
2//!
3//! Progress is tied to the attempt and the range, and survives pivot moves: catch-up carries the
4//! persisted slots forward with block access lists, and downloads resume at the new root.
5
6use crate::{common::SnapRecord, SnapAccountStore, SnapAttemptStore, SnapSyncError, SnapWrite};
7use alloy_primitives::{B256, U256};
8use reth_db_api::{
9    cursor::DbDupCursorRO,
10    tables,
11    transaction::{DbTx, DbTxMut},
12};
13use reth_storage_api::{DBProvider, MetadataProvider, MetadataWriter, SnapAttemptId, StateWriter};
14use reth_trie_common::{root::storage_root, HashedPostState, HashedStorage, EMPTY_ROOT_HASH};
15use serde::{Deserialize, Serialize};
16
17/// Persistence for contract storage downloaded ahead of its account range.
18///
19/// Blanket-implemented over the node's writers, so slots and progress join the caller's
20/// transaction and commit together or not at all.
21pub trait SnapStorageStore {
22    /// Returns how far the storage of the range requested from `origin` is persisted.
23    ///
24    /// A contract part way through when the pivot moved resumes with its root unproved.
25    fn storage_progress(
26        &self,
27        write: SnapWrite,
28        origin: B256,
29    ) -> Result<StorageProgress, SnapSyncError>;
30
31    /// Persists `chunk` for the range requested from `origin`, where the coverage must continue.
32    ///
33    /// A contract starts at the zero slot, dropping whatever an earlier attempt left for it, and
34    /// otherwise continues only where its last chunk ended.
35    fn commit_storage_chunk(
36        &self,
37        write: SnapWrite,
38        origin: B256,
39        chunk: StorageChunk,
40    ) -> Result<StorageProgress, SnapSyncError>
41    where
42        Self: MetadataWriter + StateWriter + DBProvider<Tx: DbTxMut>;
43}
44
45/// Verified slots of one contract, from the slot they were requested at.
46#[derive(Clone, Debug, PartialEq, Eq)]
47pub struct StorageChunk {
48    // Hashed address of the contract.
49    account: B256,
50    // Root the slots were proved against.
51    storage_root: B256,
52    // Slot the chunk was requested from.
53    from: B256,
54    // Non-zero slots in key order.
55    slots: Vec<(B256, U256)>,
56    // Slot the storage continues at, or none once the trie is exhausted.
57    next: Option<B256>,
58}
59
60impl StorageChunk {
61    /// Creates a chunk of `account`'s storage requested from `from` and continuing at `next`.
62    pub const fn new(
63        account: B256,
64        storage_root: B256,
65        from: B256,
66        slots: Vec<(B256, U256)>,
67        next: Option<B256>,
68    ) -> Self {
69        Self { account, storage_root, from, slots, next }
70    }
71}
72
73/// How far the contracts of the account range being downloaded have their storage persisted.
74///
75/// Contracts complete in key order, so only one is part way through per pivot, plus those carried
76/// across pivot moves.
77#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
78pub struct StorageProgress {
79    // Contracts up to this key have all their storage persisted.
80    complete: Option<B256>,
81    // Contracts part way through, in key order.
82    partial: Vec<PartialStorage>,
83}
84
85impl StorageProgress {
86    /// No storage persisted yet.
87    pub const START: Self = Self { complete: None, partial: Vec::new() };
88
89    /// Returns whether all of `account`'s storage is persisted.
90    pub fn is_complete(&self, account: B256) -> bool {
91        self.complete.is_some_and(|complete| account <= complete)
92    }
93
94    /// Returns whether `account` has storage persisted ahead of its range.
95    pub fn has_slots(&self, account: B256) -> bool {
96        self.is_complete(account) || self.partial_of(account).is_some()
97    }
98
99    // Progress of `account` if it is part way through.
100    fn partial_of(&self, account: B256) -> Option<&PartialStorage> {
101        self.partial.iter().find(|partial| partial.account == account)
102    }
103
104    // End of a request for at most `max` of `contracts` from `first`. Contracts after the first
105    // are requested whole, so one resuming part way must lead its own request.
106    pub(crate) fn request_end<T>(
107        &self,
108        contracts: &[(B256, T)],
109        first: usize,
110        max: usize,
111    ) -> usize {
112        let end = contracts.len().min(first.saturating_add(max));
113        contracts[first + 1..end]
114            .iter()
115            .position(|(account, _)| self.partial_of(*account).is_some())
116            .map_or(end, |offset| first + 1 + offset)
117    }
118
119    // Hands the progress of contracts past a committed range to the range continuing at `next`.
120    pub(crate) fn carry_to(
121        mut self,
122        provider: &impl MetadataWriter,
123        write: SnapWrite,
124        next: B256,
125    ) -> Result<(), SnapSyncError> {
126        self.complete = self.complete.filter(|complete| *complete >= next);
127        self.partial.retain(|partial| partial.account >= next);
128        if self != Self::START {
129            StoredProgress::new(write, next, self).write(provider)?;
130        }
131        Ok(())
132    }
133
134    /// Slot `account`'s storage resumes at: zero before it starts, `None` once it is complete.
135    pub fn resume_at(&self, account: B256) -> Option<B256> {
136        if self.is_complete(account) {
137            return None
138        }
139        Some(self.partial_of(account).map_or(B256::ZERO, |partial| partial.next))
140    }
141
142    // The same progress with every partial contract's root unproved, after the pivot moved.
143    fn carried(mut self) -> Self {
144        for partial in &mut self.partial {
145            partial.storage_root = None;
146        }
147        self
148    }
149
150    // The progress after `chunk`, which must continue its contract or start the next one.
151    //
152    // A contract carried across a pivot move does not block others: the new root can hold
153    // contracts before it, or no longer hold it at all.
154    fn advance(mut self, chunk: &StorageChunk) -> Result<Self, SnapSyncError> {
155        let blocked = self
156            .partial
157            .iter()
158            .any(|partial| partial.account < chunk.account && partial.storage_root.is_some());
159        if blocked || self.resume_at(chunk.account) != Some(chunk.from) {
160            return Err(SnapSyncError::OutOfOrderStorage {
161                account: chunk.account,
162                from: chunk.from,
163            })
164        }
165        if let Some(partial) = self.partial_of(chunk.account) &&
166            let Some(expected) = partial.storage_root &&
167            expected != chunk.storage_root
168        {
169            return Err(SnapSyncError::StorageRootMismatch {
170                account: chunk.account,
171                expected,
172                got: chunk.storage_root,
173            })
174        }
175        if chunk.next.is_some_and(|next| next <= chunk.from) {
176            return Err(SnapSyncError::NoProgress { origin: chunk.from })
177        }
178        // Carried contracts past this one stay resumable, those before it were skipped, so the new
179        // root no longer holds them.
180        self.partial.retain(|partial| partial.account > chunk.account);
181        match chunk.next {
182            Some(next) => {
183                let storage_root = Some(chunk.storage_root);
184                self.partial
185                    .insert(0, PartialStorage { account: chunk.account, storage_root, next });
186            }
187            None => self.complete = Some(chunk.account),
188        }
189        Ok(self)
190    }
191}
192
193// A contract whose storage is persisted up to, but not including, `next`.
194#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
195struct PartialStorage {
196    // Hashed address of the contract.
197    account: B256,
198    // Root its slots were proved against, none once the pivot moved past it.
199    storage_root: Option<B256>,
200    // Slot its storage resumes at.
201    next: B256,
202}
203
204// The progress record as persisted, tied to the write and range that recorded it.
205#[derive(Serialize, Deserialize)]
206pub(crate) struct StoredProgress {
207    // Encoding version, checked before the rest is decoded.
208    version: u32,
209    // Attempt the slots belong to.
210    attempt: SnapAttemptId,
211    // Pivot generation the last chunk was proved against.
212    state_version: u64,
213    // Key the account range was requested from.
214    origin: B256,
215    // How far that range's contracts have got.
216    progress: StorageProgress,
217}
218
219impl SnapRecord for StoredProgress {
220    const KEY: &'static str = "snap_storage_progress";
221    const VERSION: u32 = 2;
222}
223
224impl StoredProgress {
225    // `progress` for `write`'s range at `origin` at this build's version.
226    const fn new(write: SnapWrite, origin: B256, progress: StorageProgress) -> Self {
227        Self {
228            version: Self::VERSION,
229            attempt: write.attempt(),
230            state_version: write.state_version(),
231            origin,
232            progress,
233        }
234    }
235
236    // The recorded progress, if it belongs to `write`'s attempt and the range at `origin`.
237    fn into_progress(self, write: SnapWrite, origin: B256) -> Option<StorageProgress> {
238        if self.attempt != write.attempt() || self.origin != origin {
239            return None
240        }
241        Some(if self.state_version == write.state_version() {
242            self.progress
243        } else {
244            self.progress.carried()
245        })
246    }
247}
248
249impl<T: MetadataProvider> SnapStorageStore for T {
250    // A record left by another attempt or range reads as nothing persisted.
251    fn storage_progress(
252        &self,
253        write: SnapWrite,
254        origin: B256,
255    ) -> Result<StorageProgress, SnapSyncError> {
256        self.authorize_snap_write(write)?;
257        let Some(stored) = StoredProgress::read(self)? else { return Ok(StorageProgress::START) };
258        Ok(stored.into_progress(write, origin).unwrap_or(StorageProgress::START))
259    }
260
261    // Every check runs before the first write, so a refused chunk changes nothing.
262    fn commit_storage_chunk(
263        &self,
264        write: SnapWrite,
265        origin: B256,
266        chunk: StorageChunk,
267    ) -> Result<StorageProgress, SnapSyncError>
268    where
269        Self: MetadataWriter + StateWriter + DBProvider<Tx: DbTxMut>,
270    {
271        let coverage = self.account_coverage(write)?.ok_or(SnapSyncError::NoCoverage)?;
272        if coverage.next() != Some(origin) {
273            return Err(SnapSyncError::OutOfOrderRange { expected: coverage.next(), got: origin })
274        }
275        if chunk.account < origin || chunk.storage_root == EMPTY_ROOT_HASH {
276            return Err(SnapSyncError::UnexpectedStorage { account: chunk.account })
277        }
278        let progress = self.storage_progress(write, origin)?;
279        let complete = progress.complete;
280        let progress = progress.advance(&chunk)?;
281
282        if chunk.next.is_none() {
283            // Skipped accounts have empty storage at this pivot. Clear earlier attempts' slots
284            // before the completion watermark makes them eligible for reuse and BAL updates.
285            let start = complete.map_or(Some(origin), |account| {
286                U256::from_be_bytes(account.0).checked_add(U256::ONE).map(B256::from)
287            });
288            if let Some(start) = start {
289                self.remove::<tables::HashedStorages>(start..chunk.account)?;
290            }
291        }
292        if chunk.from.is_zero() {
293            self.remove::<tables::HashedStorages>(chunk.account..=chunk.account)?;
294        }
295        let state = HashedPostState::default()
296            .with_storages([(chunk.account, HashedStorage::from_iter(chunk.slots))])
297            .into_sorted();
298        self.write_hashed_state(&state)?;
299        let stored = StoredProgress::new(write, origin, progress);
300        stored.write(self)?;
301        Ok(stored.progress)
302    }
303}
304
305/// Root of the storage persisted for `account`, streamed so a large contract is never held in
306/// memory.
307pub(crate) fn persisted_storage_root(tx: &impl DbTx, account: B256) -> Result<B256, SnapSyncError> {
308    let mut cursor = tx.cursor_dup_read::<tables::HashedStorages>()?;
309    let mut failed = None;
310    let slots = cursor.walk_dup(Some(account), None)?.map_while(|entry| {
311        entry.map(|(_, slot)| (slot.key, slot.value)).map_err(|error| failed = Some(error)).ok()
312    });
313    let root = storage_root(slots);
314    failed.map_or(Ok(root), |error| Err(error.into()))
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use crate::test_utils::{
321        account, generation, hashed_factory_with_roots, state_root, storage_root_of, stored_slots,
322    };
323    use reth_provider::{
324        test_utils::MockNodeTypesWithDB, DatabaseProviderFactory, ProviderFactory,
325    };
326    use std::ops::Range;
327
328    const CONTRACT: B256 = B256::repeat_byte(0x22);
329
330    fn slot(value: u8) -> B256 {
331        B256::with_last_byte(value)
332    }
333
334    fn slots() -> Vec<(B256, U256)> {
335        vec![(slot(1), U256::from(11)), (slot(2), U256::from(12)), (slot(3), U256::from(13))]
336    }
337
338    fn root() -> B256 {
339        storage_root_of(&slots())
340    }
341
342    // `slots()[served]` requested from `from`, continuing at `next`.
343    fn chunk(served: Range<usize>, from: B256, next: Option<B256>) -> StorageChunk {
344        StorageChunk::new(CONTRACT, root(), from, slots()[served].to_vec(), next)
345    }
346
347    // An attempt whose account coverage has not reached the contract yet.
348    fn started() -> (ProviderFactory<MockNodeTypesWithDB>, SnapWrite) {
349        let mut contract = account(1);
350        contract.storage_root = root();
351        let state_root = state_root(&[(CONTRACT, contract)]);
352        let factory = hashed_factory_with_roots(&[state_root]);
353        let provider = factory.database_provider_rw().unwrap();
354        let generation = generation(1, state_root);
355        let write = provider.start_snap_attempt(generation).unwrap();
356        provider.start_account_coverage(write).unwrap();
357        provider.commit().unwrap();
358        (factory, write)
359    }
360
361    #[test]
362    fn an_interrupted_chunk_commit_keeps_the_last_committed_progress() {
363        let (factory, write) = started();
364        let provider = factory.database_provider_rw().unwrap();
365        let first = chunk(0..1, B256::ZERO, Some(slot(2)));
366        let progress = provider.commit_storage_chunk(write, B256::ZERO, first).unwrap();
367        provider.commit().unwrap();
368        assert_eq!(progress.resume_at(CONTRACT), Some(slot(2)));
369
370        let provider = factory.database_provider_rw().unwrap();
371        let rest = chunk(1..3, slot(2), None);
372        assert!(provider
373            .commit_storage_chunk(write, B256::ZERO, rest)
374            .unwrap()
375            .is_complete(CONTRACT));
376        drop(provider);
377
378        let provider = factory.database_provider_rw().unwrap();
379        assert_eq!(provider.storage_progress(write, B256::ZERO).unwrap(), progress);
380        assert_eq!(stored_slots(&provider, CONTRACT), slots()[..1]);
381    }
382
383    #[test]
384    fn a_chunk_must_continue_its_contract_before_another_starts() {
385        let (factory, write) = started();
386        let provider = factory.database_provider_rw().unwrap();
387        let first = chunk(0..1, B256::ZERO, Some(slot(2)));
388        let progress = provider.commit_storage_chunk(write, B256::ZERO, first).unwrap();
389
390        let refused = [
391            // Restarted, skipping ahead, or another contract while this one is part way through.
392            chunk(0..1, B256::ZERO, Some(slot(2))),
393            chunk(2..3, slot(3), None),
394            StorageChunk::new(B256::repeat_byte(0x44), root(), B256::ZERO, slots(), None),
395        ];
396        for chunk in refused {
397            assert!(matches!(
398                provider.commit_storage_chunk(write, B256::ZERO, chunk),
399                Err(SnapSyncError::OutOfOrderStorage { .. })
400            ));
401        }
402        let stalled = chunk(1..1, slot(2), Some(slot(2)));
403        assert!(matches!(
404            provider.commit_storage_chunk(write, B256::ZERO, stalled),
405            Err(SnapSyncError::NoProgress { .. })
406        ));
407        let other_root = StorageChunk::new(
408            CONTRACT,
409            B256::repeat_byte(0x33),
410            slot(2),
411            slots()[1..].to_vec(),
412            None,
413        );
414        assert!(matches!(
415            provider.commit_storage_chunk(write, B256::ZERO, other_root),
416            Err(SnapSyncError::StorageRootMismatch { .. })
417        ));
418
419        assert_eq!(provider.storage_progress(write, B256::ZERO).unwrap(), progress);
420        assert_eq!(stored_slots(&provider, CONTRACT), slots()[..1]);
421    }
422
423    #[test]
424    fn starting_a_contract_replaces_what_an_earlier_attempt_left() {
425        let (factory, write) = started();
426        let provider = factory.database_provider_rw().unwrap();
427        let leftover = HashedStorage::from_iter([(slot(7), U256::from(77))]);
428        let leftover = HashedPostState::default().with_storages([(CONTRACT, leftover)]);
429        provider.write_hashed_state(&leftover.into_sorted()).unwrap();
430
431        provider.commit_storage_chunk(write, B256::ZERO, chunk(0..3, B256::ZERO, None)).unwrap();
432
433        assert_eq!(stored_slots(&provider, CONTRACT), slots());
434        assert_eq!(persisted_storage_root(provider.tx_ref(), CONTRACT).unwrap(), root());
435    }
436
437    // `CONTRACT` part way through at pivot 1, then the pivot moved to block 2.
438    fn carried() -> (ProviderFactory<MockNodeTypesWithDB>, SnapWrite, SnapWrite) {
439        let (factory, write) = started();
440        let provider = factory.database_provider_rw().unwrap();
441        let first = chunk(0..1, B256::ZERO, Some(slot(2)));
442        provider.commit_storage_chunk(write, B256::ZERO, first).unwrap();
443        let advanced =
444            provider.advance_snap_pivot(write, generation(2, B256::repeat_byte(2))).unwrap();
445        provider.commit().unwrap();
446        (factory, write, advanced)
447    }
448
449    // Whole storage of `account`, proved against `root()`.
450    fn whole(account: B256) -> StorageChunk {
451        StorageChunk::new(account, root(), B256::ZERO, slots(), None)
452    }
453
454    #[test]
455    fn progress_survives_the_pivot_moving_with_its_root_unproved() {
456        let (factory, write, advanced) = carried();
457        let provider = factory.database_provider_rw().unwrap();
458
459        assert_eq!(provider.storage_progress(advanced, slot(9)).unwrap(), StorageProgress::START);
460        let progress = provider.storage_progress(advanced, B256::ZERO).unwrap();
461        assert_eq!(progress.resume_at(CONTRACT), Some(slot(2)));
462        assert!(matches!(
463            provider.commit_storage_chunk(write, B256::ZERO, chunk(1..3, slot(2), None)),
464            Err(SnapSyncError::StaleWrite { .. })
465        ));
466
467        // The new root's slots continue the carried ones, whatever root those were proved against.
468        let rest = StorageChunk::new(
469            CONTRACT,
470            B256::repeat_byte(0x33),
471            slot(2),
472            slots()[1..].to_vec(),
473            None,
474        );
475        let progress = provider.commit_storage_chunk(advanced, B256::ZERO, rest).unwrap();
476        assert!(progress.is_complete(CONTRACT));
477        assert_eq!(stored_slots(&provider, CONTRACT), slots());
478    }
479
480    #[test]
481    fn a_carried_contract_does_not_block_the_contracts_the_new_root_holds() {
482        let (factory, _, advanced) = carried();
483        let provider = factory.database_provider_rw().unwrap();
484
485        // A contract created before it since the pivot moved finishes first, keeping it resumable.
486        let before = B256::repeat_byte(0x11);
487        let progress = provider.commit_storage_chunk(advanced, B256::ZERO, whole(before)).unwrap();
488        assert!(progress.is_complete(before));
489        assert_eq!(progress.resume_at(CONTRACT), Some(slot(2)));
490
491        // One after it means the new root no longer holds it as a contract.
492        let after = B256::repeat_byte(0x44);
493        let progress = provider.commit_storage_chunk(advanced, B256::ZERO, whole(after)).unwrap();
494        assert!(progress.is_complete(after));
495        assert!(progress.partial.is_empty());
496    }
497
498    #[test]
499    fn a_new_contract_part_way_keeps_the_carried_one_resumable() {
500        let (factory, _, advanced) = carried();
501        let provider = factory.database_provider_rw().unwrap();
502        let before = B256::repeat_byte(0x11);
503        let first =
504            StorageChunk::new(before, root(), B256::ZERO, slots()[..1].to_vec(), Some(slot(2)));
505        provider.commit_storage_chunk(advanced, B256::ZERO, first).unwrap();
506
507        // Both stay resumable, even across another pivot move.
508        let advanced =
509            provider.advance_snap_pivot(advanced, generation(3, B256::repeat_byte(3))).unwrap();
510        let progress = provider.storage_progress(advanced, B256::ZERO).unwrap();
511        assert_eq!(progress.resume_at(before), Some(slot(2)));
512        assert_eq!(progress.resume_at(CONTRACT), Some(slot(2)));
513
514        let rest = StorageChunk::new(before, root(), slot(2), slots()[1..].to_vec(), None);
515        let progress = provider.commit_storage_chunk(advanced, B256::ZERO, rest).unwrap();
516        assert!(progress.is_complete(before));
517        assert_eq!(progress.resume_at(CONTRACT), Some(slot(2)));
518    }
519
520    #[test]
521    fn a_contract_resuming_part_way_leads_its_request() {
522        let progress = StorageProgress {
523            complete: None,
524            partial: vec![PartialStorage { account: CONTRACT, storage_root: None, next: slot(2) }],
525        };
526        let contracts = [(slot(1), ()), (CONTRACT, ()), (B256::repeat_byte(0x33), ())];
527
528        assert_eq!(progress.request_end(&contracts, 0, 10), 1);
529        assert_eq!(progress.request_end(&contracts, 1, 10), 3);
530        assert_eq!(progress.request_end(&contracts, 1, 1), 2);
531        assert_eq!(StorageProgress::START.request_end(&contracts, 0, 10), 3);
532    }
533
534    #[test]
535    fn storage_outside_the_range_being_downloaded_is_refused() {
536        let (factory, write) = started();
537        let provider = factory.database_provider_rw().unwrap();
538
539        let ahead = provider.commit_storage_chunk(write, slot(9), chunk(0..3, B256::ZERO, None));
540        let empty = StorageChunk::new(CONTRACT, EMPTY_ROOT_HASH, B256::ZERO, Vec::new(), None);
541        let empty = provider.commit_storage_chunk(write, B256::ZERO, empty);
542
543        assert!(matches!(ahead, Err(SnapSyncError::OutOfOrderRange { .. })));
544        assert!(matches!(empty, Err(SnapSyncError::UnexpectedStorage { .. })));
545        assert!(stored_slots(&provider, CONTRACT).is_empty());
546    }
547
548    #[test]
549    fn a_record_this_build_cannot_read_is_reported() {
550        let (factory, write) = started();
551
552        for record in [br#"{"version":999}"#.to_vec(), b"{}".to_vec()] {
553            let provider = factory.database_provider_rw().unwrap();
554            provider.write_metadata(StoredProgress::KEY, record).unwrap();
555
556            assert!(matches!(
557                provider.storage_progress(write, B256::ZERO),
558                Err(SnapSyncError::UnsupportedRecord { .. })
559            ));
560        }
561
562        for record in [b"not json".to_vec(), br#"{"version":2}"#.to_vec()] {
563            let provider = factory.database_provider_rw().unwrap();
564            provider.write_metadata(StoredProgress::KEY, record).unwrap();
565
566            assert!(matches!(
567                provider.storage_progress(write, B256::ZERO),
568                Err(SnapSyncError::UnreadableRecord { .. })
569            ));
570        }
571    }
572}