mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-07-30 09:33:28 +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>> {
|
pub fn get_unfulfilled_deposit_events(&self) -> DbResult<Vec<PendingDepositEventRecord>> {
|
||||||
self.dbio.get_pending_deposit_events()
|
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> {
|
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 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);
|
deposit_event_ids.push(deposit_op_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -203,19 +203,20 @@ fn build_public_withdraw_tx(
|
|||||||
LeeTransaction::Public(lee::PublicTransaction::new(message, witness_set))
|
LeeTransaction::Public(lee::PublicTransaction::new(message, witness_set))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The channel is ahead of the local store and its extra blocks carry
|
/// The cold-start backfill re-delivers an already-finalized deposit into the
|
||||||
/// bridge deposit and withdraw transactions. Reconstruction must replay them,
|
/// mempool before reconstruction applies the same deposit block, and the queued
|
||||||
/// reproducing both the minted/burned balances and the withdraw reconciliation
|
/// mint cannot be pulled back out. Since the bridge program does not dedup on
|
||||||
/// bookkeeping the production path records.
|
/// `l1_deposit_op_id`, block production must skip the already-submitted deposit
|
||||||
|
/// so the vault is minted exactly once.
|
||||||
#[tokio::test]
|
#[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 recipient = initial_public_user_accounts()[0].account_id;
|
||||||
let deposit_amount = 500_u64;
|
let deposit_amount = 500_u64;
|
||||||
let withdraw_amount = 100_u64;
|
let withdraw_amount = 100_u64;
|
||||||
let bedrock_account_pk = [0x22_u8; 32];
|
let bedrock_account_pk = [0x22_u8; 32];
|
||||||
let deposit_op_id = [0x0d_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 config_a = bridge_funded_config();
|
||||||
let (mut seq_a, mempool_a) =
|
let (mut seq_a, mempool_a) =
|
||||||
SequencerCoreWithMockClients::start_from_config(config_a.clone()).await;
|
SequencerCoreWithMockClients::start_from_config(config_a.clone()).await;
|
||||||
@ -224,7 +225,7 @@ async fn reconstructs_channel_with_deposit_and_withdraw() {
|
|||||||
let deposit_tx =
|
let deposit_tx =
|
||||||
crate::build_bridge_deposit_tx_from_event(&deposit_record).expect("build deposit tx");
|
crate::build_bridge_deposit_tx_from_event(&deposit_record).expect("build deposit tx");
|
||||||
mempool_a
|
mempool_a
|
||||||
.push((TransactionOrigin::Sequencer, deposit_tx))
|
.push((TransactionOrigin::Sequencer, deposit_tx.clone()))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
seq_a.produce_new_block().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 tip_slot = messages.last().unwrap().1;
|
||||||
let channel_id = config_a.bedrock_config.channel_id;
|
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 config_b = bridge_funded_config();
|
||||||
let (mut store_b, mut state_b) =
|
let (mut seq_b, mempool_b) = SequencerCoreWithMockClients::start_from_config(config_b).await;
|
||||||
SequencerCore::<MockBlockPublisher>::open_or_create_store(&config_b);
|
|
||||||
|
// 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);
|
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
|
||||||
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
||||||
&mock_b,
|
&mock_b,
|
||||||
&mut store_b,
|
&mut seq_b.store,
|
||||||
&mut state_b,
|
&mut seq_b.state,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("reconstruct");
|
.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.id, tip_a.id);
|
||||||
assert_eq!(tip_b.hash, tip_a.hash);
|
assert_eq!(tip_b.hash, tip_a.hash);
|
||||||
|
|
||||||
// The deposit minted into the vault and the withdraw burned from the sender —
|
seq_b.produce_new_block().await.unwrap();
|
||||||
// reconstructed balances agree with sequencer A for every affected account.
|
|
||||||
let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient);
|
let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient);
|
||||||
let bridge_id = system_accounts::bridge_account_id();
|
let bridge_id = system_accounts::bridge_account_id();
|
||||||
for account in [vault_id, bridge_id, recipient] {
|
for account in [vault_id, bridge_id, recipient] {
|
||||||
assert_eq!(
|
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,
|
seq_a.state().get_account_by_id(account).balance,
|
||||||
"reconstructed balance mismatch for {account:?}",
|
"reconstructed balance mismatch for {account:?}",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert_eq!(
|
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),
|
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 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");
|
let key = crate::withdraw_event_reconciliation_key(&withdraw_arg.outputs).expect("recon key");
|
||||||
assert!(
|
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"
|
"reconstruction must record an unseen withdraw for bridge reconciliation"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -446,6 +446,14 @@ impl RocksDBIO {
|
|||||||
Ok(removed)
|
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(
|
fn increment_unseen_withdraw_count(
|
||||||
&self,
|
&self,
|
||||||
withdrawal: WithdrawalReconciliationKey,
|
withdrawal: WithdrawalReconciliationKey,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user