mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-07-23 06:09:31 +00:00
fix(sequencer): fix double mint on deposit when reconstructing state
This commit is contained in:
parent
10e61bd354
commit
3e7e9ed4e1
@ -211,6 +211,10 @@ impl SequencerStore {
|
||||
pub fn get_unfulfilled_deposit_events(&self) -> DbResult<Vec<PendingDepositEventRecord>> {
|
||||
self.dbio.get_pending_deposit_events()
|
||||
}
|
||||
|
||||
pub fn is_deposit_event_submitted(&self, deposit_op_id: HashType) -> DbResult<bool> {
|
||||
self.dbio.is_deposit_event_submitted(deposit_op_id)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn block_to_transactions_map(block: &Block) -> HashMap<HashType, u64> {
|
||||
|
||||
@ -615,6 +615,14 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
};
|
||||
|
||||
if let Some(deposit_op_id) = extract_bridge_deposit_id(tx) {
|
||||
if self
|
||||
.store
|
||||
.is_deposit_event_submitted(deposit_op_id)
|
||||
.context("Failed to check whether deposit was already submitted")?
|
||||
{
|
||||
info!("Skipping already-submitted bridge deposit {deposit_op_id}");
|
||||
return Ok(false);
|
||||
}
|
||||
deposit_event_ids.push(deposit_op_id);
|
||||
}
|
||||
|
||||
|
||||
@ -203,19 +203,20 @@ fn build_public_withdraw_tx(
|
||||
LeeTransaction::Public(lee::PublicTransaction::new(message, witness_set))
|
||||
}
|
||||
|
||||
/// The channel is ahead of the local store and its extra blocks carry
|
||||
/// bridge deposit and withdraw transactions. Reconstruction must replay them,
|
||||
/// reproducing both the minted/burned balances and the withdraw reconciliation
|
||||
/// bookkeeping the production path records.
|
||||
/// The cold-start backfill re-delivers an already-finalized deposit into the
|
||||
/// mempool before reconstruction applies the same deposit block, and the queued
|
||||
/// mint cannot be pulled back out. Since the bridge program does not dedup on
|
||||
/// `l1_deposit_op_id`, block production must skip the already-submitted deposit
|
||||
/// so the vault is minted exactly once.
|
||||
#[tokio::test]
|
||||
async fn reconstructs_channel_with_deposit_and_withdraw() {
|
||||
async fn reconstructed_deposit_is_not_reminted_after_backfill_redelivery() {
|
||||
let recipient = initial_public_user_accounts()[0].account_id;
|
||||
let deposit_amount = 500_u64;
|
||||
let withdraw_amount = 100_u64;
|
||||
let bedrock_account_pk = [0x22_u8; 32];
|
||||
let deposit_op_id = [0x0d_u8; 32];
|
||||
|
||||
// Sequencer A: a deposit block (sequencer-origin) then a withdraw block (user).
|
||||
// Sequencer A produces a deposit block then a withdraw block.
|
||||
let config_a = bridge_funded_config();
|
||||
let (mut seq_a, mempool_a) =
|
||||
SequencerCoreWithMockClients::start_from_config(config_a.clone()).await;
|
||||
@ -224,7 +225,7 @@ async fn reconstructs_channel_with_deposit_and_withdraw() {
|
||||
let deposit_tx =
|
||||
crate::build_bridge_deposit_tx_from_event(&deposit_record).expect("build deposit tx");
|
||||
mempool_a
|
||||
.push((TransactionOrigin::Sequencer, deposit_tx))
|
||||
.push((TransactionOrigin::Sequencer, deposit_tx.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
seq_a.produce_new_block().await.unwrap();
|
||||
@ -247,46 +248,77 @@ async fn reconstructs_channel_with_deposit_and_withdraw() {
|
||||
let tip_slot = messages.last().unwrap().1;
|
||||
let channel_id = config_a.bedrock_config.channel_id;
|
||||
|
||||
// Sequencer B reconstructs A's chain (bridge blocks included) from empty.
|
||||
let config_b = bridge_funded_config();
|
||||
let (mut store_b, mut state_b) =
|
||||
SequencerCore::<MockBlockPublisher>::open_or_create_store(&config_b);
|
||||
let (mut seq_b, mempool_b) = SequencerCoreWithMockClients::start_from_config(config_b).await;
|
||||
|
||||
// Backfill re-delivery: persist the pending record and queue the mint, as
|
||||
// `on_deposit_event` does, before reconstruction runs.
|
||||
assert!(
|
||||
seq_b
|
||||
.block_store()
|
||||
.dbio()
|
||||
.add_pending_deposit_event(deposit_record.clone())
|
||||
.unwrap()
|
||||
);
|
||||
mempool_b
|
||||
.push((TransactionOrigin::Sequencer, deposit_tx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
|
||||
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
||||
&mock_b,
|
||||
&mut store_b,
|
||||
&mut state_b,
|
||||
&mut seq_b.store,
|
||||
&mut seq_b.state,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect("reconstruct");
|
||||
seq_b.chain_height = seq_b.store.latest_block_meta().unwrap().id;
|
||||
|
||||
let tip_b = store_b.latest_block_meta().unwrap();
|
||||
let tip_b = seq_b.block_store().latest_block_meta().unwrap();
|
||||
assert_eq!(tip_b.id, tip_a.id);
|
||||
assert_eq!(tip_b.hash, tip_a.hash);
|
||||
|
||||
// The deposit minted into the vault and the withdraw burned from the sender —
|
||||
// reconstructed balances agree with sequencer A for every affected account.
|
||||
seq_b.produce_new_block().await.unwrap();
|
||||
|
||||
let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient);
|
||||
let bridge_id = system_accounts::bridge_account_id();
|
||||
for account in [vault_id, bridge_id, recipient] {
|
||||
assert_eq!(
|
||||
state_b.get_account_by_id(account).balance,
|
||||
seq_b.state().get_account_by_id(account).balance,
|
||||
seq_a.state().get_account_by_id(account).balance,
|
||||
"reconstructed balance mismatch for {account:?}",
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
state_b.get_account_by_id(vault_id).balance,
|
||||
seq_b.state().get_account_by_id(vault_id).balance,
|
||||
u128::from(deposit_amount),
|
||||
"deposit must mint into the recipient vault"
|
||||
"deposit must mint into the recipient vault exactly once, not twice"
|
||||
);
|
||||
|
||||
let produced = seq_b
|
||||
.block_store()
|
||||
.get_block_at_id(tip_b.id + 1)
|
||||
.unwrap()
|
||||
.expect("produced block present");
|
||||
assert!(
|
||||
!produced
|
||||
.body
|
||||
.transactions
|
||||
.iter()
|
||||
.any(|tx| crate::extract_bridge_deposit_id(tx) == Some(HashType(deposit_op_id))),
|
||||
"the re-delivered mint must be skipped, not re-included in a block"
|
||||
);
|
||||
|
||||
// Reconstruction recorded the withdraw so the later L1 withdraw event reconciles.
|
||||
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!(
|
||||
store_b.dbio().consume_unseen_withdraw_count(key).unwrap(),
|
||||
seq_b
|
||||
.block_store()
|
||||
.dbio()
|
||||
.consume_unseen_withdraw_count(key)
|
||||
.unwrap(),
|
||||
"reconstruction must record an unseen withdraw for bridge reconciliation"
|
||||
);
|
||||
}
|
||||
|
||||
@ -446,6 +446,14 @@ impl RocksDBIO {
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Whether a bridge deposit for `deposit_op_id` is already recorded as
|
||||
/// included in a block (its pending record is marked submitted).
|
||||
pub fn is_deposit_event_submitted(&self, deposit_op_id: HashType) -> DbResult<bool> {
|
||||
Ok(self.get_pending_deposit_events()?.iter().any(|record| {
|
||||
record.deposit_op_id == deposit_op_id && record.submitted_in_block_id.is_some()
|
||||
}))
|
||||
}
|
||||
|
||||
fn increment_unseen_withdraw_count(
|
||||
&self,
|
||||
withdrawal: WithdrawalReconciliationKey,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user