From 8fdc3f203dbf3fd38ebe6d78fb0dd2b3732682e1 Mon Sep 17 00:00:00 2001 From: Daniil Polyakov Date: Tue, 21 Jul 2026 21:36:16 +0300 Subject: [PATCH] fix(sequencer): don't increment withdrawals count on reconstruction to prevent phantom counts --- lez/sequencer/core/src/lib.rs | 25 ++++--- .../core/src/tests/reconstruction.rs | 70 ++++++++++++++++++- 2 files changed, 80 insertions(+), 15 deletions(-) diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 88d2d020..29418919 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -390,21 +390,20 @@ impl SequencerCore { chain_consistency::apply_block(block, &mut scratch) .map_err(|err| anyhow!("Failed to apply channel block {block_id}: {err}"))?; - // Derive bridge bookkeeping from the block's transactions, matching the - // production path so the reconciliation counters stay consistent. - let mut deposit_event_ids = Vec::new(); - let mut withdrawals = Vec::new(); - for tx in &block.body.transactions { - if let Some(deposit_id) = extract_bridge_deposit_id(tx) { - deposit_event_ids.push(deposit_id); - } - if let Some(withdraw) = extract_bridge_withdraw_data(tx) { - withdrawals.push(withdraw_event_reconciliation_key(&withdraw.outputs)?); - } - } + // Mark the deposits' pending records submitted so the production-time + // guard drops the mints cold-start backfill re-queued for them. Withdraw + // intents are deliberately not counted: backfill already re-delivered and + // dropped their finalized L1 events, so an increment here would never be + // consumed and would leave a phantom count. + let deposit_event_ids: Vec<_> = block + .body + .transactions + .iter() + .filter_map(extract_bridge_deposit_id) + .collect(); store - .update(block, &deposit_event_ids, withdrawals, &scratch) + .update(block, &deposit_event_ids, Vec::new(), &scratch) .context("Failed to persist reconstructed block")?; *state = scratch; store diff --git a/lez/sequencer/core/src/tests/reconstruction.rs b/lez/sequencer/core/src/tests/reconstruction.rs index 9d3407b9..4f08631f 100644 --- a/lez/sequencer/core/src/tests/reconstruction.rs +++ b/lez/sequencer/core/src/tests/reconstruction.rs @@ -311,15 +311,81 @@ async fn reconstructed_deposit_is_not_reminted_after_backfill_redelivery() { "the re-delivered mint must be skipped, not re-included in a block" ); + // A reconstructed withdraw's finalized L1 event was already re-delivered (and + // dropped) by cold-start backfill, so it will never be consumed again. + // Reconstruction must not count it, or the count stays phantom-inflated forever. let withdraw_arg = crate::extract_bridge_withdraw_data(&withdraw_tx).expect("withdraw data"); let key = crate::withdraw_event_reconciliation_key(&withdraw_arg.outputs).expect("recon key"); assert!( - seq_b + !seq_b .block_store() .dbio() .consume_unseen_withdraw_count(key) .unwrap(), - "reconstruction must record an unseen withdraw for bridge reconciliation" + "reconstruction must not leave a phantom unseen-withdraw count" + ); +} + +/// A reconstructed withdraw block must not touch the unseen-withdraw counter. +/// Its finalized L1 Withdraw event was already re-delivered (and dropped as a +/// no-op) by cold-start backfill, so counting it during reconstruction would +/// leave a permanent phantom that nothing ever consumes. +#[tokio::test] +async fn reconstructed_withdraw_leaves_no_phantom_unseen_count() { + let recipient = initial_public_user_accounts()[0].account_id; + let withdraw_amount = 100_u64; + let bedrock_account_pk = [0x33_u8; 32]; + + // Sequencer A produces a single withdraw block; treat its chain as the channel. + let config_a = bridge_funded_config(); + let (mut seq_a, mempool_a) = + SequencerCoreWithMockClients::start_from_config(config_a.clone()).await; + let withdraw_tx = build_public_withdraw_tx( + recipient, + 0, + withdraw_amount, + bedrock_account_pk, + &create_signing_key_for_account1(), + ); + mempool_a + .push((TransactionOrigin::User, withdraw_tx.clone())) + .await + .unwrap(); + seq_a.produce_new_block().await.unwrap(); + + let withdraw_arg = crate::extract_bridge_withdraw_data(&withdraw_tx).expect("withdraw data"); + let key = crate::withdraw_event_reconciliation_key(&withdraw_arg.outputs).expect("recon key"); + // Producing the withdraw counts it as unseen, awaiting its L1 event. + assert!( + seq_a + .block_store() + .dbio() + .consume_unseen_withdraw_count(key) + .unwrap(), + "producing a withdraw must count it as unseen" + ); + + let messages = channel_from_store(seq_a.block_store(), 10); + let tip_slot = messages.last().unwrap().1; + let channel_id = config_a.bedrock_config.channel_id; + + // Sequencer B reconstructs A's chain from a fresh store. + let config_b = bridge_funded_config(); + let (mut store_b, mut state_b) = + SequencerCore::::open_or_create_store(&config_b); + let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages); + SequencerCore::::verify_and_reconstruct( + &mock_b, + &mut store_b, + &mut state_b, + true, + ) + .await + .expect("reconstruct"); + + assert!( + !store_b.dbio().consume_unseen_withdraw_count(key).unwrap(), + "reconstruction must not leave a phantom unseen-withdraw count" ); }