Merge remote-tracking branch 'origin/dev' into artem/bundle-actions

This commit is contained in:
agureev 2026-07-31 00:16:31 +04:00
commit dbcdbb29f3
36 changed files with 2215 additions and 796 deletions

2
Cargo.lock generated
View File

@ -1193,6 +1193,7 @@ name = "bridge_core"
version = "0.1.0"
dependencies = [
"lee_core",
"risc0-zkvm",
"serde",
]
@ -4155,6 +4156,7 @@ dependencies = [
"storage",
"tempfile",
"testnet_initial_state",
"thiserror 2.0.18",
"tokio",
"url",
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -47,10 +47,11 @@ async fn public_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> {
let bridge_account_id = system_accounts::bridge_account_id();
let vault_program_id = programs::vault().id();
let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id);
let receipt_id = bridge_core::deposit_receipt_account_id(programs::bridge().id(), [0_u8; 32]);
let message = public_transaction::Message::try_new(
programs::bridge().id(),
vec![bridge_account_id, recipient_vault_id],
vec![bridge_account_id, recipient_vault_id, receipt_id],
vec![],
bridge_core::Instruction::Deposit {
l1_deposit_op_id: [0_u8; 32],
@ -95,10 +96,11 @@ async fn public_bridge_deposit_with_zero_amount_is_rejected() -> anyhow::Result<
let bridge_account_id = system_accounts::bridge_account_id();
let vault_program_id = programs::vault().id();
let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id);
let receipt_id = bridge_core::deposit_receipt_account_id(programs::bridge().id(), [0_u8; 32]);
let message = public_transaction::Message::try_new(
programs::bridge().id(),
vec![bridge_account_id, recipient_vault_id],
vec![bridge_account_id, recipient_vault_id, receipt_id],
vec![],
bridge_core::Instruction::Deposit {
l1_deposit_op_id: [0_u8; 32],
@ -155,8 +157,10 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> {
let bridge_account_id = system_accounts::bridge_account_id();
let vault_program_id = programs::vault().id();
let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id);
let receipt_id = bridge_core::deposit_receipt_account_id(programs::bridge().id(), [0_u8; 32]);
// Get pre-state of bridge and vault accounts
// Get pre-state of bridge and vault accounts; the receipt is unminted (a
// default account), so the program would create it on a first mint.
let bridge_pre = AccountWithMetadata::new(
get_account(&ctx, bridge_account_id).await?,
false,
@ -167,6 +171,8 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> {
false,
recipient_vault_id,
);
let receipt_pre =
AccountWithMetadata::new(lee_core::account::Account::default(), false, receipt_id);
// Create program with dependencies
let program_with_deps =
@ -193,16 +199,24 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> {
// Execute and prove the bridge deposit
let (output, proof) = execute_and_prove(
vec![bridge_pre.clone(), vault_pre.clone()],
vec![bridge_pre.clone(), vault_pre.clone(), receipt_pre.clone()],
instruction,
vec![InputAccountIdentity::Public, InputAccountIdentity::Public],
vec![
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
],
&program_with_deps,
)
.context("Failed to execute/prove bridge deposit")?;
// Create privacy-preserving transaction from circuit output
let message = privacy_preserving_transaction::Message::from_circuit_output(
vec![bridge_pre.account.nonce, vault_pre.account.nonce],
vec![
bridge_pre.account.nonce,
vault_pre.account.nonce,
receipt_pre.account.nonce,
],
output,
);

View File

@ -270,6 +270,12 @@ impl V03State {
.unwrap_or_else(Account::default)
}
/// Borrowing counterpart of [`Self::get_account_by_id`].
#[must_use]
pub fn get_account_by_id_ref(&self, account_id: AccountId) -> Option<&Account> {
self.public_state.get(&account_id)
}
#[must_use]
pub fn get_proof_for_commitment(&self, commitment: &Commitment) -> Option<MembershipProof> {
self.private_state.0.get_proof_for(commitment)

View File

@ -37,6 +37,7 @@ async-stream.workspace = true
tokio.workspace = true
risc0-zkvm.workspace = true
hex.workspace = true
thiserror.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

@ -4,18 +4,18 @@ use std::{
time::Duration,
};
use anyhow::{Result, bail};
use anyhow::anyhow;
use common::{block::Block, transaction::LeeTransaction};
use cross_zone::{build_dispatch_from_emission, extract_emission};
use cross_zone_inbox_core::{
CrossZoneMessage, Instruction as InboxInstruction, MessageKey, ZoneId, message_key,
};
use futures::StreamExt as _;
use lee::PublicKey;
use futures::{Stream, StreamExt as _};
use lee::{GENESIS_BLOCK_ID, PublicKey};
use log::{debug, error, info};
use logos_blockchain_core::mantle::ops::channel::ChannelId;
use logos_blockchain_zone_sdk::{
CommonHttpClient, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
};
use tokio::sync::RwLock;
@ -25,39 +25,157 @@ use crate::config::IndexerConfig;
/// so a stuck wait is observable without rejecting a legitimate message.
const LAG_LOG_INTERVAL: Duration = Duration::from_secs(30);
/// How long to wait for a referenced peer block before giving up on this pass.
/// Generous, since ordinary L1 finality lag delays a peer block by minutes.
/// Expiry is not a rejection: the caller retries the same block, so the cost of a
/// premature expiry is one repeated pass.
const PEER_BLOCK_WAIT_TIMEOUT: Duration = Duration::from_secs(300);
/// How long each wait iteration sleeps. Also the unit the elapsed counter is
/// advanced by, so `waited` counts sleeps rather than wall time and, since a
/// sleep can overshoot, understates it.
const PEER_BLOCK_POLL_INTERVAL: Duration = Duration::from_secs(1);
/// Consecutive passes a peer reader re-reads the same undecodable slot before
/// giving up and reading past it.
const DECODE_RETRY_LIMIT: u32 = 3;
/// Why a cross-zone dispatch could not be verified.
///
/// A forgery is terminal and must stop the block applying; an unavailable peer
/// block is transient and must be retried, or a lagging peer reader would
/// permanently halt ingestion.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CrossZoneVerifyError {
/// The dispatch does not match the peer's finalized chain.
#[error("{0:#}")]
Forged(anyhow::Error),
/// The referenced peer block has not been read yet.
#[error(
"peer zone {} block {block_id} still unavailable after {waited:?}",
hex::encode(zone)
)]
PeerUnavailable {
zone: ZoneId,
block_id: u64,
waited: Duration,
},
}
/// One peer zone's cached blocks, plus how far this reader has read them as an
/// unbroken hash-linked run from the peer's genesis.
#[derive(Default)]
struct PeerChain {
blocks: HashMap<u64, Block>,
/// Highest id such that every block from [`GENESIS_BLOCK_ID`] up to it has
/// been read and each links to its predecessor. `None` until genesis is read.
///
/// This, not `max(blocks.keys())`, is what the forgery test gates on: a peer
/// picks its own `block_id`s, and an id that does not continue the run
/// cannot advance the run. It bounds how far this reader has read, not that
/// the chain is authentic: the link is self-asserted, since `header.hash` is
/// not recomputed on decode and the reader does not apply the pinned-key
/// check that [`crate::cross_zone_verifier::CrossZoneVerifier::rederive`]
/// applies to the block a dispatch actually names.
verified_prefix: Option<u64>,
}
impl PeerChain {
/// The id that would extend the verified run.
const fn next_expected(&self) -> u64 {
match self.verified_prefix {
Some(prefix) => prefix.saturating_add(1),
None => GENESIS_BLOCK_ID,
}
}
/// Extends the verified run as far as the cached blocks allow.
fn extend_prefix(&mut self) {
while let Some(next) = self.blocks.get(&self.next_expected()) {
let links = match self.verified_prefix {
Some(prefix) => self
.blocks
.get(&prefix)
.is_some_and(|prev| prev.header.hash == next.header.prev_block_hash),
// Genesis has no predecessor to link to.
None => true,
};
if !links {
return;
}
self.verified_prefix = Some(next.header.block_id);
}
}
}
/// What one consistent look at the peer cache says about a referenced block.
enum PeerLookup {
Cached(Box<Block>),
/// Inside the verified run but not held, so it is not on the peer chain.
InsideRun,
/// The reader has not verified this far yet.
Behind,
}
/// Cache of finalized peer-zone blocks, filled by per-peer reader tasks and read
/// by the verifier to re-derive cross-zone dispatch transactions.
#[derive(Clone, Default)]
struct PeerBlocks {
chains: Arc<RwLock<HashMap<ZoneId, HashMap<u64, Block>>>>,
chains: Arc<RwLock<HashMap<ZoneId, PeerChain>>>,
}
impl PeerBlocks {
async fn insert(&self, zone: ZoneId, block: Block) {
self.chains
.write()
.await
.entry(zone)
.or_default()
.insert(block.header.block_id, block);
let mut chains = self.chains.write().await;
let chain = chains.entry(zone).or_default();
chain.blocks.insert(block.header.block_id, block);
chain.extend_prefix();
}
/// Resolves `block_id` under a single read lock.
///
/// Answering "is it cached?" and "is it inside the verified run?" under two
/// separate locks races with the peer reader: an insert landing between them
/// reads as absent-and-inside-the-run, which is the forgery signal, for a
/// block that is in fact cached. That is the normal steady state, a waiting
/// verifier and the block it waits for arriving, so it must be one look.
async fn resolve(&self, zone: ZoneId, block_id: u64) -> PeerLookup {
let chains = self.chains.read().await;
let Some(chain) = chains.get(&zone) else {
return PeerLookup::Behind;
};
if let Some(block) = chain.blocks.get(&block_id) {
return PeerLookup::Cached(Box::new(block.clone()));
}
if chain
.verified_prefix
.is_some_and(|prefix| prefix >= block_id)
{
PeerLookup::InsideRun
} else {
PeerLookup::Behind
}
}
#[cfg(test)]
async fn get(&self, zone: ZoneId, block_id: u64) -> Option<Block> {
self.chains
.read()
.await
.get(&zone)
.and_then(|chain| chain.get(&block_id).cloned())
.and_then(|chain| chain.blocks.get(&block_id).cloned())
}
/// The highest block id this reader has finalized for `zone`, or `None` if it
/// has read nothing yet.
async fn highest_seen(&self, zone: ZoneId) -> Option<u64> {
/// How far this reader has read `zone` as an unbroken run from genesis, or
/// `None` if it has not read the peer's genesis block yet.
#[cfg(test)]
async fn verified_prefix(&self, zone: ZoneId) -> Option<u64> {
self.chains
.read()
.await
.get(&zone)
.and_then(|chain| chain.keys().copied().max())
.and_then(|chain| chain.verified_prefix)
}
}
@ -115,8 +233,12 @@ impl CrossZoneVerifier {
})
}
/// Verifies every cross-zone dispatch in a block, returning `Err` on the first
/// forged dispatch (the caller halts ingestion) or the keys to mark seen.
/// Verifies every cross-zone dispatch in a block, returning the keys to mark
/// seen, or the reason verification could not complete.
///
/// [`CrossZoneVerifyError::Forged`] means the caller must halt ingestion;
/// [`CrossZoneVerifyError::PeerUnavailable`] means the caller must hold its
/// read cursor and retry, since the block is not yet judged either way.
///
/// The caller MUST record the returned keys via [`Self::record_seen`] only
/// after the block applies, so the seen-set mirrors the inbox's on-chain
@ -124,7 +246,10 @@ impl CrossZoneVerifier {
/// forged dispatch reuse it to skip re-derivation while the inbox delivers the
/// forgery. A key already seen is a replay the inbox no-ops, so it is accepted
/// without re-derivation rather than halting on a legitimate re-delivery.
pub async fn verify_block(&self, block: &Block) -> Result<Vec<MessageKey>> {
pub async fn verify_block(
&self,
block: &Block,
) -> Result<Vec<MessageKey>, CrossZoneVerifyError> {
let mut verified = Vec::new();
for tx in &block.body.transactions {
let Some(msg) = Self::decode_dispatch(tx) else {
@ -144,12 +269,12 @@ impl CrossZoneVerifier {
let expected = self.rederive(&msg).await?;
if LeeTransaction::Public(expected) != *tx {
bail!(
return Err(CrossZoneVerifyError::Forged(anyhow!(
"forged cross-zone dispatch from zone {} block {} tx {}: re-derivation mismatch",
hex::encode(msg.src_zone),
msg.src_block_id,
msg.src_tx_index
);
)));
}
info!(
@ -194,7 +319,10 @@ impl CrossZoneVerifier {
/// Re-derives the dispatch transaction the watcher should have injected for
/// `msg`, reading the source emission from the peer's finalized block.
async fn rederive(&self, msg: &CrossZoneMessage) -> Result<lee::PublicTransaction> {
async fn rederive(
&self,
msg: &CrossZoneMessage,
) -> Result<lee::PublicTransaction, CrossZoneVerifyError> {
let peer_block = self
.wait_for_peer_block(msg.src_zone, msg.src_block_id)
.await?;
@ -204,35 +332,43 @@ impl CrossZoneVerifier {
if let Some(expected) = self.peer_pubkeys.get(&msg.src_zone)
&& !peer_block.is_signed_by(expected)
{
bail!(
return Err(CrossZoneVerifyError::Forged(anyhow!(
"forged cross-zone dispatch: peer zone {} block {} is not signed by the pinned block-signing key",
hex::encode(msg.src_zone),
msg.src_block_id
);
)));
}
// Everything below is a property of the peer block just read, so a
// mismatch is the dispatch lying about it, not a transient condition.
let emission_tx = peer_block
.body
.transactions
.get(usize::try_from(msg.src_tx_index).expect("u32 index fits in usize"))
.ok_or_else(|| {
anyhow::anyhow!(
CrossZoneVerifyError::Forged(anyhow!(
"src_tx_index {} out of range in peer block",
msg.src_tx_index
)
))
})?;
let LeeTransaction::Public(emission_tx) = emission_tx else {
bail!("peer emission transaction is not public");
return Err(CrossZoneVerifyError::Forged(anyhow!(
"peer emission transaction is not public"
)));
};
let message = emission_tx.message();
let emission =
extract_emission(message.program_id, &message.instruction_data).ok_or_else(|| {
anyhow::anyhow!("peer transaction at src_tx_index is not a recognized emitter")
CrossZoneVerifyError::Forged(anyhow!(
"peer transaction at src_tx_index is not a recognized emitter"
))
})?;
if emission.target_zone != self.self_zone {
bail!("peer emission targets a different zone");
return Err(CrossZoneVerifyError::Forged(anyhow!(
"peer emission targets a different zone"
)));
}
Ok(build_dispatch_from_emission(
@ -248,26 +384,42 @@ impl CrossZoneVerifier {
/// Resolves the referenced peer block, distinguishing forgery from lag.
///
/// If the block is cached, return it. If our peer reader has already
/// finalized past `block_id` and we still do not have it, the reference is to
/// a block that does not exist on the peer chain, a forgery, so reject now.
async fn wait_for_peer_block(&self, zone: ZoneId, block_id: u64) -> Result<Block> {
/// A `block_id` inside the run verified from the peer's genesis (see
/// [`PeerChain::verified_prefix`]) that we do not hold does not exist on the
/// peer chain, so reject it. Otherwise the reader has not reached it, so
/// wait, and give up after [`PEER_BLOCK_WAIT_TIMEOUT`] rather than block
/// ingestion forever. A reference to a block the peer will never produce
/// stalls rather than being rejected, since that is indistinguishable from a
/// peer that has not produced it yet; either way it is never applied.
async fn wait_for_peer_block(
&self,
zone: ZoneId,
block_id: u64,
) -> Result<Block, CrossZoneVerifyError> {
let mut waited = Duration::ZERO;
loop {
if let Some(block) = self.peers.get(zone, block_id).await {
return Ok(block);
match self.peers.resolve(zone, block_id).await {
PeerLookup::Cached(block) => return Ok(*block),
// A backstop, not the live path: every id inside the run is
// cached by construction. Bounding the cache must preserve that
// or track a floor alongside the prefix, since an evicted block
// is not a forged one and reporting it as forged would halt a
// legitimate dispatch.
PeerLookup::InsideRun => {
return Err(CrossZoneVerifyError::Forged(anyhow!(
"forged cross-zone reference: peer zone {} chain is verified past block {} but it is absent",
hex::encode(zone),
block_id
)));
}
PeerLookup::Behind => {}
}
if self
.peers
.highest_seen(zone)
.await
.is_some_and(|h| h >= block_id)
{
bail!(
"forged cross-zone reference: peer zone {} finalized past block {} but it is absent",
hex::encode(zone),
block_id
);
if waited >= PEER_BLOCK_WAIT_TIMEOUT {
return Err(CrossZoneVerifyError::PeerUnavailable {
zone,
block_id,
waited,
});
}
if !waited.is_zero() && waited.as_secs().is_multiple_of(LAG_LOG_INTERVAL.as_secs()) {
info!(
@ -277,12 +429,21 @@ impl CrossZoneVerifier {
waited.as_secs()
);
}
tokio::time::sleep(Duration::from_secs(1)).await;
waited = waited.saturating_add(Duration::from_secs(1));
tokio::time::sleep(PEER_BLOCK_POLL_INTERVAL).await;
waited = waited.saturating_add(PEER_BLOCK_POLL_INTERVAL);
}
}
}
/// The outcome of one pass over a peer's message stream.
#[derive(Debug, PartialEq, Eq)]
struct PeerPass {
/// Where the next pass resumes from.
cursor: Option<Slot>,
/// Set when the pass ended early on a message that would not decode.
stalled_at: Option<Slot>,
}
/// Reads a peer zone's finalized blocks from Bedrock into the shared cache.
#[expect(
clippy::infinite_loop,
@ -300,41 +461,124 @@ async fn read_peer(
);
let mut cursor = None;
// The slot the reader is stuck on and how many passes it has spent there.
// Keyed by slot: the retry budget is per slot, so a failure at a new slot
// must not inherit an older slot's count and be skipped on its first try.
let mut stalled: Option<(Slot, u32)> = None;
let mut skip_slot = None;
loop {
let stream = match zone_indexer.next_messages(cursor).await {
Ok(stream) => stream,
Err(err) => {
error!(
"Peer reader next_messages failed for {}: {err}",
hex::encode(peer_zone)
);
tokio::time::sleep(poll_interval).await;
continue;
}
};
let mut stream = std::pin::pin!(stream);
while let Some((msg, slot)) = stream.next().await {
if let ZoneMessage::Block(zone_block) = msg {
match borsh::from_slice::<Block>(&zone_block.data) {
Ok(block) => peers.insert(peer_zone, block).await,
Err(err) => error!("Peer reader failed to deserialize block: {err}"),
match zone_indexer.next_messages(cursor).await {
Ok(stream) => {
let pass = consume_peer_stream(stream, peer_zone, &peers, cursor, skip_slot).await;
cursor = pass.cursor;
if let Some(slot) = pass.stalled_at {
let attempts = match stalled {
Some((prev, attempts)) if prev == slot => attempts.saturating_add(1),
_ => 1,
};
if attempts >= DECODE_RETRY_LIMIT {
// Reading on leaves a hole: dispatches referencing the
// skipped block can no longer be verified, but every
// later block stays readable.
error!(
"Peer reader for {} could not decode slot {slot:?} after {attempts} attempts; reading past it.",
hex::encode(peer_zone)
);
skip_slot = Some(slot);
stalled = None;
} else {
stalled = Some((slot, attempts));
}
} else {
stalled = None;
skip_slot = None;
}
}
cursor = Some(slot);
Err(err) => error!(
"Peer reader next_messages failed for {}: {err}",
hex::encode(peer_zone)
),
}
tokio::time::sleep(poll_interval).await;
}
}
/// Caches the finalized peer blocks carried by `stream`.
///
/// A block that fails to deserialize ends the pass and holds the cursor at the
/// last fully-consumed slot, so the next poll re-reads it and a transient
/// failure heals itself. `skip_slot` names a slot the caller gave up on after
/// [`DECODE_RETRY_LIMIT`] attempts, which is read past instead so a permanently
/// undecodable inscription cannot wedge the reader. Skipping only leaves a hole,
/// which cannot advance [`PeerChain::verified_prefix`] past itself.
///
/// The cursor advances only on a slot boundary, since one slot can carry several
/// messages and resuming mid-slot would skip the ones after the failure. This
/// relies on the stream never truncating mid-slot, which holds because the
/// zone-sdk materializes a whole batch before yielding and batches end on slot
/// boundaries.
async fn consume_peer_stream<S>(
stream: S,
peer_zone: ZoneId,
peers: &PeerBlocks,
resume_from: Option<Slot>,
skip_slot: Option<Slot>,
) -> PeerPass
where
S: Stream<Item = (ZoneMessage, Slot)>,
{
let mut stream = std::pin::pin!(stream);
let mut cursor = resume_from;
// The slot being consumed: cached so far, but there may be more to come.
let mut in_progress: Option<Slot> = None;
while let Some((msg, slot)) = stream.next().await {
if in_progress != Some(slot) {
cursor = in_progress.or(cursor);
in_progress = Some(slot);
}
let ZoneMessage::Block(zone_block) = msg else {
continue;
};
match borsh::from_slice::<Block>(&zone_block.data) {
Ok(block) => peers.insert(peer_zone, block).await,
Err(err) if skip_slot == Some(slot) => {
debug!(
"Peer reader skipping undecodable block from {} at slot {slot:?}: {err}",
hex::encode(peer_zone)
);
}
Err(err) => {
error!(
"Peer reader failed to deserialize block from {} at slot {slot:?}: {err}. Holding the cursor and retrying.",
hex::encode(peer_zone)
);
return PeerPass {
cursor,
stalled_at: Some(slot),
};
}
}
}
PeerPass {
cursor: in_progress.or(cursor),
stalled_at: None,
}
}
#[cfg(test)]
mod tests {
use common::test_utils::produce_dummy_block;
use futures::stream;
use lee::{
PrivateKey, PublicKey, PublicTransaction,
public_transaction::{Message, WitnessSet},
};
use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription};
use logos_blockchain_zone_sdk::ZoneBlock;
use ping_core::{SenderInstruction, ping_record_pda};
use super::*;
@ -375,6 +619,44 @@ mod tests {
))
}
/// A peer-stream item inscribing `data` at `slot`.
fn peer_msg(data: Vec<u8>, slot: u64) -> (ZoneMessage, Slot) {
(
ZoneMessage::Block(ZoneBlock {
id: MsgId::from([0; 32]),
data: Inscription::try_from(data).expect("test inscription is within bounds"),
}),
Slot::from(slot),
)
}
/// A hash-linked chain of `len` blocks from genesis, each carrying a `b"hi"`
/// emission. Only a chain built this way advances the verified prefix.
fn linked_chain(len: u64) -> Vec<Block> {
let mut prev = None;
let mut blocks = Vec::new();
for offset in 0..len {
let block = produce_dummy_block(
GENESIS_BLOCK_ID.saturating_add(offset),
prev,
vec![emission(b"hi")],
);
prev = Some(block.header.hash);
blocks.push(block);
}
blocks
}
/// A peer-stream item carrying `block`.
fn peer_block_msg(block: &Block, slot: u64) -> (ZoneMessage, Slot) {
peer_msg(borsh::to_vec(block).expect("block serializes"), slot)
}
/// A peer-stream item whose inscription is not a decodable block.
fn undecodable_msg(slot: u64) -> (ZoneMessage, Slot) {
peer_msg(b"not a block".to_vec(), slot)
}
/// The dispatch a watcher would inject for a `PEER_BLOCK_ID` emission of `payload`.
fn dispatch(payload: &[u8]) -> LeeTransaction {
let receiver_id = programs::ping_receiver().id();
@ -472,28 +754,6 @@ mod tests {
);
}
#[tokio::test]
async fn rejects_reference_to_a_block_the_peer_never_finalized() {
let verifier = verifier();
// The reader has finalized past PEER_BLOCK_ID (it holds a later block) but
// never saw PEER_BLOCK_ID itself, so a dispatch referencing it is a forgery
// and must be rejected rather than waited on forever.
verifier
.peers
.insert(
PEER_ZONE,
produce_dummy_block(PEER_BLOCK_ID + 1, None, vec![emission(b"hi")]),
)
.await;
let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]);
let err = verifier.verify_block(&block).await.unwrap_err();
assert!(
err.to_string().contains("forged"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn accepts_replayed_dispatch_as_noop() {
let verifier = verifier();
@ -565,4 +825,208 @@ mod tests {
"unexpected error: {err}"
);
}
#[tokio::test]
async fn peer_reader_advances_over_a_fully_decoded_stream() {
let peers = PeerBlocks::default();
let chain = linked_chain(2);
let stream = stream::iter(vec![
peer_block_msg(&chain[0], 0),
peer_block_msg(&chain[1], 1),
]);
let pass = consume_peer_stream(stream, PEER_ZONE, &peers, None, None).await;
assert_eq!(pass.cursor, Some(Slot::from(1)));
assert_eq!(pass.stalled_at, None);
assert_eq!(peers.verified_prefix(PEER_ZONE).await, Some(2));
}
#[tokio::test]
async fn a_block_that_does_not_link_does_not_extend_the_verified_run() {
let peers = PeerBlocks::default();
let genesis = linked_chain(1);
peers.insert(PEER_ZONE, genesis[0].clone()).await;
// Claims the next id, but not the predecessor it would have to follow.
peers
.insert(
PEER_ZONE,
produce_dummy_block(GENESIS_BLOCK_ID + 1, None, vec![emission(b"hi")]),
)
.await;
assert_eq!(
peers.verified_prefix(PEER_ZONE).await,
Some(GENESIS_BLOCK_ID)
);
}
#[tokio::test]
async fn a_block_arriving_between_the_two_halves_of_a_lookup_is_not_forged() {
// `resolve` answers "cached?" and "inside the verified run?" under one
// lock. Split across two, an insert landing between them reads as
// absent-and-inside-the-run, the forgery signal, for a cached block.
let peers = PeerBlocks::default();
for block in linked_chain(2) {
peers.insert(PEER_ZONE, block).await;
}
assert!(matches!(
peers.resolve(PEER_ZONE, 2).await,
PeerLookup::Cached(_)
));
assert!(matches!(
peers.resolve(PEER_ZONE, 3).await,
PeerLookup::Behind
));
}
#[tokio::test]
async fn peer_reader_holds_its_cursor_on_an_undecodable_block() {
let peers = PeerBlocks::default();
let chain = linked_chain(3);
let stream = stream::iter(vec![
peer_block_msg(&chain[0], 0),
undecodable_msg(1),
peer_block_msg(&chain[2], 2),
]);
let pass = consume_peer_stream(stream, PEER_ZONE, &peers, None, None).await;
assert_eq!(pass.cursor, Some(Slot::from(0)));
assert_eq!(pass.stalled_at, Some(Slot::from(1)));
assert_eq!(peers.verified_prefix(PEER_ZONE).await, Some(1));
assert!(peers.get(PEER_ZONE, 3).await.is_none());
}
#[tokio::test]
async fn peer_reader_does_not_resume_inside_a_partially_failed_slot() {
let peers = PeerBlocks::default();
let chain = linked_chain(1);
// One slot can carry several messages; the second one fails.
let stream = stream::iter(vec![peer_block_msg(&chain[0], 7), undecodable_msg(7)]);
let pass = consume_peer_stream(stream, PEER_ZONE, &peers, Some(Slot::from(6)), None).await;
// Slot 7 is re-read whole next pass, not resumed past the failure.
assert_eq!(pass.cursor, Some(Slot::from(6)));
}
#[tokio::test]
async fn peer_reader_reads_past_a_slot_it_has_given_up_on() {
// After DECODE_RETRY_LIMIT attempts the caller nominates the slot to
// skip, so a permanently undecodable inscription cannot wedge the reader.
let peers = PeerBlocks::default();
let chain = linked_chain(3);
let stream = stream::iter(vec![
peer_block_msg(&chain[0], 0),
undecodable_msg(1),
peer_block_msg(&chain[2], 2),
]);
let pass = consume_peer_stream(stream, PEER_ZONE, &peers, None, Some(Slot::from(1))).await;
assert_eq!(pass.cursor, Some(Slot::from(2)), "the pass drains");
assert_eq!(pass.stalled_at, None);
// Block 3 is cached and servable, so dispatches referencing it verify.
assert!(peers.get(PEER_ZONE, 3).await.is_some());
// But the hole stops the verified run, so block 2 is never called forged.
assert_eq!(peers.verified_prefix(PEER_ZONE).await, Some(1));
}
#[tokio::test(start_paused = true)]
async fn undecodable_peer_block_does_not_make_a_later_dispatch_look_forged() {
let verifier = verifier();
let chain = linked_chain(3);
let stream = stream::iter(vec![
peer_block_msg(&chain[0], 0),
undecodable_msg(1),
peer_block_msg(&chain[2], 2),
]);
consume_peer_stream(
stream,
PEER_ZONE,
&verifier.peers,
None,
Some(Slot::from(1)),
)
.await;
// Regression: the reader cached block 3, so the old `max(cached ids)`
// high-water mark reached 3 and a dispatch referencing block 2 was
// rejected as forged, halting ingestion permanently.
let err = verifier
.wait_for_peer_block(PEER_ZONE, 2)
.await
.expect_err("block 2 was never read, so it cannot be resolved");
assert!(
matches!(err, CrossZoneVerifyError::PeerUnavailable { .. }),
"a block outside the verified run is lag, not forgery: {err}"
);
}
#[tokio::test(start_paused = true)]
async fn a_high_block_id_cannot_poison_the_forgery_test() {
// A peer picks its own block ids, so one inscribed block claiming a huge
// id would drive a `max(cached ids)` high-water mark past every real id
// and make each later dispatch look forged. It cannot extend the
// verified run, so it is inert.
let verifier = verifier();
let chain = linked_chain(2);
verifier.peers.insert(PEER_ZONE, chain[0].clone()).await;
verifier.peers.insert(PEER_ZONE, chain[1].clone()).await;
verifier
.peers
.insert(
PEER_ZONE,
produce_dummy_block(u64::MAX, None, vec![emission(b"hi")]),
)
.await;
assert_eq!(verifier.peers.verified_prefix(PEER_ZONE).await, Some(2));
let err = verifier
.wait_for_peer_block(PEER_ZONE, 3)
.await
.expect_err("block 3 has not been read yet");
assert!(
matches!(err, CrossZoneVerifyError::PeerUnavailable { .. }),
"a block beyond the verified run is lag, not forgery: {err}"
);
}
#[tokio::test]
async fn a_transient_decode_failure_heals_on_the_next_pass() {
let verifier = verifier();
let chain = linked_chain(PEER_BLOCK_ID);
let pass = consume_peer_stream(
stream::iter(vec![undecodable_msg(0)]),
PEER_ZONE,
&verifier.peers,
None,
None,
)
.await;
assert_eq!(pass.cursor, None, "the failed slot is not skipped");
assert_eq!(pass.stalled_at, Some(Slot::from(0)));
// The next pass re-reads the same slot, which now decodes.
let pass = consume_peer_stream(
stream::iter(chain.iter().enumerate().map(|(index, block)| {
peer_block_msg(block, u64::try_from(index).expect("test index fits in u64"))
})),
PEER_ZONE,
&verifier.peers,
pass.cursor,
None,
)
.await;
assert_eq!(pass.stalled_at, None);
let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]);
verifier
.verify_block(&block)
.await
.expect("the dispatch verifies once the peer block has been read");
}
}

View File

@ -16,7 +16,7 @@ use retry::ApplyRetryGate;
use crate::{
block_store::IndexerStore,
config::IndexerConfig,
cross_zone_verifier::CrossZoneVerifier,
cross_zone_verifier::{CrossZoneVerifier, CrossZoneVerifyError},
status::{IndexerStatus, IndexerSyncStatus},
};
@ -29,6 +29,16 @@ pub mod status;
/// Consecutive failed apply attempts of the same block before parking.
const APPLY_RETRY_LIMIT: u32 = 3;
/// Which slot the ingest loop is currently inside, so the read cursor only ever
/// moves on a slot boundary.
///
/// One L1 slot can carry several L2 blocks, and the channel stream resumes
/// *after* the stored slot. Advancing the cursor as each block is handled would
/// therefore put a later block in the same slot beyond the cursor whenever a
/// pass ends early, and nothing would ever read it again.
#[derive(Default)]
struct SlotProgress(Option<Slot>);
#[derive(Clone)]
pub struct IndexerCore {
pub zone_indexer: Arc<ZoneIndexer<NodeHttpClient>>,
@ -42,6 +52,23 @@ pub struct IndexerCore {
pub verifier: Option<CrossZoneVerifier>,
}
impl SlotProgress {
/// Records that a message from `slot` is being handled, returning the slot
/// that just completed, if this message begins a new one.
fn enter(&mut self, slot: Slot) -> Option<Slot> {
if self.0 == Some(slot) {
return None;
}
self.0.replace(slot)
}
/// The slot in progress when the stream drained cleanly, which is therefore
/// complete. Not called when a pass ends early: that slot must be re-read.
const fn drained(self) -> Option<Slot> {
self.0
}
}
impl IndexerCore {
/// Builds the core, then verifies the stored chain matches the channel's by
/// re-reading the channel at the stored tip's position.
@ -264,8 +291,20 @@ impl IndexerCore {
let mut announced_syncing = false;
let mut had_cycle_error = false;
// The slot being consumed: every message of it seen so far is
// handled, but another may follow, so the cursor may not move
// onto it yet. One L1 slot can carry several L2 blocks, and the
// stream resumes *after* the stored slot, so advancing inside a
// slot would put a later message in it beyond the cursor
// for ever if this pass ends early.
let mut in_progress = SlotProgress::default();
while let Some((msg, slot)) = stream.next().await {
// A message from a later slot means the previous one is complete.
if let Some(done) = in_progress.enter(slot) {
self.advance_cursor(&mut cursor, done);
}
if !announced_syncing {
self.set_status(IndexerSyncStatus::syncing());
announced_syncing = true;
@ -286,30 +325,43 @@ impl IndexerCore {
break;
}
// L1 proceeds regardless
self.advance_cursor(&mut cursor, slot);
continue;
}
};
// Option B: re-derive and verify every cross-zone dispatch
// before applying the block. A forged dispatch halts ingestion
// rather than persisting an invalid state; a replay is accepted
// since the inbox no-ops it on chain. The verified keys are
// marked seen only once the block applies (below), so a block
// that does not apply cannot poison the seen-set.
// Re-derive and verify every cross-zone dispatch the block
// carries before applying it, so the destination never trusts
// a dispatch just because a sequencer signed the block: a
// forged one halts ingestion rather than persisting invalid
// state, while a replay is accepted since the inbox no-ops it
// on chain. The verified keys are marked seen only once the
// block applies (below), so a block that does not apply
// cannot poison the seen-set.
let verified_keys = match &self.verifier {
Some(verifier) => match verifier.verify_block(&block).await {
Ok(keys) => keys,
Err(err) => {
Err(err @ CrossZoneVerifyError::Forged(_)) => {
error!(
"Cross-zone verification failed for block {}: {err:#}. Halting indexer ingestion.",
"Cross-zone verification failed for block {}: {err}. Halting indexer ingestion.",
block.header.block_id
);
self.set_status(IndexerSyncStatus::error(format!(
"cross-zone verification failed: {err:#}"
"cross-zone verification failed: {err}"
)));
return;
}
// Not judged either way yet, so retry rather than halt.
Err(err @ CrossZoneVerifyError::PeerUnavailable { .. }) => {
error!(
"Cross-zone verification of block {} stalled: {err}. Holding the cursor and retrying.",
block.header.block_id
);
self.set_status(IndexerSyncStatus::error(format!(
"cross-zone peer unavailable: {err}"
)));
had_cycle_error = true;
break;
}
},
None => Vec::new(),
};
@ -322,7 +374,6 @@ impl IndexerCore {
retry_gate.reset();
info!("Indexed L2 block {}", block.header.block_id);
self.set_status(IndexerSyncStatus::syncing());
self.advance_cursor(&mut cursor, slot);
yield Ok(block);
}
Ok(AcceptOutcome::AlreadyApplied) => {
@ -330,7 +381,6 @@ impl IndexerCore {
"Skipping already-applied block {}",
block.header.block_id
);
self.advance_cursor(&mut cursor, slot);
}
Ok(AcceptOutcome::Parked(ingest_err)) => {
error!(
@ -339,7 +389,6 @@ impl IndexerCore {
);
self.set_status(IndexerSyncStatus::stalled(ingest_err.to_string()));
// L1 proceeds regardless
self.advance_cursor(&mut cursor, slot);
}
Ok(AcceptOutcome::RetryableFailure(ingest_err)) => {
let attempts = retry_gate.register_failure(block.header.block_id);
@ -365,7 +414,6 @@ impl IndexerCore {
break;
}
self.set_status(IndexerSyncStatus::stalled(ingest_err.to_string()));
self.advance_cursor(&mut cursor, slot);
retry_gate.reset();
} else {
error!(
@ -396,10 +444,17 @@ impl IndexerCore {
}
if had_cycle_error {
// The slot in progress is not finished, so the cursor stays
// below it and the next pass re-reads it whole.
tokio::time::sleep(poll_interval).await;
continue;
}
// The stream drained cleanly, so the slot in progress completed too.
if let Some(done) = in_progress.drained() {
self.advance_cursor(&mut cursor, done);
}
// Stream drained. Stay Stalled if parked; otherwise we are caught up.
// A store error here must not be collapsed to "no stall recorded":
// that would wrongly flip us to caught-up, so we log and hold state.
@ -426,6 +481,51 @@ mod tests {
use super::*;
use crate::config::{ChannelId, ClientConfig, IndexerConfig};
/// The cursor must not move while more of the same slot may still arrive.
///
/// Two L2 blocks in one L1 slot: the first applies, the second stalls on an
/// unavailable peer and the pass retries. If handling the first had advanced
/// the cursor onto the slot, the retry would resume past it and the second
/// block would never be read again, silently losing whatever it carried.
#[test]
fn a_slot_is_only_left_behind_once_it_is_finished() {
let mut progress = SlotProgress::default();
let slot = Slot::from(7);
assert_eq!(
progress.enter(slot),
None,
"nothing precedes the first slot"
);
assert_eq!(
progress.enter(slot),
None,
"a second message in the same slot must not release it"
);
// The pass ends early here, so `drained` is never called and the cursor
// is still below slot 7: the next pass re-reads it whole.
}
#[test]
fn a_completed_slot_is_released_when_the_next_one_starts() {
let mut progress = SlotProgress::default();
assert_eq!(progress.enter(Slot::from(3)), None);
assert_eq!(progress.enter(Slot::from(3)), None);
assert_eq!(
progress.enter(Slot::from(4)),
Some(Slot::from(3)),
"slot 3 is complete once a message from slot 4 arrives"
);
assert_eq!(progress.drained(), Some(Slot::from(4)));
}
#[test]
fn draining_an_untouched_stream_releases_nothing() {
assert_eq!(SlotProgress::default().drained(), None);
}
fn unreachable_core(dir: &std::path::Path) -> IndexerCore {
let config = IndexerConfig {
consensus_info_polling_interval: Duration::from_secs(1),

View File

@ -9,4 +9,5 @@ workspace = true
[dependencies]
lee_core.workspace = true
risc0-zkvm.workspace = true
serde = { workspace = true, default-features = false }

View File

@ -3,14 +3,19 @@ use lee_core::{account::AccountId, program::ProgramId};
use serde::{Deserialize, Serialize};
const BRIDGE_SEED_DOMAIN_SEPARATOR: [u8; 32] = *b"/LEZ/v0.3/BridgeSeed/0000000000/";
const DEPOSIT_RECEIPT_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeDepositReceipt/0";
#[derive(Serialize, Deserialize)]
pub enum Instruction {
/// Transfers native tokens from the bridge PDA account to a recipient vault.
/// Transfers native tokens from the bridge PDA account to a recipient vault,
/// exactly once per `l1_deposit_op_id`.
///
/// Required accounts (2):
/// Required accounts (3):
/// - Bridge PDA account
/// - Recipient vault PDA account
/// - Deposit-receipt PDA account, derived from `l1_deposit_op_id`. Its existence records that
/// this op id was already minted; a second application of the same op id finds it present and
/// transfers nothing.
Deposit {
/// Deposit OP ID from L1, stored here to pin each [`Deposit`](Instruction::Deposit) to a
/// Deposit Event on L1.
@ -43,3 +48,58 @@ pub const fn compute_bridge_seed() -> PdaSeed {
pub fn compute_bridge_account_id(bridge_program_id: ProgramId) -> AccountId {
AccountId::for_public_pda(&bridge_program_id, &compute_bridge_seed())
}
/// Seed of the deposit-receipt PDA for `l1_deposit_op_id`, exposed so the guest
/// can claim the account. Domain-separated from [`compute_bridge_seed`].
#[must_use]
pub fn deposit_receipt_seed(l1_deposit_op_id: [u8; 32]) -> PdaSeed {
use risc0_zkvm::sha::{Impl, Sha256 as _};
let mut bytes = [0_u8; 64];
bytes[..32].copy_from_slice(&DEPOSIT_RECEIPT_SEED_DOMAIN);
bytes[32..].copy_from_slice(&l1_deposit_op_id);
let seed: [u8; 32] = Impl::hash_bytes(&bytes)
.as_bytes()
.try_into()
.unwrap_or_else(|_| unreachable!());
PdaSeed::new(seed)
}
/// The deposit-receipt PDA whose existence marks `l1_deposit_op_id` as minted.
#[must_use]
pub fn deposit_receipt_account_id(
bridge_program_id: ProgramId,
l1_deposit_op_id: [u8; 32],
) -> AccountId {
AccountId::for_public_pda(&bridge_program_id, &deposit_receipt_seed(l1_deposit_op_id))
}
#[cfg(test)]
mod tests {
use super::*;
const BRIDGE_ID: ProgramId = [7; 8];
#[test]
fn receipt_id_is_deterministic_per_op_id() {
let op = [3_u8; 32];
assert_eq!(
deposit_receipt_account_id(BRIDGE_ID, op),
deposit_receipt_account_id(BRIDGE_ID, op)
);
}
#[test]
fn distinct_op_ids_and_domains_do_not_collide() {
let a = deposit_receipt_account_id(BRIDGE_ID, [1; 32]);
let b = deposit_receipt_account_id(BRIDGE_ID, [2; 32]);
assert_ne!(a, b, "different op ids must derive different receipts");
// The op-id-derived seed must not alias the plain bridge PDA, even if an
// op id ever equals the bridge seed's raw bytes.
assert_ne!(
deposit_receipt_account_id(BRIDGE_ID, *compute_bridge_seed().as_bytes()),
compute_bridge_account_id(BRIDGE_ID)
);
}
}

View File

@ -1,6 +1,7 @@
use bridge_core::Instruction;
use lee_core::program::{
AccountPostState, ChainedCall, ProgramInput, ProgramOutput, read_lee_inputs,
use lee_core::{
account::Account,
program::{AccountPostState, ChainedCall, Claim, ProgramInput, ProgramOutput, read_lee_inputs},
};
fn unchanged_post_states(
@ -29,18 +30,17 @@ fn main() {
);
let pre_states_clone = pre_states.clone();
let post_states = unchanged_post_states(&pre_states_clone);
let chained_calls = match instruction {
let (post_states, chained_calls) = match instruction {
Instruction::Deposit {
l1_deposit_op_id: _,
l1_deposit_op_id,
vault_program_id,
recipient_id,
amount,
} => {
let [bridge, recipient_vault] = pre_states
let [bridge, recipient_vault, receipt] = pre_states
.try_into()
.expect("Deposit requires exactly 2 accounts");
.expect("Deposit requires exactly 3 accounts");
assert_eq!(
bridge.account_id,
@ -54,20 +54,54 @@ fn main() {
"Second account must be recipient vault PDA"
);
let mut bridge_for_vault = bridge;
bridge_for_vault.is_authorized = true;
assert_eq!(
receipt.account_id,
bridge_core::deposit_receipt_account_id(self_program_id, l1_deposit_op_id),
"Third account must be the deposit-receipt PDA"
);
vec![
ChainedCall::new(
vault_program_id,
vec![bridge_for_vault, recipient_vault],
&vault_core::Instruction::Transfer {
recipient_id,
amount: u128::from(amount),
},
)
.with_pda_seeds(vec![bridge_core::compute_bridge_seed()]),
]
// Replay protection: the receipt PDA exists iff this op id was
// already minted. On replay it is non-default and the whole
// instruction is a no-op.
//
// Observability note: a no-op replay and a real first mint are both
// successful txs, so an indexer cannot tell "credited here" from
// "already credited by a peer" without deriving the receipt id and
// checking whether it existed before this block — the receipt claim
// is the only on-chain signal. Relevant once the explorer surfaces
// deposits.
if receipt.account != Account::default() {
(unchanged_post_states(&pre_states_clone), vec![])
} else {
// First mint: claim the receipt — its existence is the record,
// the account's contents are never read — and chain the vault
// transfer.
let receipt_post = AccountPostState::new_claimed_if_default(
receipt.account,
Claim::Pda(bridge_core::deposit_receipt_seed(l1_deposit_op_id)),
);
let post_states = vec![
AccountPostState::new(bridge.account.clone()),
AccountPostState::new(recipient_vault.account.clone()),
receipt_post,
];
let mut bridge_for_vault = bridge;
bridge_for_vault.is_authorized = true;
let chained_calls = vec![
ChainedCall::new(
vault_program_id,
vec![bridge_for_vault, recipient_vault],
&vault_core::Instruction::Transfer {
recipient_id,
amount: u128::from(amount),
},
)
.with_pda_seeds(vec![bridge_core::compute_bridge_seed()]),
];
(post_states, chained_calls)
}
}
Instruction::Withdraw {
amount,
@ -89,13 +123,14 @@ fn main() {
"Sender account must be owned by the authenticated transfer program"
);
vec![ChainedCall::new(
let chained_calls = vec![ChainedCall::new(
auth_transfer_program_id,
vec![sender, bridge],
&authenticated_transfer_core::Instruction::Transfer {
amount: u128::from(amount),
},
)]
)];
(unchanged_post_states(&pre_states_clone), chained_calls)
}
};

View File

@ -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 common::block::Block;
@ -47,26 +47,15 @@ use crate::config::BedrockConfig;
/// backpressure if the drive task stalls (reconnect, long backfill).
const PUBLISH_INBOX_CAPACITY: usize = 32;
/// Sink for `Event::Published` checkpoints emitted by the drive task.
/// Caller is responsible for persistence (e.g. writing to rocksdb).
pub type CheckpointSink = Box<dyn Fn(SequencerCheckpoint) + Send + 'static>;
/// Sink for finalized L2 block ids derived from `Event::TxsFinalized` and
/// `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.
/// Everything one `Event::BlocksProcessed` carries, with inscription payloads
/// decoded into `(MsgId, Block)` pairs.
///
/// One struct rather than a sink per effect, because the `checkpoint` and
/// everything it covers must reach the store in a single write.
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
/// extend (or, after a reorg, replace part of) the `head` tier.
pub adopted: Vec<(MsgId, Block)>,
@ -76,19 +65,24 @@ pub struct FollowUpdate {
/// Inscriptions whose containing L1 block reached finality: their blocks
/// move into the irreversible `final` tier.
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
/// revert orphaned ones.
/// Sink for the follow path: apply the channel delta to chain state and
/// persist the whole event in one write.
pub type OnFollowSink = Box<dyn Fn(FollowUpdate) + Send + 'static>;
/// Commands the drive task executes with `&mut sequencer`.
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 {
inscription: Inscription,
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")]
pub trait BlockPublisherTrait: Sized {
#[expect(
clippy::too_many_arguments,
reason = "Looks better than bundling all those callbacks into a struct"
)]
async fn new(
config: &BedrockConfig,
bedrock_signing_key: Ed25519Key,
resubmit_interval: Duration,
initial_checkpoint: Option<SequencerCheckpoint>,
on_checkpoint: CheckpointSink,
on_finalized_block: FinalizedBlockSink,
on_deposit_event: OnDepositEventSink,
on_withdraw_event: OnWithdrawEventSink,
on_follow: OnFollowSink,
) -> Result<Self>;
/// Publish a block and return the `MsgId` zone-sdk assigned its inscription.
/// Zone-sdk drives the actual submission and retries internally.
async fn publish_block(&self, block: &Block, withdrawals: Vec<WithdrawArg>) -> Result<MsgId>;
/// Publish a block and return the `MsgId` zone-sdk assigned its inscription
/// together with the checkpoint that now holds it as pending. Zone-sdk
/// 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;
@ -168,10 +162,6 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
bedrock_signing_key: Ed25519Key,
resubmit_interval: Duration,
initial_checkpoint: Option<SequencerCheckpoint>,
on_checkpoint: CheckpointSink,
on_finalized_block: FinalizedBlockSink,
on_deposit_event: OnDepositEventSink,
on_withdraw_event: OnWithdrawEventSink,
on_follow: OnFollowSink,
) -> Result<Self> {
let basic_auth = config.auth.clone().map(Into::into);
@ -229,7 +219,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
};
let msg_result = published
.map(|(result, _checkpoint)| result.tx.inscription().this_msg);
.map(|(result, checkpoint)| (result.tx.inscription().this_msg, checkpoint));
match &msg_result {
Ok(_) if withdraw_count == 0 => {
info!("Published block with the size of {data_byte_size} bytes");
@ -254,8 +244,6 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
channel_update,
finalized,
} => {
on_checkpoint(checkpoint);
let adopted = channel_update
.adopted
.iter()
@ -269,29 +257,35 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
.collect();
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) {
match op {
FinalizedOp::Inscription(inscription) => {
if let Some((msg, block)) =
if let Some(entry) =
block_from_inscription(&inscription)
{
on_finalized_block(block.header.block_id);
finalized_blocks.push((msg, block));
finalized_blocks.push(entry);
}
}
FinalizedOp::Deposit(deposit) => {
on_deposit_event(deposit).await;
}
FinalizedOp::Deposit(deposit) => deposits.push(deposit),
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 {
checkpoint,
adopted,
orphaned,
finalized: finalized_blocks,
deposits,
withdrawals,
});
}
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_bounded: Inscription = data
.try_into()

View File

@ -155,13 +155,13 @@ impl SequencerStore {
pub(crate) fn update(
&mut self,
block: &Block,
deposit_event_ids: &[HashType],
withdrawals: Vec<WithdrawalReconciliationKey>,
withdrawals: &[WithdrawalReconciliationKey],
state: &V03State,
checkpoint: Option<&[u8]>,
) -> DbResult<()> {
let new_transactions_map = block_to_transactions_map(block);
self.dbio
.atomic_update(block, deposit_event_ids, withdrawals, state)?;
.atomic_update(block, withdrawals, state, checkpoint)?;
self.tx_hash_to_block_map.extend(new_transactions_map);
Ok(())
}
@ -194,10 +194,12 @@ impl SequencerStore {
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<()> {
let bytes =
serde_json::to_vec(checkpoint).context("Failed to serialize zone-sdk checkpoint")?;
self.dbio.put_zone_sdk_checkpoint_bytes(&bytes)?;
self.dbio
.put_zone_sdk_checkpoint_bytes(&checkpoint_bytes(checkpoint)?)?;
Ok(())
}
@ -211,23 +213,15 @@ impl SequencerStore {
self.dbio.put_zone_anchor(anchor)
}
pub fn get_unfulfilled_deposit_events(&self) -> DbResult<Vec<PendingDepositEventRecord>> {
pub fn get_pending_deposit_events(&self) -> DbResult<Vec<PendingDepositEventRecord>> {
self.dbio.get_pending_deposit_events()
}
}
pub fn is_deposit_event_submitted(&self, deposit_op_id: HashType) -> DbResult<bool> {
self.dbio.is_deposit_event_submitted(deposit_op_id)
}
/// 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<()> {
self.dbio
.mark_deposit_events_submitted(deposit_op_ids, submitted_block_id)
}
/// The checkpoint's on-disk encoding. `serde_json` because `SequencerCheckpoint`
/// derives serde but not borsh; paired with `get_zone_checkpoint`'s decode.
pub(crate) fn checkpoint_bytes(checkpoint: &SequencerCheckpoint) -> Result<Vec<u8>> {
serde_json::to_vec(checkpoint).context("Failed to serialize zone-sdk checkpoint")
}
pub(crate) fn block_to_transactions_map(block: &Block) -> HashMap<HashType, u64> {
@ -278,9 +272,7 @@ mod tests {
assert_eq!(None, retrieved_tx);
// Add the block with the transaction
let dummy_state = V03State::new();
node_store
.update(&block, &[], vec![], &dummy_state)
.unwrap();
node_store.update(&block, &[], &dummy_state, None).unwrap();
// Try again
let output = node_store.get_transaction_by_hash(tx.hash());
assert_eq!(Some((tx, 1)), output);
@ -345,9 +337,7 @@ mod tests {
let block_hash = block.header.hash;
let dummy_state = V03State::new();
node_store
.update(&block, &[], vec![], &dummy_state)
.unwrap();
node_store.update(&block, &[], &dummy_state, None).unwrap();
// Verify that the latest block meta now equals the new block's hash
let latest_meta = node_store.latest_block_meta().unwrap().unwrap();
@ -383,9 +373,7 @@ mod tests {
let block_id = block.header.block_id;
let dummy_state = V03State::new();
node_store
.update(&block, &[], vec![], &dummy_state)
.unwrap();
node_store.update(&block, &[], &dummy_state, None).unwrap();
// Verify initial status is Pending
let retrieved_block = node_store.get_block_at_id(block_id).unwrap().unwrap();
@ -434,7 +422,7 @@ mod tests {
// Add a new block
let block = common::test_utils::produce_dummy_block(1, None, vec![tx.clone()]);
node_store
.update(&block, &[], vec![], &V03State::new())
.update(&block, &[], &V03State::new(), None)
.unwrap();
}

View File

@ -1,4 +1,5 @@
use std::{
collections::VecDeque,
path::Path,
sync::{Arc, Mutex},
time::Instant,
@ -31,7 +32,7 @@ pub use mock::SequencerCoreWithMockClients;
use num_bigint::BigUint;
pub use storage::error::DbError;
use storage::sequencer::{
RocksDBIO,
RocksDBIO, StoreUpdate,
sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey, ZoneAnchorRecord},
};
@ -188,17 +189,12 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
let is_fresh_start = initial_checkpoint.is_none();
let (mempool, mempool_handle) = MemPool::new(config.mempool_max_size);
replay_unfulfilled_deposit_events(&store, mempool_handle.clone());
let block_publisher = BP::new(
&config.bedrock_config,
bedrock_signing_key,
config.retry_pending_blocks_timeout,
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()),
)
.await
@ -241,8 +237,9 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
"First pending block on fresh start should be the genesis block"
);
let mut last_checkpoint = None;
for block in &pending_blocks {
block_publisher
let (_msg, checkpoint) = block_publisher
.publish_block(block, vec![])
.await
.unwrap_or_else(|err| {
@ -251,6 +248,16 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
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,171 +455,36 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
}
}
// Persist like the follow path: the tip meta stays pinned to the head
// tip even when the reconstructed block lands below it.
let head_tip = chain.head_tip().map(|head| BlockMeta::from(&head));
let final_meta = chain.final_tip().map(|meta| BlockMeta::from(&meta));
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.
let deposit_event_ids: Vec<_> = block
// A reconstructed block is finalized, so any deposit it mints is
// permanently reflected in state (its receipt PDA); drop the pending
// record backfill may have re-delivered, so the drain stops re-minting.
let finalized_deposit_ids: Vec<_> = block
.body
.transactions
.iter()
.filter_map(extract_bridge_deposit_id)
.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
.set_zone_anchor(&record)
.context("Failed to persist zone anchor")?;
.dbio()
.store_update(&StoreUpdate {
blocks: &[(block, true)],
head_tip: head_tip.as_ref(),
final_snapshot: final_meta.as_ref().map(|meta| (chain.final_state(), meta)),
remove_deposit_records: &finalized_deposit_ids,
zone_anchor: Some(&record),
..StoreUpdate::new(chain.head_state())
})
.context("Failed to persist reconstructed block")?;
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`].
fn on_follow(
dbio: Arc<RocksDBIO>,
@ -626,21 +498,17 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
/// Produces a new block from mempool transactions and publishes it via zone-sdk.
pub async fn produce_new_block(&mut self) -> Result<u64> {
let BlockWithMeta {
block,
deposit_event_ids,
withdrawals,
} = self
let BlockWithMeta { block, withdrawals } = self
.build_block_from_mempool()
.context("Failed to build block from mempool transactions")?;
let withdrawal_reconciliation_keys = withdrawals
let withdrawal_reconciliation_keys: Vec<_> = withdrawals
.iter()
.map(|withdraw| withdraw_event_reconciliation_key(&withdraw.outputs))
.collect::<Result<_>>()
.collect::<Result<Vec<_>>>()
.context("Failed to build reconciliation keys for block withdrawals")?;
let this_msg = self
let (this_msg, checkpoint) = self
.block_publisher
.publish_block(&block, withdrawals)
.await
@ -649,8 +517,8 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
self.record_produced_block(
this_msg,
&block,
&deposit_event_ids,
withdrawal_reconciliation_keys,
&withdrawal_reconciliation_keys,
&checkpoint,
)?;
Ok(block.header.block_id)
@ -669,9 +537,11 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
&mut self,
this_msg: MsgId,
block: &Block,
deposit_event_ids: &[HashType],
withdrawal_reconciliation_keys: Vec<WithdrawalReconciliationKey>,
withdrawal_reconciliation_keys: &[WithdrawalReconciliationKey],
checkpoint: &block_publisher::SequencerCheckpoint,
) -> Result<()> {
let checkpoint_bytes = block_store::checkpoint_bytes(checkpoint)?;
let mut chain = self.chain.lock().expect("chain state mutex poisoned");
match chain.apply_produced(this_msg, block) {
AcceptOutcome::Applied => {
@ -679,11 +549,14 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
// with the follow path.
self.store.update(
block,
deposit_event_ids,
withdrawal_reconciliation_keys,
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 => {
warn!(
"Produced block {} lost a competing-write race, skipping persistence",
@ -704,20 +577,14 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
/// Validates and applies a single mempool transaction to the current state.
/// Returns `Ok(true)` if the transaction was valid and applied, `Ok(false)` if
/// it was skipped due to validation failure.
#[expect(
clippy::too_many_arguments,
reason = "splitting the produce-path accumulators into a struct buys nothing"
)]
fn apply_mempool_transaction(
store: &SequencerStore,
state: &mut lee::V03State,
origin: TransactionOrigin,
tx: &LeeTransaction,
block_height: u64,
timestamp: u64,
deposit_event_ids: &mut Vec<HashType>,
withdrawals: &mut Vec<WithdrawArg>,
) -> Result<bool> {
) -> bool {
let tx_hash = tx.hash();
match origin {
TransactionOrigin::User => {
@ -727,7 +594,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
error!(
"Transaction with hash {tx_hash} failed execution check with error: {err:#?}, skipping it",
);
return Ok(false);
return false;
}
};
@ -742,25 +609,30 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
panic!("Sequencer may only generate Public transactions, found {tx:#?}");
};
if let Some(deposit_op_id) = extract_bridge_deposit_id(tx) {
if store
.is_deposit_event_submitted(deposit_op_id)
.context("Failed to check whether deposit was already submitted")?
{
info!("Skipping already-submitted bridge deposit {deposit_op_id}");
return Ok(false);
}
deposit_event_ids.push(deposit_op_id);
// Bridge deposits are deduped by their receipt PDA in chain
// state (drained only when unminted, no-op on replay), so no
// node-local guard is needed here.
//
// Skip-and-log rather than propagate: a drained deposit is
// re-fed from the store every turn and only finality removes it,
// so a `?` here would let a single unexecutable mint (e.g. a
// bridge escrow under-funded relative to the L1 deposit, which
// every sequencer hits identically) abort production on all of
// them forever. Skipping keeps the record queued for retry
// without halting the node.
if let Err(err) =
state.transition_from_public_transaction(public_tx, block_height, timestamp)
{
error!(
"Sequencer-generated transaction {tx_hash} failed execution: {err:#?}, skipping it",
);
return false;
}
state
.transition_from_public_transaction(public_tx, block_height, timestamp)
.context("Failed to execute sequencer-generated transaction")?;
}
}
info!("Validated transaction with hash {tx_hash}, including it in block");
Ok(true)
true
}
fn build_block_from_mempool(&mut self) -> Result<BlockWithMeta> {
@ -780,9 +652,36 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
};
let mut valid_transactions = Vec::new();
let mut deposit_event_ids = 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). Draining here also subsumes the old
// startup replay.
//
// Skip any deposit whose receipt PDA already exists in the state we
// build on — it was minted by us or by a peer whose block we adopted.
// An orphan reverts the receipt with the block, so the next turn
// re-mints without any bookkeeping of our own.
let mut pending_deposits: VecDeque<LeeTransaction> = self
.store
.get_pending_deposit_events()
.context("Failed to load pending deposit events")?
.into_iter()
.filter(|record| !deposit_already_minted(&working_state, record.deposit_op_id))
.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())
.expect("`max_block_size` should fit into usize");
@ -792,7 +691,14 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
let clock_tx = clock_invocation(new_block_timestamp);
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 temp_valid_transactions = [
@ -817,20 +723,22 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
"Transaction with hash {tx_hash} deferred to next block: \
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;
}
if Self::apply_mempool_transaction(
&self.store,
&mut working_state,
origin,
&tx,
new_block_height,
new_block_timestamp,
&mut deposit_event_ids,
&mut withdrawals,
)? {
) {
valid_transactions.push(tx);
}
@ -861,11 +769,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
now.elapsed().as_secs()
);
Ok(BlockWithMeta {
block,
deposit_event_ids,
withdrawals,
})
Ok(BlockWithMeta { block, withdrawals })
}
/// Reads the current head state under the lock without cloning it, so callers
@ -896,10 +800,9 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
}
/// Marks all pending blocks with `block_id <= last_finalized_block_id` as
/// finalized. Idempotent. Production callers don't invoke this directly —
/// it's wired up in `start_from_config` to the publisher's
/// `on_finalized_block` sink, which fires on `Event::TxsFinalized` /
/// `Event::FinalizedInscriptions`. Kept on the type for tests.
/// finalized. Idempotent. Production no longer calls this: finalization
/// flips now ride the follow path's atomic write via
/// [`StoreUpdate::finalized_up_to`]. Kept on the type for tests.
// TODO: Delete blocks instead of marking them as finalized. Current
// approach is used because we still have `GetBlockDataRequest`.
pub fn clean_finalized_blocks_from_db(&self, last_finalized_block_id: u64) -> Result<()> {
@ -941,15 +844,26 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
struct BlockWithMeta {
block: Block,
deposit_event_ids: Vec<HashType>,
withdrawals: Vec<WithdrawArg>,
}
/// Whether `deposit_op_id`'s mint is already reflected in `state` — its receipt
/// PDA exists. The receipt is the exactly-once ledger the bridge program keeps.
fn deposit_already_minted(state: &lee::V03State, deposit_op_id: HashType) -> bool {
let receipt_id =
bridge_core::deposit_receipt_account_id(programs::bridge().id(), deposit_op_id.0);
state
.get_account_by_id_ref(receipt_id)
.is_some_and(|receipt| *receipt != lee::Account::default())
}
/// Feed one channel delta into the follow state and mirror it to the store:
/// revert orphaned, then apply and persist adopted and finalized blocks.
/// Production builds on this same head. Wired to the publisher via
/// [`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
/// `is_retryable` (transient) apply failures — a failed block just parks and
/// relies on a valid successor or a restart. `ChainState` never emits
@ -962,14 +876,42 @@ fn apply_follow_update(
update: block_publisher::FollowUpdate,
) {
let block_publisher::FollowUpdate {
checkpoint,
adopted,
orphaned,
finalized,
deposits,
withdrawals,
} = 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
// 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");
// User txs of orphaned blocks, returned to the mempool below.
@ -987,16 +929,28 @@ fn apply_follow_update(
.map(|((_, block), _)| (block, false))
.collect();
// Only blocks the final tier holds drive the bookkeeping below: a parked
// one never became irreversible, so marking blocks finalized through it
// or dropping its deposit records would lose them for good.
let mut irreversible: Vec<&Block> = Vec::new();
let mut final_advanced = false;
for (this_msg, block) in &finalized {
// FIXME: thread the finalized inscription's L1 slot once the
// sdk surfaces it; only used for the invalid-finalized stall.
if matches!(
chain.apply_finalized(*this_msg, block, Slot::from(0)),
AcceptOutcome::Applied
) {
to_persist.push((block, true));
final_advanced = true;
// FIXME: thread the finalized inscription's L1 slot instead of
// `Slot::from(0)`; only used for the invalid-finalized stall.
// logos-blockchain PR #3147 surfaces it as `FinalizedTx.l1_slot` —
// wire it through `FollowUpdate::finalized` once the zone-sdk pin is
// bumped past that (a separate PR).
match chain.apply_finalized(*this_msg, block, Slot::from(0)) {
AcceptOutcome::Applied => {
to_persist.push((block, true));
irreversible.push(block);
final_advanced = true;
}
// A re-delivery of a block the final tier already holds: no new
// payload and the tier does not move, but it is irreversible all
// the same, so it still settles its deposits.
AcceptOutcome::AlreadyApplied => irreversible.push(block),
AcceptOutcome::Parked(_) | AcceptOutcome::RetryableFailure(_) => {}
}
}
// Snapshot the advanced final tier so a restart re-anchors on it.
@ -1006,36 +960,65 @@ fn apply_follow_update(
});
let head_tip = chain.head_tip().map(|tip| BlockMeta::from(&tip));
// One atomic write for the whole update: blocks, tip meta and the
// state after the last block land together, so a crash can never
// leave the stored state ahead of the stored blocks. 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.
//
// 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:#}"));
// Every block at or below the highest finalized one is irreversible, so
// stored blocks there can be marked finalized.
let last_finalized = irreversible.iter().map(|block| block.header.block_id).max();
resubmit_txs
// A deposit observed in a finalized block is permanently minted (its
// receipt is now in the irreversible tier), so its pending record can be
// dropped. Keyed by op id, not block id: a record only goes once its own
// deposit finalizes, never because some other block finalized at its
// height.
let finalized_deposit_ids: Vec<HashType> = irreversible
.iter()
.flat_map(|block| block.body.transactions.iter())
.filter_map(extract_bridge_deposit_id)
.collect();
// A persist failure is fatal: the in-memory chain has already advanced,
// 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,
remove_deposit_records: &finalized_deposit_ids,
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
// next on-turn production re-includes them on the new head.
//
// We use [`try_push`] here because this is called from the
// publisher's drive task, and only the block production drains the mempool.
// A blocking push on a full mempool would deadlock here.
// We use [`try_push`] here because this is called from the publisher's
// drive task, and only block production drains the mempool. A blocking
// 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 {
let tx_hash = tx.hash();
if let Err(err) = mempool_handle.try_push((TransactionOrigin::User, tx)) {
@ -1044,55 +1027,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 only accounts seeded outside any transaction. Cross-zone config is seeded
/// by genesis `InitConfig` transactions and reconstructed by replaying them.
@ -1232,7 +1166,6 @@ fn pending_deposit_event_record(deposit: &DepositInfo) -> PendingDepositEventRec
source_tx_hash: HashType(deposit.tx_hash.0),
amount: deposit.amount,
metadata: deposit.metadata.clone().into(),
submitted_in_block_id: None,
}
}
@ -1244,10 +1177,18 @@ fn build_bridge_deposit_tx_from_event(event: &PendingDepositEventRecord) -> Resu
let vault_program_id = programs::vault().id();
let recipient_vault_id =
vault_core::compute_vault_account_id(vault_program_id, metadata.recipient_id);
// The receipt PDA carries the exactly-once check: the program reads it to
// detect a replay, so it must be in the tx's account list.
let receipt_id =
bridge_core::deposit_receipt_account_id(bridge_program_id, event.deposit_op_id.0);
let message = Message::try_new(
bridge_program_id,
vec![system_accounts::bridge_account_id(), recipient_vault_id],
vec![
system_accounts::bridge_account_id(),
recipient_vault_id,
receipt_id,
],
vec![],
bridge_core::Instruction::Deposit {
l1_deposit_op_id: event.deposit_op_id.0,

View File

@ -3,16 +3,16 @@ use std::time::Duration;
use anyhow::Result;
use common::block::Block;
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_zone_sdk::{Slot, ZoneMessage, sequencer::WithdrawArg};
use tokio_util::sync::CancellationToken;
use crate::{
block_publisher::{
BlockPublisherTrait, CheckpointSink, FinalizedBlockSink, OnDepositEventSink, OnFollowSink,
OnWithdrawEventSink, SequencerCheckpoint,
},
block_publisher::{BlockPublisherTrait, OnFollowSink, SequencerCheckpoint},
config::BedrockConfig,
};
@ -54,16 +54,15 @@ impl BlockPublisherTrait for MockBlockPublisher {
_bedrock_signing_key: Ed25519Key,
_resubmit_interval: Duration,
_initial_checkpoint: Option<SequencerCheckpoint>,
_on_checkpoint: CheckpointSink,
_on_finalized_block: FinalizedBlockSink,
_on_deposit_event: OnDepositEventSink,
_on_withdraw_event: OnWithdrawEventSink,
_on_follow: OnFollowSink,
) -> Result<Self> {
Ok(Self {
channel_id: config.channel_id,
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(),
})
}
@ -72,11 +71,11 @@ impl BlockPublisherTrait for MockBlockPublisher {
&self,
block: &Block,
_bridge_withdrawals: Vec<WithdrawArg>,
) -> Result<MsgId> {
) -> Result<(MsgId, SequencerCheckpoint)> {
// Deterministic per-block id so head dedup behaves in tests.
//
// 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 {
@ -108,3 +107,16 @@ impl BlockPublisherTrait for MockBlockPublisher {
Ok(futures::stream::iter(messages))
}
}
/// A zeroed checkpoint, for [`MockBlockPublisher::publish_block`] and for tests
/// building a [`crate::block_publisher::FollowUpdate`]. Tests only assert *that*
/// a checkpoint was persisted alongside its effects, never what is in it.
#[must_use]
pub(crate) fn mock_checkpoint() -> SequencerCheckpoint {
SequencerCheckpoint {
last_msg_id: MsgId::from([0; 32]),
pending_txs: Vec::new(),
lib: HeaderId::from([0; 32]),
lib_slot: Slot::from(0),
}
}

View File

@ -22,7 +22,12 @@ use lee_core::{
account::{AccountWithMetadata, Nonce},
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 storage::sequencer::sequencer_cells::PendingDepositEventRecord;
use tempfile::tempdir;
@ -33,9 +38,9 @@ use crate::{
block_publisher::FollowUpdate,
block_store::SequencerStore,
build_bridge_deposit_tx_from_event, build_genesis_state,
config::{BedrockConfig, SequencerConfig},
is_sequencer_only_program,
mock::SequencerCoreWithMockClients,
config::{BedrockConfig, GenesisAction, SequencerConfig},
deposit_already_minted, is_sequencer_only_program,
mock::{SequencerCoreWithMockClients, mock_checkpoint},
resubmittable_txs,
};
@ -46,6 +51,19 @@ struct DepositMetadataForEncoding {
recipient_id: lee::AccountId,
}
/// 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(),
}
}
fn setup_sequencer_config() -> SequencerConfig {
let tempdir = tempfile::tempdir().unwrap();
let home = tempdir.path().to_path_buf();
@ -211,8 +229,10 @@ async fn start_from_config_panics_when_db_open_returns_non_not_found_error() {
}
#[tokio::test]
async fn start_from_config_replays_unfulfilled_deposit_events_from_db() {
let config = setup_sequencer_config();
async fn unfulfilled_deposit_events_are_drained_from_the_store_on_production() {
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 expected_amount = 1_u64;
let recipient_id = initial_public_user_accounts()[0].account_id;
@ -227,7 +247,6 @@ async fn start_from_config_replays_unfulfilled_deposit_events_from_db() {
source_tx_hash: HashType([7_u8; 32]),
amount: expected_amount,
metadata: borsh::to_vec(&DepositMetadataForEncoding { recipient_id }).unwrap(),
submitted_in_block_id: None,
};
{
@ -241,36 +260,221 @@ async fn start_from_config_replays_unfulfilled_deposit_events_from_db() {
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) =
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 {
loop {
if let Some((origin, tx)) = sequencer.mempool.pop() {
return (origin, tx);
}
let block_id = sequencer.produce_new_block().await.unwrap();
let block = sequencer
.store
.get_block_at_id(block_id)
.unwrap()
.expect("produced block is stored");
assert!(
block
.body
.transactions
.iter()
.any(|tx| tx_is_bridge_deposit(tx, deposit_op_id, expected_amount)),
"the drained deposit mint should be included in the produced block"
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
// The record stays until its deposit finalizes; exactly-once is enforced by
// the receipt PDA now in head state, not by any marker on the record.
assert!(
sequencer
.store
.get_pending_deposit_events()
.unwrap()
.iter()
.any(|event| event.deposit_op_id == HashType(deposit_op_id)),
"the record remains until the deposit finalizes"
);
assert!(
sequencer.with_state(|state| deposit_already_minted(state, HashType(deposit_op_id))),
"the deposit's receipt PDA marks it minted in head state"
);
}
#[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(),
})
.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 receipt PDA from the first mint must keep the drain from re-minting"
);
}
#[tokio::test]
async fn an_orphaned_deposit_is_reminted_exactly_once_in_the_replacement() {
// Manifestation 2 from #639: a deposit-carrying block is orphaned. Recovery
// rests entirely on the receipt PDA reverting with the block — no requeue,
// no bookkeeping of our own — so the still-pending record is drained again
// on the next turn and the vault is credited exactly once across the reorg.
let mut config = setup_sequencer_config();
config.genesis = vec![GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }];
let recipient_id = initial_public_user_accounts()[0].account_id;
let deposit_op_id = [0x2c_u8; 32];
let amount = 500_u64;
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,
metadata: borsh::to_vec(&DepositMetadataForEncoding { recipient_id }).unwrap(),
})
.unwrap();
// Produce the block that mints the deposit; its receipt marks it minted.
sequencer.produce_new_block().await.unwrap();
let minted_block = sequencer.store.get_block_at_id(2).unwrap().unwrap();
assert!(
sequencer.with_state(|s| deposit_already_minted(s, HashType(deposit_op_id))),
"the first mint claims the receipt in head state"
);
// Orphan that block. The receipt reverts with it — nothing else tracks the
// mint — so the deposit reads as unminted again.
apply_follow_update(
&sequencer.store.dbio(),
&sequencer.chain(),
&mempool_handle,
FollowUpdate {
adopted: vec![],
orphaned: vec![(MsgId::from(minted_block.header.hash.0), minted_block)],
..empty_follow_update()
},
);
assert_eq!(sequencer.chain_height(), 1, "the minting block is orphaned");
assert!(
!sequencer.with_state(|s| deposit_already_minted(s, HashType(deposit_op_id))),
"the receipt reverts with the orphaned block"
);
// Next turn: the still-pending record is drained and re-minted on the new
// head, exactly once.
let replacement = sequencer.produce_new_block().await.unwrap();
let mints = sequencer
.store
.get_block_at_id(replacement)
.unwrap()
.expect("replacement block is stored")
.body
.transactions
.iter()
.filter(|tx| tx_is_bridge_deposit(tx, deposit_op_id, amount))
.count();
assert_eq!(
mints, 1,
"the deposit is re-minted exactly once after the orphan"
);
let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient_id);
assert_eq!(
sequencer.with_state(|s| s.get_account_by_id(vault_id).balance),
u128::from(amount),
"the vault is credited exactly once across the reorg"
);
}
#[tokio::test]
async fn a_replayed_deposit_mint_no_ops_in_the_guest() {
// Runs the bridge guest directly with a pre-existing receipt — the replay
// no-op branch the exactly-once guarantee rests on. The store drain filters
// duplicates out before the program executes, so this is the only test that
// reaches that branch; applying the same mint twice asserts the second is a
// no-op (credited once) rather than an error.
let mut config = setup_sequencer_config();
config.genesis = vec![GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }];
let recipient_id = initial_public_user_accounts()[0].account_id;
let deposit_op_id = [0x5a_u8; 32];
let amount = 500_u64;
let (sequencer, _mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config).await;
let deposit_tx = build_bridge_deposit_tx_from_event(&PendingDepositEventRecord {
deposit_op_id: HashType(deposit_op_id),
source_tx_hash: HashType([7_u8; 32]),
amount,
metadata: borsh::to_vec(&DepositMetadataForEncoding { recipient_id }).unwrap(),
})
.await
.expect("Timed out waiting for pending deposit event to be replayed into mempool");
.unwrap();
let LeeTransaction::Public(public_tx) = &deposit_tx else {
panic!("bridge deposit tx is public");
};
match origin {
TransactionOrigin::Sequencer => {}
TransactionOrigin::User => {
panic!("Unexpected user transaction in empty mempool replay test")
}
}
let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient_id);
let mut state = sequencer.chain().lock().unwrap().head_state().clone();
assert!(tx_is_bridge_deposit(&tx, deposit_op_id, expected_amount));
// First mint: claims the receipt and credits the recipient vault.
state
.transition_from_public_transaction(public_tx, 1, 0)
.expect("first mint executes");
assert_eq!(
state.get_account_by_id(vault_id).balance,
u128::from(amount)
);
assert!(
deposit_already_minted(&state, HashType(deposit_op_id)),
"the first mint claims the receipt PDA"
);
let pending_events = sequencer.store.get_unfulfilled_deposit_events().unwrap();
let replayed_event = pending_events
.into_iter()
.find(|event| event.deposit_op_id == HashType(deposit_op_id))
.expect("Pending deposit event should remain in DB until included in a block");
assert!(replayed_event.submitted_in_block_id.is_none());
// Replay the identical mint. The guest sees the receipt already exists and
// no-ops instead of failing, so the vault is credited exactly once.
state
.transition_from_public_transaction(public_tx, 2, 0)
.expect("a replayed deposit is a no-op, not an error");
assert_eq!(
state.get_account_by_id(vault_id).balance,
u128::from(amount),
"a replayed deposit must not re-credit the vault"
);
}
#[test]
@ -1287,7 +1491,6 @@ fn resubmittable_txs_drops_clock_and_bridge_deposits() {
recipient_id: initial_public_user_accounts()[0].account_id,
})
.unwrap(),
submitted_in_block_id: None,
})
.unwrap();
let withdraw_tx = {
@ -1334,6 +1537,67 @@ fn resubmittable_txs_of_blocks_without_user_txs_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_pending_deposit_events().unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].deposit_op_id, HashType([21; 32]));
}
#[tokio::test]
async fn follow_adopted_peer_block_applies_and_persists() {
let config = setup_sequencer_config();
@ -1361,8 +1625,7 @@ async fn follow_adopted_peer_block_applies_and_persists() {
&mempool_handle,
FollowUpdate {
adopted: vec![(MsgId::from([1; 32]), peer_block.clone())],
orphaned: vec![],
finalized: vec![],
..empty_follow_update()
},
);
@ -1409,8 +1672,7 @@ async fn follow_redelivery_of_own_block_is_deduped() {
&mempool_handle,
FollowUpdate {
adopted: vec![(MsgId::from(block2.header.hash.0), block2)],
orphaned: vec![],
finalized: vec![],
..empty_follow_update()
},
);
@ -1451,7 +1713,7 @@ async fn follow_orphan_reverts_head_and_requeues_user_txs() {
FollowUpdate {
adopted: vec![],
orphaned: vec![(MsgId::from(block2.header.hash.0), block2)],
finalized: vec![],
..empty_follow_update()
},
);
@ -1495,6 +1757,7 @@ async fn follow_finalized_own_block_moves_final_tier_and_marks_store() {
adopted: vec![],
orphaned: vec![],
finalized: vec![(MsgId::from(block2.header.hash.0), block2)],
..empty_follow_update()
},
);
@ -1532,6 +1795,7 @@ async fn follow_finalized_backfill_block_is_applied_and_marked_finalized() {
adopted: vec![],
orphaned: vec![],
finalized: vec![(MsgId::from([2; 32]), peer_block.clone())],
..empty_follow_update()
},
);
@ -1549,6 +1813,76 @@ async fn follow_finalized_backfill_block_is_applied_and_marked_finalized() {
assert!(matches!(stored.bedrock_status, BedrockStatus::Finalized));
}
#[tokio::test]
async fn parked_finalized_block_neither_sweeps_the_store_nor_drops_its_deposit_record() {
let config = setup_sequencer_config();
let (mut sequencer, mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config).await;
// A produced block at head, still pending on the channel.
let tx = common::test_utils::produce_dummy_empty_transaction();
mempool_handle
.push((TransactionOrigin::User, tx))
.await
.unwrap();
sequencer.produce_new_block().await.unwrap();
let deposit_op_id = HashType([21; 32]);
let record = PendingDepositEventRecord {
deposit_op_id,
source_tx_hash: HashType([22; 32]),
amount: 5,
metadata: borsh::to_vec(&DepositMetadataForEncoding {
recipient_id: initial_public_user_accounts()[0].account_id,
})
.unwrap(),
};
let deposit_tx = build_bridge_deposit_tx_from_event(&record).unwrap();
assert!(
sequencer
.store
.dbio()
.add_pending_deposit_event(record)
.unwrap()
);
// Skip-ahead block carrying that deposit: not in head and linking to
// nothing we hold, so the final tier parks it instead of applying it.
let parked =
common::test_utils::produce_dummy_block(9, Some(HashType([44; 32])), vec![deposit_tx]);
apply_follow_update(
&sequencer.store.dbio(),
&sequencer.chain(),
&mempool_handle,
FollowUpdate {
adopted: vec![],
orphaned: vec![],
finalized: vec![(MsgId::from([9; 32]), parked)],
..empty_follow_update()
},
);
// Nothing became irreversible, so the store must not be swept through the
// parked block's height.
let stored = sequencer.store.get_block_at_id(2).unwrap().unwrap();
assert!(
matches!(stored.bedrock_status, BedrockStatus::Pending),
"a parked finalized block must not mark earlier blocks finalized"
);
// And its deposit is not minted anywhere, so dropping the record would lose
// the deposit for good once the stall clears.
assert!(
sequencer
.store
.get_pending_deposit_events()
.unwrap()
.iter()
.any(|event| event.deposit_op_id == deposit_op_id),
"a parked finalized block must not drop its deposit record"
);
}
#[tokio::test]
async fn restart_restores_head_tier_and_recovers_from_orphan() {
let config = setup_sequencer_config();
@ -1592,7 +1926,7 @@ async fn restart_restores_head_tier_and_recovers_from_orphan() {
FollowUpdate {
adopted: vec![(MsgId::from([21; 32]), block2_prime.clone())],
orphaned: vec![(MsgId::from([20; 32]), block2)],
finalized: vec![],
..empty_follow_update()
},
);
@ -1645,6 +1979,7 @@ async fn restart_reanchors_on_the_persisted_final_snapshot() {
adopted: vec![],
orphaned: vec![],
finalized: vec![(MsgId::from(block2.header.hash.0), block2)],
..empty_follow_update()
},
);
}
@ -1694,7 +2029,7 @@ async fn record_produced_block_skips_persistence_on_lost_race() {
MsgId::from(our_block.header.hash.0),
&our_block,
&[],
vec![],
&mock_checkpoint(),
)
.unwrap();
@ -1718,7 +2053,12 @@ 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.
let stale = common::test_utils::produce_dummy_block(2, Some(HashType([9; 32])), vec![]);
sequencer
.record_produced_block(MsgId::from(stale.header.hash.0), &stale, &[], vec![])
.record_produced_block(
MsgId::from(stale.header.hash.0),
&stale,
&[],
&mock_checkpoint(),
)
.unwrap();
assert!(sequencer.store.get_block_at_id(2).unwrap().is_none());
@ -1759,6 +2099,7 @@ async fn follow_update_persists_blocks_meta_and_state_atomically() {
],
orphaned: vec![],
finalized: vec![(MsgId::from([2; 32]), block2)],
..empty_follow_update()
},
);

View File

@ -295,7 +295,6 @@ fn deposit_event_record(
recipient_id: recipient,
})
.unwrap(),
submitted_in_block_id: None,
}
}
@ -321,11 +320,11 @@ fn build_public_withdraw_tx(
LeeTransaction::Public(lee::PublicTransaction::new(message, witness_set))
}
/// The cold-start backfill re-delivers an already-finalized deposit into the
/// mempool before reconstruction applies the same deposit block, and the queued
/// mint cannot be pulled back out. Since the bridge program does not dedup on
/// `l1_deposit_op_id`, block production must skip the already-submitted deposit
/// so the vault is minted exactly once.
/// Cold-start backfill re-records an already-finalized deposit event as a
/// pending record before reconstruction replays the same deposit block.
/// Reconstruction must drop that record — its mint is permanently reflected in
/// the reconstructed state (the receipt PDA) — so the next production neither
/// re-mints the vault nor emits a stray deposit tx.
#[tokio::test]
async fn reconstructed_deposit_is_not_reminted_after_backfill_redelivery() {
let recipient = initial_public_user_accounts()[0].account_id;
@ -343,7 +342,7 @@ async fn reconstructed_deposit_is_not_reminted_after_backfill_redelivery() {
let deposit_tx =
crate::build_bridge_deposit_tx_from_event(&deposit_record).expect("build deposit tx");
mempool_a
.push((TransactionOrigin::Sequencer, deposit_tx.clone()))
.push((TransactionOrigin::Sequencer, deposit_tx))
.await
.unwrap();
seq_a.produce_new_block().await.unwrap();
@ -367,10 +366,11 @@ async fn reconstructed_deposit_is_not_reminted_after_backfill_redelivery() {
let channel_id = config_a.bedrock_config.channel_id;
let config_b = bridge_funded_config();
let (mut seq_b, mempool_b) = SequencerCoreWithMockClients::start_from_config(config_b).await;
let (mut seq_b, _mempool_b) = SequencerCoreWithMockClients::start_from_config(config_b).await;
// Backfill re-delivery: persist the pending record and queue the mint, as
// `on_deposit_event` does, before reconstruction runs.
// Backfill re-delivery: the deposit event is re-recorded as a pending record
// before reconstruction runs. The mint no longer flows through the mempool
// (that sink was removed); the store drain is the only source.
assert!(
seq_b
.block_store()
@ -378,10 +378,6 @@ async fn reconstructed_deposit_is_not_reminted_after_backfill_redelivery() {
.add_pending_deposit_event(deposit_record.clone())
.unwrap()
);
mempool_b
.push((TransactionOrigin::Sequencer, deposit_tx))
.await
.unwrap();
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
@ -397,6 +393,20 @@ async fn reconstructed_deposit_is_not_reminted_after_backfill_redelivery() {
assert_eq!(tip_b.id, tip_a.id);
assert_eq!(tip_b.hash, tip_a.hash);
// Reconstruction replays the finalized deposit block, minting the receipt
// into state and dropping the re-recorded pending event — so the drain has
// nothing left to re-mint. This is the mechanism that protects against the
// re-delivery, in place of the removed mempool sink.
assert!(
seq_b
.block_store()
.dbio()
.get_pending_deposit_events()
.unwrap()
.is_empty(),
"reconstruction must drop the re-delivered pending deposit record"
);
seq_b.produce_new_block().await.unwrap();
let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient);
@ -525,7 +535,6 @@ async fn reconstruction_reconciles_already_finished_deposit() {
.await
.unwrap();
seq_a.produce_new_block().await.unwrap();
let deposit_block_id = seq_a.block_store().latest_block_meta().unwrap().unwrap().id;
let messages = channel_from_store(seq_a.block_store(), 10);
let tip_slot = messages.last().unwrap().1;
@ -561,18 +570,19 @@ async fn reconstruction_reconciles_already_finished_deposit() {
"already-finished deposit must be applied exactly once"
);
// The pending event is now marked submitted in the reconstructed block, so the
// startup replay would not re-queue it — no double mint on restart.
let record = store_b
.get_unfulfilled_deposit_events()
.unwrap()
.into_iter()
.find(|event| event.deposit_op_id == HashType(deposit_op_id))
.expect("pending deposit event should still be recorded");
assert_eq!(
record.submitted_in_block_id,
Some(deposit_block_id),
"reconstruction must reconcile the already-finished deposit against its channel block"
// The mint's receipt PDA is in the reconstructed state, and reconstruction
// dropped the pending record backfill had re-delivered — so the production
// drain sees the deposit as minted and never re-emits it.
assert!(
crate::deposit_already_minted(
chain_b.lock().unwrap().head_state(),
HashType(deposit_op_id)
),
"the reconstructed deposit's receipt marks it minted"
);
assert!(
store_b.get_pending_deposit_events().unwrap().is_empty(),
"reconstruction drops the finalized deposit's pending record"
);
}

View File

@ -67,6 +67,18 @@ pub trait DBIO {
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).
fn del<T: SimpleStorableCell>(&self, params: T::KeyParams) -> DbResult<()> {
let cf_ref = T::column_ref(self.db());

View File

@ -1,4 +1,4 @@
use std::{path::Path, sync::Arc};
use std::{collections::BTreeMap, path::Path, sync::Arc};
use borsh::{BorshDeserialize, BorshSerialize};
use common::{
@ -13,10 +13,7 @@ use rocksdb::{
use crate::{
CF_BLOCK_NAME, CF_META_NAME, DB_META_FIRST_BLOCK_IN_DB_KEY, DBIO, DbResult,
cells::{
SimpleStorableCell,
shared_cells::{BlockCell, FirstBlockCell, FirstBlockSetCell, LastBlockCell},
},
cells::shared_cells::{BlockCell, FirstBlockCell, FirstBlockSetCell, LastBlockCell},
error::DbError,
sequencer::sequencer_cells::{
FinalBlockMetaCellOwned, FinalBlockMetaCellRef, FinalLeeStateCellOwned,
@ -97,6 +94,77 @@ 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<'update> {
/// Serialized zone-sdk checkpoint for this event.
pub checkpoint: Option<&'update [u8]>,
/// `(block, finalized)` payloads to write.
pub blocks: &'update [(&'update Block, bool)],
/// Head tip to pin the stored chain to; `None` only for an empty chain.
pub head_tip: Option<&'update BlockMeta>,
/// State after the last applied block.
pub head_state: &'update V03State,
/// `(state, meta)` of the final tier, when it advanced.
pub final_snapshot: Option<(&'update V03State, &'update BlockMeta)>,
/// Highest block id this event made irreversible: stored blocks at or below
/// it become [`BedrockStatus::Finalized`].
pub finalized_up_to: Option<u64>,
/// Deposit events observed on L1, recorded unless already pending.
pub new_deposit_events: &'update [PendingDepositEventRecord],
/// Deposit op ids whose mint finalized: their pending records are dropped.
pub remove_deposit_records: &'update [HashType],
/// L1 withdraw events to reconcile against the local unseen counters.
pub consumed_withdrawals: &'update [WithdrawalReconciliationKey],
/// L2 withdraw intents this update raises, awaiting their L1 event.
pub new_withdraw_intents: &'update [WithdrawalReconciliationKey],
/// Advance the channel-read anchor.
pub zone_anchor: Option<&'update ZoneAnchorRecord>,
}
impl<'update> StoreUpdate<'update> {
/// 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: &'update V03State) -> Self {
Self {
checkpoint: None,
blocks: &[],
head_tip: None,
head_state,
final_snapshot: None,
finalized_up_to: None,
new_deposit_events: &[],
remove_deposit_records: &[],
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 db: DBWithThreadMode<MultiThreaded>,
}
@ -384,10 +452,6 @@ impl RocksDBIO {
.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(
&self,
records: &[PendingDepositEventRecord],
@ -396,135 +460,218 @@ impl RocksDBIO {
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> {
let mut records = self.get_pending_deposit_events()?;
if records
.iter()
.any(|record| record.deposit_op_id == event.deposit_op_id)
{
let mut batch = WriteBatch::default();
let accepted = self.stage_pending_deposit_events(&[event], &[], &mut batch)?;
// A re-delivery of an already-pending deposit — the steady state — stages
// nothing; skip the write rather than sync an empty batch.
if batch.is_empty() {
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();
self.mark_pending_deposit_events_submitted(deposit_op_ids, submitted_block_id, &mut batch)?;
self.db.write(batch).map_err(|rerr| {
DbError::rocksdb_cast_message(
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,
deposit_op_ids: &[HashType],
submitted_block_id: u64,
new_events: &[PendingDepositEventRecord],
remove_op_ids: &[HashType],
batch: &mut WriteBatch,
) -> DbResult<usize> {
let mut records = self.get_pending_deposit_events()?;
let mut updated: usize = 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 new_events.is_empty() && remove_op_ids.is_empty() {
return Ok(0);
}
if updated > 0 {
// A set for the membership test: a backfill can finalize many deposits
// against many still-pending records at once, and a linear `contains`
// per record would be quadratic.
let to_remove: std::collections::HashSet<&HashType> = remove_op_ids.iter().collect();
let mut records = self.get_pending_deposit_events()?;
let before_append = records.len();
// `accepted` is the count of records that will actually be drained on a
// future turn, so an op id both observed and finalized in this same
// event (backfill can deliver both at once) is neither appended nor
// counted — its mint already happened, and counting it would log an
// incoming mint that never comes. It is a length delta of the appends
// alone; the retain below only touches pre-existing records.
for event in new_events {
if to_remove.contains(&event.deposit_op_id)
|| records
.iter()
.any(|record| record.deposit_op_id == event.deposit_op_id)
{
continue;
}
records.push(event.clone());
}
let accepted = records.len().saturating_sub(before_append);
let removed = if remove_op_ids.is_empty() {
0
} else {
let before_retain = records.len();
records.retain(|record| !to_remove.contains(&record.deposit_op_id));
before_retain.saturating_sub(records.len())
};
// Guard on both counts: the common finalizing event appends nothing yet
// still mutates the cell, and a pure re-delivery mutates neither and
// must not rewrite it.
if accepted > 0 || removed > 0 {
self.put_pending_deposit_events_batch(&records, batch)?;
}
Ok(updated)
Ok(accepted)
}
pub fn remove_fulfilled_pending_deposit_events_up_to_block(
/// 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,
finalized_block_id: u64,
) -> DbResult<usize> {
let mut records = self.get_pending_deposit_events()?;
let before = records.len();
records.retain(|record| {
record
.submitted_in_block_id
.is_none_or(|submitted_id| submitted_id > finalized_block_id)
});
let removed = before.saturating_sub(records.len());
if removed > 0 {
self.put_pending_deposit_events(&records)?;
withdrawals: &[WithdrawalReconciliationKey],
batch: &mut WriteBatch,
) -> DbResult<Vec<WithdrawalReconciliationKey>> {
let mut unmatched = Vec::new();
if withdrawals.is_empty() {
return Ok(unmatched);
}
Ok(removed)
// A `Vec` rather than a map: the per-update count is tiny, and it keeps
// the staging order deterministic.
let mut occurrences: Vec<(WithdrawalReconciliationKey, u64)> = Vec::new();
for withdrawal in withdrawals {
match occurrences.iter_mut().find(|(key, _)| key == withdrawal) {
Some((_, times)) => *times = times.saturating_add(1),
None => occurrences.push((*withdrawal, 1)),
}
}
for (withdrawal, times) in occurrences {
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))
.expect("unmatched withdrawal count fits usize"),
));
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)
}
/// Whether a bridge deposit for `deposit_op_id` is already recorded as
/// included in a block (its pending record is marked submitted).
pub fn is_deposit_event_submitted(&self, deposit_op_id: HashType) -> DbResult<bool> {
Ok(self.get_pending_deposit_events()?.iter().any(|record| {
record.deposit_op_id == deposit_op_id && record.submitted_in_block_id.is_some()
}))
/// 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>) {
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);
}
}
fn increment_unseen_withdraw_count(
/// Stages the unseen-withdraw increments for one update into `batch`.
///
/// Occurrences are folded per key for the same reason as
/// [`Self::stage_consumed_withdrawals`]: two intents in one update can share
/// a reconciliation key, and a per-occurrence disk read would miss the
/// staged increment and count the pair once.
fn stage_new_withdraw_intents(
&self,
withdrawal: WithdrawalReconciliationKey,
withdrawals: &[WithdrawalReconciliationKey],
batch: &mut WriteBatch,
) -> DbResult<u64> {
let current = self
.get_opt::<UnseenWithdrawCountCell>(withdrawal)?
.map_or(0, |cell| cell.0);
) -> DbResult<()> {
if withdrawals.is_empty() {
return Ok(());
}
let next = current.checked_add(1).ok_or_else(|| {
DbError::db_interaction_error("Unseen withdraw counter overflow".to_owned())
})?;
let mut occurrences: Vec<(WithdrawalReconciliationKey, u64)> = Vec::new();
for withdrawal in withdrawals {
match occurrences.iter_mut().find(|(key, _)| key == withdrawal) {
Some((_, times)) => *times = times.saturating_add(1),
None => occurrences.push((*withdrawal, 1)),
}
}
self.put_batch(&UnseenWithdrawCountCell(next), withdrawal, batch)?;
for (withdrawal, times) in occurrences {
let current = self
.get_opt::<UnseenWithdrawCountCell>(withdrawal)?
.map_or(0, |cell| cell.0);
Ok(next)
let next = current.checked_add(times).ok_or_else(|| {
DbError::db_interaction_error("Unseen withdraw counter overflow".to_owned())
})?;
self.put_batch(&UnseenWithdrawCountCell(next), withdrawal, batch)?;
}
Ok(())
}
/// 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(
&self,
withdrawal: WithdrawalReconciliationKey,
) -> DbResult<bool> {
let Some(current) = self
.get_opt::<UnseenWithdrawCountCell>(withdrawal)?
.map(|cell| cell.0)
else {
return Ok(false);
};
if let Some(next) = current.checked_sub(1) {
self.put(&UnseenWithdrawCountCell(next), withdrawal)?;
} 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)
let mut batch = WriteBatch::default();
let unmatched = self.stage_consumed_withdrawals(&[withdrawal], &mut batch)?;
self.db.write(batch).map_err(|rerr| {
DbError::rocksdb_cast_message(
rerr,
Some("Failed to consume unseen withdraw count".to_owned()),
)
})?;
Ok(unmatched.is_empty())
}
pub fn put_block(&self, block: &Block, first: bool, batch: &mut WriteBatch) -> DbResult<()> {
@ -623,20 +770,23 @@ impl RocksDBIO {
Ok(())
}
/// Mark every pending block with `block_id <= last_finalized` as finalized.
/// Idempotent — already-finalized blocks are skipped.
/// Mark every pending block with `block_id <= last_finalized` as finalized,
/// 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<()> {
let pending_ids: Vec<u64> = self
.get_all_blocks()
.filter_map(Result::ok)
.filter(|b| matches!(b.bedrock_status, BedrockStatus::Pending))
.map(|b| b.header.block_id)
.filter(|id| *id <= last_finalized)
.collect();
for id in pending_ids {
self.mark_block_as_finalized(id)?;
let mut to_write = BTreeMap::new();
self.collect_finalized_up_to(last_finalized, &mut to_write);
let mut batch = WriteBatch::default();
for block in to_write.values() {
self.put_block_payload(block, &mut batch)?;
}
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<()> {
@ -691,8 +841,8 @@ impl RocksDBIO {
Ok(())
}
/// One-block form of [`Self::store_followed_blocks`], with the block as the
/// head tip and no final snapshot. Production always uses the batch form.
/// One-block form of [`Self::store_update`], with the block as the head tip
/// and no final snapshot. Production always uses the batch form.
#[cfg(test)]
fn store_followed_block(
&self,
@ -700,16 +850,17 @@ impl RocksDBIO {
state: &V03State,
finalized: bool,
) -> DbResult<()> {
self.store_followed_blocks(
&[(block, finalized)],
Some(&BlockMeta::from(block)),
state,
None,
)
self.store_update(&StoreUpdate {
blocks: &[(block, finalized)],
head_tip: Some(&BlockMeta::from(block)),
..StoreUpdate::new(state)
})
.map(|_outcome| ())
}
/// Persists a batch of followed blocks, the caller's head-tip `state`, and
/// the optional final-tier snapshot in one atomic write.
/// Persists everything one sequencer event produced — checkpoint, blocks,
/// 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
/// behind by a net-shortening reorg) are deleted in the same write, so
@ -717,69 +868,115 @@ impl RocksDBIO {
///
/// 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
/// finalized status. A no-op update (nothing to write, tip unchanged)
/// writes nothing.
/// finalized status.
///
/// TODO: the zone-sdk checkpoint is persisted by `on_checkpoint` *before*
/// this write. Full `BlocksProcessed` atomicity (checkpoint, blocks, state
/// and orphan reverts in one batch) is a follow-up.
pub fn store_followed_blocks(
&self,
blocks: &[(&Block, bool)],
head_tip: Option<&BlockMeta>,
state: &V03State,
final_snapshot: Option<(&V03State, &BlockMeta)>,
) -> DbResult<()> {
/// The head state and tip meta are only rewritten when the chain actually
/// moved. A checkpoint alone (the common case — every follow event carries
/// one, most carry nothing else) must not drag a full state serialization
/// with it.
pub fn store_update(&self, update: &StoreUpdate<'_>) -> DbResult<StoreUpdateOutcome> {
let StoreUpdate {
checkpoint,
blocks,
head_tip,
head_state,
final_snapshot,
finalized_up_to,
new_deposit_events,
remove_deposit_records,
consumed_withdrawals,
new_withdraw_intents,
zone_anchor,
} = *update;
let last_block_in_db = self.get_meta_last_block_in_db()?;
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 {
let already_stored = self
.get_block(block.header.block_id)?
.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(stored) => stored,
None => (*block).clone(),
};
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,
remove_deposit_records,
&mut batch,
)?;
let unmatched_withdrawals =
self.stage_consumed_withdrawals(consumed_withdrawals, &mut batch)?;
self.stage_new_withdraw_intents(new_withdraw_intents, &mut batch)?;
// `head_tip` is `None` only for a chain holding no blocks at all, which
// implies nothing was applied — and the store, created with genesis,
// cannot represent it. No tip to pin, nothing to persist.
let Some(tip) = head_tip else {
debug_assert!(batch.is_empty() && final_snapshot.is_none());
return Ok(());
// the store — created with genesis — cannot represent. Nothing to pin.
if chain_changed && let Some(tip) = head_tip {
// `last_block_in_db` predates this batch, so on its own it misses
// payloads staged above the pinned tip — a finalized block landing
// below an adopted one rewinds the tip under blocks this same update
// wrote. Leaving one there fails the restart replay. The deletes are
// staged after the puts, so the batch order resolves the overlap.
let highest_staged = to_write.last_key_value().map_or(0, |(id, _)| *id);
for stale_id in tip.id.saturating_add(1)..=last_block_in_db.max(highest_staged) {
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(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
// payloads to write but must still rewind the tip meta, or the stored
// 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)?;
if batch.is_empty() {
return Ok(outcome);
}
self.db.write(batch).map_err(|rerr| {
DbError::rocksdb_cast_message(
rerr,
Some("Failed to write followed blocks batch".to_owned()),
)
})
DbError::rocksdb_cast_message(rerr, Some("Failed to write store update".to_owned()))
})?;
Ok(outcome)
}
pub fn get_all_blocks(&self) -> impl Iterator<Item = DbResult<Block>> {
@ -803,32 +1000,30 @@ impl RocksDBIO {
})
}
/// Persists a block we produced, its withdraw intents, 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(
&self,
block: &Block,
deposit_op_ids: &[HashType],
withdrawals: Vec<WithdrawalReconciliationKey>,
withdrawals: &[WithdrawalReconciliationKey],
state: &V03State,
checkpoint: Option<&[u8]>,
) -> DbResult<()> {
let block_id = block.header.block_id;
let mut batch = WriteBatch::default();
self.put_block(block, false, &mut batch)?;
self.mark_pending_deposit_events_submitted(deposit_op_ids, block_id, &mut batch)?;
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}")),
)
self.store_update(&StoreUpdate {
checkpoint,
blocks: &[(block, false)],
head_tip: Some(&BlockMeta::from(block)),
new_withdraw_intents: withdrawals,
..StoreUpdate::new(state)
})
.map(|_outcome| ())
}
}

View File

@ -232,14 +232,17 @@ impl SimpleWritableCell for ZoneAnchorCell {
}
}
/// An L1 deposit event observed but not yet seen finalized.
///
/// Purely a liveness queue: whether to actually emit a mint is decided against
/// chain state (the deposit-receipt PDA), and the record is dropped once its
/// mint finalizes.
#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct PendingDepositEventRecord {
pub deposit_op_id: HashType,
pub source_tx_hash: HashType,
pub amount: u64,
pub metadata: Vec<u8>,
/// Set when block containing the deposit event is submitted, but not necessarily finalized.
pub submitted_in_block_id: Option<u64>,
}
#[derive(BorshDeserialize)]
@ -275,7 +278,7 @@ impl SimpleWritableCell for PendingDepositEventsCellRef<'_> {
}
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct WithdrawalReconciliationKey {
pub amount: u64,
pub bedrock_account_pk: [u8; 32],

View File

@ -28,6 +28,15 @@ fn dbio_with_genesis(path: &Path) -> (RocksDBIO, Block) {
(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],
}
}
fn stored_balance(dbio: &RocksDBIO) -> u128 {
dbio.get_lee_state()
.unwrap()
@ -106,12 +115,11 @@ fn store_followed_blocks_batch_lands_meta_and_state_on_last_block() {
id: 3,
hash: block3.header.hash,
};
dbio.store_followed_blocks(
&[(&block2, true), (&block3, false)],
Some(&head_tip),
&state_with_balance(300),
None,
)
dbio.store_update(&StoreUpdate {
blocks: &[(&block2, true), (&block3, false)],
head_tip: Some(&head_tip),
..StoreUpdate::new(&state_with_balance(300))
})
.unwrap();
let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored");
@ -140,12 +148,12 @@ fn final_snapshot_round_trips_and_is_absent_on_fresh_store() {
id: 2,
hash: block2.header.hash,
};
dbio.store_followed_blocks(
&[(&block2, true)],
Some(&final_meta),
&state_with_balance(300),
Some((&state_with_balance(200), &final_meta)),
)
dbio.store_update(&StoreUpdate {
blocks: &[(&block2, true)],
head_tip: Some(&final_meta),
final_snapshot: Some((&state_with_balance(200), &final_meta)),
..StoreUpdate::new(&state_with_balance(300))
})
.unwrap();
let (final_state, meta) = dbio
@ -204,12 +212,11 @@ fn net_shortening_reorg_drops_stale_blocks() {
id: 2,
hash: block2b.header.hash,
};
dbio.store_followed_blocks(
&[(&block2b, false)],
Some(&head_tip),
&state_with_balance(400),
None,
)
dbio.store_update(&StoreUpdate {
blocks: &[(&block2b, false)],
head_tip: Some(&head_tip),
..StoreUpdate::new(&state_with_balance(400))
})
.unwrap();
let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored");
@ -241,8 +248,11 @@ fn shrink_only_reorg_rewinds_tip_meta() {
id: 2,
hash: block2.header.hash,
};
dbio.store_followed_blocks(&[], Some(&head_tip), &state_with_balance(200), None)
.unwrap();
dbio.store_update(&StoreUpdate {
head_tip: Some(&head_tip),
..StoreUpdate::new(&state_with_balance(200))
})
.unwrap();
assert!(
dbio.get_block(3).unwrap().is_none(),
@ -254,6 +264,232 @@ fn shrink_only_reorg_rewinds_tip_meta() {
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: std::slice::from_ref(&record),
..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 finalized_deposit_records_are_removed_by_op_id() {
let temp_dir = tempdir().unwrap();
let (dbio, _genesis) = dbio_with_genesis(temp_dir.path());
let first = deposit_record(1);
let second = deposit_record(2);
dbio.store_update(&StoreUpdate {
new_deposit_events: &[first.clone(), second.clone()],
..StoreUpdate::new(&state_with_balance(100))
})
.unwrap();
// Only the finalized op id is dropped; the other record stays.
dbio.store_update(&StoreUpdate {
remove_deposit_records: &[first.deposit_op_id],
..StoreUpdate::new(&state_with_balance(100))
})
.unwrap();
let stored = dbio.get_pending_deposit_events().unwrap();
assert_eq!(stored, vec![second]);
}
#[test]
fn repeated_withdrawal_key_in_one_update_folds_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 in one update — two withdrawals of the
// same amount to the same L1 key. A per-occurrence disk read would miss the
// staged increment and record the pair as one.
dbio.store_update(&StoreUpdate {
new_withdraw_intents: &[key, key],
..StoreUpdate::new(&state_with_balance(100))
})
.unwrap();
let recorded = dbio
.get_opt::<UnseenWithdrawCountCell>(key)
.unwrap()
.map(|cell| cell.0);
assert_eq!(recorded, Some(2));
// 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 — `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, &[], &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]
fn produced_block_below_disk_head_pins_meta_and_prunes() {
let temp_dir = tempdir().unwrap();
@ -270,7 +506,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
// the same write, mirroring the follow path.
let block2b = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.atomic_update(&block2b, &[], vec![], &state_with_balance(400))
dbio.atomic_update(&block2b, &[], &state_with_balance(400), None)
.unwrap();
let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored");