diff --git a/lez/chain_state/src/chain.rs b/lez/chain_state/src/chain.rs index f49f1d05..39e1efba 100644 --- a/lez/chain_state/src/chain.rs +++ b/lez/chain_state/src/chain.rs @@ -161,6 +161,22 @@ impl ChainState { } } + /// Applies a block we produced ourselves. + /// + /// Unlike [`Self::apply_adopted`] this never reorgs: our block is not on + /// the channel yet, so it may only *extend* the head. A head already at + /// (or past) this height means a peer's block won the race on the + /// channel — ours is stale and the caller drops it. + pub fn apply_produced(&mut self, this_msg: MsgId, block: &Block) -> AcceptOutcome { + if self + .head_tip() + .is_some_and(|tip| block.header.block_id <= tip.block_id) + { + return AcceptOutcome::AlreadyApplied; + } + self.apply_adopted(this_msg, block) + } + /// Reverts an orphaned head block and everything after it, then re-derives head. pub fn revert_orphan(&mut self, this_msg: MsgId, block: &Block) { if let Some(idx) = self.head_position_of(this_msg, block) { @@ -632,6 +648,49 @@ mod tests { assert_head_matches_replay(&chain); } + #[test] + fn produced_block_losing_a_race_does_not_reorg_the_head() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + // A peer's block wins height 2 on the channel. + let peer = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + chain.apply_adopted(msg(2), &peer); + + // Our own block at that height is not on the channel, so — unlike an + // adopted competitor — it must not reorg the head onto itself. + let tx = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let ours = produce_dummy_block(2, Some(genesis.header.hash), vec![tx]); + assert!(matches!( + chain.apply_produced(msg(12), &ours), + AcceptOutcome::AlreadyApplied + )); + assert_eq!(chain.head_tip().expect("head tip").hash, peer.header.hash); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20000); + assert_head_matches_replay(&chain); + } + + #[test] + fn produced_block_extending_the_head_applies() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + let ours = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_produced(msg(2), &ours), + AcceptOutcome::Applied + )); + assert_eq!(chain.head_tip().expect("head tip").hash, ours.header.hash); + assert_head_matches_replay(&chain); + } + #[test] fn invalid_adopted_competitor_leaves_head_intact() { let mut chain = ChainState::new(initial_state()); diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 9ceff359..c33dcac4 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -668,7 +668,7 @@ impl SequencerCore { withdrawal_reconciliation_keys: Vec, ) -> Result<()> { let mut chain = self.chain.lock().expect("chain state mutex poisoned"); - match chain.apply_adopted(this_msg, block) { + match chain.apply_produced(this_msg, block) { AcceptOutcome::Applied => { // Persisted under the lock so disk writes land in apply order // with the follow path. diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index 4dd68712..4b6e4cae 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -1680,7 +1680,15 @@ async fn record_produced_block_skips_persistence_on_lost_race() { .apply_adopted(MsgId::from([9; 32]), &peer_block); // Our competing block at the same height: same parent, different content. - let tx = common::test_utils::produce_dummy_empty_transaction(); + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, + 0, + acc2, + 10, + &create_signing_key_for_account1(), + ); let our_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![tx]); sequencer .record_produced_block( diff --git a/lez/storage/src/sequencer/mod.rs b/lez/storage/src/sequencer/mod.rs index 299cd27d..0150a99f 100644 --- a/lez/storage/src/sequencer/mod.rs +++ b/lez/storage/src/sequencer/mod.rs @@ -749,21 +749,26 @@ impl RocksDBIO { self.put_block_payload(&to_write, &mut batch)?; } + // `head_tip` is `None` only for a chain holding no blocks at all, which + // implies nothing was applied — and the store, created with genesis, + // cannot represent it. No tip to pin, nothing to persist. + let Some(tip) = head_tip else { + debug_assert!(batch.is_empty() && final_snapshot.is_none()); + return Ok(()); + }; + // A shrink-only update (orphans without adopted replacements) has no // payloads to write but must still rewind the tip meta, or the stored // state tears against the stale disk head on the next produce. - let tip_rewound = head_tip.is_some_and(|tip| tip.id < last_block_in_db); - if batch.is_empty() && final_snapshot.is_none() && !tip_rewound { + if batch.is_empty() && final_snapshot.is_none() && tip.id == last_block_in_db { return Ok(()); } - if let Some(tip) = head_tip { - for stale_id in tip.id.saturating_add(1)..=last_block_in_db { - self.delete_block_payload(stale_id, &mut batch)?; - } - self.put_meta_last_block_in_db_batch(tip.id, &mut batch)?; - self.put_meta_latest_block_meta_batch(tip, &mut batch)?; + for stale_id in tip.id.saturating_add(1)..=last_block_in_db { + self.delete_block_payload(stale_id, &mut batch)?; } + self.put_meta_last_block_in_db_batch(tip.id, &mut batch)?; + self.put_meta_latest_block_meta_batch(tip, &mut batch)?; self.put_lee_state_in_db_batch(state, &mut batch)?; if let Some((final_state, final_meta)) = final_snapshot { self.put_final_snapshot_batch(final_state, final_meta, &mut batch)?;