@@ -641,11 +641,26 @@ fn read_hex_file_bytes(path: impl AsRef<Path>) -> eyre::Result<Vec<u8>> {
641641
642642/// Fetch the initial state for the node.
643643///
644- /// If `checkpoint_urls` is empty, creates a genesis state from the local
645- /// genesis configuration. Otherwise performs checkpoint sync by downloading
646- /// and verifying the finalized state AND signed block from a peer. URLs are
647- /// tried in order: the first peer that succeeds wins, and failures fall over
648- /// to the next URL. Startup only aborts if every URL fails.
644+ /// State already on disk wins: a previous run's DB is resumed from whenever it
645+ /// exists and belongs to this network, whether or not `checkpoint_urls` is
646+ /// supplied. `checkpoint_urls` is the fallback for when there is nothing
647+ /// resumable on disk, or when what is there has fallen too far behind the
648+ /// current slot to be worth catching up over P2P
649+ /// ([`MAX_RESUMABLE_DB_STATE_AGE`]).
650+ ///
651+ /// With no resumable DB state, a non-empty `checkpoint_urls` performs checkpoint
652+ /// sync by downloading and verifying the finalized state AND signed block from a
653+ /// peer. URLs are tried in order: the first peer that succeeds wins, and
654+ /// failures fall over to the next URL. Startup only aborts if every URL fails.
655+ /// An empty `checkpoint_urls` creates a genesis state from the local genesis
656+ /// configuration.
657+ ///
658+ /// Aborting when every URL fails is deliberate, and applies even when a stale
659+ /// resumable DB is in hand: an operator who configured a checkpoint URL asked
660+ /// for a specific anchor, so an unreachable one is a misconfiguration to
661+ /// surface at boot rather than paper over by silently starting a node that is
662+ /// hours behind. Dropping the flag is the way to say "resume whatever is on
663+ /// disk"; that path never aborts.
649664///
650665/// Fetching the matching signed block lets the local store serve a valid
651666/// anchor via the `BlocksByRoot` req-resp protocol; without it, peers
@@ -669,20 +684,10 @@ async fn fetch_initial_state(
669684) -> Result < Store , checkpoint_sync:: CheckpointSyncError > {
670685 let validators = genesis. validators ( ) ;
671686
672- if checkpoint_urls. is_empty ( ) {
673- info ! ( "No checkpoint sync URL provided, initializing from genesis state" ) ;
674- let genesis_state = State :: from_genesis ( genesis. genesis_time , validators) ;
675- return Ok ( Store :: from_anchor_state ( backend, genesis_state) ) ;
676- } ;
677-
678- // Checkpoint sync path: try URLs in order, fail over to the next on error.
679- info ! (
680- url_count = checkpoint_urls. len( ) ,
681- "Starting checkpoint sync"
682- ) ;
683- // Checkpoint sync path
684-
685- // Prefer resuming from a fresh on-disk state to avoid re-downloading what we already have.
687+ // Prefer resuming from on-disk state to avoid re-downloading what we already
688+ // have. Tried before the checkpoint-sync and genesis paths so that a restart
689+ // without `--checkpoint-sync-url` keeps the chain instead of writing a
690+ // slot-0 anchor over it.
686691 if let Ok ( Some ( store) ) = Store :: from_db_state ( backend. clone ( ) , genesis. genesis_time ) {
687692 let now_ms = SystemTime :: UNIX_EPOCH
688693 . elapsed ( )
@@ -693,18 +698,30 @@ async fn fetch_initial_state(
693698 let head_slot = store. head_slot ( ) ;
694699 let gap = current_slot. saturating_sub ( head_slot) ;
695700 if gap <= MAX_RESUMABLE_DB_STATE_AGE {
696- info ! (
697- head_slot,
698- current_slot, gap, "Resuming from existing DB state"
699- ) ;
701+ info ! ( head_slot, current_slot, gap, "Resuming from existing DB" ) ;
700702 return Ok ( store) ;
701703 }
702- warn ! (
703- head_slot,
704- current_slot, gap, "Existing DB state is stale; falling through to checkpoint sync"
705- ) ;
704+ // No checkpoint URL was configured, so just run the node against the
705+ // data directory it was given: that is the setup asked for, and there
706+ // is no anchor to switch to. The warning is the point of this arm,
707+ // since the DB is known to be stale and range sync may not be able to
708+ // close a gap this large: peers prune block signatures past
709+ // `SIGNATURE_PRUNING_RANGE`, so beyond that horizon they cannot serve
710+ // the history the node is missing.
711+ if checkpoint_urls. is_empty ( ) {
712+ warn ! ( head_slot, current_slot, gap, "DB is stale; resuming anyway" ) ;
713+ return Ok ( store) ;
714+ }
715+ warn ! ( head_slot, current_slot, gap, "DB is stale; checkpoint sync" ) ;
716+ }
717+
718+ if checkpoint_urls. is_empty ( ) {
719+ info ! ( "No checkpoint sync URL provided, initializing from genesis state" ) ;
720+ let genesis_state = State :: from_genesis ( genesis. genesis_time , validators) ;
721+ return Ok ( Store :: from_anchor_state ( backend, genesis_state) ) ;
706722 }
707723
724+ // Checkpoint sync path: try URLs in order, fail over to the next on error.
708725 info ! ( ?checkpoint_urls, "Starting checkpoint sync" ) ;
709726
710727 let ( state, signed_block) = checkpoint_sync:: fetch_anchor_with_retry (
@@ -740,6 +757,8 @@ async fn fetch_initial_state(
740757#[ cfg( test) ]
741758mod tests {
742759 use super :: * ;
760+ use ethlambda_storage:: backend:: InMemoryBackend ;
761+ use ethlambda_types:: genesis:: GenesisValidatorEntry ;
743762
744763 /// Validator-config snippet matching `lean-quickstart`'s ansible-devnet
745764 /// where networks share a non-default committee count.
@@ -828,4 +847,157 @@ validators:
828847 . unwrap_or ( 1 ) ;
829848 assert_eq ! ( resolved, 1 ) ;
830849 }
850+
851+ /// Slot of the anchor seeded into the test DB. Any non-zero slot works: a
852+ /// genesis re-initialization always anchors at slot 0, so a non-zero head
853+ /// slot is what distinguishes "resumed from disk" from "started over".
854+ const SEEDED_HEAD_SLOT : u64 = 12 ;
855+
856+ /// Loopback port 1 refuses connections immediately, so the checkpoint-sync
857+ /// path fails fast and deterministically without reaching the network.
858+ const UNREACHABLE_CHECKPOINT_URL : & str = "http://127.0.0.1:1" ;
859+
860+ fn now_secs ( ) -> u64 {
861+ SystemTime :: UNIX_EPOCH
862+ . elapsed ( )
863+ . expect ( "already past the unix epoch" )
864+ . as_secs ( )
865+ }
866+
867+ /// A `genesis_time` placing the current slot exactly `gap` slots ahead of
868+ /// [`SEEDED_HEAD_SLOT`], so a test picks which side of
869+ /// [`MAX_RESUMABLE_DB_STATE_AGE`] the seeded DB lands on.
870+ ///
871+ /// `current_slot` is derived from the wall clock inside
872+ /// [`fetch_initial_state`], so `genesis_time` is the only knob and no clock
873+ /// injection is needed. Sub-second truncation here only ever *shortens* the
874+ /// elapsed time, and a whole slot of it would have to pass between this
875+ /// call and the read inside the function to shift the gap.
876+ fn genesis_time_for_gap ( gap : u64 ) -> u64 {
877+ let seconds_per_slot = MILLISECONDS_PER_SLOT / 1_000 ;
878+ now_secs ( ) - ( SEEDED_HEAD_SLOT + gap) * seconds_per_slot
879+ }
880+
881+ /// Single-validator genesis config. The pubkeys are placeholders; none of
882+ /// the paths under test verify signatures.
883+ fn test_genesis ( genesis_time : u64 ) -> GenesisConfig {
884+ GenesisConfig {
885+ genesis_time,
886+ genesis_validators : vec ! [ GenesisValidatorEntry {
887+ attestation_pubkey: [ 1u8 ; 52 ] ,
888+ proposal_pubkey: [ 2u8 ; 52 ] ,
889+ } ] ,
890+ }
891+ }
892+
893+ /// Write an anchor at [`SEEDED_HEAD_SLOT`] into `backend`, standing in for a
894+ /// previous run's persisted chain state.
895+ fn seed_db ( backend : Arc < dyn StorageBackend > , genesis : & GenesisConfig ) {
896+ let mut anchor = State :: from_genesis ( genesis. genesis_time , genesis. validators ( ) ) ;
897+ anchor. slot = SEEDED_HEAD_SLOT ;
898+ anchor. latest_block_header . slot = SEEDED_HEAD_SLOT ;
899+ Store :: from_anchor_state ( backend, anchor) ;
900+ }
901+
902+ #[ tokio:: test]
903+ async fn initializes_from_genesis_when_db_is_empty ( ) {
904+ let genesis = test_genesis ( now_secs ( ) ) ;
905+ let backend = Arc :: new ( InMemoryBackend :: default ( ) ) ;
906+
907+ let store = fetch_initial_state ( & [ ] , & genesis, backend) . await . unwrap ( ) ;
908+
909+ assert_eq ! ( store. head_slot( ) , 0 ) ;
910+ }
911+
912+ #[ tokio:: test]
913+ async fn resumes_from_fresh_db_without_checkpoint_url ( ) {
914+ let genesis = test_genesis ( genesis_time_for_gap ( MAX_RESUMABLE_DB_STATE_AGE / 2 ) ) ;
915+ let backend = Arc :: new ( InMemoryBackend :: default ( ) ) ;
916+ seed_db ( backend. clone ( ) , & genesis) ;
917+
918+ let store = fetch_initial_state ( & [ ] , & genesis, backend) . await . unwrap ( ) ;
919+
920+ assert_eq ! ( store. head_slot( ) , SEEDED_HEAD_SLOT ) ;
921+ }
922+
923+ /// With no checkpoint URL to fall back to, resuming a stale DB beats
924+ /// clobbering it with a slot-0 genesis anchor: P2P forward-sync can close
925+ /// the gap, a genesis re-init cannot.
926+ #[ tokio:: test]
927+ async fn resumes_from_stale_db_without_checkpoint_url ( ) {
928+ let genesis = test_genesis ( genesis_time_for_gap ( MAX_RESUMABLE_DB_STATE_AGE + 100 ) ) ;
929+ let backend = Arc :: new ( InMemoryBackend :: default ( ) ) ;
930+ seed_db ( backend. clone ( ) , & genesis) ;
931+
932+ let store = fetch_initial_state ( & [ ] , & genesis, backend) . await . unwrap ( ) ;
933+
934+ assert_eq ! ( store. head_slot( ) , SEEDED_HEAD_SLOT ) ;
935+ }
936+
937+ /// A DB inside the resume window wins over a checkpoint URL: the store
938+ /// comes back even though the URL is unreachable, so nothing was dialed.
939+ ///
940+ /// This and [`falls_through_to_checkpoint_sync_when_db_is_stale`] are what
941+ /// pin the [`MAX_RESUMABLE_DB_STATE_AGE`] comparison. The no-URL tests
942+ /// cannot: both of their branches return the same store, so inverting the
943+ /// threshold leaves them green. The gap is exactly the window bound here,
944+ /// so an off-by-one to `<` also fails this test.
945+ #[ tokio:: test( start_paused = true ) ]
946+ async fn resumes_from_fresh_db_with_checkpoint_url ( ) {
947+ let genesis = test_genesis ( genesis_time_for_gap ( MAX_RESUMABLE_DB_STATE_AGE ) ) ;
948+ let backend = Arc :: new ( InMemoryBackend :: default ( ) ) ;
949+ seed_db ( backend. clone ( ) , & genesis) ;
950+
951+ let urls = [ UNREACHABLE_CHECKPOINT_URL . to_string ( ) ] ;
952+ let store = fetch_initial_state ( & urls, & genesis, backend) . await . unwrap ( ) ;
953+
954+ assert_eq ! ( store. head_slot( ) , SEEDED_HEAD_SLOT ) ;
955+ }
956+
957+ /// Past the resume window a checkpoint URL takes over, so an unreachable
958+ /// one surfaces as a startup error rather than a silent stale resume.
959+ ///
960+ /// Paused time collapses the `CHECKPOINT_RETRY_BACKOFF` sleeps between
961+ /// attempts; the connection refusal itself is immediate.
962+ #[ tokio:: test( start_paused = true ) ]
963+ async fn falls_through_to_checkpoint_sync_when_db_is_stale ( ) {
964+ let genesis = test_genesis ( genesis_time_for_gap ( MAX_RESUMABLE_DB_STATE_AGE + 1 ) ) ;
965+ let backend = Arc :: new ( InMemoryBackend :: default ( ) ) ;
966+ seed_db ( backend. clone ( ) , & genesis) ;
967+
968+ let urls = [ UNREACHABLE_CHECKPOINT_URL . to_string ( ) ] ;
969+ // `Store` is not `Debug`, so unwrap the error by pattern rather than
970+ // with `expect_err`.
971+ let Err ( err) = fetch_initial_state ( & urls, & genesis, backend) . await else {
972+ panic ! ( "unreachable checkpoint URL must abort startup" ) ;
973+ } ;
974+
975+ assert ! (
976+ matches!( err, checkpoint_sync:: CheckpointSyncError :: Http ( _) ) ,
977+ "expected a transport error, got {err:?}"
978+ ) ;
979+ }
980+
981+ /// A DB from another network is not resumable, so the no-URL path still
982+ /// falls back to genesis.
983+ ///
984+ /// This pins current behavior, not a desired one. `from_db_state` treats a
985+ /// `GENESIS_TIME` mismatch as an empty DB and only warns, so with no
986+ /// checkpoint URL the node writes a genesis anchor over a populated
987+ /// foreign-network directory: the same data loss the resume ordering
988+ /// removes everywhere else. Left as-is deliberately, and this test is here
989+ /// to make the change visible when someone fixes it.
990+ #[ tokio:: test]
991+ async fn initializes_from_genesis_when_db_genesis_time_differs ( ) {
992+ let seeded_genesis = test_genesis ( now_secs ( ) ) ;
993+ let backend = Arc :: new ( InMemoryBackend :: default ( ) ) ;
994+ seed_db ( backend. clone ( ) , & seeded_genesis) ;
995+
996+ let other_genesis = test_genesis ( seeded_genesis. genesis_time + 1 ) ;
997+ let store = fetch_initial_state ( & [ ] , & other_genesis, backend)
998+ . await
999+ . unwrap ( ) ;
1000+
1001+ assert_eq ! ( store. head_slot( ) , 0 ) ;
1002+ }
8311003}
0 commit comments