mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-07-31 10:03:21 +00:00
fix(bridge): enforce deposit exactly-once via a per-op-id receipt PDA
This commit is contained in:
parent
f9ef68dbf8
commit
57759d8953
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -1193,6 +1193,7 @@ name = "bridge_core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lee_core",
|
"lee_core",
|
||||||
|
"risc0-zkvm",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -47,10 +47,11 @@ async fn public_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> {
|
|||||||
let bridge_account_id = system_accounts::bridge_account_id();
|
let bridge_account_id = system_accounts::bridge_account_id();
|
||||||
let vault_program_id = programs::vault().id();
|
let vault_program_id = programs::vault().id();
|
||||||
let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id);
|
let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id);
|
||||||
|
let receipt_id = bridge_core::deposit_receipt_account_id(programs::bridge().id(), [0_u8; 32]);
|
||||||
|
|
||||||
let message = public_transaction::Message::try_new(
|
let message = public_transaction::Message::try_new(
|
||||||
programs::bridge().id(),
|
programs::bridge().id(),
|
||||||
vec![bridge_account_id, recipient_vault_id],
|
vec![bridge_account_id, recipient_vault_id, receipt_id],
|
||||||
vec![],
|
vec![],
|
||||||
bridge_core::Instruction::Deposit {
|
bridge_core::Instruction::Deposit {
|
||||||
l1_deposit_op_id: [0_u8; 32],
|
l1_deposit_op_id: [0_u8; 32],
|
||||||
@ -95,10 +96,11 @@ async fn public_bridge_deposit_with_zero_amount_is_rejected() -> anyhow::Result<
|
|||||||
let bridge_account_id = system_accounts::bridge_account_id();
|
let bridge_account_id = system_accounts::bridge_account_id();
|
||||||
let vault_program_id = programs::vault().id();
|
let vault_program_id = programs::vault().id();
|
||||||
let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id);
|
let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id);
|
||||||
|
let receipt_id = bridge_core::deposit_receipt_account_id(programs::bridge().id(), [0_u8; 32]);
|
||||||
|
|
||||||
let message = public_transaction::Message::try_new(
|
let message = public_transaction::Message::try_new(
|
||||||
programs::bridge().id(),
|
programs::bridge().id(),
|
||||||
vec![bridge_account_id, recipient_vault_id],
|
vec![bridge_account_id, recipient_vault_id, receipt_id],
|
||||||
vec![],
|
vec![],
|
||||||
bridge_core::Instruction::Deposit {
|
bridge_core::Instruction::Deposit {
|
||||||
l1_deposit_op_id: [0_u8; 32],
|
l1_deposit_op_id: [0_u8; 32],
|
||||||
@ -155,8 +157,10 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> {
|
|||||||
let bridge_account_id = system_accounts::bridge_account_id();
|
let bridge_account_id = system_accounts::bridge_account_id();
|
||||||
let vault_program_id = programs::vault().id();
|
let vault_program_id = programs::vault().id();
|
||||||
let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id);
|
let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id);
|
||||||
|
let receipt_id = bridge_core::deposit_receipt_account_id(programs::bridge().id(), [0_u8; 32]);
|
||||||
|
|
||||||
// Get pre-state of bridge and vault accounts
|
// Get pre-state of bridge and vault accounts; the receipt is unminted (a
|
||||||
|
// default account), so the program would create it on a first mint.
|
||||||
let bridge_pre = AccountWithMetadata::new(
|
let bridge_pre = AccountWithMetadata::new(
|
||||||
get_account(&ctx, bridge_account_id).await?,
|
get_account(&ctx, bridge_account_id).await?,
|
||||||
false,
|
false,
|
||||||
@ -167,6 +171,8 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> {
|
|||||||
false,
|
false,
|
||||||
recipient_vault_id,
|
recipient_vault_id,
|
||||||
);
|
);
|
||||||
|
let receipt_pre =
|
||||||
|
AccountWithMetadata::new(lee_core::account::Account::default(), false, receipt_id);
|
||||||
|
|
||||||
// Create program with dependencies
|
// Create program with dependencies
|
||||||
let program_with_deps =
|
let program_with_deps =
|
||||||
@ -193,17 +199,25 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// Execute and prove the bridge deposit
|
// Execute and prove the bridge deposit
|
||||||
let (output, proof) = execute_and_prove(
|
let (output, proof) = execute_and_prove(
|
||||||
vec![bridge_pre.clone(), vault_pre.clone()],
|
vec![bridge_pre.clone(), vault_pre.clone(), receipt_pre.clone()],
|
||||||
instruction,
|
instruction,
|
||||||
vec![InputAccountIdentity::Public, InputAccountIdentity::Public],
|
vec![
|
||||||
|
InputAccountIdentity::Public,
|
||||||
|
InputAccountIdentity::Public,
|
||||||
|
InputAccountIdentity::Public,
|
||||||
|
],
|
||||||
&program_with_deps,
|
&program_with_deps,
|
||||||
)
|
)
|
||||||
.context("Failed to execute/prove bridge deposit")?;
|
.context("Failed to execute/prove bridge deposit")?;
|
||||||
|
|
||||||
// Create privacy-preserving transaction from circuit output
|
// Create privacy-preserving transaction from circuit output
|
||||||
let message = privacy_preserving_transaction::Message::try_from_circuit_output(
|
let message = privacy_preserving_transaction::Message::try_from_circuit_output(
|
||||||
vec![bridge_account_id, recipient_vault_id],
|
vec![bridge_account_id, recipient_vault_id, receipt_id],
|
||||||
vec![bridge_pre.account.nonce, vault_pre.account.nonce],
|
vec![
|
||||||
|
bridge_pre.account.nonce,
|
||||||
|
vault_pre.account.nonce,
|
||||||
|
receipt_pre.account.nonce,
|
||||||
|
],
|
||||||
output,
|
output,
|
||||||
)
|
)
|
||||||
.context("Failed to build privacy-preserving bridge deposit message")?;
|
.context("Failed to build privacy-preserving bridge deposit message")?;
|
||||||
|
|||||||
@ -270,6 +270,13 @@ impl V03State {
|
|||||||
.unwrap_or_else(Account::default)
|
.unwrap_or_else(Account::default)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Borrowing counterpart of [`Self::get_account_by_id`], for callers that
|
||||||
|
/// only inspect the account and would otherwise clone it to drop it.
|
||||||
|
#[must_use]
|
||||||
|
pub fn get_public_account(&self, account_id: AccountId) -> Option<&Account> {
|
||||||
|
self.public_state.get(&account_id)
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn get_proof_for_commitment(&self, commitment: &Commitment) -> Option<MembershipProof> {
|
pub fn get_proof_for_commitment(&self, commitment: &Commitment) -> Option<MembershipProof> {
|
||||||
self.private_state.0.get_proof_for(commitment)
|
self.private_state.0.get_proof_for(commitment)
|
||||||
|
|||||||
@ -9,4 +9,5 @@ workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
lee_core.workspace = true
|
lee_core.workspace = true
|
||||||
|
risc0-zkvm.workspace = true
|
||||||
serde = { workspace = true, default-features = false }
|
serde = { workspace = true, default-features = false }
|
||||||
|
|||||||
@ -3,14 +3,19 @@ use lee_core::{account::AccountId, program::ProgramId};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
const BRIDGE_SEED_DOMAIN_SEPARATOR: [u8; 32] = *b"/LEZ/v0.3/BridgeSeed/0000000000/";
|
const BRIDGE_SEED_DOMAIN_SEPARATOR: [u8; 32] = *b"/LEZ/v0.3/BridgeSeed/0000000000/";
|
||||||
|
const DEPOSIT_RECEIPT_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeDepositReceipt/0";
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub enum Instruction {
|
pub enum Instruction {
|
||||||
/// Transfers native tokens from the bridge PDA account to a recipient vault.
|
/// Transfers native tokens from the bridge PDA account to a recipient vault,
|
||||||
|
/// exactly once per `l1_deposit_op_id`.
|
||||||
///
|
///
|
||||||
/// Required accounts (2):
|
/// Required accounts (3):
|
||||||
/// - Bridge PDA account
|
/// - Bridge PDA account
|
||||||
/// - Recipient vault PDA account
|
/// - Recipient vault PDA account
|
||||||
|
/// - Deposit-receipt PDA account, derived from `l1_deposit_op_id`. Its existence records that
|
||||||
|
/// this op id was already minted; a second application of the same op id finds it present and
|
||||||
|
/// transfers nothing.
|
||||||
Deposit {
|
Deposit {
|
||||||
/// Deposit OP ID from L1, stored here to pin each [`Deposit`](Instruction::Deposit) to a
|
/// Deposit OP ID from L1, stored here to pin each [`Deposit`](Instruction::Deposit) to a
|
||||||
/// Deposit Event on L1.
|
/// Deposit Event on L1.
|
||||||
@ -43,3 +48,58 @@ pub const fn compute_bridge_seed() -> PdaSeed {
|
|||||||
pub fn compute_bridge_account_id(bridge_program_id: ProgramId) -> AccountId {
|
pub fn compute_bridge_account_id(bridge_program_id: ProgramId) -> AccountId {
|
||||||
AccountId::for_public_pda(&bridge_program_id, &compute_bridge_seed())
|
AccountId::for_public_pda(&bridge_program_id, &compute_bridge_seed())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Seed of the deposit-receipt PDA for `l1_deposit_op_id`, exposed so the guest
|
||||||
|
/// can claim the account. Domain-separated from [`compute_bridge_seed`].
|
||||||
|
#[must_use]
|
||||||
|
pub fn deposit_receipt_seed(l1_deposit_op_id: [u8; 32]) -> PdaSeed {
|
||||||
|
use risc0_zkvm::sha::{Impl, Sha256 as _};
|
||||||
|
|
||||||
|
let mut bytes = [0_u8; 64];
|
||||||
|
bytes[..32].copy_from_slice(&DEPOSIT_RECEIPT_SEED_DOMAIN);
|
||||||
|
bytes[32..].copy_from_slice(&l1_deposit_op_id);
|
||||||
|
|
||||||
|
let seed: [u8; 32] = Impl::hash_bytes(&bytes)
|
||||||
|
.as_bytes()
|
||||||
|
.try_into()
|
||||||
|
.unwrap_or_else(|_| unreachable!());
|
||||||
|
PdaSeed::new(seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The deposit-receipt PDA whose existence marks `l1_deposit_op_id` as minted.
|
||||||
|
#[must_use]
|
||||||
|
pub fn deposit_receipt_account_id(
|
||||||
|
bridge_program_id: ProgramId,
|
||||||
|
l1_deposit_op_id: [u8; 32],
|
||||||
|
) -> AccountId {
|
||||||
|
AccountId::for_public_pda(&bridge_program_id, &deposit_receipt_seed(l1_deposit_op_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const BRIDGE_ID: ProgramId = [7; 8];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn receipt_id_is_deterministic_per_op_id() {
|
||||||
|
let op = [3_u8; 32];
|
||||||
|
assert_eq!(
|
||||||
|
deposit_receipt_account_id(BRIDGE_ID, op),
|
||||||
|
deposit_receipt_account_id(BRIDGE_ID, op)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distinct_op_ids_and_domains_do_not_collide() {
|
||||||
|
let a = deposit_receipt_account_id(BRIDGE_ID, [1; 32]);
|
||||||
|
let b = deposit_receipt_account_id(BRIDGE_ID, [2; 32]);
|
||||||
|
assert_ne!(a, b, "different op ids must derive different receipts");
|
||||||
|
// The op-id-derived seed must not alias the plain bridge PDA, even if an
|
||||||
|
// op id ever equals the bridge seed's raw bytes.
|
||||||
|
assert_ne!(
|
||||||
|
deposit_receipt_account_id(BRIDGE_ID, *compute_bridge_seed().as_bytes()),
|
||||||
|
compute_bridge_account_id(BRIDGE_ID)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
use bridge_core::Instruction;
|
use bridge_core::Instruction;
|
||||||
use lee_core::program::{
|
use lee_core::{
|
||||||
AccountPostState, ChainedCall, ProgramInput, ProgramOutput, read_lee_inputs,
|
account::Account,
|
||||||
|
program::{AccountPostState, ChainedCall, Claim, ProgramInput, ProgramOutput, read_lee_inputs},
|
||||||
};
|
};
|
||||||
|
|
||||||
fn unchanged_post_states(
|
fn unchanged_post_states(
|
||||||
@ -29,18 +30,17 @@ fn main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let pre_states_clone = pre_states.clone();
|
let pre_states_clone = pre_states.clone();
|
||||||
let post_states = unchanged_post_states(&pre_states_clone);
|
|
||||||
|
|
||||||
let chained_calls = match instruction {
|
let (post_states, chained_calls) = match instruction {
|
||||||
Instruction::Deposit {
|
Instruction::Deposit {
|
||||||
l1_deposit_op_id: _,
|
l1_deposit_op_id,
|
||||||
vault_program_id,
|
vault_program_id,
|
||||||
recipient_id,
|
recipient_id,
|
||||||
amount,
|
amount,
|
||||||
} => {
|
} => {
|
||||||
let [bridge, recipient_vault] = pre_states
|
let [bridge, recipient_vault, receipt] = pre_states
|
||||||
.try_into()
|
.try_into()
|
||||||
.expect("Deposit requires exactly 2 accounts");
|
.expect("Deposit requires exactly 3 accounts");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bridge.account_id,
|
bridge.account_id,
|
||||||
@ -54,20 +54,47 @@ fn main() {
|
|||||||
"Second account must be recipient vault PDA"
|
"Second account must be recipient vault PDA"
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut bridge_for_vault = bridge;
|
assert_eq!(
|
||||||
bridge_for_vault.is_authorized = true;
|
receipt.account_id,
|
||||||
|
bridge_core::deposit_receipt_account_id(self_program_id, l1_deposit_op_id),
|
||||||
|
"Third account must be the deposit-receipt PDA"
|
||||||
|
);
|
||||||
|
|
||||||
vec![
|
// Replay protection: the receipt PDA exists iff this op id was
|
||||||
ChainedCall::new(
|
// already minted. On replay it is non-default and the whole
|
||||||
vault_program_id,
|
// instruction is a no-op.
|
||||||
vec![bridge_for_vault, recipient_vault],
|
if receipt.account != Account::default() {
|
||||||
&vault_core::Instruction::Transfer {
|
(unchanged_post_states(&pre_states_clone), vec![])
|
||||||
recipient_id,
|
} else {
|
||||||
amount: u128::from(amount),
|
// First mint: claim the receipt — its existence is the record,
|
||||||
},
|
// the account's contents are never read — and chain the vault
|
||||||
)
|
// transfer.
|
||||||
.with_pda_seeds(vec![bridge_core::compute_bridge_seed()]),
|
let receipt_post = AccountPostState::new_claimed_if_default(
|
||||||
]
|
receipt.account,
|
||||||
|
Claim::Pda(bridge_core::deposit_receipt_seed(l1_deposit_op_id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let post_states = vec![
|
||||||
|
AccountPostState::new(bridge.account.clone()),
|
||||||
|
AccountPostState::new(recipient_vault.account.clone()),
|
||||||
|
receipt_post,
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut bridge_for_vault = bridge;
|
||||||
|
bridge_for_vault.is_authorized = true;
|
||||||
|
let chained_calls = vec![
|
||||||
|
ChainedCall::new(
|
||||||
|
vault_program_id,
|
||||||
|
vec![bridge_for_vault, recipient_vault],
|
||||||
|
&vault_core::Instruction::Transfer {
|
||||||
|
recipient_id,
|
||||||
|
amount: u128::from(amount),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.with_pda_seeds(vec![bridge_core::compute_bridge_seed()]),
|
||||||
|
];
|
||||||
|
(post_states, chained_calls)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Instruction::Withdraw {
|
Instruction::Withdraw {
|
||||||
amount,
|
amount,
|
||||||
@ -89,13 +116,14 @@ fn main() {
|
|||||||
"Sender account must be owned by the authenticated transfer program"
|
"Sender account must be owned by the authenticated transfer program"
|
||||||
);
|
);
|
||||||
|
|
||||||
vec![ChainedCall::new(
|
let chained_calls = vec![ChainedCall::new(
|
||||||
auth_transfer_program_id,
|
auth_transfer_program_id,
|
||||||
vec![sender, bridge],
|
vec![sender, bridge],
|
||||||
&authenticated_transfer_core::Instruction::Transfer {
|
&authenticated_transfer_core::Instruction::Transfer {
|
||||||
amount: u128::from(amount),
|
amount: u128::from(amount),
|
||||||
},
|
},
|
||||||
)]
|
)];
|
||||||
|
(unchanged_post_states(&pre_states_clone), chained_calls)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -155,14 +155,13 @@ impl SequencerStore {
|
|||||||
pub(crate) fn update(
|
pub(crate) fn update(
|
||||||
&mut self,
|
&mut self,
|
||||||
block: &Block,
|
block: &Block,
|
||||||
deposit_event_ids: &[HashType],
|
withdrawals: &[WithdrawalReconciliationKey],
|
||||||
withdrawals: Vec<WithdrawalReconciliationKey>,
|
|
||||||
state: &V03State,
|
state: &V03State,
|
||||||
checkpoint: Option<&[u8]>,
|
checkpoint: Option<&[u8]>,
|
||||||
) -> DbResult<()> {
|
) -> DbResult<()> {
|
||||||
let new_transactions_map = block_to_transactions_map(block);
|
let new_transactions_map = block_to_transactions_map(block);
|
||||||
self.dbio
|
self.dbio
|
||||||
.atomic_update(block, deposit_event_ids, withdrawals, state, checkpoint)?;
|
.atomic_update(block, withdrawals, state, checkpoint)?;
|
||||||
self.tx_hash_to_block_map.extend(new_transactions_map);
|
self.tx_hash_to_block_map.extend(new_transactions_map);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -214,13 +213,9 @@ impl SequencerStore {
|
|||||||
self.dbio.put_zone_anchor(anchor)
|
self.dbio.put_zone_anchor(anchor)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_unfulfilled_deposit_events(&self) -> DbResult<Vec<PendingDepositEventRecord>> {
|
pub fn get_pending_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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The checkpoint's on-disk encoding. `serde_json` because `SequencerCheckpoint`
|
/// The checkpoint's on-disk encoding. `serde_json` because `SequencerCheckpoint`
|
||||||
@ -277,9 +272,7 @@ mod tests {
|
|||||||
assert_eq!(None, retrieved_tx);
|
assert_eq!(None, retrieved_tx);
|
||||||
// Add the block with the transaction
|
// Add the block with the transaction
|
||||||
let dummy_state = V03State::new();
|
let dummy_state = V03State::new();
|
||||||
node_store
|
node_store.update(&block, &[], &dummy_state, None).unwrap();
|
||||||
.update(&block, &[], vec![], &dummy_state, None)
|
|
||||||
.unwrap();
|
|
||||||
// Try again
|
// Try again
|
||||||
let output = node_store.get_transaction_by_hash(tx.hash());
|
let output = node_store.get_transaction_by_hash(tx.hash());
|
||||||
assert_eq!(Some((tx, 1)), output);
|
assert_eq!(Some((tx, 1)), output);
|
||||||
@ -344,9 +337,7 @@ mod tests {
|
|||||||
let block_hash = block.header.hash;
|
let block_hash = block.header.hash;
|
||||||
|
|
||||||
let dummy_state = V03State::new();
|
let dummy_state = V03State::new();
|
||||||
node_store
|
node_store.update(&block, &[], &dummy_state, None).unwrap();
|
||||||
.update(&block, &[], vec![], &dummy_state, None)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Verify that the latest block meta now equals the new block's hash
|
// Verify that the latest block meta now equals the new block's hash
|
||||||
let latest_meta = node_store.latest_block_meta().unwrap().unwrap();
|
let latest_meta = node_store.latest_block_meta().unwrap().unwrap();
|
||||||
@ -382,9 +373,7 @@ mod tests {
|
|||||||
let block_id = block.header.block_id;
|
let block_id = block.header.block_id;
|
||||||
|
|
||||||
let dummy_state = V03State::new();
|
let dummy_state = V03State::new();
|
||||||
node_store
|
node_store.update(&block, &[], &dummy_state, None).unwrap();
|
||||||
.update(&block, &[], vec![], &dummy_state, None)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Verify initial status is Pending
|
// Verify initial status is Pending
|
||||||
let retrieved_block = node_store.get_block_at_id(block_id).unwrap().unwrap();
|
let retrieved_block = node_store.get_block_at_id(block_id).unwrap().unwrap();
|
||||||
@ -433,7 +422,7 @@ mod tests {
|
|||||||
// Add a new block
|
// Add a new block
|
||||||
let block = common::test_utils::produce_dummy_block(1, None, vec![tx.clone()]);
|
let block = common::test_utils::produce_dummy_block(1, None, vec![tx.clone()]);
|
||||||
node_store
|
node_store
|
||||||
.update(&block, &[], vec![], &V03State::new(), None)
|
.update(&block, &[], &V03State::new(), None)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -455,12 +455,10 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark the deposits' pending records submitted so the production drain
|
// A reconstructed block is finalized, so any deposit it mints is
|
||||||
// skips mints this block already carries. Withdraw intents are
|
// permanently reflected in state (its receipt PDA); drop the pending
|
||||||
// deliberately not counted: backfill already re-delivered and dropped
|
// record backfill may have re-delivered, so the drain stops re-minting.
|
||||||
// their finalized L1 events, so an increment here would never be
|
let finalized_deposit_ids: Vec<_> = block
|
||||||
// consumed and would leave a phantom count.
|
|
||||||
let deposit_event_ids: Vec<_> = block
|
|
||||||
.body
|
.body
|
||||||
.transactions
|
.transactions
|
||||||
.iter()
|
.iter()
|
||||||
@ -478,7 +476,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
blocks: &[(block, true)],
|
blocks: &[(block, true)],
|
||||||
head_tip: head_tip.as_ref(),
|
head_tip: head_tip.as_ref(),
|
||||||
final_snapshot: final_meta.as_ref().map(|meta| (chain.final_state(), meta)),
|
final_snapshot: final_meta.as_ref().map(|meta| (chain.final_state(), meta)),
|
||||||
mark_deposits_submitted: Some((&deposit_event_ids, block_id)),
|
remove_deposit_records: &finalized_deposit_ids,
|
||||||
zone_anchor: Some(&record),
|
zone_anchor: Some(&record),
|
||||||
..StoreUpdate::new(chain.head_state())
|
..StoreUpdate::new(chain.head_state())
|
||||||
})
|
})
|
||||||
@ -500,18 +498,14 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
|
|
||||||
/// Produces a new block from mempool transactions and publishes it via zone-sdk.
|
/// Produces a new block from mempool transactions and publishes it via zone-sdk.
|
||||||
pub async fn produce_new_block(&mut self) -> Result<u64> {
|
pub async fn produce_new_block(&mut self) -> Result<u64> {
|
||||||
let BlockWithMeta {
|
let BlockWithMeta { block, withdrawals } = self
|
||||||
block,
|
|
||||||
deposit_event_ids,
|
|
||||||
withdrawals,
|
|
||||||
} = self
|
|
||||||
.build_block_from_mempool()
|
.build_block_from_mempool()
|
||||||
.context("Failed to build block from mempool transactions")?;
|
.context("Failed to build block from mempool transactions")?;
|
||||||
|
|
||||||
let withdrawal_reconciliation_keys = withdrawals
|
let withdrawal_reconciliation_keys: Vec<_> = withdrawals
|
||||||
.iter()
|
.iter()
|
||||||
.map(|withdraw| withdraw_event_reconciliation_key(&withdraw.outputs))
|
.map(|withdraw| withdraw_event_reconciliation_key(&withdraw.outputs))
|
||||||
.collect::<Result<_>>()
|
.collect::<Result<Vec<_>>>()
|
||||||
.context("Failed to build reconciliation keys for block withdrawals")?;
|
.context("Failed to build reconciliation keys for block withdrawals")?;
|
||||||
|
|
||||||
let (this_msg, checkpoint) = self
|
let (this_msg, checkpoint) = self
|
||||||
@ -523,8 +517,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
self.record_produced_block(
|
self.record_produced_block(
|
||||||
this_msg,
|
this_msg,
|
||||||
&block,
|
&block,
|
||||||
&deposit_event_ids,
|
&withdrawal_reconciliation_keys,
|
||||||
withdrawal_reconciliation_keys,
|
|
||||||
&checkpoint,
|
&checkpoint,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@ -544,8 +537,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
&mut self,
|
&mut self,
|
||||||
this_msg: MsgId,
|
this_msg: MsgId,
|
||||||
block: &Block,
|
block: &Block,
|
||||||
deposit_event_ids: &[HashType],
|
withdrawal_reconciliation_keys: &[WithdrawalReconciliationKey],
|
||||||
withdrawal_reconciliation_keys: Vec<WithdrawalReconciliationKey>,
|
|
||||||
checkpoint: &block_publisher::SequencerCheckpoint,
|
checkpoint: &block_publisher::SequencerCheckpoint,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let checkpoint_bytes = block_store::checkpoint_bytes(checkpoint)?;
|
let checkpoint_bytes = block_store::checkpoint_bytes(checkpoint)?;
|
||||||
@ -557,7 +549,6 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
// with the follow path.
|
// with the follow path.
|
||||||
self.store.update(
|
self.store.update(
|
||||||
block,
|
block,
|
||||||
deposit_event_ids,
|
|
||||||
withdrawal_reconciliation_keys,
|
withdrawal_reconciliation_keys,
|
||||||
chain.head_state(),
|
chain.head_state(),
|
||||||
Some(&checkpoint_bytes),
|
Some(&checkpoint_bytes),
|
||||||
@ -586,18 +577,12 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
/// Validates and applies a single mempool transaction to the current state.
|
/// Validates and applies a single mempool transaction to the current state.
|
||||||
/// Returns `Ok(true)` if the transaction was valid and applied, `Ok(false)` if
|
/// Returns `Ok(true)` if the transaction was valid and applied, `Ok(false)` if
|
||||||
/// it was skipped due to validation failure.
|
/// it was skipped due to validation failure.
|
||||||
#[expect(
|
|
||||||
clippy::too_many_arguments,
|
|
||||||
reason = "splitting the produce-path accumulators into a struct buys nothing"
|
|
||||||
)]
|
|
||||||
fn apply_mempool_transaction(
|
fn apply_mempool_transaction(
|
||||||
store: &SequencerStore,
|
|
||||||
state: &mut lee::V03State,
|
state: &mut lee::V03State,
|
||||||
origin: TransactionOrigin,
|
origin: TransactionOrigin,
|
||||||
tx: &LeeTransaction,
|
tx: &LeeTransaction,
|
||||||
block_height: u64,
|
block_height: u64,
|
||||||
timestamp: u64,
|
timestamp: u64,
|
||||||
deposit_event_ids: &mut Vec<HashType>,
|
|
||||||
withdrawals: &mut Vec<WithdrawArg>,
|
withdrawals: &mut Vec<WithdrawArg>,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
let tx_hash = tx.hash();
|
let tx_hash = tx.hash();
|
||||||
@ -624,17 +609,9 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
panic!("Sequencer may only generate Public transactions, found {tx:#?}");
|
panic!("Sequencer may only generate Public transactions, found {tx:#?}");
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(deposit_op_id) = extract_bridge_deposit_id(tx) {
|
// Bridge deposits are deduped by their receipt PDA in chain
|
||||||
if store
|
// state (drained only when unminted, no-op on replay), so no
|
||||||
.is_deposit_event_submitted(deposit_op_id)
|
// node-local guard is needed here.
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
state
|
state
|
||||||
.transition_from_public_transaction(public_tx, block_height, timestamp)
|
.transition_from_public_transaction(public_tx, block_height, timestamp)
|
||||||
.context("Failed to execute sequencer-generated transaction")?;
|
.context("Failed to execute sequencer-generated transaction")?;
|
||||||
@ -662,22 +639,24 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut valid_transactions = Vec::new();
|
let mut valid_transactions = Vec::new();
|
||||||
let mut deposit_event_ids = Vec::new();
|
|
||||||
let mut withdrawals = Vec::new();
|
let mut withdrawals = Vec::new();
|
||||||
|
|
||||||
// Bridge deposit mints are drained from the store, not the mempool.
|
// Bridge deposit mints are drained from the store, not the mempool: the
|
||||||
// The follow path records the event durably but cannot enqueue the
|
// follow path records the event durably but cannot enqueue the mint
|
||||||
// mint itself (it runs on the publisher's drive task, where an await
|
// itself (it runs on the publisher's drive task, where an await stalls
|
||||||
// stalls the very task production needs), and a mempool copy would
|
// the very task production needs). Draining here also subsumes the old
|
||||||
// race this drain into minting the same deposit twice in one block.
|
// startup replay.
|
||||||
//
|
//
|
||||||
// Draining here also subsumes the old startup replay.
|
// Skip any deposit whose receipt PDA already exists in the state we
|
||||||
|
// build on — it was minted by us or by a peer whose block we adopted.
|
||||||
|
// An orphan reverts the receipt with the block, so the next turn
|
||||||
|
// re-mints without any bookkeeping of our own.
|
||||||
let mut pending_deposits: VecDeque<LeeTransaction> = self
|
let mut pending_deposits: VecDeque<LeeTransaction> = self
|
||||||
.store
|
.store
|
||||||
.get_unfulfilled_deposit_events()
|
.get_pending_deposit_events()
|
||||||
.context("Failed to load unfulfilled deposit events")?
|
.context("Failed to load pending deposit events")?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|record| record.submitted_in_block_id.is_none())
|
.filter(|record| !deposit_already_minted(&working_state, record.deposit_op_id))
|
||||||
.filter_map(|record| {
|
.filter_map(|record| {
|
||||||
build_bridge_deposit_tx_from_event(&record)
|
build_bridge_deposit_tx_from_event(&record)
|
||||||
.inspect_err(|err| {
|
.inspect_err(|err| {
|
||||||
@ -740,13 +719,11 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if Self::apply_mempool_transaction(
|
if Self::apply_mempool_transaction(
|
||||||
&self.store,
|
|
||||||
&mut working_state,
|
&mut working_state,
|
||||||
origin,
|
origin,
|
||||||
&tx,
|
&tx,
|
||||||
new_block_height,
|
new_block_height,
|
||||||
new_block_timestamp,
|
new_block_timestamp,
|
||||||
&mut deposit_event_ids,
|
|
||||||
&mut withdrawals,
|
&mut withdrawals,
|
||||||
)? {
|
)? {
|
||||||
valid_transactions.push(tx);
|
valid_transactions.push(tx);
|
||||||
@ -779,11 +756,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
now.elapsed().as_secs()
|
now.elapsed().as_secs()
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(BlockWithMeta {
|
Ok(BlockWithMeta { block, withdrawals })
|
||||||
block,
|
|
||||||
deposit_event_ids,
|
|
||||||
withdrawals,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads the current head state under the lock without cloning it, so callers
|
/// Reads the current head state under the lock without cloning it, so callers
|
||||||
@ -859,10 +832,19 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
|
|
||||||
struct BlockWithMeta {
|
struct BlockWithMeta {
|
||||||
block: Block,
|
block: Block,
|
||||||
deposit_event_ids: Vec<HashType>,
|
|
||||||
withdrawals: Vec<WithdrawArg>,
|
withdrawals: Vec<WithdrawArg>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `deposit_op_id`'s mint is already reflected in `state` — its receipt
|
||||||
|
/// PDA exists. The receipt is the exactly-once ledger the bridge program keeps.
|
||||||
|
fn deposit_already_minted(state: &lee::V03State, deposit_op_id: HashType) -> bool {
|
||||||
|
let receipt_id =
|
||||||
|
bridge_core::deposit_receipt_account_id(programs::bridge().id(), deposit_op_id.0);
|
||||||
|
state
|
||||||
|
.get_public_account(receipt_id)
|
||||||
|
.is_some_and(|receipt| *receipt != lee::Account::default())
|
||||||
|
}
|
||||||
|
|
||||||
/// Feed one channel delta into the follow state and mirror it to the store:
|
/// Feed one channel delta into the follow state and mirror it to the store:
|
||||||
/// revert orphaned, then apply and persist adopted and finalized blocks.
|
/// revert orphaned, then apply and persist adopted and finalized blocks.
|
||||||
/// Production builds on this same head. Wired to the publisher via
|
/// Production builds on this same head. Wired to the publisher via
|
||||||
@ -954,14 +936,24 @@ fn apply_follow_update(
|
|||||||
});
|
});
|
||||||
let head_tip = chain.head_tip().map(|tip| BlockMeta::from(&tip));
|
let head_tip = chain.head_tip().map(|tip| BlockMeta::from(&tip));
|
||||||
|
|
||||||
// Every block at or below the highest finalized one is irreversible,
|
// Every block at or below the highest finalized one is irreversible, so
|
||||||
// so pending records submitted there can go and stored blocks can be
|
// stored blocks there can be marked finalized.
|
||||||
// marked finalized.
|
|
||||||
let last_finalized = finalized
|
let last_finalized = finalized
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(_, block)| block.header.block_id)
|
.map(|(_, block)| block.header.block_id)
|
||||||
.max();
|
.max();
|
||||||
|
|
||||||
|
// A deposit observed in a finalized block is permanently minted (its
|
||||||
|
// receipt is now in the irreversible tier), so its pending record can be
|
||||||
|
// dropped. Keyed by op id, not block id: a record only goes once its own
|
||||||
|
// deposit finalizes, never because some other block finalized at its
|
||||||
|
// height.
|
||||||
|
let finalized_deposit_ids: Vec<HashType> = finalized
|
||||||
|
.iter()
|
||||||
|
.flat_map(|(_, block)| block.body.transactions.iter())
|
||||||
|
.filter_map(extract_bridge_deposit_id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
// A persist failure is fatal: the in-memory chain has already advanced,
|
// A persist failure is fatal: the in-memory chain has already advanced,
|
||||||
// and continuing would leave a permanent gap in the store. The `panic!`
|
// and continuing would leave a permanent gap in the store. The `panic!`
|
||||||
// ends the drive task, whose cancellation halts the node.
|
// ends the drive task, whose cancellation halts the node.
|
||||||
@ -973,6 +965,7 @@ fn apply_follow_update(
|
|||||||
final_snapshot: final_meta.as_ref().map(|meta| (chain.final_state(), meta)),
|
final_snapshot: final_meta.as_ref().map(|meta| (chain.final_state(), meta)),
|
||||||
finalized_up_to: last_finalized,
|
finalized_up_to: last_finalized,
|
||||||
new_deposit_events: &deposit_records,
|
new_deposit_events: &deposit_records,
|
||||||
|
remove_deposit_records: &finalized_deposit_ids,
|
||||||
consumed_withdrawals: &consumed_withdrawals,
|
consumed_withdrawals: &consumed_withdrawals,
|
||||||
..StoreUpdate::new(chain.head_state())
|
..StoreUpdate::new(chain.head_state())
|
||||||
})
|
})
|
||||||
@ -1152,7 +1145,6 @@ fn pending_deposit_event_record(deposit: &DepositInfo) -> PendingDepositEventRec
|
|||||||
source_tx_hash: HashType(deposit.tx_hash.0),
|
source_tx_hash: HashType(deposit.tx_hash.0),
|
||||||
amount: deposit.amount,
|
amount: deposit.amount,
|
||||||
metadata: deposit.metadata.clone().into(),
|
metadata: deposit.metadata.clone().into(),
|
||||||
submitted_in_block_id: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1164,10 +1156,18 @@ fn build_bridge_deposit_tx_from_event(event: &PendingDepositEventRecord) -> Resu
|
|||||||
let vault_program_id = programs::vault().id();
|
let vault_program_id = programs::vault().id();
|
||||||
let recipient_vault_id =
|
let recipient_vault_id =
|
||||||
vault_core::compute_vault_account_id(vault_program_id, metadata.recipient_id);
|
vault_core::compute_vault_account_id(vault_program_id, metadata.recipient_id);
|
||||||
|
// The receipt PDA carries the exactly-once check: the program reads it to
|
||||||
|
// detect a replay, so it must be in the tx's account list.
|
||||||
|
let receipt_id =
|
||||||
|
bridge_core::deposit_receipt_account_id(bridge_program_id, event.deposit_op_id.0);
|
||||||
|
|
||||||
let message = Message::try_new(
|
let message = Message::try_new(
|
||||||
bridge_program_id,
|
bridge_program_id,
|
||||||
vec![system_accounts::bridge_account_id(), recipient_vault_id],
|
vec![
|
||||||
|
system_accounts::bridge_account_id(),
|
||||||
|
recipient_vault_id,
|
||||||
|
receipt_id,
|
||||||
|
],
|
||||||
vec![],
|
vec![],
|
||||||
bridge_core::Instruction::Deposit {
|
bridge_core::Instruction::Deposit {
|
||||||
l1_deposit_op_id: event.deposit_op_id.0,
|
l1_deposit_op_id: event.deposit_op_id.0,
|
||||||
|
|||||||
@ -18,18 +18,6 @@ use crate::{
|
|||||||
|
|
||||||
pub type SequencerCoreWithMockClients = crate::SequencerCore<MockBlockPublisher>;
|
pub type SequencerCoreWithMockClients = crate::SequencerCore<MockBlockPublisher>;
|
||||||
|
|
||||||
/// A zeroed checkpoint. Tests only assert *that* a checkpoint was persisted
|
|
||||||
/// alongside its effects, never what is in it.
|
|
||||||
#[must_use]
|
|
||||||
pub fn mock_checkpoint() -> SequencerCheckpoint {
|
|
||||||
SequencerCheckpoint {
|
|
||||||
last_msg_id: MsgId::from([0; 32]),
|
|
||||||
pending_txs: Vec::new(),
|
|
||||||
lib: HeaderId::from([0; 32]),
|
|
||||||
lib_slot: Slot::from(0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct MockBlockPublisher {
|
pub struct MockBlockPublisher {
|
||||||
channel_id: ChannelId,
|
channel_id: ChannelId,
|
||||||
@ -119,3 +107,16 @@ impl BlockPublisherTrait for MockBlockPublisher {
|
|||||||
Ok(futures::stream::iter(messages))
|
Ok(futures::stream::iter(messages))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A zeroed checkpoint, for [`MockBlockPublisher::publish_block`] and for tests
|
||||||
|
/// building a [`crate::block_publisher::FollowUpdate`]. Tests only assert *that*
|
||||||
|
/// a checkpoint was persisted alongside its effects, never what is in it.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn mock_checkpoint() -> SequencerCheckpoint {
|
||||||
|
SequencerCheckpoint {
|
||||||
|
last_msg_id: MsgId::from([0; 32]),
|
||||||
|
pending_txs: Vec::new(),
|
||||||
|
lib: HeaderId::from([0; 32]),
|
||||||
|
lib_slot: Slot::from(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -39,11 +39,18 @@ use crate::{
|
|||||||
block_store::SequencerStore,
|
block_store::SequencerStore,
|
||||||
build_bridge_deposit_tx_from_event, build_genesis_state,
|
build_bridge_deposit_tx_from_event, build_genesis_state,
|
||||||
config::{BedrockConfig, GenesisAction, SequencerConfig},
|
config::{BedrockConfig, GenesisAction, SequencerConfig},
|
||||||
is_sequencer_only_program,
|
deposit_already_minted, is_sequencer_only_program,
|
||||||
mock::{SequencerCoreWithMockClients, mock_checkpoint},
|
mock::{SequencerCoreWithMockClients, mock_checkpoint},
|
||||||
resubmittable_txs,
|
resubmittable_txs,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mod reconstruction;
|
||||||
|
|
||||||
|
#[derive(borsh::BorshSerialize)]
|
||||||
|
struct DepositMetadataForEncoding {
|
||||||
|
recipient_id: lee::AccountId,
|
||||||
|
}
|
||||||
|
|
||||||
/// A follow update carrying nothing, to fill in the fields a test does not
|
/// A follow update carrying nothing, to fill in the fields a test does not
|
||||||
/// exercise via `..empty_follow_update()`.
|
/// exercise via `..empty_follow_update()`.
|
||||||
fn empty_follow_update() -> FollowUpdate {
|
fn empty_follow_update() -> FollowUpdate {
|
||||||
@ -57,13 +64,6 @@ fn empty_follow_update() -> FollowUpdate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mod reconstruction;
|
|
||||||
|
|
||||||
#[derive(borsh::BorshSerialize)]
|
|
||||||
struct DepositMetadataForEncoding {
|
|
||||||
recipient_id: lee::AccountId,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn setup_sequencer_config() -> SequencerConfig {
|
fn setup_sequencer_config() -> SequencerConfig {
|
||||||
let tempdir = tempfile::tempdir().unwrap();
|
let tempdir = tempfile::tempdir().unwrap();
|
||||||
let home = tempdir.path().to_path_buf();
|
let home = tempdir.path().to_path_buf();
|
||||||
@ -247,7 +247,6 @@ async fn unfulfilled_deposit_events_are_drained_from_the_store_on_production() {
|
|||||||
source_tx_hash: HashType([7_u8; 32]),
|
source_tx_hash: HashType([7_u8; 32]),
|
||||||
amount: expected_amount,
|
amount: expected_amount,
|
||||||
metadata: borsh::to_vec(&DepositMetadataForEncoding { recipient_id }).unwrap(),
|
metadata: borsh::to_vec(&DepositMetadataForEncoding { recipient_id }).unwrap(),
|
||||||
submitted_in_block_id: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
{
|
{
|
||||||
@ -286,15 +285,20 @@ async fn unfulfilled_deposit_events_are_drained_from_the_store_on_production() {
|
|||||||
"the drained deposit mint should be included in the produced block"
|
"the drained deposit mint should be included in the produced block"
|
||||||
);
|
);
|
||||||
|
|
||||||
let pending_events = sequencer.store.get_unfulfilled_deposit_events().unwrap();
|
// The record stays until its deposit finalizes; exactly-once is enforced by
|
||||||
let drained_event = pending_events
|
// the receipt PDA now in head state, not by any marker on the record.
|
||||||
.into_iter()
|
assert!(
|
||||||
.find(|event| event.deposit_op_id == HashType(deposit_op_id))
|
sequencer
|
||||||
.expect("Pending deposit event should remain in DB until finalized");
|
.store
|
||||||
assert_eq!(
|
.get_pending_deposit_events()
|
||||||
drained_event.submitted_in_block_id,
|
.unwrap()
|
||||||
Some(block_id),
|
.iter()
|
||||||
"inclusion marks the record submitted, so the next turn does not mint it again"
|
.any(|event| event.deposit_op_id == HashType(deposit_op_id)),
|
||||||
|
"the record remains until the deposit finalizes"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
sequencer.with_state(|state| deposit_already_minted(state, HashType(deposit_op_id))),
|
||||||
|
"the deposit's receipt PDA marks it minted in head state"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -315,7 +319,6 @@ async fn a_drained_deposit_is_not_minted_twice_across_turns() {
|
|||||||
source_tx_hash: HashType([7_u8; 32]),
|
source_tx_hash: HashType([7_u8; 32]),
|
||||||
amount: 1,
|
amount: 1,
|
||||||
metadata: borsh::to_vec(&DepositMetadataForEncoding { recipient_id }).unwrap(),
|
metadata: borsh::to_vec(&DepositMetadataForEncoding { recipient_id }).unwrap(),
|
||||||
submitted_in_block_id: None,
|
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@ -339,7 +342,7 @@ async fn a_drained_deposit_is_not_minted_twice_across_turns() {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
minted_in(second),
|
minted_in(second),
|
||||||
0,
|
0,
|
||||||
"the submitted marker must keep the next turn from re-draining the record"
|
"the receipt PDA from the first mint must keep the drain from re-minting"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1358,7 +1361,6 @@ fn resubmittable_txs_drops_clock_and_bridge_deposits() {
|
|||||||
recipient_id: initial_public_user_accounts()[0].account_id,
|
recipient_id: initial_public_user_accounts()[0].account_id,
|
||||||
})
|
})
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
submitted_in_block_id: None,
|
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let withdraw_tx = {
|
let withdraw_tx = {
|
||||||
@ -1461,13 +1463,9 @@ async fn follow_update_records_deposits_for_the_production_drain() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let pending = sequencer.store.get_unfulfilled_deposit_events().unwrap();
|
let pending = sequencer.store.get_pending_deposit_events().unwrap();
|
||||||
assert_eq!(pending.len(), 1);
|
assert_eq!(pending.len(), 1);
|
||||||
assert_eq!(pending[0].deposit_op_id, HashType([21; 32]));
|
assert_eq!(pending[0].deposit_op_id, HashType([21; 32]));
|
||||||
assert!(
|
|
||||||
pending[0].submitted_in_block_id.is_none(),
|
|
||||||
"an observed deposit is owed until a block includes its mint"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@ -1831,7 +1829,6 @@ async fn record_produced_block_skips_persistence_on_lost_race() {
|
|||||||
MsgId::from(our_block.header.hash.0),
|
MsgId::from(our_block.header.hash.0),
|
||||||
&our_block,
|
&our_block,
|
||||||
&[],
|
&[],
|
||||||
vec![],
|
|
||||||
&mock_checkpoint(),
|
&mock_checkpoint(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@ -1860,7 +1857,6 @@ async fn record_produced_block_skips_persistence_when_block_no_longer_chains() {
|
|||||||
MsgId::from(stale.header.hash.0),
|
MsgId::from(stale.header.hash.0),
|
||||||
&stale,
|
&stale,
|
||||||
&[],
|
&[],
|
||||||
vec![],
|
|
||||||
&mock_checkpoint(),
|
&mock_checkpoint(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@ -295,7 +295,6 @@ fn deposit_event_record(
|
|||||||
recipient_id: recipient,
|
recipient_id: recipient,
|
||||||
})
|
})
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
submitted_in_block_id: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -525,7 +524,6 @@ async fn reconstruction_reconciles_already_finished_deposit() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
seq_a.produce_new_block().await.unwrap();
|
seq_a.produce_new_block().await.unwrap();
|
||||||
let deposit_block_id = seq_a.block_store().latest_block_meta().unwrap().unwrap().id;
|
|
||||||
|
|
||||||
let messages = channel_from_store(seq_a.block_store(), 10);
|
let messages = channel_from_store(seq_a.block_store(), 10);
|
||||||
let tip_slot = messages.last().unwrap().1;
|
let tip_slot = messages.last().unwrap().1;
|
||||||
@ -561,18 +559,19 @@ async fn reconstruction_reconciles_already_finished_deposit() {
|
|||||||
"already-finished deposit must be applied exactly once"
|
"already-finished deposit must be applied exactly once"
|
||||||
);
|
);
|
||||||
|
|
||||||
// The pending event is now marked submitted in the reconstructed block, so the
|
// The mint's receipt PDA is in the reconstructed state, and reconstruction
|
||||||
// startup replay would not re-queue it — no double mint on restart.
|
// dropped the pending record backfill had re-delivered — so the production
|
||||||
let record = store_b
|
// drain sees the deposit as minted and never re-emits it.
|
||||||
.get_unfulfilled_deposit_events()
|
assert!(
|
||||||
.unwrap()
|
crate::deposit_already_minted(
|
||||||
.into_iter()
|
chain_b.lock().unwrap().head_state(),
|
||||||
.find(|event| event.deposit_op_id == HashType(deposit_op_id))
|
HashType(deposit_op_id)
|
||||||
.expect("pending deposit event should still be recorded");
|
),
|
||||||
assert_eq!(
|
"the reconstructed deposit's receipt marks it minted"
|
||||||
record.submitted_in_block_id,
|
);
|
||||||
Some(deposit_block_id),
|
assert!(
|
||||||
"reconstruction must reconcile the already-finished deposit against its channel block"
|
store_b.get_pending_deposit_events().unwrap().is_empty(),
|
||||||
|
"reconstruction drops the finalized deposit's pending record"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
use std::{
|
use std::{collections::BTreeMap, path::Path, sync::Arc};
|
||||||
collections::{BTreeMap, HashMap},
|
|
||||||
path::Path,
|
|
||||||
sync::Arc,
|
|
||||||
};
|
|
||||||
|
|
||||||
use borsh::{BorshDeserialize, BorshSerialize};
|
use borsh::{BorshDeserialize, BorshSerialize};
|
||||||
use common::{
|
use common::{
|
||||||
@ -105,43 +101,42 @@ impl DbDump {
|
|||||||
/// cursor, so it must land in the *same* write as the effects it covers.
|
/// cursor, so it must land in the *same* write as the effects it covers.
|
||||||
/// Persisted ahead of them, a crash in between resumes the stream past blocks
|
/// Persisted ahead of them, a crash in between resumes the stream past blocks
|
||||||
/// that never reached the store — a gap the node cannot backfill.
|
/// that never reached the store — a gap the node cannot backfill.
|
||||||
pub struct StoreUpdate<'a> {
|
pub struct StoreUpdate<'update> {
|
||||||
/// Serialized zone-sdk checkpoint for this event.
|
/// Serialized zone-sdk checkpoint for this event.
|
||||||
pub checkpoint: Option<&'a [u8]>,
|
pub checkpoint: Option<&'update [u8]>,
|
||||||
|
|
||||||
/// `(block, finalized)` payloads to write.
|
/// `(block, finalized)` payloads to write.
|
||||||
pub blocks: &'a [(&'a Block, bool)],
|
pub blocks: &'update [(&'update Block, bool)],
|
||||||
|
|
||||||
/// Head tip to pin the stored chain to; `None` only for an empty chain.
|
/// Head tip to pin the stored chain to; `None` only for an empty chain.
|
||||||
pub head_tip: Option<&'a BlockMeta>,
|
pub head_tip: Option<&'update BlockMeta>,
|
||||||
/// State after the last applied block.
|
/// State after the last applied block.
|
||||||
pub head_state: &'a V03State,
|
pub head_state: &'update V03State,
|
||||||
|
|
||||||
/// `(state, meta)` of the final tier, when it advanced.
|
/// `(state, meta)` of the final tier, when it advanced.
|
||||||
pub final_snapshot: Option<(&'a V03State, &'a BlockMeta)>,
|
pub final_snapshot: Option<(&'update V03State, &'update BlockMeta)>,
|
||||||
/// Highest block id this event made irreversible: stored blocks at or below
|
/// Highest block id this event made irreversible: stored blocks at or below
|
||||||
/// it become [`BedrockStatus::Finalized`], and pending deposit records
|
/// it become [`BedrockStatus::Finalized`].
|
||||||
/// submitted there are dropped.
|
|
||||||
pub finalized_up_to: Option<u64>,
|
pub finalized_up_to: Option<u64>,
|
||||||
|
|
||||||
/// Deposit events observed on L1, recorded unless already pending.
|
/// Deposit events observed on L1, recorded unless already pending.
|
||||||
pub new_deposit_events: &'a [PendingDepositEventRecord],
|
pub new_deposit_events: &'update [PendingDepositEventRecord],
|
||||||
/// Deposit op ids included in `block_id`, marked submitted.
|
/// Deposit op ids whose mint finalized: their pending records are dropped.
|
||||||
pub mark_deposits_submitted: Option<(&'a [HashType], u64)>,
|
pub remove_deposit_records: &'update [HashType],
|
||||||
/// L1 withdraw events to reconcile against the local unseen counters.
|
/// L1 withdraw events to reconcile against the local unseen counters.
|
||||||
pub consumed_withdrawals: &'a [WithdrawalReconciliationKey],
|
pub consumed_withdrawals: &'update [WithdrawalReconciliationKey],
|
||||||
/// L2 withdraw intents this update raises, awaiting their L1 event.
|
/// L2 withdraw intents this update raises, awaiting their L1 event.
|
||||||
pub new_withdraw_intents: &'a [WithdrawalReconciliationKey],
|
pub new_withdraw_intents: &'update [WithdrawalReconciliationKey],
|
||||||
|
|
||||||
/// Advance the channel-read anchor.
|
/// Advance the channel-read anchor.
|
||||||
pub zone_anchor: Option<&'a ZoneAnchorRecord>,
|
pub zone_anchor: Option<&'update ZoneAnchorRecord>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> StoreUpdate<'a> {
|
impl<'update> StoreUpdate<'update> {
|
||||||
/// An update that writes nothing but the caller's head `state`, to be
|
/// An update that writes nothing but the caller's head `state`, to be
|
||||||
/// filled in with `..StoreUpdate::new(state)`.
|
/// filled in with `..StoreUpdate::new(state)`.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn new(head_state: &'a V03State) -> Self {
|
pub const fn new(head_state: &'update V03State) -> Self {
|
||||||
Self {
|
Self {
|
||||||
checkpoint: None,
|
checkpoint: None,
|
||||||
blocks: &[],
|
blocks: &[],
|
||||||
@ -150,7 +145,7 @@ impl<'a> StoreUpdate<'a> {
|
|||||||
final_snapshot: None,
|
final_snapshot: None,
|
||||||
finalized_up_to: None,
|
finalized_up_to: None,
|
||||||
new_deposit_events: &[],
|
new_deposit_events: &[],
|
||||||
mark_deposits_submitted: None,
|
remove_deposit_records: &[],
|
||||||
consumed_withdrawals: &[],
|
consumed_withdrawals: &[],
|
||||||
new_withdraw_intents: &[],
|
new_withdraw_intents: &[],
|
||||||
zone_anchor: None,
|
zone_anchor: None,
|
||||||
@ -469,7 +464,7 @@ impl RocksDBIO {
|
|||||||
/// One-shot form of [`RocksDBIO::store_update`]'s `new_deposit_events`.
|
/// One-shot form of [`RocksDBIO::store_update`]'s `new_deposit_events`.
|
||||||
pub fn add_pending_deposit_event(&self, event: PendingDepositEventRecord) -> DbResult<bool> {
|
pub fn add_pending_deposit_event(&self, event: PendingDepositEventRecord) -> DbResult<bool> {
|
||||||
let mut batch = WriteBatch::default();
|
let mut batch = WriteBatch::default();
|
||||||
let accepted = self.stage_pending_deposit_events(&[event], None, None, &mut batch)?;
|
let accepted = self.stage_pending_deposit_events(&[event], &[], &mut batch)?;
|
||||||
self.db.write(batch).map_err(|rerr| {
|
self.db.write(batch).map_err(|rerr| {
|
||||||
DbError::rocksdb_cast_message(
|
DbError::rocksdb_cast_message(
|
||||||
rerr,
|
rerr,
|
||||||
@ -489,18 +484,15 @@ impl RocksDBIO {
|
|||||||
fn stage_pending_deposit_events(
|
fn stage_pending_deposit_events(
|
||||||
&self,
|
&self,
|
||||||
new_events: &[PendingDepositEventRecord],
|
new_events: &[PendingDepositEventRecord],
|
||||||
mark_submitted: Option<(&[HashType], u64)>,
|
remove_op_ids: &[HashType],
|
||||||
finalized_up_to: Option<u64>,
|
|
||||||
batch: &mut WriteBatch,
|
batch: &mut WriteBatch,
|
||||||
) -> DbResult<usize> {
|
) -> DbResult<usize> {
|
||||||
let marks_nothing = mark_submitted.is_none_or(|(ids, _)| ids.is_empty());
|
if new_events.is_empty() && remove_op_ids.is_empty() {
|
||||||
if new_events.is_empty() && marks_nothing && finalized_up_to.is_none() {
|
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut records = self.get_pending_deposit_events()?;
|
let mut records = self.get_pending_deposit_events()?;
|
||||||
let before = records.len();
|
let before = records.len();
|
||||||
let mut changed = false;
|
|
||||||
|
|
||||||
for event in new_events {
|
for event in new_events {
|
||||||
if records
|
if records
|
||||||
@ -513,30 +505,19 @@ impl RocksDBIO {
|
|||||||
}
|
}
|
||||||
let accepted = records.len().saturating_sub(before);
|
let accepted = records.len().saturating_sub(before);
|
||||||
|
|
||||||
if let Some((deposit_op_ids, submitted_block_id)) = mark_submitted {
|
let removed = if remove_op_ids.is_empty() {
|
||||||
for record in records
|
0
|
||||||
.iter_mut()
|
} else {
|
||||||
.filter(|record| record.submitted_in_block_id != Some(submitted_block_id))
|
let after_append = records.len();
|
||||||
.filter(|record| deposit_op_ids.contains(&record.deposit_op_id))
|
records.retain(|record| !remove_op_ids.contains(&record.deposit_op_id));
|
||||||
{
|
after_append.saturating_sub(records.len())
|
||||||
record.submitted_in_block_id = Some(submitted_block_id);
|
};
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Everything at or below a finalized block is irreversible, so records
|
// Tracked separately rather than inferred from the length: one event can
|
||||||
// submitted there have served their purpose.
|
// both observe a deposit and finalize another, which nets to the same
|
||||||
if let Some(finalized_block_id) = finalized_up_to {
|
// count while the contents changed. The common finalizing event owns no
|
||||||
records.retain(|record| {
|
// pending record at all, and must not rewrite the cell.
|
||||||
record
|
if accepted > 0 || removed > 0 {
|
||||||
.submitted_in_block_id
|
|
||||||
.is_none_or(|submitted_id| submitted_id > finalized_block_id)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// The common event finalizes something but touches no record; without
|
|
||||||
// this the cell is rewritten on every one of them.
|
|
||||||
if changed || records.len() != before {
|
|
||||||
self.put_pending_deposit_events_batch(&records, batch)?;
|
self.put_pending_deposit_events_batch(&records, batch)?;
|
||||||
}
|
}
|
||||||
Ok(accepted)
|
Ok(accepted)
|
||||||
@ -558,9 +539,14 @@ impl RocksDBIO {
|
|||||||
return Ok(unmatched);
|
return Ok(unmatched);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut occurrences: HashMap<WithdrawalReconciliationKey, u64> = HashMap::new();
|
// A `Vec` rather than a map: the per-update count is tiny, and it keeps
|
||||||
|
// the staging order deterministic.
|
||||||
|
let mut occurrences: Vec<(WithdrawalReconciliationKey, u64)> = Vec::new();
|
||||||
for withdrawal in withdrawals {
|
for withdrawal in withdrawals {
|
||||||
*occurrences.entry(*withdrawal).or_default() += 1;
|
match occurrences.iter_mut().find(|(key, _)| key == withdrawal) {
|
||||||
|
Some((_, times)) => *times = times.saturating_add(1),
|
||||||
|
None => occurrences.push((*withdrawal, 1)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (withdrawal, times) in occurrences {
|
for (withdrawal, times) in occurrences {
|
||||||
@ -579,12 +565,12 @@ impl RocksDBIO {
|
|||||||
|
|
||||||
match stored.and_then(|count| count.checked_sub(times)) {
|
match stored.and_then(|count| count.checked_sub(times)) {
|
||||||
Some(count) => {
|
Some(count) => {
|
||||||
self.put_batch(&UnseenWithdrawCountCell(count), withdrawal, batch)?
|
self.put_batch(&UnseenWithdrawCountCell(count), withdrawal, batch)?;
|
||||||
}
|
}
|
||||||
// Only stage a delete for a key that was actually there, so a
|
// Only stage a delete for a key that was actually there, so a
|
||||||
// fully unmatched update leaves the batch empty.
|
// fully unmatched update leaves the batch empty.
|
||||||
None if stored.is_some() => {
|
None if stored.is_some() => {
|
||||||
self.del_batch::<UnseenWithdrawCountCell>(withdrawal, batch)?
|
self.del_batch::<UnseenWithdrawCountCell>(withdrawal, batch)?;
|
||||||
}
|
}
|
||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
@ -599,11 +585,7 @@ impl RocksDBIO {
|
|||||||
/// Reads from disk, so blocks the caller is writing itself are already in
|
/// Reads from disk, so blocks the caller is writing itself are already in
|
||||||
/// `to_write` and keep their own version — one `put` per block id, no
|
/// `to_write` and keep their own version — one `put` per block id, no
|
||||||
/// reliance on the order writes are staged in.
|
/// reliance on the order writes are staged in.
|
||||||
fn collect_finalized_up_to(
|
fn collect_finalized_up_to(&self, last_finalized: u64, to_write: &mut BTreeMap<u64, Block>) {
|
||||||
&self,
|
|
||||||
last_finalized: u64,
|
|
||||||
to_write: &mut BTreeMap<u64, Block>,
|
|
||||||
) -> DbResult<()> {
|
|
||||||
let newly_finalized: Vec<Block> = self
|
let newly_finalized: Vec<Block> = self
|
||||||
.get_all_blocks()
|
.get_all_blocks()
|
||||||
.filter_map(Result::ok)
|
.filter_map(Result::ok)
|
||||||
@ -617,15 +599,6 @@ impl RocksDBIO {
|
|||||||
block.bedrock_status = BedrockStatus::Finalized;
|
block.bedrock_status = BedrockStatus::Finalized;
|
||||||
to_write.entry(block.header.block_id).or_insert(block);
|
to_write.entry(block.header.block_id).or_insert(block);
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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(
|
||||||
@ -765,7 +738,7 @@ impl RocksDBIO {
|
|||||||
/// One-shot form of [`RocksDBIO::store_update`]'s `finalized_up_to`.
|
/// One-shot form of [`RocksDBIO::store_update`]'s `finalized_up_to`.
|
||||||
pub fn clean_pending_blocks_up_to(&self, last_finalized: u64) -> DbResult<()> {
|
pub fn clean_pending_blocks_up_to(&self, last_finalized: u64) -> DbResult<()> {
|
||||||
let mut to_write = BTreeMap::new();
|
let mut to_write = BTreeMap::new();
|
||||||
self.collect_finalized_up_to(last_finalized, &mut to_write)?;
|
self.collect_finalized_up_to(last_finalized, &mut to_write);
|
||||||
|
|
||||||
let mut batch = WriteBatch::default();
|
let mut batch = WriteBatch::default();
|
||||||
for block in to_write.values() {
|
for block in to_write.values() {
|
||||||
@ -873,7 +846,7 @@ impl RocksDBIO {
|
|||||||
final_snapshot,
|
final_snapshot,
|
||||||
finalized_up_to,
|
finalized_up_to,
|
||||||
new_deposit_events,
|
new_deposit_events,
|
||||||
mark_deposits_submitted,
|
remove_deposit_records,
|
||||||
consumed_withdrawals,
|
consumed_withdrawals,
|
||||||
new_withdraw_intents,
|
new_withdraw_intents,
|
||||||
zone_anchor,
|
zone_anchor,
|
||||||
@ -919,7 +892,7 @@ impl RocksDBIO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(last_finalized) = finalized_up_to {
|
if let Some(last_finalized) = finalized_up_to {
|
||||||
self.collect_finalized_up_to(last_finalized, &mut to_write)?;
|
self.collect_finalized_up_to(last_finalized, &mut to_write);
|
||||||
}
|
}
|
||||||
for block in to_write.values() {
|
for block in to_write.values() {
|
||||||
self.put_block_payload(block, &mut batch)?;
|
self.put_block_payload(block, &mut batch)?;
|
||||||
@ -927,8 +900,7 @@ impl RocksDBIO {
|
|||||||
|
|
||||||
let accepted_deposits = self.stage_pending_deposit_events(
|
let accepted_deposits = self.stage_pending_deposit_events(
|
||||||
new_deposit_events,
|
new_deposit_events,
|
||||||
mark_deposits_submitted,
|
remove_deposit_records,
|
||||||
finalized_up_to,
|
|
||||||
&mut batch,
|
&mut batch,
|
||||||
)?;
|
)?;
|
||||||
let unmatched_withdrawals =
|
let unmatched_withdrawals =
|
||||||
@ -987,8 +959,8 @@ impl RocksDBIO {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persists a block we produced, its deposit/withdraw bookkeeping, the
|
/// Persists a block we produced, its withdraw intents, the resulting state
|
||||||
/// resulting state and the publish `checkpoint` in one atomic write.
|
/// and the publish `checkpoint` in one atomic write.
|
||||||
///
|
///
|
||||||
/// The produce path is [`Self::store_update`] with a single block that is
|
/// The produce path is [`Self::store_update`] with a single block that is
|
||||||
/// the new tip; the checkpoint belongs in the same write for the same
|
/// the new tip; the checkpoint belongs in the same write for the same
|
||||||
@ -999,8 +971,7 @@ impl RocksDBIO {
|
|||||||
pub fn atomic_update(
|
pub fn atomic_update(
|
||||||
&self,
|
&self,
|
||||||
block: &Block,
|
block: &Block,
|
||||||
deposit_op_ids: &[HashType],
|
withdrawals: &[WithdrawalReconciliationKey],
|
||||||
withdrawals: Vec<WithdrawalReconciliationKey>,
|
|
||||||
state: &V03State,
|
state: &V03State,
|
||||||
checkpoint: Option<&[u8]>,
|
checkpoint: Option<&[u8]>,
|
||||||
) -> DbResult<()> {
|
) -> DbResult<()> {
|
||||||
@ -1008,8 +979,7 @@ impl RocksDBIO {
|
|||||||
checkpoint,
|
checkpoint,
|
||||||
blocks: &[(block, false)],
|
blocks: &[(block, false)],
|
||||||
head_tip: Some(&BlockMeta::from(block)),
|
head_tip: Some(&BlockMeta::from(block)),
|
||||||
mark_deposits_submitted: Some((deposit_op_ids, block.header.block_id)),
|
new_withdraw_intents: withdrawals,
|
||||||
new_withdraw_intents: &withdrawals,
|
|
||||||
..StoreUpdate::new(state)
|
..StoreUpdate::new(state)
|
||||||
})
|
})
|
||||||
.map(|_outcome| ())
|
.map(|_outcome| ())
|
||||||
|
|||||||
@ -232,14 +232,17 @@ impl SimpleWritableCell for ZoneAnchorCell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An L1 deposit event observed but not yet seen finalized.
|
||||||
|
///
|
||||||
|
/// Purely a liveness queue: whether to actually emit a mint is decided against
|
||||||
|
/// chain state (the deposit-receipt PDA), and the record is dropped once its
|
||||||
|
/// mint finalizes.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
|
||||||
pub struct PendingDepositEventRecord {
|
pub struct PendingDepositEventRecord {
|
||||||
pub deposit_op_id: HashType,
|
pub deposit_op_id: HashType,
|
||||||
pub source_tx_hash: HashType,
|
pub source_tx_hash: HashType,
|
||||||
pub amount: u64,
|
pub amount: u64,
|
||||||
pub metadata: Vec<u8>,
|
pub metadata: Vec<u8>,
|
||||||
/// Set when block containing the deposit event is submitted, but not necessarily finalized.
|
|
||||||
pub submitted_in_block_id: Option<u64>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(BorshDeserialize)]
|
#[derive(BorshDeserialize)]
|
||||||
|
|||||||
@ -34,7 +34,6 @@ fn deposit_record(seed: u8) -> PendingDepositEventRecord {
|
|||||||
source_tx_hash: HashType([seed; 32]),
|
source_tx_hash: HashType([seed; 32]),
|
||||||
amount: u64::from(seed),
|
amount: u64::from(seed),
|
||||||
metadata: vec![seed],
|
metadata: vec![seed],
|
||||||
submitted_in_block_id: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -357,7 +356,7 @@ fn redelivered_deposit_is_not_accepted_twice() {
|
|||||||
|
|
||||||
let record = deposit_record(1);
|
let record = deposit_record(1);
|
||||||
dbio.store_update(&StoreUpdate {
|
dbio.store_update(&StoreUpdate {
|
||||||
new_deposit_events: &[record.clone()],
|
new_deposit_events: std::slice::from_ref(&record),
|
||||||
..StoreUpdate::new(&state_with_balance(100))
|
..StoreUpdate::new(&state_with_balance(100))
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@ -376,6 +375,30 @@ fn redelivered_deposit_is_not_accepted_twice() {
|
|||||||
assert_eq!(dbio.get_pending_deposit_events().unwrap().len(), 1);
|
assert_eq!(dbio.get_pending_deposit_events().unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finalized_deposit_records_are_removed_by_op_id() {
|
||||||
|
let temp_dir = tempdir().unwrap();
|
||||||
|
let (dbio, _genesis) = dbio_with_genesis(temp_dir.path());
|
||||||
|
|
||||||
|
let first = deposit_record(1);
|
||||||
|
let second = deposit_record(2);
|
||||||
|
dbio.store_update(&StoreUpdate {
|
||||||
|
new_deposit_events: &[first.clone(), second.clone()],
|
||||||
|
..StoreUpdate::new(&state_with_balance(100))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Only the finalized op id is dropped; the other record stays.
|
||||||
|
dbio.store_update(&StoreUpdate {
|
||||||
|
remove_deposit_records: &[first.deposit_op_id],
|
||||||
|
..StoreUpdate::new(&state_with_balance(100))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let stored = dbio.get_pending_deposit_events().unwrap();
|
||||||
|
assert_eq!(stored, vec![second]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn repeated_withdrawal_key_in_one_update_decrements_once_per_occurrence() {
|
fn repeated_withdrawal_key_in_one_update_decrements_once_per_occurrence() {
|
||||||
let temp_dir = tempdir().unwrap();
|
let temp_dir = tempdir().unwrap();
|
||||||
@ -386,12 +409,12 @@ fn repeated_withdrawal_key_in_one_update_decrements_once_per_occurrence() {
|
|||||||
bedrock_account_pk: [3; 32],
|
bedrock_account_pk: [3; 32],
|
||||||
};
|
};
|
||||||
// Two local intents for the same key.
|
// Two local intents for the same key.
|
||||||
let mut batch = WriteBatch::default();
|
for _ in 0..2 {
|
||||||
dbio.increment_unseen_withdraw_count(key, &mut batch).unwrap();
|
let mut batch = WriteBatch::default();
|
||||||
dbio.db.write(batch).unwrap();
|
dbio.increment_unseen_withdraw_count(key, &mut batch)
|
||||||
let mut batch = WriteBatch::default();
|
.unwrap();
|
||||||
dbio.increment_unseen_withdraw_count(key, &mut batch).unwrap();
|
dbio.db.write(batch).unwrap();
|
||||||
dbio.db.write(batch).unwrap();
|
}
|
||||||
|
|
||||||
// Both L1 events arrive in one update; a per-occurrence disk read would
|
// Both L1 events arrive in one update; a per-occurrence disk read would
|
||||||
// miss the staged decrement and consume only one.
|
// miss the staged decrement and consume only one.
|
||||||
@ -432,7 +455,9 @@ fn unmatched_withdrawal_is_reported_and_writes_nothing() {
|
|||||||
|
|
||||||
assert_eq!(outcome.unmatched_withdrawals.len(), 1);
|
assert_eq!(outcome.unmatched_withdrawals.len(), 1);
|
||||||
assert!(
|
assert!(
|
||||||
dbio.get_opt::<UnseenWithdrawCountCell>(key).unwrap().is_none(),
|
dbio.get_opt::<UnseenWithdrawCountCell>(key)
|
||||||
|
.unwrap()
|
||||||
|
.is_none(),
|
||||||
"an unmatched withdraw must not leave a counter behind"
|
"an unmatched withdraw must not leave a counter behind"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -443,14 +468,8 @@ fn produced_block_persists_its_publish_checkpoint() {
|
|||||||
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
|
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
|
||||||
|
|
||||||
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
|
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
|
||||||
dbio.atomic_update(
|
dbio.atomic_update(&block2, &[], &state_with_balance(200), Some(b"cp-produced"))
|
||||||
&block2,
|
.unwrap();
|
||||||
&[],
|
|
||||||
vec![],
|
|
||||||
&state_with_balance(200),
|
|
||||||
Some(b"cp-produced"),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Storing the block without the checkpoint would let a restart restore a
|
// Storing the block without the checkpoint would let a restart restore a
|
||||||
// pending set that no longer holds the inscription we just published.
|
// pending set that no longer holds the inscription we just published.
|
||||||
@ -458,7 +477,10 @@ fn produced_block_persists_its_publish_checkpoint() {
|
|||||||
dbio.get_zone_sdk_checkpoint_bytes().unwrap().as_deref(),
|
dbio.get_zone_sdk_checkpoint_bytes().unwrap().as_deref(),
|
||||||
Some(b"cp-produced".as_slice())
|
Some(b"cp-produced".as_slice())
|
||||||
);
|
);
|
||||||
assert_eq!(dbio.get_block(2).unwrap().unwrap().header.hash, block2.header.hash);
|
assert_eq!(
|
||||||
|
dbio.get_block(2).unwrap().unwrap().header.hash,
|
||||||
|
block2.header.hash
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -477,7 +499,7 @@ fn produced_block_below_disk_head_pins_meta_and_prunes() {
|
|||||||
// pins the tip meta to the produced block and drops the stale suffix in
|
// pins the tip meta to the produced block and drops the stale suffix in
|
||||||
// the same write, mirroring the follow path.
|
// the same write, mirroring the follow path.
|
||||||
let block2b = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
|
let block2b = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
|
||||||
dbio.atomic_update(&block2b, &[], vec![], &state_with_balance(400), None)
|
dbio.atomic_update(&block2b, &[], &state_with_balance(400), None)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored");
|
let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored");
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user