diff --git a/Cargo.lock b/Cargo.lock index fa99c5d9..bcc95943 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1193,6 +1193,7 @@ name = "bridge_core" version = "0.1.0" dependencies = [ "lee_core", + "risc0-zkvm", "serde", ] diff --git a/integration_tests/tests/bridge.rs b/integration_tests/tests/bridge.rs index 8da35f4d..bf7a42af 100644 --- a/integration_tests/tests/bridge.rs +++ b/integration_tests/tests/bridge.rs @@ -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,17 +199,25 @@ 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::try_from_circuit_output( - vec![bridge_account_id, recipient_vault_id], - vec![bridge_pre.account.nonce, vault_pre.account.nonce], + vec![bridge_account_id, recipient_vault_id, receipt_id], + vec![ + bridge_pre.account.nonce, + vault_pre.account.nonce, + receipt_pre.account.nonce, + ], output, ) .context("Failed to build privacy-preserving bridge deposit message")?; diff --git a/lee/state_machine/src/state/mod.rs b/lee/state_machine/src/state/mod.rs index 8d278ea5..d49c1c8f 100644 --- a/lee/state_machine/src/state/mod.rs +++ b/lee/state_machine/src/state/mod.rs @@ -270,6 +270,13 @@ impl V03State { .unwrap_or_else(Account::default) } + /// Borrowing counterpart of [`Self::get_account_by_id`], for callers that + /// only inspect the account and would otherwise clone it to drop it. + #[must_use] + pub fn get_public_account(&self, account_id: AccountId) -> Option<&Account> { + self.public_state.get(&account_id) + } + #[must_use] pub fn get_proof_for_commitment(&self, commitment: &Commitment) -> Option { self.private_state.0.get_proof_for(commitment) diff --git a/lez/programs/bridge/core/Cargo.toml b/lez/programs/bridge/core/Cargo.toml index 201e899f..d08f2cd6 100644 --- a/lez/programs/bridge/core/Cargo.toml +++ b/lez/programs/bridge/core/Cargo.toml @@ -9,4 +9,5 @@ workspace = true [dependencies] lee_core.workspace = true +risc0-zkvm.workspace = true serde = { workspace = true, default-features = false } diff --git a/lez/programs/bridge/core/src/lib.rs b/lez/programs/bridge/core/src/lib.rs index c9666f27..129276dc 100644 --- a/lez/programs/bridge/core/src/lib.rs +++ b/lez/programs/bridge/core/src/lib.rs @@ -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) + ); + } +} diff --git a/lez/programs/bridge/src/main.rs b/lez/programs/bridge/src/main.rs index 19d6509c..e91dfe1c 100644 --- a/lez/programs/bridge/src/main.rs +++ b/lez/programs/bridge/src/main.rs @@ -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,47 @@ 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. + 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 +116,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) } }; diff --git a/lez/sequencer/core/src/block_store.rs b/lez/sequencer/core/src/block_store.rs index 1dce9192..57c94990 100644 --- a/lez/sequencer/core/src/block_store.rs +++ b/lez/sequencer/core/src/block_store.rs @@ -155,14 +155,13 @@ impl SequencerStore { pub(crate) fn update( &mut self, block: &Block, - deposit_event_ids: &[HashType], - withdrawals: Vec, + 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, checkpoint)?; + .atomic_update(block, withdrawals, state, checkpoint)?; self.tx_hash_to_block_map.extend(new_transactions_map); Ok(()) } @@ -214,13 +213,9 @@ impl SequencerStore { self.dbio.put_zone_anchor(anchor) } - pub fn get_unfulfilled_deposit_events(&self) -> DbResult> { + pub fn get_pending_deposit_events(&self) -> DbResult> { self.dbio.get_pending_deposit_events() } - - pub fn is_deposit_event_submitted(&self, deposit_op_id: HashType) -> DbResult { - self.dbio.is_deposit_event_submitted(deposit_op_id) - } } /// The checkpoint's on-disk encoding. `serde_json` because `SequencerCheckpoint` @@ -277,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, None) - .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); @@ -344,9 +337,7 @@ mod tests { let block_hash = block.header.hash; let dummy_state = V03State::new(); - node_store - .update(&block, &[], vec![], &dummy_state, None) - .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(); @@ -382,9 +373,7 @@ mod tests { let block_id = block.header.block_id; let dummy_state = V03State::new(); - node_store - .update(&block, &[], vec![], &dummy_state, None) - .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(); @@ -433,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(), None) + .update(&block, &[], &V03State::new(), None) .unwrap(); } diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 7e27dec8..29c5cf7f 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -455,12 +455,10 @@ impl SequencerCore { } } - // Mark the deposits' pending records submitted so the production drain - // skips mints this block already carries. 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() @@ -478,7 +476,7 @@ impl SequencerCore { blocks: &[(block, true)], head_tip: head_tip.as_ref(), final_snapshot: final_meta.as_ref().map(|meta| (chain.final_state(), meta)), - mark_deposits_submitted: Some((&deposit_event_ids, block_id)), + remove_deposit_records: &finalized_deposit_ids, zone_anchor: Some(&record), ..StoreUpdate::new(chain.head_state()) }) @@ -500,18 +498,14 @@ impl SequencerCore { /// Produces a new block from mempool transactions and publishes it via zone-sdk. pub async fn produce_new_block(&mut self) -> Result { - 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::>() + .collect::>>() .context("Failed to build reconciliation keys for block withdrawals")?; let (this_msg, checkpoint) = self @@ -523,8 +517,7 @@ impl SequencerCore { self.record_produced_block( this_msg, &block, - &deposit_event_ids, - withdrawal_reconciliation_keys, + &withdrawal_reconciliation_keys, &checkpoint, )?; @@ -544,8 +537,7 @@ impl SequencerCore { &mut self, this_msg: MsgId, block: &Block, - deposit_event_ids: &[HashType], - withdrawal_reconciliation_keys: Vec, + withdrawal_reconciliation_keys: &[WithdrawalReconciliationKey], checkpoint: &block_publisher::SequencerCheckpoint, ) -> Result<()> { let checkpoint_bytes = block_store::checkpoint_bytes(checkpoint)?; @@ -557,7 +549,6 @@ impl SequencerCore { // with the follow path. self.store.update( block, - deposit_event_ids, withdrawal_reconciliation_keys, chain.head_state(), Some(&checkpoint_bytes), @@ -586,18 +577,12 @@ impl SequencerCore { /// 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, withdrawals: &mut Vec, ) -> Result { let tx_hash = tx.hash(); @@ -624,17 +609,9 @@ impl SequencerCore { 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. state .transition_from_public_transaction(public_tx, block_height, timestamp) .context("Failed to execute sequencer-generated transaction")?; @@ -662,22 +639,24 @@ impl SequencerCore { }; 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), and a mempool copy would - // race this drain into minting the same deposit twice in one block. + // 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. // - // 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 = self .store - .get_unfulfilled_deposit_events() - .context("Failed to load unfulfilled deposit events")? + .get_pending_deposit_events() + .context("Failed to load pending deposit events")? .into_iter() - .filter(|record| record.submitted_in_block_id.is_none()) + .filter(|record| !deposit_already_minted(&working_state, record.deposit_op_id)) .filter_map(|record| { build_bridge_deposit_tx_from_event(&record) .inspect_err(|err| { @@ -740,13 +719,11 @@ impl SequencerCore { } 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); @@ -779,11 +756,7 @@ impl SequencerCore { 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 @@ -859,10 +832,19 @@ impl SequencerCore { struct BlockWithMeta { block: Block, - deposit_event_ids: Vec, withdrawals: Vec, } +/// Whether `deposit_op_id`'s mint is already reflected in `state` — its receipt +/// PDA exists. The receipt is the exactly-once ledger the bridge program keeps. +fn deposit_already_minted(state: &lee::V03State, deposit_op_id: HashType) -> bool { + let receipt_id = + bridge_core::deposit_receipt_account_id(programs::bridge().id(), deposit_op_id.0); + state + .get_public_account(receipt_id) + .is_some_and(|receipt| *receipt != lee::Account::default()) +} + /// Feed one channel delta into the follow state and mirror it to the store: /// revert orphaned, then apply and persist adopted and finalized blocks. /// Production builds on this same head. Wired to the publisher via @@ -954,14 +936,24 @@ fn apply_follow_update( }); let head_tip = chain.head_tip().map(|tip| BlockMeta::from(&tip)); - // Every block at or below the highest finalized one is irreversible, - // so pending records submitted there can go and stored blocks can be - // marked finalized. + // Every block at or below the highest finalized one is irreversible, so + // stored blocks there can be marked finalized. let last_finalized = finalized .iter() .map(|(_, block)| block.header.block_id) .max(); + // A deposit observed in a finalized block is permanently minted (its + // receipt is now in the irreversible tier), so its pending record can be + // dropped. Keyed by op id, not block id: a record only goes once its own + // deposit finalizes, never because some other block finalized at its + // height. + let finalized_deposit_ids: Vec = finalized + .iter() + .flat_map(|(_, block)| block.body.transactions.iter()) + .filter_map(extract_bridge_deposit_id) + .collect(); + // A persist failure is fatal: the in-memory chain has already advanced, // and continuing would leave a permanent gap in the store. The `panic!` // ends the drive task, whose cancellation halts the node. @@ -973,6 +965,7 @@ fn apply_follow_update( final_snapshot: final_meta.as_ref().map(|meta| (chain.final_state(), meta)), 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()) }) @@ -1152,7 +1145,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, } } @@ -1164,10 +1156,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, diff --git a/lez/sequencer/core/src/mock.rs b/lez/sequencer/core/src/mock.rs index e1b22295..5b126f46 100644 --- a/lez/sequencer/core/src/mock.rs +++ b/lez/sequencer/core/src/mock.rs @@ -18,18 +18,6 @@ use crate::{ pub type SequencerCoreWithMockClients = crate::SequencerCore; -/// A zeroed checkpoint. Tests only assert *that* a checkpoint was persisted -/// alongside its effects, never what is in it. -#[must_use] -pub fn mock_checkpoint() -> SequencerCheckpoint { - SequencerCheckpoint { - last_msg_id: MsgId::from([0; 32]), - pending_txs: Vec::new(), - lib: HeaderId::from([0; 32]), - lib_slot: Slot::from(0), - } -} - #[derive(Clone)] pub struct MockBlockPublisher { channel_id: ChannelId, @@ -119,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), + } +} diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index 5f51ce59..46d3a4af 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -39,11 +39,18 @@ use crate::{ block_store::SequencerStore, build_bridge_deposit_tx_from_event, build_genesis_state, config::{BedrockConfig, GenesisAction, SequencerConfig}, - is_sequencer_only_program, + deposit_already_minted, is_sequencer_only_program, mock::{SequencerCoreWithMockClients, mock_checkpoint}, resubmittable_txs, }; +mod reconstruction; + +#[derive(borsh::BorshSerialize)] +struct DepositMetadataForEncoding { + recipient_id: lee::AccountId, +} + /// A follow update carrying nothing, to fill in the fields a test does not /// exercise via `..empty_follow_update()`. fn empty_follow_update() -> FollowUpdate { @@ -57,13 +64,6 @@ fn empty_follow_update() -> FollowUpdate { } } -mod reconstruction; - -#[derive(borsh::BorshSerialize)] -struct DepositMetadataForEncoding { - recipient_id: lee::AccountId, -} - fn setup_sequencer_config() -> SequencerConfig { let tempdir = tempfile::tempdir().unwrap(); let home = tempdir.path().to_path_buf(); @@ -247,7 +247,6 @@ async fn unfulfilled_deposit_events_are_drained_from_the_store_on_production() { source_tx_hash: HashType([7_u8; 32]), amount: expected_amount, metadata: borsh::to_vec(&DepositMetadataForEncoding { recipient_id }).unwrap(), - submitted_in_block_id: None, }; { @@ -286,15 +285,20 @@ async fn unfulfilled_deposit_events_are_drained_from_the_store_on_production() { "the drained deposit mint should be included in the produced block" ); - let pending_events = sequencer.store.get_unfulfilled_deposit_events().unwrap(); - let drained_event = pending_events - .into_iter() - .find(|event| event.deposit_op_id == HashType(deposit_op_id)) - .expect("Pending deposit event should remain in DB until finalized"); - assert_eq!( - drained_event.submitted_in_block_id, - Some(block_id), - "inclusion marks the record submitted, so the next turn does not mint it again" + // 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" ); } @@ -315,7 +319,6 @@ async fn a_drained_deposit_is_not_minted_twice_across_turns() { source_tx_hash: HashType([7_u8; 32]), amount: 1, metadata: borsh::to_vec(&DepositMetadataForEncoding { recipient_id }).unwrap(), - submitted_in_block_id: None, }) .unwrap(); @@ -339,7 +342,7 @@ async fn a_drained_deposit_is_not_minted_twice_across_turns() { assert_eq!( minted_in(second), 0, - "the submitted marker must keep the next turn from re-draining the record" + "the receipt PDA from the first mint must keep the drain from re-minting" ); } @@ -1358,7 +1361,6 @@ fn resubmittable_txs_drops_clock_and_bridge_deposits() { recipient_id: initial_public_user_accounts()[0].account_id, }) .unwrap(), - submitted_in_block_id: None, }) .unwrap(); let withdraw_tx = { @@ -1461,13 +1463,9 @@ async fn follow_update_records_deposits_for_the_production_drain() { }, ); - let pending = sequencer.store.get_unfulfilled_deposit_events().unwrap(); + let pending = sequencer.store.get_pending_deposit_events().unwrap(); assert_eq!(pending.len(), 1); assert_eq!(pending[0].deposit_op_id, HashType([21; 32])); - assert!( - pending[0].submitted_in_block_id.is_none(), - "an observed deposit is owed until a block includes its mint" - ); } #[tokio::test] @@ -1831,7 +1829,6 @@ async fn record_produced_block_skips_persistence_on_lost_race() { MsgId::from(our_block.header.hash.0), &our_block, &[], - vec![], &mock_checkpoint(), ) .unwrap(); @@ -1860,7 +1857,6 @@ async fn record_produced_block_skips_persistence_when_block_no_longer_chains() { MsgId::from(stale.header.hash.0), &stale, &[], - vec![], &mock_checkpoint(), ) .unwrap(); diff --git a/lez/sequencer/core/src/tests/reconstruction.rs b/lez/sequencer/core/src/tests/reconstruction.rs index e52c3364..7a57e496 100644 --- a/lez/sequencer/core/src/tests/reconstruction.rs +++ b/lez/sequencer/core/src/tests/reconstruction.rs @@ -295,7 +295,6 @@ fn deposit_event_record( recipient_id: recipient, }) .unwrap(), - submitted_in_block_id: None, } } @@ -525,7 +524,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 +559,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" ); } diff --git a/lez/storage/src/sequencer/mod.rs b/lez/storage/src/sequencer/mod.rs index 047e9450..e6bb6679 100644 --- a/lez/storage/src/sequencer/mod.rs +++ b/lez/storage/src/sequencer/mod.rs @@ -1,8 +1,4 @@ -use std::{ - collections::{BTreeMap, HashMap}, - path::Path, - sync::Arc, -}; +use std::{collections::BTreeMap, path::Path, sync::Arc}; use borsh::{BorshDeserialize, BorshSerialize}; use common::{ @@ -105,43 +101,42 @@ impl DbDump { /// cursor, so it must land in the *same* write as the effects it covers. /// Persisted ahead of them, a crash in between resumes the stream past blocks /// that never reached the store — a gap the node cannot backfill. -pub struct StoreUpdate<'a> { +pub struct StoreUpdate<'update> { /// Serialized zone-sdk checkpoint for this event. - pub checkpoint: Option<&'a [u8]>, + pub checkpoint: Option<&'update [u8]>, /// `(block, finalized)` payloads to write. - pub blocks: &'a [(&'a Block, bool)], + pub blocks: &'update [(&'update Block, bool)], /// Head tip to pin the stored chain to; `None` only for an empty chain. - pub head_tip: Option<&'a BlockMeta>, + pub head_tip: Option<&'update BlockMeta>, /// State after the last applied block. - pub head_state: &'a V03State, + pub head_state: &'update V03State, /// `(state, meta)` of the final tier, when it advanced. - pub final_snapshot: Option<(&'a V03State, &'a BlockMeta)>, + pub final_snapshot: Option<(&'update V03State, &'update BlockMeta)>, /// Highest block id this event made irreversible: stored blocks at or below - /// it become [`BedrockStatus::Finalized`], and pending deposit records - /// submitted there are dropped. + /// it become [`BedrockStatus::Finalized`]. pub finalized_up_to: Option, /// Deposit events observed on L1, recorded unless already pending. - pub new_deposit_events: &'a [PendingDepositEventRecord], - /// Deposit op ids included in `block_id`, marked submitted. - pub mark_deposits_submitted: Option<(&'a [HashType], u64)>, + 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: &'a [WithdrawalReconciliationKey], + pub consumed_withdrawals: &'update [WithdrawalReconciliationKey], /// L2 withdraw intents this update raises, awaiting their L1 event. - pub new_withdraw_intents: &'a [WithdrawalReconciliationKey], + pub new_withdraw_intents: &'update [WithdrawalReconciliationKey], /// Advance the channel-read anchor. - pub zone_anchor: Option<&'a ZoneAnchorRecord>, + pub zone_anchor: Option<&'update ZoneAnchorRecord>, } -impl<'a> StoreUpdate<'a> { +impl<'update> StoreUpdate<'update> { /// An update that writes nothing but the caller's head `state`, to be /// filled in with `..StoreUpdate::new(state)`. #[must_use] - pub const fn new(head_state: &'a V03State) -> Self { + pub const fn new(head_state: &'update V03State) -> Self { Self { checkpoint: None, blocks: &[], @@ -150,7 +145,7 @@ impl<'a> StoreUpdate<'a> { final_snapshot: None, finalized_up_to: None, new_deposit_events: &[], - mark_deposits_submitted: None, + remove_deposit_records: &[], consumed_withdrawals: &[], new_withdraw_intents: &[], zone_anchor: None, @@ -469,7 +464,7 @@ impl RocksDBIO { /// One-shot form of [`RocksDBIO::store_update`]'s `new_deposit_events`. pub fn add_pending_deposit_event(&self, event: PendingDepositEventRecord) -> DbResult { let mut batch = WriteBatch::default(); - let accepted = self.stage_pending_deposit_events(&[event], None, None, &mut batch)?; + let accepted = self.stage_pending_deposit_events(&[event], &[], &mut batch)?; self.db.write(batch).map_err(|rerr| { DbError::rocksdb_cast_message( rerr, @@ -489,18 +484,15 @@ impl RocksDBIO { fn stage_pending_deposit_events( &self, new_events: &[PendingDepositEventRecord], - mark_submitted: Option<(&[HashType], u64)>, - finalized_up_to: Option, + remove_op_ids: &[HashType], batch: &mut WriteBatch, ) -> DbResult { - let marks_nothing = mark_submitted.is_none_or(|(ids, _)| ids.is_empty()); - if new_events.is_empty() && marks_nothing && finalized_up_to.is_none() { + if new_events.is_empty() && remove_op_ids.is_empty() { return Ok(0); } let mut records = self.get_pending_deposit_events()?; let before = records.len(); - let mut changed = false; for event in new_events { if records @@ -513,30 +505,19 @@ impl RocksDBIO { } let accepted = records.len().saturating_sub(before); - if let Some((deposit_op_ids, submitted_block_id)) = mark_submitted { - for record in records - .iter_mut() - .filter(|record| record.submitted_in_block_id != Some(submitted_block_id)) - .filter(|record| deposit_op_ids.contains(&record.deposit_op_id)) - { - record.submitted_in_block_id = Some(submitted_block_id); - changed = true; - } - } + let removed = if remove_op_ids.is_empty() { + 0 + } else { + let after_append = records.len(); + records.retain(|record| !remove_op_ids.contains(&record.deposit_op_id)); + after_append.saturating_sub(records.len()) + }; - // Everything at or below a finalized block is irreversible, so records - // submitted there have served their purpose. - if let Some(finalized_block_id) = finalized_up_to { - records.retain(|record| { - record - .submitted_in_block_id - .is_none_or(|submitted_id| submitted_id > finalized_block_id) - }); - } - - // The common event finalizes something but touches no record; without - // this the cell is rewritten on every one of them. - if changed || records.len() != before { + // Tracked separately rather than inferred from the length: one event can + // both observe a deposit and finalize another, which nets to the same + // count while the contents changed. The common finalizing event owns no + // pending record at all, and must not rewrite the cell. + if accepted > 0 || removed > 0 { self.put_pending_deposit_events_batch(&records, batch)?; } Ok(accepted) @@ -558,9 +539,14 @@ impl RocksDBIO { return Ok(unmatched); } - let mut occurrences: HashMap = HashMap::new(); + // A `Vec` rather than a map: the per-update count is tiny, and it keeps + // the staging order deterministic. + let mut occurrences: Vec<(WithdrawalReconciliationKey, u64)> = Vec::new(); for withdrawal in withdrawals { - *occurrences.entry(*withdrawal).or_default() += 1; + match occurrences.iter_mut().find(|(key, _)| key == withdrawal) { + Some((_, times)) => *times = times.saturating_add(1), + None => occurrences.push((*withdrawal, 1)), + } } for (withdrawal, times) in occurrences { @@ -579,12 +565,12 @@ impl RocksDBIO { match stored.and_then(|count| count.checked_sub(times)) { Some(count) => { - self.put_batch(&UnseenWithdrawCountCell(count), withdrawal, batch)? + self.put_batch(&UnseenWithdrawCountCell(count), withdrawal, batch)?; } // Only stage a delete for a key that was actually there, so a // fully unmatched update leaves the batch empty. None if stored.is_some() => { - self.del_batch::(withdrawal, batch)? + self.del_batch::(withdrawal, batch)?; } None => {} } @@ -599,11 +585,7 @@ impl RocksDBIO { /// 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, - ) -> DbResult<()> { + fn collect_finalized_up_to(&self, last_finalized: u64, to_write: &mut BTreeMap) { let newly_finalized: Vec = self .get_all_blocks() .filter_map(Result::ok) @@ -617,15 +599,6 @@ impl RocksDBIO { block.bedrock_status = BedrockStatus::Finalized; to_write.entry(block.header.block_id).or_insert(block); } - Ok(()) - } - - /// Whether a bridge deposit for `deposit_op_id` is already recorded as - /// included in a block (its pending record is marked submitted). - pub fn is_deposit_event_submitted(&self, deposit_op_id: HashType) -> DbResult { - Ok(self.get_pending_deposit_events()?.iter().any(|record| { - record.deposit_op_id == deposit_op_id && record.submitted_in_block_id.is_some() - })) } fn increment_unseen_withdraw_count( @@ -765,7 +738,7 @@ impl RocksDBIO { /// One-shot form of [`RocksDBIO::store_update`]'s `finalized_up_to`. pub fn clean_pending_blocks_up_to(&self, last_finalized: u64) -> DbResult<()> { let mut to_write = BTreeMap::new(); - self.collect_finalized_up_to(last_finalized, &mut to_write)?; + self.collect_finalized_up_to(last_finalized, &mut to_write); let mut batch = WriteBatch::default(); for block in to_write.values() { @@ -873,7 +846,7 @@ impl RocksDBIO { final_snapshot, finalized_up_to, new_deposit_events, - mark_deposits_submitted, + remove_deposit_records, consumed_withdrawals, new_withdraw_intents, zone_anchor, @@ -919,7 +892,7 @@ impl RocksDBIO { } if let Some(last_finalized) = finalized_up_to { - self.collect_finalized_up_to(last_finalized, &mut to_write)?; + self.collect_finalized_up_to(last_finalized, &mut to_write); } for block in to_write.values() { self.put_block_payload(block, &mut batch)?; @@ -927,8 +900,7 @@ impl RocksDBIO { let accepted_deposits = self.stage_pending_deposit_events( new_deposit_events, - mark_deposits_submitted, - finalized_up_to, + remove_deposit_records, &mut batch, )?; let unmatched_withdrawals = @@ -987,8 +959,8 @@ impl RocksDBIO { }) } - /// Persists a block we produced, its deposit/withdraw bookkeeping, the - /// resulting state and the publish `checkpoint` in one atomic write. + /// 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 @@ -999,8 +971,7 @@ impl RocksDBIO { pub fn atomic_update( &self, block: &Block, - deposit_op_ids: &[HashType], - withdrawals: Vec, + withdrawals: &[WithdrawalReconciliationKey], state: &V03State, checkpoint: Option<&[u8]>, ) -> DbResult<()> { @@ -1008,8 +979,7 @@ impl RocksDBIO { checkpoint, blocks: &[(block, false)], head_tip: Some(&BlockMeta::from(block)), - mark_deposits_submitted: Some((deposit_op_ids, block.header.block_id)), - new_withdraw_intents: &withdrawals, + new_withdraw_intents: withdrawals, ..StoreUpdate::new(state) }) .map(|_outcome| ()) diff --git a/lez/storage/src/sequencer/sequencer_cells.rs b/lez/storage/src/sequencer/sequencer_cells.rs index c1c4b71b..8a4223c0 100644 --- a/lez/storage/src/sequencer/sequencer_cells.rs +++ b/lez/storage/src/sequencer/sequencer_cells.rs @@ -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, - /// Set when block containing the deposit event is submitted, but not necessarily finalized. - pub submitted_in_block_id: Option, } #[derive(BorshDeserialize)] diff --git a/lez/storage/src/sequencer/tests.rs b/lez/storage/src/sequencer/tests.rs index 27c8ac76..b3e8c070 100644 --- a/lez/storage/src/sequencer/tests.rs +++ b/lez/storage/src/sequencer/tests.rs @@ -34,7 +34,6 @@ fn deposit_record(seed: u8) -> PendingDepositEventRecord { source_tx_hash: HashType([seed; 32]), amount: u64::from(seed), metadata: vec![seed], - submitted_in_block_id: None, } } @@ -357,7 +356,7 @@ fn redelivered_deposit_is_not_accepted_twice() { let record = deposit_record(1); dbio.store_update(&StoreUpdate { - new_deposit_events: &[record.clone()], + new_deposit_events: std::slice::from_ref(&record), ..StoreUpdate::new(&state_with_balance(100)) }) .unwrap(); @@ -376,6 +375,30 @@ fn redelivered_deposit_is_not_accepted_twice() { 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_decrements_once_per_occurrence() { let temp_dir = tempdir().unwrap(); @@ -386,12 +409,12 @@ fn repeated_withdrawal_key_in_one_update_decrements_once_per_occurrence() { bedrock_account_pk: [3; 32], }; // Two local intents for the same key. - let mut batch = WriteBatch::default(); - dbio.increment_unseen_withdraw_count(key, &mut batch).unwrap(); - dbio.db.write(batch).unwrap(); - let mut batch = WriteBatch::default(); - dbio.increment_unseen_withdraw_count(key, &mut batch).unwrap(); - dbio.db.write(batch).unwrap(); + for _ in 0..2 { + let mut batch = WriteBatch::default(); + dbio.increment_unseen_withdraw_count(key, &mut batch) + .unwrap(); + dbio.db.write(batch).unwrap(); + } // Both L1 events arrive in one update; a per-occurrence disk read would // miss the staged decrement and consume only one. @@ -432,7 +455,9 @@ fn unmatched_withdrawal_is_reported_and_writes_nothing() { assert_eq!(outcome.unmatched_withdrawals.len(), 1); assert!( - dbio.get_opt::(key).unwrap().is_none(), + dbio.get_opt::(key) + .unwrap() + .is_none(), "an unmatched withdraw must not leave a counter behind" ); } @@ -443,14 +468,8 @@ fn produced_block_persists_its_publish_checkpoint() { let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); - dbio.atomic_update( - &block2, - &[], - vec![], - &state_with_balance(200), - Some(b"cp-produced"), - ) - .unwrap(); + 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. @@ -458,7 +477,10 @@ fn produced_block_persists_its_publish_checkpoint() { 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); + assert_eq!( + dbio.get_block(2).unwrap().unwrap().header.hash, + block2.header.hash + ); } #[test] @@ -477,7 +499,7 @@ fn produced_block_below_disk_head_pins_meta_and_prunes() { // pins the tip meta to the produced block and drops the stale suffix in // 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), None) + dbio.atomic_update(&block2b, &[], &state_with_balance(400), None) .unwrap(); let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored");