mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-07-31 10:03:21 +00:00
fix(sequencer): proper deposit and withdraw handling initial fixes for #633
This commit is contained in:
parent
397d6d49b3
commit
f9ef68dbf8
@ -1,4 +1,4 @@
|
|||||||
use std::{pin::Pin, sync::Arc, time::Duration};
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
use anyhow::{Context as _, Result, anyhow, ensure};
|
use anyhow::{Context as _, Result, anyhow, ensure};
|
||||||
use common::block::Block;
|
use common::block::Block;
|
||||||
@ -47,26 +47,15 @@ use crate::config::BedrockConfig;
|
|||||||
/// backpressure if the drive task stalls (reconnect, long backfill).
|
/// backpressure if the drive task stalls (reconnect, long backfill).
|
||||||
const PUBLISH_INBOX_CAPACITY: usize = 32;
|
const PUBLISH_INBOX_CAPACITY: usize = 32;
|
||||||
|
|
||||||
/// Sink for `Event::Published` checkpoints emitted by the drive task.
|
/// Everything one `Event::BlocksProcessed` carries, with inscription payloads
|
||||||
/// Caller is responsible for persistence (e.g. writing to rocksdb).
|
/// decoded into `(MsgId, Block)` pairs.
|
||||||
pub type CheckpointSink = Box<dyn Fn(SequencerCheckpoint) + Send + 'static>;
|
///
|
||||||
|
/// One struct rather than a sink per effect, because the `checkpoint` and
|
||||||
/// Sink for finalized L2 block ids derived from `Event::TxsFinalized` and
|
/// everything it covers must reach the store in a single write.
|
||||||
/// `Event::FinalizedInscriptions`. Caller is responsible for cleanup
|
|
||||||
/// (e.g. marking pending blocks as finalized in storage).
|
|
||||||
pub type FinalizedBlockSink = Box<dyn Fn(u64) + Send + 'static>;
|
|
||||||
|
|
||||||
/// Sink for finalized Bedrock deposit events.
|
|
||||||
pub type OnDepositEventSink =
|
|
||||||
Box<dyn Fn(DepositInfo) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
|
|
||||||
|
|
||||||
/// Sink for finalized Bedrock withdraw events.
|
|
||||||
pub type OnWithdrawEventSink =
|
|
||||||
Box<dyn Fn(WithdrawInfo) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
|
|
||||||
|
|
||||||
/// The channel delta the follow path consumes from one `Event::BlocksProcessed`,
|
|
||||||
/// with inscription payloads decoded into `(MsgId, Block)` pairs.
|
|
||||||
pub struct FollowUpdate {
|
pub struct FollowUpdate {
|
||||||
|
/// Resume cursor for this event. Persist only together with the effects
|
||||||
|
/// below, never ahead of them.
|
||||||
|
pub checkpoint: SequencerCheckpoint,
|
||||||
/// Inscriptions newly on the followed L1 branch, in channel order: they
|
/// Inscriptions newly on the followed L1 branch, in channel order: they
|
||||||
/// extend (or, after a reorg, replace part of) the `head` tier.
|
/// extend (or, after a reorg, replace part of) the `head` tier.
|
||||||
pub adopted: Vec<(MsgId, Block)>,
|
pub adopted: Vec<(MsgId, Block)>,
|
||||||
@ -76,19 +65,24 @@ pub struct FollowUpdate {
|
|||||||
/// Inscriptions whose containing L1 block reached finality: their blocks
|
/// Inscriptions whose containing L1 block reached finality: their blocks
|
||||||
/// move into the irreversible `final` tier.
|
/// move into the irreversible `final` tier.
|
||||||
pub finalized: Vec<(MsgId, Block)>,
|
pub finalized: Vec<(MsgId, Block)>,
|
||||||
|
/// Finalized Bedrock deposit events, to record and mint on L2.
|
||||||
|
pub deposits: Vec<DepositInfo>,
|
||||||
|
/// Finalized Bedrock withdraw events, to reconcile against local intents.
|
||||||
|
pub withdrawals: Vec<WithdrawInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sink for the follow path: apply adopted/finalized blocks to chain state and
|
/// Sink for the follow path: apply the channel delta to chain state and
|
||||||
/// revert orphaned ones.
|
/// persist the whole event in one write.
|
||||||
pub type OnFollowSink = Box<dyn Fn(FollowUpdate) + Send + 'static>;
|
pub type OnFollowSink = Box<dyn Fn(FollowUpdate) + Send + 'static>;
|
||||||
|
|
||||||
/// Commands the drive task executes with `&mut sequencer`.
|
/// Commands the drive task executes with `&mut sequencer`.
|
||||||
enum Command {
|
enum Command {
|
||||||
/// Publish an inscription (+ atomic withdrawals); responds with the assigned `MsgId`.
|
/// Publish an inscription (+ atomic withdrawals); responds with the assigned
|
||||||
|
/// `MsgId` and the checkpoint that now includes it as pending.
|
||||||
Publish {
|
Publish {
|
||||||
inscription: Inscription,
|
inscription: Inscription,
|
||||||
withdrawals: Vec<WithdrawArg>,
|
withdrawals: Vec<WithdrawArg>,
|
||||||
resp: oneshot::Sender<Result<MsgId>>,
|
resp: oneshot::Sender<Result<(MsgId, SequencerCheckpoint)>>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,25 +90,25 @@ type CommandSender = mpsc::Sender<Command>;
|
|||||||
|
|
||||||
#[expect(async_fn_in_trait, reason = "We don't care about Send/Sync here")]
|
#[expect(async_fn_in_trait, reason = "We don't care about Send/Sync here")]
|
||||||
pub trait BlockPublisherTrait: Sized {
|
pub trait BlockPublisherTrait: Sized {
|
||||||
#[expect(
|
|
||||||
clippy::too_many_arguments,
|
|
||||||
reason = "Looks better than bundling all those callbacks into a struct"
|
|
||||||
)]
|
|
||||||
async fn new(
|
async fn new(
|
||||||
config: &BedrockConfig,
|
config: &BedrockConfig,
|
||||||
bedrock_signing_key: Ed25519Key,
|
bedrock_signing_key: Ed25519Key,
|
||||||
resubmit_interval: Duration,
|
resubmit_interval: Duration,
|
||||||
initial_checkpoint: Option<SequencerCheckpoint>,
|
initial_checkpoint: Option<SequencerCheckpoint>,
|
||||||
on_checkpoint: CheckpointSink,
|
|
||||||
on_finalized_block: FinalizedBlockSink,
|
|
||||||
on_deposit_event: OnDepositEventSink,
|
|
||||||
on_withdraw_event: OnWithdrawEventSink,
|
|
||||||
on_follow: OnFollowSink,
|
on_follow: OnFollowSink,
|
||||||
) -> Result<Self>;
|
) -> Result<Self>;
|
||||||
|
|
||||||
/// Publish a block and return the `MsgId` zone-sdk assigned its inscription.
|
/// Publish a block and return the `MsgId` zone-sdk assigned its inscription
|
||||||
/// Zone-sdk drives the actual submission and retries internally.
|
/// together with the checkpoint that now holds it as pending. Zone-sdk
|
||||||
async fn publish_block(&self, block: &Block, withdrawals: Vec<WithdrawArg>) -> Result<MsgId>;
|
/// drives the actual submission and retries internally.
|
||||||
|
///
|
||||||
|
/// The checkpoint must be persisted with the block — restoring an older one
|
||||||
|
/// drops the inscription from the pending set, and it is never resubmitted.
|
||||||
|
async fn publish_block(
|
||||||
|
&self,
|
||||||
|
block: &Block,
|
||||||
|
withdrawals: Vec<WithdrawArg>,
|
||||||
|
) -> Result<(MsgId, SequencerCheckpoint)>;
|
||||||
|
|
||||||
fn channel_id(&self) -> ChannelId;
|
fn channel_id(&self) -> ChannelId;
|
||||||
|
|
||||||
@ -168,10 +162,6 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
|||||||
bedrock_signing_key: Ed25519Key,
|
bedrock_signing_key: Ed25519Key,
|
||||||
resubmit_interval: Duration,
|
resubmit_interval: Duration,
|
||||||
initial_checkpoint: Option<SequencerCheckpoint>,
|
initial_checkpoint: Option<SequencerCheckpoint>,
|
||||||
on_checkpoint: CheckpointSink,
|
|
||||||
on_finalized_block: FinalizedBlockSink,
|
|
||||||
on_deposit_event: OnDepositEventSink,
|
|
||||||
on_withdraw_event: OnWithdrawEventSink,
|
|
||||||
on_follow: OnFollowSink,
|
on_follow: OnFollowSink,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let basic_auth = config.auth.clone().map(Into::into);
|
let basic_auth = config.auth.clone().map(Into::into);
|
||||||
@ -229,7 +219,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let msg_result = published
|
let msg_result = published
|
||||||
.map(|(result, _checkpoint)| result.tx.inscription().this_msg);
|
.map(|(result, checkpoint)| (result.tx.inscription().this_msg, checkpoint));
|
||||||
match &msg_result {
|
match &msg_result {
|
||||||
Ok(_) if withdraw_count == 0 => {
|
Ok(_) if withdraw_count == 0 => {
|
||||||
info!("Published block with the size of {data_byte_size} bytes");
|
info!("Published block with the size of {data_byte_size} bytes");
|
||||||
@ -254,8 +244,6 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
|||||||
channel_update,
|
channel_update,
|
||||||
finalized,
|
finalized,
|
||||||
} => {
|
} => {
|
||||||
on_checkpoint(checkpoint);
|
|
||||||
|
|
||||||
let adopted = channel_update
|
let adopted = channel_update
|
||||||
.adopted
|
.adopted
|
||||||
.iter()
|
.iter()
|
||||||
@ -269,29 +257,35 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut finalized_blocks = Vec::new();
|
let mut finalized_blocks = Vec::new();
|
||||||
|
let mut deposits = Vec::new();
|
||||||
|
let mut withdrawals = Vec::new();
|
||||||
for op in finalized.into_iter().flat_map(|item| item.ops) {
|
for op in finalized.into_iter().flat_map(|item| item.ops) {
|
||||||
match op {
|
match op {
|
||||||
FinalizedOp::Inscription(inscription) => {
|
FinalizedOp::Inscription(inscription) => {
|
||||||
if let Some((msg, block)) =
|
if let Some(entry) =
|
||||||
block_from_inscription(&inscription)
|
block_from_inscription(&inscription)
|
||||||
{
|
{
|
||||||
on_finalized_block(block.header.block_id);
|
finalized_blocks.push(entry);
|
||||||
finalized_blocks.push((msg, block));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FinalizedOp::Deposit(deposit) => {
|
FinalizedOp::Deposit(deposit) => deposits.push(deposit),
|
||||||
on_deposit_event(deposit).await;
|
|
||||||
}
|
|
||||||
FinalizedOp::Withdraw(withdraw) => {
|
FinalizedOp::Withdraw(withdraw) => {
|
||||||
on_withdraw_event(withdraw).await;
|
withdrawals.push(withdraw);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nothing is awaited here: an await in this
|
||||||
|
// arm blocks the same task `publish_block`
|
||||||
|
// needs, and a non-turn sequencer never
|
||||||
|
// drains what it would be waiting on.
|
||||||
on_follow(FollowUpdate {
|
on_follow(FollowUpdate {
|
||||||
|
checkpoint,
|
||||||
adopted,
|
adopted,
|
||||||
orphaned,
|
orphaned,
|
||||||
finalized: finalized_blocks,
|
finalized: finalized_blocks,
|
||||||
|
deposits,
|
||||||
|
withdrawals,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Event::Ready => {}
|
Event::Ready => {}
|
||||||
@ -328,7 +322,11 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn publish_block(&self, block: &Block, withdrawals: Vec<WithdrawArg>) -> Result<MsgId> {
|
async fn publish_block(
|
||||||
|
&self,
|
||||||
|
block: &Block,
|
||||||
|
withdrawals: Vec<WithdrawArg>,
|
||||||
|
) -> Result<(MsgId, SequencerCheckpoint)> {
|
||||||
let data = borsh::to_vec(block).context("Failed to serialize block")?;
|
let data = borsh::to_vec(block).context("Failed to serialize block")?;
|
||||||
let data_bounded: Inscription = data
|
let data_bounded: Inscription = data
|
||||||
.try_into()
|
.try_into()
|
||||||
|
|||||||
@ -158,10 +158,11 @@ impl SequencerStore {
|
|||||||
deposit_event_ids: &[HashType],
|
deposit_event_ids: &[HashType],
|
||||||
withdrawals: Vec<WithdrawalReconciliationKey>,
|
withdrawals: Vec<WithdrawalReconciliationKey>,
|
||||||
state: &V03State,
|
state: &V03State,
|
||||||
|
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)?;
|
.atomic_update(block, deposit_event_ids, 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(())
|
||||||
}
|
}
|
||||||
@ -194,10 +195,12 @@ impl SequencerStore {
|
|||||||
Ok(Some(checkpoint))
|
Ok(Some(checkpoint))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persists `checkpoint` on its own. Only valid when the effects it covers
|
||||||
|
/// are already durable — otherwise it must ride in the same write as them,
|
||||||
|
/// via [`storage::sequencer::StoreUpdate`].
|
||||||
pub fn set_zone_checkpoint(&self, checkpoint: &SequencerCheckpoint) -> Result<()> {
|
pub fn set_zone_checkpoint(&self, checkpoint: &SequencerCheckpoint) -> Result<()> {
|
||||||
let bytes =
|
self.dbio
|
||||||
serde_json::to_vec(checkpoint).context("Failed to serialize zone-sdk checkpoint")?;
|
.put_zone_sdk_checkpoint_bytes(&checkpoint_bytes(checkpoint)?)?;
|
||||||
self.dbio.put_zone_sdk_checkpoint_bytes(&bytes)?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,16 +221,12 @@ impl SequencerStore {
|
|||||||
pub fn is_deposit_event_submitted(&self, deposit_op_id: HashType) -> DbResult<bool> {
|
pub fn is_deposit_event_submitted(&self, deposit_op_id: HashType) -> DbResult<bool> {
|
||||||
self.dbio.is_deposit_event_submitted(deposit_op_id)
|
self.dbio.is_deposit_event_submitted(deposit_op_id)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Marks the given deposit events submitted in `block_id`, in one write.
|
/// The checkpoint's on-disk encoding. `serde_json` because `SequencerCheckpoint`
|
||||||
pub fn mark_deposit_events_submitted(
|
/// derives serde but not borsh; paired with `get_zone_checkpoint`'s decode.
|
||||||
&self,
|
pub(crate) fn checkpoint_bytes(checkpoint: &SequencerCheckpoint) -> Result<Vec<u8>> {
|
||||||
deposit_op_ids: &[HashType],
|
serde_json::to_vec(checkpoint).context("Failed to serialize zone-sdk checkpoint")
|
||||||
submitted_block_id: u64,
|
|
||||||
) -> DbResult<()> {
|
|
||||||
self.dbio
|
|
||||||
.mark_deposit_events_submitted(deposit_op_ids, submitted_block_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> {
|
||||||
@ -279,7 +278,7 @@ mod tests {
|
|||||||
// 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, &[], vec![], &dummy_state)
|
.update(&block, &[], vec![], &dummy_state, None)
|
||||||
.unwrap();
|
.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());
|
||||||
@ -346,7 +345,7 @@ mod tests {
|
|||||||
|
|
||||||
let dummy_state = V03State::new();
|
let dummy_state = V03State::new();
|
||||||
node_store
|
node_store
|
||||||
.update(&block, &[], vec![], &dummy_state)
|
.update(&block, &[], vec![], &dummy_state, None)
|
||||||
.unwrap();
|
.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
|
||||||
@ -384,7 +383,7 @@ mod tests {
|
|||||||
|
|
||||||
let dummy_state = V03State::new();
|
let dummy_state = V03State::new();
|
||||||
node_store
|
node_store
|
||||||
.update(&block, &[], vec![], &dummy_state)
|
.update(&block, &[], vec![], &dummy_state, None)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Verify initial status is Pending
|
// Verify initial status is Pending
|
||||||
@ -434,7 +433,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())
|
.update(&block, &[], vec![], &V03State::new(), None)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
use std::{
|
use std::{
|
||||||
|
collections::VecDeque,
|
||||||
path::Path,
|
path::Path,
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
time::Instant,
|
time::Instant,
|
||||||
@ -31,7 +32,7 @@ pub use mock::SequencerCoreWithMockClients;
|
|||||||
use num_bigint::BigUint;
|
use num_bigint::BigUint;
|
||||||
pub use storage::error::DbError;
|
pub use storage::error::DbError;
|
||||||
use storage::sequencer::{
|
use storage::sequencer::{
|
||||||
RocksDBIO,
|
RocksDBIO, StoreUpdate,
|
||||||
sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey, ZoneAnchorRecord},
|
sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey, ZoneAnchorRecord},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -188,17 +189,12 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
let is_fresh_start = initial_checkpoint.is_none();
|
let is_fresh_start = initial_checkpoint.is_none();
|
||||||
|
|
||||||
let (mempool, mempool_handle) = MemPool::new(config.mempool_max_size);
|
let (mempool, mempool_handle) = MemPool::new(config.mempool_max_size);
|
||||||
replay_unfulfilled_deposit_events(&store, mempool_handle.clone());
|
|
||||||
|
|
||||||
let block_publisher = BP::new(
|
let block_publisher = BP::new(
|
||||||
&config.bedrock_config,
|
&config.bedrock_config,
|
||||||
bedrock_signing_key,
|
bedrock_signing_key,
|
||||||
config.retry_pending_blocks_timeout,
|
config.retry_pending_blocks_timeout,
|
||||||
initial_checkpoint,
|
initial_checkpoint,
|
||||||
Self::on_checkpoint(store.dbio()),
|
|
||||||
Self::on_finalized_block(store.dbio()),
|
|
||||||
Self::on_deposit_event(store.dbio(), mempool_handle.clone()),
|
|
||||||
Self::on_withdraw_event(store.dbio()),
|
|
||||||
Self::on_follow(store.dbio(), Arc::clone(&chain), mempool_handle.clone()),
|
Self::on_follow(store.dbio(), Arc::clone(&chain), mempool_handle.clone()),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@ -241,8 +237,9 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
"First pending block on fresh start should be the genesis block"
|
"First pending block on fresh start should be the genesis block"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let mut last_checkpoint = None;
|
||||||
for block in &pending_blocks {
|
for block in &pending_blocks {
|
||||||
block_publisher
|
let (_msg, checkpoint) = block_publisher
|
||||||
.publish_block(block, vec![])
|
.publish_block(block, vec![])
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|err| {
|
.unwrap_or_else(|err| {
|
||||||
@ -251,6 +248,16 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
block.header.block_id
|
block.header.block_id
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
last_checkpoint = Some(checkpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// These blocks are already stored, so only the sdk's pending set
|
||||||
|
// moved. Checkpoints are cumulative — persisting just the last one
|
||||||
|
// is both sufficient and the only way to keep this loop linear.
|
||||||
|
if let Some(checkpoint) = last_checkpoint {
|
||||||
|
store
|
||||||
|
.set_zone_checkpoint(&checkpoint)
|
||||||
|
.expect("Failed to persist checkpoint after republishing on fresh start");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -448,24 +455,10 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist like the follow path: the tip meta stays pinned to the head
|
// Mark the deposits' pending records submitted so the production drain
|
||||||
// tip even when the reconstructed block lands below it.
|
// skips mints this block already carries. Withdraw intents are
|
||||||
let head_tip = chain.head_tip().map(|head| BlockMeta::from(&head));
|
// deliberately not counted: backfill already re-delivered and dropped
|
||||||
let final_meta = chain.final_tip().map(|meta| BlockMeta::from(&meta));
|
// their finalized L1 events, so an increment here would never be
|
||||||
store
|
|
||||||
.dbio()
|
|
||||||
.store_followed_blocks(
|
|
||||||
&[(block, true)],
|
|
||||||
head_tip.as_ref(),
|
|
||||||
chain.head_state(),
|
|
||||||
final_meta.as_ref().map(|meta| (chain.final_state(), meta)),
|
|
||||||
)
|
|
||||||
.context("Failed to persist reconstructed block")?;
|
|
||||||
|
|
||||||
// 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.
|
// consumed and would leave a phantom count.
|
||||||
let deposit_event_ids: Vec<_> = block
|
let deposit_event_ids: Vec<_> = block
|
||||||
.body
|
.body
|
||||||
@ -473,146 +466,27 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(extract_bridge_deposit_id)
|
.filter_map(extract_bridge_deposit_id)
|
||||||
.collect();
|
.collect();
|
||||||
store
|
|
||||||
.mark_deposit_events_submitted(&deposit_event_ids, block_id)
|
|
||||||
.context("Failed to mark reconstructed deposits submitted")?;
|
|
||||||
|
|
||||||
|
// The tip meta stays pinned to the head tip even when the reconstructed
|
||||||
|
// block lands below it, and the anchor only advances if the block
|
||||||
|
// itself landed.
|
||||||
|
let head_tip = chain.head_tip().map(|head| BlockMeta::from(&head));
|
||||||
|
let final_meta = chain.final_tip().map(|meta| BlockMeta::from(&meta));
|
||||||
store
|
store
|
||||||
.set_zone_anchor(&record)
|
.dbio()
|
||||||
.context("Failed to persist zone anchor")?;
|
.store_update(&StoreUpdate {
|
||||||
|
blocks: &[(block, true)],
|
||||||
|
head_tip: head_tip.as_ref(),
|
||||||
|
final_snapshot: final_meta.as_ref().map(|meta| (chain.final_state(), meta)),
|
||||||
|
mark_deposits_submitted: Some((&deposit_event_ids, block_id)),
|
||||||
|
zone_anchor: Some(&record),
|
||||||
|
..StoreUpdate::new(chain.head_state())
|
||||||
|
})
|
||||||
|
.context("Failed to persist reconstructed block")?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_checkpoint(dbio: Arc<RocksDBIO>) -> block_publisher::CheckpointSink {
|
|
||||||
Box::new(move |cp| {
|
|
||||||
let bytes = match serde_json::to_vec(&cp) {
|
|
||||||
Ok(b) => b,
|
|
||||||
Err(err) => {
|
|
||||||
error!("Failed to serialize zone-sdk checkpoint: {err:#}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Err(err) = dbio.put_zone_sdk_checkpoint_bytes(&bytes) {
|
|
||||||
error!("Failed to persist zone-sdk checkpoint: {err:#}");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_finalized_block(dbio: Arc<RocksDBIO>) -> block_publisher::FinalizedBlockSink {
|
|
||||||
Box::new(move |block_id| {
|
|
||||||
// NOTE: Theoretically Zone SDK may report finalization happening multiple times for the
|
|
||||||
// same block. In practice this is very unlikely to happen. For that to
|
|
||||||
// happen Sequencer should crash between receiving Finalized and Checkpoint events while
|
|
||||||
// these events happen very fast (because Checkpoints are generated by Zone SDK
|
|
||||||
// locally).
|
|
||||||
|
|
||||||
if let Err(err) = dbio.clean_pending_blocks_up_to(block_id) {
|
|
||||||
error!("Failed to mark pending blocks finalized up to {block_id}: {err:#}");
|
|
||||||
}
|
|
||||||
|
|
||||||
match dbio.remove_fulfilled_pending_deposit_events_up_to_block(block_id) {
|
|
||||||
Ok(0) => {}
|
|
||||||
Ok(removed) => {
|
|
||||||
info!(
|
|
||||||
"Removed {removed} fulfilled pending deposit events up to finalized block {block_id}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
error!(
|
|
||||||
"Failed to remove fulfilled pending deposit events up to block {block_id}: {err:#}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_deposit_event(
|
|
||||||
dbio: Arc<RocksDBIO>,
|
|
||||||
mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
|
|
||||||
) -> block_publisher::OnDepositEventSink {
|
|
||||||
Box::new(move |deposit| {
|
|
||||||
// NOTE: Theoretically Zone SDK may report multiple identical deposits. In practice this
|
|
||||||
// is very unlikely to happen. For that to happen Sequencer should crash
|
|
||||||
// between receiving Deposit and Checkpoint events while these events happen
|
|
||||||
// very fast (because Checkpoints are generated by Zone SDK locally).
|
|
||||||
|
|
||||||
let dbio = Arc::clone(&dbio);
|
|
||||||
let mempool_handle = mempool_handle.clone();
|
|
||||||
|
|
||||||
Box::pin(async move {
|
|
||||||
let id_hex = hex::encode(deposit.op_id);
|
|
||||||
info!("Observed Bedrock Deposit event with id: {id_hex}");
|
|
||||||
|
|
||||||
let event_record = pending_deposit_event_record(&deposit);
|
|
||||||
|
|
||||||
match dbio.add_pending_deposit_event(event_record.clone()) {
|
|
||||||
Ok(true) => {}
|
|
||||||
Ok(false) => {
|
|
||||||
info!(
|
|
||||||
"Deposit event {id_hex} already persisted as unfulfilled, skipping duplicate enqueue",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
error!(
|
|
||||||
"Failed to persist unfulfilled deposit event {id_hex} before enqueue: {err:#}. Deposit will be lost.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let tx = match build_bridge_deposit_tx_from_event(&event_record) {
|
|
||||||
Ok(tx) => tx,
|
|
||||||
Err(err) => {
|
|
||||||
error!(
|
|
||||||
"Failed to build transaction from Bedrock deposit event {id_hex}: {err:#}. Deposit will be lost.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(err) = mempool_handle
|
|
||||||
.push((TransactionOrigin::Sequencer, tx))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!(
|
|
||||||
"Failed to queue sequencer transaction built from finalized Bedrock event: {err:#}. Deposit will be lost."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_withdraw_event(dbio: Arc<RocksDBIO>) -> block_publisher::OnWithdrawEventSink {
|
|
||||||
Box::new(move |withdraw| {
|
|
||||||
let dbio = Arc::clone(&dbio);
|
|
||||||
Box::pin(async move {
|
|
||||||
let hash_encoded = hex::encode(withdraw.tx_hash.as_ref());
|
|
||||||
let withdraw_key = match withdraw_event_reconciliation_key(&withdraw.op.outputs) {
|
|
||||||
Ok(key) => key,
|
|
||||||
Err(err) => {
|
|
||||||
error!(
|
|
||||||
"Failed to build reconciliation key for Bedrock Withdraw event with tx_hash {hash_encoded}: {err:#}"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match dbio.consume_unseen_withdraw_count(withdraw_key) {
|
|
||||||
Ok(true) => {
|
|
||||||
info!("Validated Bedrock Withdraw event with tx_hash: {hash_encoded}");
|
|
||||||
}
|
|
||||||
Ok(false) => warn!(
|
|
||||||
"Unexpected Bedrock Withdraw event with tx_hash {hash_encoded}: no matching unseen withdraw found"
|
|
||||||
),
|
|
||||||
Err(err) => error!(
|
|
||||||
"Failed to reconcile Bedrock Withdraw event with tx_hash {hash_encoded}: {err:#}"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Publisher sink adapter over [`apply_follow_update`].
|
/// Publisher sink adapter over [`apply_follow_update`].
|
||||||
fn on_follow(
|
fn on_follow(
|
||||||
dbio: Arc<RocksDBIO>,
|
dbio: Arc<RocksDBIO>,
|
||||||
@ -640,7 +514,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
.collect::<Result<_>>()
|
.collect::<Result<_>>()
|
||||||
.context("Failed to build reconciliation keys for block withdrawals")?;
|
.context("Failed to build reconciliation keys for block withdrawals")?;
|
||||||
|
|
||||||
let this_msg = self
|
let (this_msg, checkpoint) = self
|
||||||
.block_publisher
|
.block_publisher
|
||||||
.publish_block(&block, withdrawals)
|
.publish_block(&block, withdrawals)
|
||||||
.await
|
.await
|
||||||
@ -651,6 +525,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
&block,
|
&block,
|
||||||
&deposit_event_ids,
|
&deposit_event_ids,
|
||||||
withdrawal_reconciliation_keys,
|
withdrawal_reconciliation_keys,
|
||||||
|
&checkpoint,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(block.header.block_id)
|
Ok(block.header.block_id)
|
||||||
@ -671,7 +546,10 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
block: &Block,
|
block: &Block,
|
||||||
deposit_event_ids: &[HashType],
|
deposit_event_ids: &[HashType],
|
||||||
withdrawal_reconciliation_keys: Vec<WithdrawalReconciliationKey>,
|
withdrawal_reconciliation_keys: Vec<WithdrawalReconciliationKey>,
|
||||||
|
checkpoint: &block_publisher::SequencerCheckpoint,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
let checkpoint_bytes = block_store::checkpoint_bytes(checkpoint)?;
|
||||||
|
|
||||||
let mut chain = self.chain.lock().expect("chain state mutex poisoned");
|
let mut chain = self.chain.lock().expect("chain state mutex poisoned");
|
||||||
match chain.apply_produced(this_msg, block) {
|
match chain.apply_produced(this_msg, block) {
|
||||||
AcceptOutcome::Applied => {
|
AcceptOutcome::Applied => {
|
||||||
@ -682,8 +560,12 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
deposit_event_ids,
|
deposit_event_ids,
|
||||||
withdrawal_reconciliation_keys,
|
withdrawal_reconciliation_keys,
|
||||||
chain.head_state(),
|
chain.head_state(),
|
||||||
|
Some(&checkpoint_bytes),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
// Neither branch persists anything, checkpoint included: the
|
||||||
|
// inscription it holds as pending belongs to a block that is not
|
||||||
|
// ours to keep.
|
||||||
AcceptOutcome::AlreadyApplied => {
|
AcceptOutcome::AlreadyApplied => {
|
||||||
warn!(
|
warn!(
|
||||||
"Produced block {} lost a competing-write race, skipping persistence",
|
"Produced block {} lost a competing-write race, skipping persistence",
|
||||||
@ -783,6 +665,31 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
let mut deposit_event_ids = 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.
|
||||||
|
// The follow path records the event durably but cannot enqueue the
|
||||||
|
// mint itself (it runs on the publisher's drive task, where an await
|
||||||
|
// stalls the very task production needs), and a mempool copy would
|
||||||
|
// race this drain into minting the same deposit twice in one block.
|
||||||
|
//
|
||||||
|
// Draining here also subsumes the old startup replay.
|
||||||
|
let mut pending_deposits: VecDeque<LeeTransaction> = self
|
||||||
|
.store
|
||||||
|
.get_unfulfilled_deposit_events()
|
||||||
|
.context("Failed to load unfulfilled deposit events")?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|record| record.submitted_in_block_id.is_none())
|
||||||
|
.filter_map(|record| {
|
||||||
|
build_bridge_deposit_tx_from_event(&record)
|
||||||
|
.inspect_err(|err| {
|
||||||
|
warn!(
|
||||||
|
"Skipping pending deposit event {} due to tx build failure: {err:#}",
|
||||||
|
hex::encode(record.deposit_op_id)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
let max_block_size = usize::try_from(self.sequencer_config.max_block_size.as_u64())
|
let max_block_size = usize::try_from(self.sequencer_config.max_block_size.as_u64())
|
||||||
.expect("`max_block_size` should fit into usize");
|
.expect("`max_block_size` should fit into usize");
|
||||||
|
|
||||||
@ -792,7 +699,14 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
let clock_tx = clock_invocation(new_block_timestamp);
|
let clock_tx = clock_invocation(new_block_timestamp);
|
||||||
let clock_lee_tx = LeeTransaction::Public(clock_tx.clone());
|
let clock_lee_tx = LeeTransaction::Public(clock_tx.clone());
|
||||||
|
|
||||||
while let Some((origin, tx)) = self.mempool.pop() {
|
// Pending deposit mints first, then user work. `from_store` is not the
|
||||||
|
// same as a `Sequencer` origin — the cross-zone watcher pushes those
|
||||||
|
// into the mempool too.
|
||||||
|
while let Some((origin, tx, from_store)) = pending_deposits
|
||||||
|
.pop_front()
|
||||||
|
.map(|tx| (TransactionOrigin::Sequencer, tx, true))
|
||||||
|
.or_else(|| self.mempool.pop().map(|(origin, tx)| (origin, tx, false)))
|
||||||
|
{
|
||||||
let tx_hash = tx.hash();
|
let tx_hash = tx.hash();
|
||||||
|
|
||||||
let temp_valid_transactions = [
|
let temp_valid_transactions = [
|
||||||
@ -817,7 +731,11 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
"Transaction with hash {tx_hash} deferred to next block: \
|
"Transaction with hash {tx_hash} deferred to next block: \
|
||||||
block size {block_size} bytes would exceed limit of {max_block_size} bytes",
|
block size {block_size} bytes would exceed limit of {max_block_size} bytes",
|
||||||
);
|
);
|
||||||
self.mempool.push_front((origin, tx));
|
// A deposit mint needs no requeue: its record stays unfulfilled
|
||||||
|
// in the store and is drained again on the next turn.
|
||||||
|
if !from_store {
|
||||||
|
self.mempool.push_front((origin, tx));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -950,6 +868,8 @@ struct BlockWithMeta {
|
|||||||
/// Production builds on this same head. Wired to the publisher via
|
/// Production builds on this same head. Wired to the publisher via
|
||||||
/// [`SequencerCore::on_follow`]; a free function so tests can drive it directly.
|
/// [`SequencerCore::on_follow`]; a free function so tests can drive it directly.
|
||||||
///
|
///
|
||||||
|
/// Everything the event produced lands in one write — see [`StoreUpdate`].
|
||||||
|
///
|
||||||
/// TODO: unlike the indexer's ingest loop, this path does not retry
|
/// TODO: unlike the indexer's ingest loop, this path does not retry
|
||||||
/// `is_retryable` (transient) apply failures — a failed block just parks and
|
/// `is_retryable` (transient) apply failures — a failed block just parks and
|
||||||
/// relies on a valid successor or a restart. `ChainState` never emits
|
/// relies on a valid successor or a restart. `ChainState` never emits
|
||||||
@ -962,14 +882,42 @@ fn apply_follow_update(
|
|||||||
update: block_publisher::FollowUpdate,
|
update: block_publisher::FollowUpdate,
|
||||||
) {
|
) {
|
||||||
let block_publisher::FollowUpdate {
|
let block_publisher::FollowUpdate {
|
||||||
|
checkpoint,
|
||||||
adopted,
|
adopted,
|
||||||
orphaned,
|
orphaned,
|
||||||
finalized,
|
finalized,
|
||||||
|
deposits,
|
||||||
|
withdrawals,
|
||||||
} = update;
|
} = update;
|
||||||
|
|
||||||
|
let checkpoint_bytes = block_store::checkpoint_bytes(&checkpoint)
|
||||||
|
.unwrap_or_else(|err| panic!("Failed to serialize zone-sdk checkpoint: {err:#}"));
|
||||||
|
|
||||||
|
// NOTE: Theoretically Zone SDK may re-deliver an already seen deposit or
|
||||||
|
// finalization. Both are idempotent here: a deposit already on record is
|
||||||
|
// not re-appended, and a finalization only ever moves the tier forward.
|
||||||
|
let deposit_records: Vec<PendingDepositEventRecord> =
|
||||||
|
deposits.iter().map(pending_deposit_event_record).collect();
|
||||||
|
|
||||||
|
// A withdraw whose outputs we cannot read has no counter to reconcile
|
||||||
|
// against; log and drop it rather than fail the whole update.
|
||||||
|
let consumed_withdrawals: Vec<WithdrawalReconciliationKey> = withdrawals
|
||||||
|
.iter()
|
||||||
|
.filter_map(|withdraw| {
|
||||||
|
withdraw_event_reconciliation_key(&withdraw.op.outputs)
|
||||||
|
.inspect_err(|err| {
|
||||||
|
error!(
|
||||||
|
"Failed to build reconciliation key for Bedrock Withdraw event with tx_hash {}: {err:#}",
|
||||||
|
hex::encode(withdraw.tx_hash.as_ref())
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
// The lock is held across the persist below so disk writes land in apply
|
// The lock is held across the persist below so disk writes land in apply
|
||||||
// order — the produce path persists under this same lock.
|
// order — the produce path persists under this same lock.
|
||||||
let resubmit_txs = {
|
let (resubmit_txs, outcome) = {
|
||||||
let mut chain = chain.lock().expect("chain state mutex poisoned");
|
let mut chain = chain.lock().expect("chain state mutex poisoned");
|
||||||
|
|
||||||
// User txs of orphaned blocks, returned to the mempool below.
|
// User txs of orphaned blocks, returned to the mempool below.
|
||||||
@ -1006,36 +954,57 @@ 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));
|
||||||
|
|
||||||
// One atomic write for the whole update: blocks, tip meta and the
|
// Every block at or below the highest finalized one is irreversible,
|
||||||
// state after the last block land together, so a crash can never
|
// so pending records submitted there can go and stored blocks can be
|
||||||
// leave the stored state ahead of the stored blocks. A persist
|
// marked finalized.
|
||||||
// failure is fatal: the in-memory chain has already advanced, and
|
let last_finalized = finalized
|
||||||
// continuing would leave a permanent gap in the store.
|
.iter()
|
||||||
//
|
.map(|(_, block)| block.header.block_id)
|
||||||
// The `panic!` ends the drive task, whose cancellation halts the node.
|
.max();
|
||||||
//
|
|
||||||
// TODO: the zone-sdk checkpoint is persisted by `on_checkpoint`
|
|
||||||
// before this write; a crash in between resumes past these blocks
|
|
||||||
// without them ever landing in the store. Full `BlocksProcessed`
|
|
||||||
// atomicity (checkpoint + blocks + state in one batch, per the sdk's
|
|
||||||
// event contract) is a follow-up.
|
|
||||||
dbio.store_followed_blocks(
|
|
||||||
&to_persist,
|
|
||||||
head_tip.as_ref(),
|
|
||||||
chain.head_state(),
|
|
||||||
final_meta.as_ref().map(|meta| (chain.final_state(), meta)),
|
|
||||||
)
|
|
||||||
.unwrap_or_else(|err| panic!("Failed to persist followed blocks: {err:#}"));
|
|
||||||
|
|
||||||
resubmit_txs
|
// A persist failure is fatal: the in-memory chain has already advanced,
|
||||||
|
// and continuing would leave a permanent gap in the store. The `panic!`
|
||||||
|
// ends the drive task, whose cancellation halts the node.
|
||||||
|
let outcome = dbio
|
||||||
|
.store_update(&StoreUpdate {
|
||||||
|
checkpoint: Some(&checkpoint_bytes),
|
||||||
|
blocks: &to_persist,
|
||||||
|
head_tip: head_tip.as_ref(),
|
||||||
|
final_snapshot: final_meta.as_ref().map(|meta| (chain.final_state(), meta)),
|
||||||
|
finalized_up_to: last_finalized,
|
||||||
|
new_deposit_events: &deposit_records,
|
||||||
|
consumed_withdrawals: &consumed_withdrawals,
|
||||||
|
..StoreUpdate::new(chain.head_state())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|err| panic!("Failed to persist follow update: {err:#}"));
|
||||||
|
|
||||||
|
(resubmit_txs, outcome)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if outcome.accepted_deposits > 0 {
|
||||||
|
info!(
|
||||||
|
"Recorded {} Bedrock Deposit event(s); their mints are drained from the store on our next turn",
|
||||||
|
outcome.accepted_deposits
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for withdrawal in &outcome.unmatched_withdrawals {
|
||||||
|
warn!(
|
||||||
|
"Unexpected Bedrock Withdraw event of {} to {}: no matching unseen withdraw found",
|
||||||
|
withdrawal.amount,
|
||||||
|
hex::encode(withdrawal.bedrock_account_pk)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Rebuild orphaned work: return its user txs to the mempool so the
|
// Rebuild orphaned work: return its user txs to the mempool so the
|
||||||
// next on-turn production re-includes them on the new head.
|
// next on-turn production re-includes them on the new head.
|
||||||
//
|
//
|
||||||
// We use [`try_push`] here because this is called from the
|
// We use [`try_push`] here because this is called from the publisher's
|
||||||
// publisher's drive task, and only the block production drains the mempool.
|
// drive task, and only block production drains the mempool. A blocking
|
||||||
// A blocking push on a full mempool would deadlock here.
|
// push would stall the drive task, and a sequencer that is not on turn
|
||||||
|
// never produces — so nothing would ever drain it again.
|
||||||
|
//
|
||||||
|
// TODO: a full mempool still drops the transaction; a durable resubmit
|
||||||
|
// queue is a follow-up.
|
||||||
for tx in resubmit_txs {
|
for tx in resubmit_txs {
|
||||||
let tx_hash = tx.hash();
|
let tx_hash = tx.hash();
|
||||||
if let Err(err) = mempool_handle.try_push((TransactionOrigin::User, tx)) {
|
if let Err(err) = mempool_handle.try_push((TransactionOrigin::User, tx)) {
|
||||||
@ -1044,55 +1013,6 @@ fn apply_follow_update(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks the database for any pending deposit events that have not yet been marked as submitted in
|
|
||||||
/// a block, and re-queues them in the mempool in a separate async task for inclusion in the next
|
|
||||||
/// block.
|
|
||||||
fn replay_unfulfilled_deposit_events(
|
|
||||||
store: &SequencerStore,
|
|
||||||
mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
|
|
||||||
) {
|
|
||||||
let replay_records: Vec<PendingDepositEventRecord> = store
|
|
||||||
.get_unfulfilled_deposit_events()
|
|
||||||
.expect("Failed to load unfulfilled deposit events")
|
|
||||||
.into_iter()
|
|
||||||
.filter(|record| record.submitted_in_block_id.is_none())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if replay_records.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
info!(
|
|
||||||
"Found {} unfulfilled deposit events in DB, re-queueing",
|
|
||||||
replay_records.len()
|
|
||||||
);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
for record in replay_records {
|
|
||||||
let tx = match build_bridge_deposit_tx_from_event(&record) {
|
|
||||||
Ok(tx) => tx,
|
|
||||||
Err(err) => {
|
|
||||||
warn!(
|
|
||||||
"Skipping replay of pending deposit event {} due to tx build failure: {err:#}",
|
|
||||||
hex::encode(record.deposit_op_id)
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(err) = mempool_handle
|
|
||||||
.push((TransactionOrigin::Sequencer, tx))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!(
|
|
||||||
"Failed to re-queue unfulfilled deposit event {} from DB: {err:#}",
|
|
||||||
hex::encode(record.deposit_op_id)
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The pre-genesis state: `testnet_initial_state` plus the bridge-lock holdings,
|
/// The pre-genesis state: `testnet_initial_state` plus the bridge-lock holdings,
|
||||||
/// the only accounts seeded outside any transaction. Cross-zone config is seeded
|
/// the only accounts seeded outside any transaction. Cross-zone config is seeded
|
||||||
/// by genesis `InitConfig` transactions and reconstructed by replaying them.
|
/// by genesis `InitConfig` transactions and reconstructed by replaying them.
|
||||||
|
|||||||
@ -3,21 +3,33 @@ use std::time::Duration;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use common::block::Block;
|
use common::block::Block;
|
||||||
use futures::Stream;
|
use futures::Stream;
|
||||||
use logos_blockchain_core::mantle::ops::channel::{ChannelId, MsgId};
|
use logos_blockchain_core::{
|
||||||
|
header::HeaderId,
|
||||||
|
mantle::ops::channel::{ChannelId, MsgId},
|
||||||
|
};
|
||||||
use logos_blockchain_key_management_system_service::keys::Ed25519Key;
|
use logos_blockchain_key_management_system_service::keys::Ed25519Key;
|
||||||
use logos_blockchain_zone_sdk::{Slot, ZoneMessage, sequencer::WithdrawArg};
|
use logos_blockchain_zone_sdk::{Slot, ZoneMessage, sequencer::WithdrawArg};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
block_publisher::{
|
block_publisher::{BlockPublisherTrait, OnFollowSink, SequencerCheckpoint},
|
||||||
BlockPublisherTrait, CheckpointSink, FinalizedBlockSink, OnDepositEventSink, OnFollowSink,
|
|
||||||
OnWithdrawEventSink, SequencerCheckpoint,
|
|
||||||
},
|
|
||||||
config::BedrockConfig,
|
config::BedrockConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
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,
|
||||||
@ -54,16 +66,15 @@ impl BlockPublisherTrait for MockBlockPublisher {
|
|||||||
_bedrock_signing_key: Ed25519Key,
|
_bedrock_signing_key: Ed25519Key,
|
||||||
_resubmit_interval: Duration,
|
_resubmit_interval: Duration,
|
||||||
_initial_checkpoint: Option<SequencerCheckpoint>,
|
_initial_checkpoint: Option<SequencerCheckpoint>,
|
||||||
_on_checkpoint: CheckpointSink,
|
|
||||||
_on_finalized_block: FinalizedBlockSink,
|
|
||||||
_on_deposit_event: OnDepositEventSink,
|
|
||||||
_on_withdraw_event: OnWithdrawEventSink,
|
|
||||||
_on_follow: OnFollowSink,
|
_on_follow: OnFollowSink,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
channel_id: config.channel_id,
|
channel_id: config.channel_id,
|
||||||
driver_cancellation: CancellationToken::new(),
|
driver_cancellation: CancellationToken::new(),
|
||||||
tip_slot: None,
|
// An existing but empty channel: `None` means *missing*, which the
|
||||||
|
// startup guard reads as a wiped Bedrock. Tests that want that say
|
||||||
|
// so via [`Self::with_canned_channel`].
|
||||||
|
tip_slot: Some(Slot::from(0)),
|
||||||
messages: Vec::new(),
|
messages: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -72,11 +83,11 @@ impl BlockPublisherTrait for MockBlockPublisher {
|
|||||||
&self,
|
&self,
|
||||||
block: &Block,
|
block: &Block,
|
||||||
_bridge_withdrawals: Vec<WithdrawArg>,
|
_bridge_withdrawals: Vec<WithdrawArg>,
|
||||||
) -> Result<MsgId> {
|
) -> Result<(MsgId, SequencerCheckpoint)> {
|
||||||
// Deterministic per-block id so head dedup behaves in tests.
|
// Deterministic per-block id so head dedup behaves in tests.
|
||||||
//
|
//
|
||||||
// TODO: should we allow more "mockability" here?
|
// TODO: should we allow more "mockability" here?
|
||||||
Ok(MsgId::from(block.header.hash.0))
|
Ok((MsgId::from(block.header.hash.0), mock_checkpoint()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn channel_id(&self) -> ChannelId {
|
fn channel_id(&self) -> ChannelId {
|
||||||
|
|||||||
@ -22,7 +22,12 @@ use lee_core::{
|
|||||||
account::{AccountWithMetadata, Nonce},
|
account::{AccountWithMetadata, Nonce},
|
||||||
program::PdaSeed,
|
program::PdaSeed,
|
||||||
};
|
};
|
||||||
use logos_blockchain_core::mantle::ops::channel::{ChannelId, MsgId};
|
use logos_blockchain_core::mantle::{
|
||||||
|
ledger::Inputs,
|
||||||
|
ops::channel::{ChannelId, MsgId, deposit::Metadata},
|
||||||
|
tx::TxHash,
|
||||||
|
};
|
||||||
|
use logos_blockchain_zone_sdk::sequencer::DepositInfo;
|
||||||
use mempool::MemPoolHandle;
|
use mempool::MemPoolHandle;
|
||||||
use storage::sequencer::sequencer_cells::PendingDepositEventRecord;
|
use storage::sequencer::sequencer_cells::PendingDepositEventRecord;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
@ -33,12 +38,25 @@ use crate::{
|
|||||||
block_publisher::FollowUpdate,
|
block_publisher::FollowUpdate,
|
||||||
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, SequencerConfig},
|
config::{BedrockConfig, GenesisAction, SequencerConfig},
|
||||||
is_sequencer_only_program,
|
is_sequencer_only_program,
|
||||||
mock::SequencerCoreWithMockClients,
|
mock::{SequencerCoreWithMockClients, mock_checkpoint},
|
||||||
resubmittable_txs,
|
resubmittable_txs,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// A follow update carrying nothing, to fill in the fields a test does not
|
||||||
|
/// exercise via `..empty_follow_update()`.
|
||||||
|
fn empty_follow_update() -> FollowUpdate {
|
||||||
|
FollowUpdate {
|
||||||
|
checkpoint: mock_checkpoint(),
|
||||||
|
adopted: Vec::new(),
|
||||||
|
orphaned: Vec::new(),
|
||||||
|
finalized: Vec::new(),
|
||||||
|
deposits: Vec::new(),
|
||||||
|
withdrawals: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mod reconstruction;
|
mod reconstruction;
|
||||||
|
|
||||||
#[derive(borsh::BorshSerialize)]
|
#[derive(borsh::BorshSerialize)]
|
||||||
@ -211,8 +229,10 @@ async fn start_from_config_panics_when_db_open_returns_non_not_found_error() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn start_from_config_replays_unfulfilled_deposit_events_from_db() {
|
async fn unfulfilled_deposit_events_are_drained_from_the_store_on_production() {
|
||||||
let config = setup_sequencer_config();
|
let mut config = setup_sequencer_config();
|
||||||
|
// The mint moves funds out of the bridge account, so it has to hold some.
|
||||||
|
config.genesis = vec![GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }];
|
||||||
let deposit_op_id = [13_u8; 32];
|
let deposit_op_id = [13_u8; 32];
|
||||||
let expected_amount = 1_u64;
|
let expected_amount = 1_u64;
|
||||||
let recipient_id = initial_public_user_accounts()[0].account_id;
|
let recipient_id = initial_public_user_accounts()[0].account_id;
|
||||||
@ -241,36 +261,86 @@ async fn start_from_config_replays_unfulfilled_deposit_events_from_db() {
|
|||||||
assert!(inserted);
|
assert!(inserted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The mint never goes through the mempool: the record is the queue, and
|
||||||
|
// production drains it. That is what makes a restart — or a follow event
|
||||||
|
// arriving while a full mempool would have dropped the push — lossless.
|
||||||
let (mut sequencer, _mempool_handle) =
|
let (mut sequencer, _mempool_handle) =
|
||||||
SequencerCoreWithMockClients::start_from_config(config).await;
|
SequencerCoreWithMockClients::start_from_config(config).await;
|
||||||
|
assert!(
|
||||||
|
sequencer.mempool.pop().is_none(),
|
||||||
|
"deposit mints are drained from the store, never queued in the mempool"
|
||||||
|
);
|
||||||
|
|
||||||
let (origin, tx) = tokio::time::timeout(Duration::from_secs(5), async {
|
let block_id = sequencer.produce_new_block().await.unwrap();
|
||||||
loop {
|
let block = sequencer
|
||||||
if let Some((origin, tx)) = sequencer.mempool.pop() {
|
.store
|
||||||
return (origin, tx);
|
.get_block_at_id(block_id)
|
||||||
}
|
.unwrap()
|
||||||
|
.expect("produced block is stored");
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
assert!(
|
||||||
}
|
block
|
||||||
})
|
.body
|
||||||
.await
|
.transactions
|
||||||
.expect("Timed out waiting for pending deposit event to be replayed into mempool");
|
.iter()
|
||||||
|
.any(|tx| tx_is_bridge_deposit(tx, deposit_op_id, expected_amount)),
|
||||||
match origin {
|
"the drained deposit mint should be included in the produced block"
|
||||||
TransactionOrigin::Sequencer => {}
|
);
|
||||||
TransactionOrigin::User => {
|
|
||||||
panic!("Unexpected user transaction in empty mempool replay test")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert!(tx_is_bridge_deposit(&tx, deposit_op_id, expected_amount));
|
|
||||||
|
|
||||||
let pending_events = sequencer.store.get_unfulfilled_deposit_events().unwrap();
|
let pending_events = sequencer.store.get_unfulfilled_deposit_events().unwrap();
|
||||||
let replayed_event = pending_events
|
let drained_event = pending_events
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|event| event.deposit_op_id == HashType(deposit_op_id))
|
.find(|event| event.deposit_op_id == HashType(deposit_op_id))
|
||||||
.expect("Pending deposit event should remain in DB until included in a block");
|
.expect("Pending deposit event should remain in DB until finalized");
|
||||||
assert!(replayed_event.submitted_in_block_id.is_none());
|
assert_eq!(
|
||||||
|
drained_event.submitted_in_block_id,
|
||||||
|
Some(block_id),
|
||||||
|
"inclusion marks the record submitted, so the next turn does not mint it again"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_drained_deposit_is_not_minted_twice_across_turns() {
|
||||||
|
let mut config = setup_sequencer_config();
|
||||||
|
config.genesis = vec![GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }];
|
||||||
|
let deposit_op_id = [17_u8; 32];
|
||||||
|
let recipient_id = initial_public_user_accounts()[0].account_id;
|
||||||
|
|
||||||
|
let (mut sequencer, _mempool_handle) =
|
||||||
|
SequencerCoreWithMockClients::start_from_config(config).await;
|
||||||
|
sequencer
|
||||||
|
.store
|
||||||
|
.dbio()
|
||||||
|
.add_pending_deposit_event(PendingDepositEventRecord {
|
||||||
|
deposit_op_id: HashType(deposit_op_id),
|
||||||
|
source_tx_hash: HashType([7_u8; 32]),
|
||||||
|
amount: 1,
|
||||||
|
metadata: borsh::to_vec(&DepositMetadataForEncoding { recipient_id }).unwrap(),
|
||||||
|
submitted_in_block_id: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let first = sequencer.produce_new_block().await.unwrap();
|
||||||
|
let second = sequencer.produce_new_block().await.unwrap();
|
||||||
|
|
||||||
|
let minted_in = |block_id: u64| {
|
||||||
|
sequencer
|
||||||
|
.store
|
||||||
|
.get_block_at_id(block_id)
|
||||||
|
.unwrap()
|
||||||
|
.expect("produced block is stored")
|
||||||
|
.body
|
||||||
|
.transactions
|
||||||
|
.iter()
|
||||||
|
.filter(|tx| tx_is_bridge_deposit(tx, deposit_op_id, 1))
|
||||||
|
.count()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(minted_in(first), 1);
|
||||||
|
assert_eq!(
|
||||||
|
minted_in(second),
|
||||||
|
0,
|
||||||
|
"the submitted marker must keep the next turn from re-draining the record"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1335,6 +1405,71 @@ fn resubmittable_txs_of_blocks_without_user_txs_is_empty() {
|
|||||||
assert!(resubmittable_txs(&clock_only).is_empty());
|
assert!(resubmittable_txs(&clock_only).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn follow_update_persists_the_checkpoint_with_its_effects() {
|
||||||
|
let config = setup_sequencer_config();
|
||||||
|
let (sequencer, mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await;
|
||||||
|
let genesis_meta = sequencer
|
||||||
|
.store
|
||||||
|
.latest_block_meta()
|
||||||
|
.unwrap()
|
||||||
|
.expect("genesis meta is set");
|
||||||
|
|
||||||
|
let peer_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![]);
|
||||||
|
apply_follow_update(
|
||||||
|
&sequencer.store.dbio(),
|
||||||
|
&sequencer.chain(),
|
||||||
|
&mempool_handle,
|
||||||
|
FollowUpdate {
|
||||||
|
adopted: vec![(MsgId::from([1; 32]), peer_block)],
|
||||||
|
..empty_follow_update()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// The checkpoint is the sdk resume cursor; landing it without the block
|
||||||
|
// would let a restart stream past a block the store never got.
|
||||||
|
assert!(
|
||||||
|
sequencer.store.get_zone_checkpoint().unwrap().is_some(),
|
||||||
|
"the event's checkpoint must be persisted alongside the block it covers"
|
||||||
|
);
|
||||||
|
assert!(sequencer.store.get_block_at_id(2).unwrap().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn follow_update_records_deposits_for_the_production_drain() {
|
||||||
|
let config = setup_sequencer_config();
|
||||||
|
let (sequencer, mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await;
|
||||||
|
|
||||||
|
let recipient_id = initial_public_user_accounts()[0].account_id;
|
||||||
|
let metadata = borsh::to_vec(&DepositMetadataForEncoding { recipient_id }).unwrap();
|
||||||
|
let deposit = DepositInfo {
|
||||||
|
op_id: [21; 32],
|
||||||
|
tx_hash: TxHash::from([9; 32]),
|
||||||
|
channel_id: ChannelId::from([0; 32]),
|
||||||
|
inputs: Inputs::empty(),
|
||||||
|
amount: 5,
|
||||||
|
metadata: Metadata::try_from(metadata).expect("deposit metadata fits"),
|
||||||
|
};
|
||||||
|
|
||||||
|
apply_follow_update(
|
||||||
|
&sequencer.store.dbio(),
|
||||||
|
&sequencer.chain(),
|
||||||
|
&mempool_handle,
|
||||||
|
FollowUpdate {
|
||||||
|
deposits: vec![deposit],
|
||||||
|
..empty_follow_update()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let pending = sequencer.store.get_unfulfilled_deposit_events().unwrap();
|
||||||
|
assert_eq!(pending.len(), 1);
|
||||||
|
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]
|
||||||
async fn follow_adopted_peer_block_applies_and_persists() {
|
async fn follow_adopted_peer_block_applies_and_persists() {
|
||||||
let config = setup_sequencer_config();
|
let config = setup_sequencer_config();
|
||||||
@ -1362,8 +1497,7 @@ async fn follow_adopted_peer_block_applies_and_persists() {
|
|||||||
&mempool_handle,
|
&mempool_handle,
|
||||||
FollowUpdate {
|
FollowUpdate {
|
||||||
adopted: vec![(MsgId::from([1; 32]), peer_block.clone())],
|
adopted: vec![(MsgId::from([1; 32]), peer_block.clone())],
|
||||||
orphaned: vec![],
|
..empty_follow_update()
|
||||||
finalized: vec![],
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1410,8 +1544,7 @@ async fn follow_redelivery_of_own_block_is_deduped() {
|
|||||||
&mempool_handle,
|
&mempool_handle,
|
||||||
FollowUpdate {
|
FollowUpdate {
|
||||||
adopted: vec![(MsgId::from(block2.header.hash.0), block2)],
|
adopted: vec![(MsgId::from(block2.header.hash.0), block2)],
|
||||||
orphaned: vec![],
|
..empty_follow_update()
|
||||||
finalized: vec![],
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1452,7 +1585,7 @@ async fn follow_orphan_reverts_head_and_requeues_user_txs() {
|
|||||||
FollowUpdate {
|
FollowUpdate {
|
||||||
adopted: vec![],
|
adopted: vec![],
|
||||||
orphaned: vec![(MsgId::from(block2.header.hash.0), block2)],
|
orphaned: vec![(MsgId::from(block2.header.hash.0), block2)],
|
||||||
finalized: vec![],
|
..empty_follow_update()
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1496,6 +1629,7 @@ async fn follow_finalized_own_block_moves_final_tier_and_marks_store() {
|
|||||||
adopted: vec![],
|
adopted: vec![],
|
||||||
orphaned: vec![],
|
orphaned: vec![],
|
||||||
finalized: vec![(MsgId::from(block2.header.hash.0), block2)],
|
finalized: vec![(MsgId::from(block2.header.hash.0), block2)],
|
||||||
|
..empty_follow_update()
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1533,6 +1667,7 @@ async fn follow_finalized_backfill_block_is_applied_and_marked_finalized() {
|
|||||||
adopted: vec![],
|
adopted: vec![],
|
||||||
orphaned: vec![],
|
orphaned: vec![],
|
||||||
finalized: vec![(MsgId::from([2; 32]), peer_block.clone())],
|
finalized: vec![(MsgId::from([2; 32]), peer_block.clone())],
|
||||||
|
..empty_follow_update()
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1593,7 +1728,7 @@ async fn restart_restores_head_tier_and_recovers_from_orphan() {
|
|||||||
FollowUpdate {
|
FollowUpdate {
|
||||||
adopted: vec![(MsgId::from([21; 32]), block2_prime.clone())],
|
adopted: vec![(MsgId::from([21; 32]), block2_prime.clone())],
|
||||||
orphaned: vec![(MsgId::from([20; 32]), block2)],
|
orphaned: vec![(MsgId::from([20; 32]), block2)],
|
||||||
finalized: vec![],
|
..empty_follow_update()
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1646,6 +1781,7 @@ async fn restart_reanchors_on_the_persisted_final_snapshot() {
|
|||||||
adopted: vec![],
|
adopted: vec![],
|
||||||
orphaned: vec![],
|
orphaned: vec![],
|
||||||
finalized: vec![(MsgId::from(block2.header.hash.0), block2)],
|
finalized: vec![(MsgId::from(block2.header.hash.0), block2)],
|
||||||
|
..empty_follow_update()
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1696,6 +1832,7 @@ async fn record_produced_block_skips_persistence_on_lost_race() {
|
|||||||
&our_block,
|
&our_block,
|
||||||
&[],
|
&[],
|
||||||
vec![],
|
vec![],
|
||||||
|
&mock_checkpoint(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@ -1719,7 +1856,13 @@ async fn record_produced_block_skips_persistence_when_block_no_longer_chains() {
|
|||||||
// The head reorged under us: our block's parent is no longer the tip.
|
// The head reorged under us: our block's parent is no longer the tip.
|
||||||
let stale = common::test_utils::produce_dummy_block(2, Some(HashType([9; 32])), vec![]);
|
let stale = common::test_utils::produce_dummy_block(2, Some(HashType([9; 32])), vec![]);
|
||||||
sequencer
|
sequencer
|
||||||
.record_produced_block(MsgId::from(stale.header.hash.0), &stale, &[], vec![])
|
.record_produced_block(
|
||||||
|
MsgId::from(stale.header.hash.0),
|
||||||
|
&stale,
|
||||||
|
&[],
|
||||||
|
vec![],
|
||||||
|
&mock_checkpoint(),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(sequencer.store.get_block_at_id(2).unwrap().is_none());
|
assert!(sequencer.store.get_block_at_id(2).unwrap().is_none());
|
||||||
@ -1760,6 +1903,7 @@ async fn follow_update_persists_blocks_meta_and_state_atomically() {
|
|||||||
],
|
],
|
||||||
orphaned: vec![],
|
orphaned: vec![],
|
||||||
finalized: vec![(MsgId::from([2; 32]), block2)],
|
finalized: vec![(MsgId::from([2; 32]), block2)],
|
||||||
|
..empty_follow_update()
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -67,6 +67,18 @@ pub trait DBIO {
|
|||||||
cell.put_batch(self.db(), params, write_batch)
|
cell.put_batch(self.db(), params, write_batch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stage a cell deletion into `write_batch`, the counterpart of
|
||||||
|
/// [`Self::put_batch`]. Deleting an absent key is a no-op (rocksdb
|
||||||
|
/// semantics).
|
||||||
|
fn del_batch<T: SimpleStorableCell>(
|
||||||
|
&self,
|
||||||
|
params: T::KeyParams,
|
||||||
|
write_batch: &mut WriteBatch,
|
||||||
|
) -> DbResult<()> {
|
||||||
|
write_batch.delete_cf(&T::column_ref(self.db()), T::key_constructor(params)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete a cell. Deleting an absent key is a no-op (rocksdb semantics).
|
/// Delete a cell. Deleting an absent key is a no-op (rocksdb semantics).
|
||||||
fn del<T: SimpleStorableCell>(&self, params: T::KeyParams) -> DbResult<()> {
|
fn del<T: SimpleStorableCell>(&self, params: T::KeyParams) -> DbResult<()> {
|
||||||
let cf_ref = T::column_ref(self.db());
|
let cf_ref = T::column_ref(self.db());
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
use std::{path::Path, sync::Arc};
|
use std::{
|
||||||
|
collections::{BTreeMap, HashMap},
|
||||||
|
path::Path,
|
||||||
|
sync::Arc,
|
||||||
|
};
|
||||||
|
|
||||||
use borsh::{BorshDeserialize, BorshSerialize};
|
use borsh::{BorshDeserialize, BorshSerialize};
|
||||||
use common::{
|
use common::{
|
||||||
@ -13,10 +17,7 @@ use rocksdb::{
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
CF_BLOCK_NAME, CF_META_NAME, DB_META_FIRST_BLOCK_IN_DB_KEY, DBIO, DbResult,
|
CF_BLOCK_NAME, CF_META_NAME, DB_META_FIRST_BLOCK_IN_DB_KEY, DBIO, DbResult,
|
||||||
cells::{
|
cells::shared_cells::{BlockCell, FirstBlockCell, FirstBlockSetCell, LastBlockCell},
|
||||||
SimpleStorableCell,
|
|
||||||
shared_cells::{BlockCell, FirstBlockCell, FirstBlockSetCell, LastBlockCell},
|
|
||||||
},
|
|
||||||
error::DbError,
|
error::DbError,
|
||||||
sequencer::sequencer_cells::{
|
sequencer::sequencer_cells::{
|
||||||
FinalBlockMetaCellOwned, FinalBlockMetaCellRef, FinalLeeStateCellOwned,
|
FinalBlockMetaCellOwned, FinalBlockMetaCellRef, FinalLeeStateCellOwned,
|
||||||
@ -97,6 +98,78 @@ impl DbDump {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Everything one sequencer event writes, staged into a single [`WriteBatch`]
|
||||||
|
/// by [`RocksDBIO::store_update`].
|
||||||
|
///
|
||||||
|
/// The point of the struct is the `checkpoint`: it is the zone-sdk's resume
|
||||||
|
/// 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
|
||||||
|
/// that never reached the store — a gap the node cannot backfill.
|
||||||
|
pub struct StoreUpdate<'a> {
|
||||||
|
/// Serialized zone-sdk checkpoint for this event.
|
||||||
|
pub checkpoint: Option<&'a [u8]>,
|
||||||
|
|
||||||
|
/// `(block, finalized)` payloads to write.
|
||||||
|
pub blocks: &'a [(&'a Block, bool)],
|
||||||
|
|
||||||
|
/// Head tip to pin the stored chain to; `None` only for an empty chain.
|
||||||
|
pub head_tip: Option<&'a BlockMeta>,
|
||||||
|
/// State after the last applied block.
|
||||||
|
pub head_state: &'a V03State,
|
||||||
|
|
||||||
|
/// `(state, meta)` of the final tier, when it advanced.
|
||||||
|
pub final_snapshot: Option<(&'a V03State, &'a BlockMeta)>,
|
||||||
|
/// Highest block id this event made irreversible: stored blocks at or below
|
||||||
|
/// it become [`BedrockStatus::Finalized`], and pending deposit records
|
||||||
|
/// submitted there are dropped.
|
||||||
|
pub finalized_up_to: Option<u64>,
|
||||||
|
|
||||||
|
/// Deposit events observed on L1, recorded unless already pending.
|
||||||
|
pub new_deposit_events: &'a [PendingDepositEventRecord],
|
||||||
|
/// Deposit op ids included in `block_id`, marked submitted.
|
||||||
|
pub mark_deposits_submitted: Option<(&'a [HashType], u64)>,
|
||||||
|
/// L1 withdraw events to reconcile against the local unseen counters.
|
||||||
|
pub consumed_withdrawals: &'a [WithdrawalReconciliationKey],
|
||||||
|
/// L2 withdraw intents this update raises, awaiting their L1 event.
|
||||||
|
pub new_withdraw_intents: &'a [WithdrawalReconciliationKey],
|
||||||
|
|
||||||
|
/// Advance the channel-read anchor.
|
||||||
|
pub zone_anchor: Option<&'a ZoneAnchorRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> StoreUpdate<'a> {
|
||||||
|
/// An update that writes nothing but the caller's head `state`, to be
|
||||||
|
/// filled in with `..StoreUpdate::new(state)`.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(head_state: &'a V03State) -> Self {
|
||||||
|
Self {
|
||||||
|
checkpoint: None,
|
||||||
|
blocks: &[],
|
||||||
|
head_tip: None,
|
||||||
|
head_state,
|
||||||
|
final_snapshot: None,
|
||||||
|
finalized_up_to: None,
|
||||||
|
new_deposit_events: &[],
|
||||||
|
mark_deposits_submitted: None,
|
||||||
|
consumed_withdrawals: &[],
|
||||||
|
new_withdraw_intents: &[],
|
||||||
|
zone_anchor: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [`RocksDBIO::store_update`] observed while staging, for the caller to
|
||||||
|
/// act on *after* the write committed.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct StoreUpdateOutcome {
|
||||||
|
/// How many deposit events were newly recorded; the rest were already
|
||||||
|
/// pending, and so already owed.
|
||||||
|
pub accepted_deposits: usize,
|
||||||
|
/// Withdraw events with no matching local unseen counter, one entry per
|
||||||
|
/// unmatched occurrence.
|
||||||
|
pub unmatched_withdrawals: Vec<WithdrawalReconciliationKey>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct RocksDBIO {
|
pub struct RocksDBIO {
|
||||||
pub db: DBWithThreadMode<MultiThreaded>,
|
pub db: DBWithThreadMode<MultiThreaded>,
|
||||||
}
|
}
|
||||||
@ -384,10 +457,6 @@ impl RocksDBIO {
|
|||||||
.map_or_else(Vec::new, |cell| cell.0))
|
.map_or_else(Vec::new, |cell| cell.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn put_pending_deposit_events(&self, records: &[PendingDepositEventRecord]) -> DbResult<()> {
|
|
||||||
self.put(&PendingDepositEventsCellRef(records), ())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn put_pending_deposit_events_batch(
|
fn put_pending_deposit_events_batch(
|
||||||
&self,
|
&self,
|
||||||
records: &[PendingDepositEventRecord],
|
records: &[PendingDepositEventRecord],
|
||||||
@ -396,80 +465,159 @@ impl RocksDBIO {
|
|||||||
self.put_batch(&PendingDepositEventsCellRef(records), (), batch)
|
self.put_batch(&PendingDepositEventsCellRef(records), (), batch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records a single deposit event, returning whether it was new.
|
||||||
|
/// 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 records = self.get_pending_deposit_events()?;
|
|
||||||
if records
|
|
||||||
.iter()
|
|
||||||
.any(|record| record.deposit_op_id == event.deposit_op_id)
|
|
||||||
{
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
records.push(event);
|
|
||||||
self.put_pending_deposit_events(&records)?;
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marks the given deposit events submitted in `block_id`, in one write.
|
|
||||||
pub fn mark_deposit_events_submitted(
|
|
||||||
&self,
|
|
||||||
deposit_op_ids: &[HashType],
|
|
||||||
submitted_block_id: u64,
|
|
||||||
) -> DbResult<()> {
|
|
||||||
if deposit_op_ids.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let mut batch = WriteBatch::default();
|
let mut batch = WriteBatch::default();
|
||||||
self.mark_pending_deposit_events_submitted(deposit_op_ids, submitted_block_id, &mut batch)?;
|
let accepted = self.stage_pending_deposit_events(&[event], None, None, &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,
|
||||||
Some("Failed to mark deposit events submitted".to_owned()),
|
Some("Failed to add pending deposit event".to_owned()),
|
||||||
)
|
)
|
||||||
})
|
})?;
|
||||||
|
Ok(accepted > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mark_pending_deposit_events_submitted(
|
/// Stages every mutation of the pending-deposit records into `batch`,
|
||||||
|
/// returning how many were newly appended.
|
||||||
|
///
|
||||||
|
/// The records live in a *single* whole-vector cell, so each mutation kind
|
||||||
|
/// cannot re-read it from disk and stage its own `put`: a later read would
|
||||||
|
/// not see the earlier staged write and would silently drop it. Everything
|
||||||
|
/// is folded in memory here instead, and written exactly once.
|
||||||
|
fn stage_pending_deposit_events(
|
||||||
&self,
|
&self,
|
||||||
deposit_op_ids: &[HashType],
|
new_events: &[PendingDepositEventRecord],
|
||||||
submitted_block_id: u64,
|
mark_submitted: Option<(&[HashType], u64)>,
|
||||||
|
finalized_up_to: Option<u64>,
|
||||||
batch: &mut WriteBatch,
|
batch: &mut WriteBatch,
|
||||||
) -> DbResult<usize> {
|
) -> DbResult<usize> {
|
||||||
let mut records = self.get_pending_deposit_events()?;
|
let marks_nothing = mark_submitted.is_none_or(|(ids, _)| ids.is_empty());
|
||||||
let mut updated: usize = 0;
|
if new_events.is_empty() && marks_nothing && finalized_up_to.is_none() {
|
||||||
|
return Ok(0);
|
||||||
for record in records
|
|
||||||
.iter_mut()
|
|
||||||
.filter(|record| deposit_op_ids.contains(&record.deposit_op_id))
|
|
||||||
{
|
|
||||||
record.submitted_in_block_id = Some(submitted_block_id);
|
|
||||||
updated = updated.saturating_add(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if updated > 0 {
|
|
||||||
self.put_pending_deposit_events_batch(&records, batch)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(updated)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove_fulfilled_pending_deposit_events_up_to_block(
|
|
||||||
&self,
|
|
||||||
finalized_block_id: u64,
|
|
||||||
) -> DbResult<usize> {
|
|
||||||
let mut records = self.get_pending_deposit_events()?;
|
let mut records = self.get_pending_deposit_events()?;
|
||||||
let before = records.len();
|
let before = records.len();
|
||||||
records.retain(|record| {
|
let mut changed = false;
|
||||||
record
|
|
||||||
.submitted_in_block_id
|
|
||||||
.is_none_or(|submitted_id| submitted_id > finalized_block_id)
|
|
||||||
});
|
|
||||||
|
|
||||||
let removed = before.saturating_sub(records.len());
|
for event in new_events {
|
||||||
if removed > 0 {
|
if records
|
||||||
self.put_pending_deposit_events(&records)?;
|
.iter()
|
||||||
|
.any(|record| record.deposit_op_id == event.deposit_op_id)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
records.push(event.clone());
|
||||||
|
}
|
||||||
|
let accepted = records.len().saturating_sub(before);
|
||||||
|
|
||||||
|
if let Some((deposit_op_ids, submitted_block_id)) = mark_submitted {
|
||||||
|
for record in records
|
||||||
|
.iter_mut()
|
||||||
|
.filter(|record| record.submitted_in_block_id != Some(submitted_block_id))
|
||||||
|
.filter(|record| deposit_op_ids.contains(&record.deposit_op_id))
|
||||||
|
{
|
||||||
|
record.submitted_in_block_id = Some(submitted_block_id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(removed)
|
// Everything at or below a finalized block is irreversible, so records
|
||||||
|
// submitted there have served their purpose.
|
||||||
|
if let Some(finalized_block_id) = finalized_up_to {
|
||||||
|
records.retain(|record| {
|
||||||
|
record
|
||||||
|
.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)?;
|
||||||
|
}
|
||||||
|
Ok(accepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stages the unseen-withdraw decrements for one update into `batch`,
|
||||||
|
/// returning one entry per occurrence that matched no local counter.
|
||||||
|
///
|
||||||
|
/// Occurrences are folded per key for the same reason as the deposit
|
||||||
|
/// records: two withdrawals in one update can share a reconciliation key,
|
||||||
|
/// and a per-occurrence disk read would miss the staged decrement.
|
||||||
|
fn stage_consumed_withdrawals(
|
||||||
|
&self,
|
||||||
|
withdrawals: &[WithdrawalReconciliationKey],
|
||||||
|
batch: &mut WriteBatch,
|
||||||
|
) -> DbResult<Vec<WithdrawalReconciliationKey>> {
|
||||||
|
let mut unmatched = Vec::new();
|
||||||
|
if withdrawals.is_empty() {
|
||||||
|
return Ok(unmatched);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut occurrences: HashMap<WithdrawalReconciliationKey, u64> = HashMap::new();
|
||||||
|
for withdrawal in withdrawals {
|
||||||
|
*occurrences.entry(*withdrawal).or_default() += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (withdrawal, times) in occurrences {
|
||||||
|
let stored = self
|
||||||
|
.get_opt::<UnseenWithdrawCountCell>(withdrawal)?
|
||||||
|
.map(|cell| cell.0);
|
||||||
|
|
||||||
|
// A stored `count` satisfies `count + 1` occurrences: the last one
|
||||||
|
// consumes the key by deleting it. Matches the one-shot
|
||||||
|
// [`Self::consume_unseen_withdraw_count`].
|
||||||
|
let matched = times.min(stored.map_or(0, |count| count.saturating_add(1)));
|
||||||
|
unmatched.extend(std::iter::repeat_n(
|
||||||
|
withdrawal,
|
||||||
|
usize::try_from(times.saturating_sub(matched)).unwrap_or(usize::MAX),
|
||||||
|
));
|
||||||
|
|
||||||
|
match stored.and_then(|count| count.checked_sub(times)) {
|
||||||
|
Some(count) => {
|
||||||
|
self.put_batch(&UnseenWithdrawCountCell(count), withdrawal, batch)?
|
||||||
|
}
|
||||||
|
// Only stage a delete for a key that was actually there, so a
|
||||||
|
// fully unmatched update leaves the batch empty.
|
||||||
|
None if stored.is_some() => {
|
||||||
|
self.del_batch::<UnseenWithdrawCountCell>(withdrawal, batch)?
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(unmatched)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects the [`BedrockStatus::Finalized`] flip for every stored pending
|
||||||
|
/// block at or below `last_finalized` into `to_write`.
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
/// reliance on the order writes are staged in.
|
||||||
|
fn collect_finalized_up_to(
|
||||||
|
&self,
|
||||||
|
last_finalized: u64,
|
||||||
|
to_write: &mut BTreeMap<u64, Block>,
|
||||||
|
) -> DbResult<()> {
|
||||||
|
let newly_finalized: Vec<Block> = self
|
||||||
|
.get_all_blocks()
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.filter(|block| {
|
||||||
|
matches!(block.bedrock_status, BedrockStatus::Pending)
|
||||||
|
&& block.header.block_id <= last_finalized
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for mut block in newly_finalized {
|
||||||
|
block.bedrock_status = BedrockStatus::Finalized;
|
||||||
|
to_write.entry(block.header.block_id).or_insert(block);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a bridge deposit for `deposit_op_id` is already recorded as
|
/// Whether a bridge deposit for `deposit_op_id` is already recorded as
|
||||||
@ -498,33 +646,22 @@ impl RocksDBIO {
|
|||||||
Ok(next)
|
Ok(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reconciles a single L1 withdraw event, returning whether it matched a
|
||||||
|
/// local intent. One-shot form of [`RocksDBIO::store_update`]'s
|
||||||
|
/// `consumed_withdrawals`.
|
||||||
pub fn consume_unseen_withdraw_count(
|
pub fn consume_unseen_withdraw_count(
|
||||||
&self,
|
&self,
|
||||||
withdrawal: WithdrawalReconciliationKey,
|
withdrawal: WithdrawalReconciliationKey,
|
||||||
) -> DbResult<bool> {
|
) -> DbResult<bool> {
|
||||||
let Some(current) = self
|
let mut batch = WriteBatch::default();
|
||||||
.get_opt::<UnseenWithdrawCountCell>(withdrawal)?
|
let unmatched = self.stage_consumed_withdrawals(&[withdrawal], &mut batch)?;
|
||||||
.map(|cell| cell.0)
|
self.db.write(batch).map_err(|rerr| {
|
||||||
else {
|
DbError::rocksdb_cast_message(
|
||||||
return Ok(false);
|
rerr,
|
||||||
};
|
Some("Failed to consume unseen withdraw count".to_owned()),
|
||||||
|
)
|
||||||
if let Some(next) = current.checked_sub(1) {
|
})?;
|
||||||
self.put(&UnseenWithdrawCountCell(next), withdrawal)?;
|
Ok(unmatched.is_empty())
|
||||||
} else {
|
|
||||||
let cf_meta = self.meta_column();
|
|
||||||
let db_key =
|
|
||||||
<UnseenWithdrawCountCell as SimpleStorableCell>::key_constructor(withdrawal)?;
|
|
||||||
|
|
||||||
self.db.delete_cf(&cf_meta, db_key).map_err(|rerr| {
|
|
||||||
DbError::rocksdb_cast_message(
|
|
||||||
rerr,
|
|
||||||
Some("Failed to delete unseen withdraw count".to_owned()),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn put_block(&self, block: &Block, first: bool, batch: &mut WriteBatch) -> DbResult<()> {
|
pub fn put_block(&self, block: &Block, first: bool, batch: &mut WriteBatch) -> DbResult<()> {
|
||||||
@ -623,20 +760,23 @@ impl RocksDBIO {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark every pending block with `block_id <= last_finalized` as finalized.
|
/// Mark every pending block with `block_id <= last_finalized` as finalized,
|
||||||
/// Idempotent — already-finalized blocks are skipped.
|
/// in one atomic write. Idempotent — already-finalized blocks are skipped.
|
||||||
|
/// 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 pending_ids: Vec<u64> = self
|
let mut to_write = BTreeMap::new();
|
||||||
.get_all_blocks()
|
self.collect_finalized_up_to(last_finalized, &mut to_write)?;
|
||||||
.filter_map(Result::ok)
|
|
||||||
.filter(|b| matches!(b.bedrock_status, BedrockStatus::Pending))
|
let mut batch = WriteBatch::default();
|
||||||
.map(|b| b.header.block_id)
|
for block in to_write.values() {
|
||||||
.filter(|id| *id <= last_finalized)
|
self.put_block_payload(block, &mut batch)?;
|
||||||
.collect();
|
|
||||||
for id in pending_ids {
|
|
||||||
self.mark_block_as_finalized(id)?;
|
|
||||||
}
|
}
|
||||||
Ok(())
|
self.db.write(batch).map_err(|rerr| {
|
||||||
|
DbError::rocksdb_cast_message(
|
||||||
|
rerr,
|
||||||
|
Some("Failed to mark pending blocks finalized".to_owned()),
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mark_block_as_finalized(&self, block_id: u64) -> DbResult<()> {
|
pub fn mark_block_as_finalized(&self, block_id: u64) -> DbResult<()> {
|
||||||
@ -691,8 +831,8 @@ impl RocksDBIO {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One-block form of [`Self::store_followed_blocks`], with the block as the
|
/// One-block form of [`Self::store_update`], with the block as the head tip
|
||||||
/// head tip and no final snapshot. Production always uses the batch form.
|
/// and no final snapshot. Production always uses the batch form.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn store_followed_block(
|
fn store_followed_block(
|
||||||
&self,
|
&self,
|
||||||
@ -700,16 +840,17 @@ impl RocksDBIO {
|
|||||||
state: &V03State,
|
state: &V03State,
|
||||||
finalized: bool,
|
finalized: bool,
|
||||||
) -> DbResult<()> {
|
) -> DbResult<()> {
|
||||||
self.store_followed_blocks(
|
self.store_update(&StoreUpdate {
|
||||||
&[(block, finalized)],
|
blocks: &[(block, finalized)],
|
||||||
Some(&BlockMeta::from(block)),
|
head_tip: Some(&BlockMeta::from(block)),
|
||||||
state,
|
..StoreUpdate::new(state)
|
||||||
None,
|
})
|
||||||
)
|
.map(|_outcome| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persists a batch of followed blocks, the caller's head-tip `state`, and
|
/// Persists everything one sequencer event produced — checkpoint, blocks,
|
||||||
/// the optional final-tier snapshot in one atomic write.
|
/// tip meta, head state, final snapshot, deposit and withdraw bookkeeping
|
||||||
|
/// and the channel anchor — in one atomic write.
|
||||||
///
|
///
|
||||||
/// The tip meta is pinned to `head_tip`, and blocks stored above it (left
|
/// The tip meta is pinned to `head_tip`, and blocks stored above it (left
|
||||||
/// behind by a net-shortening reorg) are deleted in the same write, so
|
/// behind by a net-shortening reorg) are deleted in the same write, so
|
||||||
@ -717,69 +858,112 @@ impl RocksDBIO {
|
|||||||
///
|
///
|
||||||
/// Per block: skips the payload write when the store already holds it (by
|
/// Per block: skips the payload write when the store already holds it (by
|
||||||
/// id and hash), unless `finalized` is set, which rewrites it with the
|
/// id and hash), unless `finalized` is set, which rewrites it with the
|
||||||
/// finalized status. A no-op update (nothing to write, tip unchanged)
|
/// finalized status.
|
||||||
/// writes nothing.
|
|
||||||
///
|
///
|
||||||
/// TODO: the zone-sdk checkpoint is persisted by `on_checkpoint` *before*
|
/// The head state and tip meta are only rewritten when the chain actually
|
||||||
/// this write. Full `BlocksProcessed` atomicity (checkpoint, blocks, state
|
/// moved. A checkpoint alone (the common case — every follow event carries
|
||||||
/// and orphan reverts in one batch) is a follow-up.
|
/// one, most carry nothing else) must not drag a full state serialization
|
||||||
pub fn store_followed_blocks(
|
/// with it.
|
||||||
&self,
|
pub fn store_update(&self, update: &StoreUpdate<'_>) -> DbResult<StoreUpdateOutcome> {
|
||||||
blocks: &[(&Block, bool)],
|
let StoreUpdate {
|
||||||
head_tip: Option<&BlockMeta>,
|
checkpoint,
|
||||||
state: &V03State,
|
blocks,
|
||||||
final_snapshot: Option<(&V03State, &BlockMeta)>,
|
head_tip,
|
||||||
) -> DbResult<()> {
|
head_state,
|
||||||
|
final_snapshot,
|
||||||
|
finalized_up_to,
|
||||||
|
new_deposit_events,
|
||||||
|
mark_deposits_submitted,
|
||||||
|
consumed_withdrawals,
|
||||||
|
new_withdraw_intents,
|
||||||
|
zone_anchor,
|
||||||
|
} = *update;
|
||||||
|
|
||||||
let last_block_in_db = self.get_meta_last_block_in_db()?;
|
let last_block_in_db = self.get_meta_last_block_in_db()?;
|
||||||
let mut batch = WriteBatch::default();
|
let mut batch = WriteBatch::default();
|
||||||
|
|
||||||
|
if let Some(bytes) = checkpoint {
|
||||||
|
self.put_batch(&ZoneSdkCheckpointCellRef(bytes), (), &mut batch)?;
|
||||||
|
}
|
||||||
|
if let Some(anchor) = zone_anchor {
|
||||||
|
self.put_batch(&ZoneAnchorCell(*anchor), (), &mut batch)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every block payload this update writes, keyed by id so a block that
|
||||||
|
// is both explicitly written and swept by `finalized_up_to` is written
|
||||||
|
// once, with the caller's version.
|
||||||
|
let mut to_write: BTreeMap<u64, Block> = BTreeMap::new();
|
||||||
|
|
||||||
|
// Whether the stored chain moved, and with it the head state. A
|
||||||
|
// shrink-only update (orphans without adopted replacements) writes no
|
||||||
|
// payloads but still rewinds the tip, or the stored state tears
|
||||||
|
// against the stale disk head on the next produce.
|
||||||
|
let mut chain_changed =
|
||||||
|
final_snapshot.is_some() || head_tip.is_some_and(|tip| tip.id != last_block_in_db);
|
||||||
|
|
||||||
for (block, finalized) in blocks {
|
for (block, finalized) in blocks {
|
||||||
let already_stored = self
|
let already_stored = self
|
||||||
.get_block(block.header.block_id)?
|
.get_block(block.header.block_id)?
|
||||||
.filter(|stored| stored.header.hash == block.header.hash);
|
.filter(|stored| stored.header.hash == block.header.hash);
|
||||||
|
|
||||||
let mut to_write = match already_stored {
|
let mut block_to_write = match already_stored {
|
||||||
Some(_) if !finalized => continue,
|
Some(_) if !finalized => continue,
|
||||||
Some(stored) => stored,
|
Some(stored) => stored,
|
||||||
None => (*block).clone(),
|
None => (*block).clone(),
|
||||||
};
|
};
|
||||||
if *finalized {
|
if *finalized {
|
||||||
to_write.bedrock_status = BedrockStatus::Finalized;
|
block_to_write.bedrock_status = BedrockStatus::Finalized;
|
||||||
}
|
}
|
||||||
self.put_block_payload(&to_write, &mut batch)?;
|
to_write.insert(block_to_write.header.block_id, block_to_write);
|
||||||
|
chain_changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(last_finalized) = finalized_up_to {
|
||||||
|
self.collect_finalized_up_to(last_finalized, &mut to_write)?;
|
||||||
|
}
|
||||||
|
for block in to_write.values() {
|
||||||
|
self.put_block_payload(block, &mut batch)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let accepted_deposits = self.stage_pending_deposit_events(
|
||||||
|
new_deposit_events,
|
||||||
|
mark_deposits_submitted,
|
||||||
|
finalized_up_to,
|
||||||
|
&mut batch,
|
||||||
|
)?;
|
||||||
|
let unmatched_withdrawals =
|
||||||
|
self.stage_consumed_withdrawals(consumed_withdrawals, &mut batch)?;
|
||||||
|
for withdrawal in new_withdraw_intents {
|
||||||
|
self.increment_unseen_withdraw_count(*withdrawal, &mut batch)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `head_tip` is `None` only for a chain holding no blocks at all, which
|
// `head_tip` is `None` only for a chain holding no blocks at all, which
|
||||||
// implies nothing was applied — and the store, created with genesis,
|
// the store — created with genesis — cannot represent. Nothing to pin.
|
||||||
// cannot represent it. No tip to pin, nothing to persist.
|
if chain_changed && let Some(tip) = head_tip {
|
||||||
let Some(tip) = head_tip else {
|
for stale_id in tip.id.saturating_add(1)..=last_block_in_db {
|
||||||
debug_assert!(batch.is_empty() && final_snapshot.is_none());
|
self.delete_block_payload(stale_id, &mut batch)?;
|
||||||
return Ok(());
|
}
|
||||||
|
self.put_meta_last_block_in_db_batch(tip.id, &mut batch)?;
|
||||||
|
self.put_meta_latest_block_meta_batch(tip, &mut batch)?;
|
||||||
|
self.put_lee_state_in_db_batch(head_state, &mut batch)?;
|
||||||
|
if let Some((final_state, final_meta)) = final_snapshot {
|
||||||
|
self.put_final_snapshot_batch(final_state, final_meta, &mut batch)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let outcome = StoreUpdateOutcome {
|
||||||
|
accepted_deposits,
|
||||||
|
unmatched_withdrawals,
|
||||||
};
|
};
|
||||||
|
|
||||||
// A shrink-only update (orphans without adopted replacements) has no
|
if batch.is_empty() {
|
||||||
// payloads to write but must still rewind the tip meta, or the stored
|
return Ok(outcome);
|
||||||
// state tears against the stale disk head on the next produce.
|
|
||||||
if batch.is_empty() && final_snapshot.is_none() && tip.id == last_block_in_db {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
for stale_id in tip.id.saturating_add(1)..=last_block_in_db {
|
|
||||||
self.delete_block_payload(stale_id, &mut batch)?;
|
|
||||||
}
|
|
||||||
self.put_meta_last_block_in_db_batch(tip.id, &mut batch)?;
|
|
||||||
self.put_meta_latest_block_meta_batch(tip, &mut batch)?;
|
|
||||||
self.put_lee_state_in_db_batch(state, &mut batch)?;
|
|
||||||
if let Some((final_state, final_meta)) = final_snapshot {
|
|
||||||
self.put_final_snapshot_batch(final_state, final_meta, &mut batch)?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.db.write(batch).map_err(|rerr| {
|
self.db.write(batch).map_err(|rerr| {
|
||||||
DbError::rocksdb_cast_message(
|
DbError::rocksdb_cast_message(rerr, Some("Failed to write store update".to_owned()))
|
||||||
rerr,
|
})?;
|
||||||
Some("Failed to write followed blocks batch".to_owned()),
|
Ok(outcome)
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_all_blocks(&self) -> impl Iterator<Item = DbResult<Block>> {
|
pub fn get_all_blocks(&self) -> impl Iterator<Item = DbResult<Block>> {
|
||||||
@ -803,32 +987,32 @@ impl RocksDBIO {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persists a block we produced, its deposit/withdraw bookkeeping, the
|
||||||
|
/// resulting state and the publish `checkpoint` in one atomic write.
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
/// reason it does there — it carries the sdk's `pending_txs`, so a
|
||||||
|
/// checkpoint persisted without this block would restore a pending set
|
||||||
|
/// that no longer contains the inscription we just published, and the sdk
|
||||||
|
/// would never resubmit it.
|
||||||
pub fn atomic_update(
|
pub fn atomic_update(
|
||||||
&self,
|
&self,
|
||||||
block: &Block,
|
block: &Block,
|
||||||
deposit_op_ids: &[HashType],
|
deposit_op_ids: &[HashType],
|
||||||
withdrawals: Vec<WithdrawalReconciliationKey>,
|
withdrawals: Vec<WithdrawalReconciliationKey>,
|
||||||
state: &V03State,
|
state: &V03State,
|
||||||
|
checkpoint: Option<&[u8]>,
|
||||||
) -> DbResult<()> {
|
) -> DbResult<()> {
|
||||||
let block_id = block.header.block_id;
|
self.store_update(&StoreUpdate {
|
||||||
let mut batch = WriteBatch::default();
|
checkpoint,
|
||||||
|
blocks: &[(block, false)],
|
||||||
self.put_block(block, false, &mut batch)?;
|
head_tip: Some(&BlockMeta::from(block)),
|
||||||
|
mark_deposits_submitted: Some((deposit_op_ids, block.header.block_id)),
|
||||||
self.mark_pending_deposit_events_submitted(deposit_op_ids, block_id, &mut batch)?;
|
new_withdraw_intents: &withdrawals,
|
||||||
|
..StoreUpdate::new(state)
|
||||||
for withdrawal in withdrawals {
|
|
||||||
self.increment_unseen_withdraw_count(withdrawal, &mut batch)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.put_lee_state_in_db_batch(state, &mut batch)?;
|
|
||||||
|
|
||||||
self.db.write(batch).map_err(|rerr| {
|
|
||||||
DbError::rocksdb_cast_message(
|
|
||||||
rerr,
|
|
||||||
Some(format!("Failed to udpate db with block {block_id}")),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
.map(|_outcome| ())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -275,7 +275,7 @@ impl SimpleWritableCell for PendingDepositEventsCellRef<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub struct WithdrawalReconciliationKey {
|
pub struct WithdrawalReconciliationKey {
|
||||||
pub amount: u64,
|
pub amount: u64,
|
||||||
pub bedrock_account_pk: [u8; 32],
|
pub bedrock_account_pk: [u8; 32],
|
||||||
|
|||||||
@ -28,6 +28,16 @@ fn dbio_with_genesis(path: &Path) -> (RocksDBIO, Block) {
|
|||||||
(dbio, genesis)
|
(dbio, genesis)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn deposit_record(seed: u8) -> PendingDepositEventRecord {
|
||||||
|
PendingDepositEventRecord {
|
||||||
|
deposit_op_id: HashType([seed; 32]),
|
||||||
|
source_tx_hash: HashType([seed; 32]),
|
||||||
|
amount: u64::from(seed),
|
||||||
|
metadata: vec![seed],
|
||||||
|
submitted_in_block_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn stored_balance(dbio: &RocksDBIO) -> u128 {
|
fn stored_balance(dbio: &RocksDBIO) -> u128 {
|
||||||
dbio.get_lee_state()
|
dbio.get_lee_state()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@ -106,12 +116,11 @@ fn store_followed_blocks_batch_lands_meta_and_state_on_last_block() {
|
|||||||
id: 3,
|
id: 3,
|
||||||
hash: block3.header.hash,
|
hash: block3.header.hash,
|
||||||
};
|
};
|
||||||
dbio.store_followed_blocks(
|
dbio.store_update(&StoreUpdate {
|
||||||
&[(&block2, true), (&block3, false)],
|
blocks: &[(&block2, true), (&block3, false)],
|
||||||
Some(&head_tip),
|
head_tip: Some(&head_tip),
|
||||||
&state_with_balance(300),
|
..StoreUpdate::new(&state_with_balance(300))
|
||||||
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");
|
||||||
@ -140,12 +149,12 @@ fn final_snapshot_round_trips_and_is_absent_on_fresh_store() {
|
|||||||
id: 2,
|
id: 2,
|
||||||
hash: block2.header.hash,
|
hash: block2.header.hash,
|
||||||
};
|
};
|
||||||
dbio.store_followed_blocks(
|
dbio.store_update(&StoreUpdate {
|
||||||
&[(&block2, true)],
|
blocks: &[(&block2, true)],
|
||||||
Some(&final_meta),
|
head_tip: Some(&final_meta),
|
||||||
&state_with_balance(300),
|
final_snapshot: Some((&state_with_balance(200), &final_meta)),
|
||||||
Some((&state_with_balance(200), &final_meta)),
|
..StoreUpdate::new(&state_with_balance(300))
|
||||||
)
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let (final_state, meta) = dbio
|
let (final_state, meta) = dbio
|
||||||
@ -204,12 +213,11 @@ fn net_shortening_reorg_drops_stale_blocks() {
|
|||||||
id: 2,
|
id: 2,
|
||||||
hash: block2b.header.hash,
|
hash: block2b.header.hash,
|
||||||
};
|
};
|
||||||
dbio.store_followed_blocks(
|
dbio.store_update(&StoreUpdate {
|
||||||
&[(&block2b, false)],
|
blocks: &[(&block2b, false)],
|
||||||
Some(&head_tip),
|
head_tip: Some(&head_tip),
|
||||||
&state_with_balance(400),
|
..StoreUpdate::new(&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");
|
||||||
@ -241,8 +249,11 @@ fn shrink_only_reorg_rewinds_tip_meta() {
|
|||||||
id: 2,
|
id: 2,
|
||||||
hash: block2.header.hash,
|
hash: block2.header.hash,
|
||||||
};
|
};
|
||||||
dbio.store_followed_blocks(&[], Some(&head_tip), &state_with_balance(200), None)
|
dbio.store_update(&StoreUpdate {
|
||||||
.unwrap();
|
head_tip: Some(&head_tip),
|
||||||
|
..StoreUpdate::new(&state_with_balance(200))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
dbio.get_block(3).unwrap().is_none(),
|
dbio.get_block(3).unwrap().is_none(),
|
||||||
@ -254,6 +265,202 @@ fn shrink_only_reorg_rewinds_tip_meta() {
|
|||||||
assert_eq!(stored_balance(&dbio), 200);
|
assert_eq!(stored_balance(&dbio), 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checkpoint_lands_with_an_orphan_only_update() {
|
||||||
|
let temp_dir = tempdir().unwrap();
|
||||||
|
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
|
||||||
|
|
||||||
|
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
|
||||||
|
dbio.store_followed_block(&block2, &state_with_balance(200), false)
|
||||||
|
.unwrap();
|
||||||
|
let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]);
|
||||||
|
dbio.store_followed_block(&block3, &state_with_balance(300), false)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Orphan-only update: no payload to write, but the checkpoint covering it
|
||||||
|
// must still land, or a restart resumes past the orphan.
|
||||||
|
let head_tip = BlockMeta {
|
||||||
|
id: 2,
|
||||||
|
hash: block2.header.hash,
|
||||||
|
};
|
||||||
|
dbio.store_update(&StoreUpdate {
|
||||||
|
checkpoint: Some(b"cp-orphan"),
|
||||||
|
head_tip: Some(&head_tip),
|
||||||
|
..StoreUpdate::new(&state_with_balance(200))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
dbio.get_zone_sdk_checkpoint_bytes().unwrap().as_deref(),
|
||||||
|
Some(b"cp-orphan".as_slice())
|
||||||
|
);
|
||||||
|
assert!(dbio.get_block(3).unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checkpoint_only_update_does_not_rewrite_the_head_state() {
|
||||||
|
let temp_dir = tempdir().unwrap();
|
||||||
|
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
|
||||||
|
|
||||||
|
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
|
||||||
|
dbio.store_followed_block(&block2, &state_with_balance(200), false)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// An event carrying nothing but a checkpoint (the common case) must not
|
||||||
|
// drag a full state serialization along with it — the caller's state is
|
||||||
|
// ignored while the chain stands still.
|
||||||
|
let head_tip = BlockMeta::from(&block2);
|
||||||
|
dbio.store_update(&StoreUpdate {
|
||||||
|
checkpoint: Some(b"cp-idle"),
|
||||||
|
head_tip: Some(&head_tip),
|
||||||
|
..StoreUpdate::new(&state_with_balance(999))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
dbio.get_zone_sdk_checkpoint_bytes().unwrap().as_deref(),
|
||||||
|
Some(b"cp-idle".as_slice())
|
||||||
|
);
|
||||||
|
assert_eq!(stored_balance(&dbio), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn several_deposits_in_one_update_are_all_recorded() {
|
||||||
|
let temp_dir = tempdir().unwrap();
|
||||||
|
let (dbio, _genesis) = dbio_with_genesis(temp_dir.path());
|
||||||
|
|
||||||
|
// The records live in one whole-vector cell: staged per event against a
|
||||||
|
// fresh disk read, the second would clobber the first.
|
||||||
|
let first = deposit_record(1);
|
||||||
|
let second = deposit_record(2);
|
||||||
|
let already_known = dbio.get_pending_deposit_events().unwrap();
|
||||||
|
assert!(already_known.is_empty());
|
||||||
|
|
||||||
|
let outcome = dbio
|
||||||
|
.store_update(&StoreUpdate {
|
||||||
|
new_deposit_events: &[first.clone(), second.clone()],
|
||||||
|
..StoreUpdate::new(&state_with_balance(100))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(outcome.accepted_deposits, 2);
|
||||||
|
let stored = dbio.get_pending_deposit_events().unwrap();
|
||||||
|
assert_eq!(stored.len(), 2);
|
||||||
|
assert!(stored.contains(&first));
|
||||||
|
assert!(stored.contains(&second));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redelivered_deposit_is_not_accepted_twice() {
|
||||||
|
let temp_dir = tempdir().unwrap();
|
||||||
|
let (dbio, _genesis) = dbio_with_genesis(temp_dir.path());
|
||||||
|
|
||||||
|
let record = deposit_record(1);
|
||||||
|
dbio.store_update(&StoreUpdate {
|
||||||
|
new_deposit_events: &[record.clone()],
|
||||||
|
..StoreUpdate::new(&state_with_balance(100))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let outcome = dbio
|
||||||
|
.store_update(&StoreUpdate {
|
||||||
|
new_deposit_events: &[record],
|
||||||
|
..StoreUpdate::new(&state_with_balance(100))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
outcome.accepted_deposits, 0,
|
||||||
|
"a re-delivered deposit is already owed, not newly accepted"
|
||||||
|
);
|
||||||
|
assert_eq!(dbio.get_pending_deposit_events().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeated_withdrawal_key_in_one_update_decrements_once_per_occurrence() {
|
||||||
|
let temp_dir = tempdir().unwrap();
|
||||||
|
let (dbio, _genesis) = dbio_with_genesis(temp_dir.path());
|
||||||
|
|
||||||
|
let key = WithdrawalReconciliationKey {
|
||||||
|
amount: 7,
|
||||||
|
bedrock_account_pk: [3; 32],
|
||||||
|
};
|
||||||
|
// Two local intents for the same key.
|
||||||
|
let mut batch = WriteBatch::default();
|
||||||
|
dbio.increment_unseen_withdraw_count(key, &mut batch).unwrap();
|
||||||
|
dbio.db.write(batch).unwrap();
|
||||||
|
let mut batch = WriteBatch::default();
|
||||||
|
dbio.increment_unseen_withdraw_count(key, &mut batch).unwrap();
|
||||||
|
dbio.db.write(batch).unwrap();
|
||||||
|
|
||||||
|
// Both L1 events arrive in one update; a per-occurrence disk read would
|
||||||
|
// miss the staged decrement and consume only one.
|
||||||
|
let outcome = dbio
|
||||||
|
.store_update(&StoreUpdate {
|
||||||
|
consumed_withdrawals: &[key, key],
|
||||||
|
..StoreUpdate::new(&state_with_balance(100))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(outcome.unmatched_withdrawals.is_empty());
|
||||||
|
// Both decrements landed; a per-occurrence disk read would leave `Some(1)`.
|
||||||
|
// (The absolute value trails the intent count by one — `increment` stores 1
|
||||||
|
// for the first intent while `consume` still treats a stored 0 as
|
||||||
|
// consumable — but that predates the batching and is replicated as-is.)
|
||||||
|
let remaining = dbio
|
||||||
|
.get_opt::<UnseenWithdrawCountCell>(key)
|
||||||
|
.unwrap()
|
||||||
|
.map(|cell| cell.0);
|
||||||
|
assert_eq!(remaining, Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unmatched_withdrawal_is_reported_and_writes_nothing() {
|
||||||
|
let temp_dir = tempdir().unwrap();
|
||||||
|
let (dbio, _genesis) = dbio_with_genesis(temp_dir.path());
|
||||||
|
|
||||||
|
let key = WithdrawalReconciliationKey {
|
||||||
|
amount: 5,
|
||||||
|
bedrock_account_pk: [4; 32],
|
||||||
|
};
|
||||||
|
let outcome = dbio
|
||||||
|
.store_update(&StoreUpdate {
|
||||||
|
consumed_withdrawals: &[key],
|
||||||
|
..StoreUpdate::new(&state_with_balance(100))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(outcome.unmatched_withdrawals.len(), 1);
|
||||||
|
assert!(
|
||||||
|
dbio.get_opt::<UnseenWithdrawCountCell>(key).unwrap().is_none(),
|
||||||
|
"an unmatched withdraw must not leave a counter behind"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn produced_block_persists_its_publish_checkpoint() {
|
||||||
|
let temp_dir = tempdir().unwrap();
|
||||||
|
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
|
||||||
|
|
||||||
|
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
|
||||||
|
dbio.atomic_update(
|
||||||
|
&block2,
|
||||||
|
&[],
|
||||||
|
vec![],
|
||||||
|
&state_with_balance(200),
|
||||||
|
Some(b"cp-produced"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Storing the block without the checkpoint would let a restart restore a
|
||||||
|
// pending set that no longer holds the inscription we just published.
|
||||||
|
assert_eq!(
|
||||||
|
dbio.get_zone_sdk_checkpoint_bytes().unwrap().as_deref(),
|
||||||
|
Some(b"cp-produced".as_slice())
|
||||||
|
);
|
||||||
|
assert_eq!(dbio.get_block(2).unwrap().unwrap().header.hash, block2.header.hash);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn produced_block_below_disk_head_pins_meta_and_prunes() {
|
fn produced_block_below_disk_head_pins_meta_and_prunes() {
|
||||||
let temp_dir = tempdir().unwrap();
|
let temp_dir = tempdir().unwrap();
|
||||||
@ -270,7 +477,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))
|
dbio.atomic_update(&block2b, &[], vec![], &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