diff --git a/crates/store/src/db/mod.rs b/crates/store/src/db/mod.rs index 7fe96137f..5f4def2a2 100644 --- a/crates/store/src/db/mod.rs +++ b/crates/store/src/db/mod.rs @@ -30,7 +30,7 @@ use tokio::sync::oneshot; use tracing::{info, instrument}; use crate::COMPONENT; -use crate::db::migrations::{bootstrap_database, migrate_database, verify_latest_schema}; +use crate::db::migrations::{migrate_database, verify_latest_schema}; use crate::db::models::conv::SqlTypeConvert; use crate::db::models::queries; pub use crate::db::models::queries::{ @@ -51,6 +51,8 @@ fn default_storage_map_entries_limit() -> usize { } mod migrations; +#[cfg(test)] +pub(crate) use migrations::bootstrap_database; #[cfg(test)] mod tests; @@ -193,7 +195,8 @@ impl Db { err, )] pub fn bootstrap(database_filepath: PathBuf, genesis: GenesisBlock) -> anyhow::Result<()> { - bootstrap_database(&database_filepath).context("failed to bootstrap database schema")?; + migrations::bootstrap_database(&database_filepath) + .context("failed to bootstrap database schema")?; let mut conn: SqliteConnection = diesel::sqlite::SqliteConnection::establish( database_filepath.to_str().context("database filepath is invalid")?, diff --git a/crates/store/src/errors.rs b/crates/store/src/errors.rs index 9fcb7e03b..e3361d42b 100644 --- a/crates/store/src/errors.rs +++ b/crates/store/src/errors.rs @@ -121,6 +121,21 @@ pub enum StateInitializationError { tree_root: Word, block_root: Word, }, + #[error("loaded chain MMR cannot produce peaks for block {block_num}")] + ChainMmrLoadError { + block_num: BlockNumber, + #[source] + source: MmrError, + }, + #[error( + "chain MMR commitment ({chain_mmr_commitment:?}) does not match expected chain commitment \ + from block {block_num} ({block_header_commitment:?})" + )] + ChainMmrStorageDiverged { + block_num: BlockNumber, + chain_mmr_commitment: Word, + block_header_commitment: Word, + }, #[error( "account state forest root ({forest_root}) does not match SQLite root \ ({database_root}) for account {account_id}, slot {slot_name:?}. Delete the account \ @@ -136,6 +151,8 @@ pub enum StateInitializationError { PublicAccountMissingDetails(AccountId), #[error("failed to convert account to delta: {0}")] AccountToDeltaConversionFailed(String), + #[error("genesis block missing. The database should be bootstrapped first.")] + GenesisBlockMissing, } // ENDPOINT ERRORS diff --git a/crates/store/src/state/loader.rs b/crates/store/src/state/loader.rs index 294cae3fe..ca8966274 100644 --- a/crates/store/src/state/loader.rs +++ b/crates/store/src/state/loader.rs @@ -23,7 +23,7 @@ use miden_node_utils::clap::RocksDbOptions; use miden_protocol::account::{AccountId, AccountStorageHeader, StorageSlotType}; use miden_protocol::block::account_tree::{AccountIdKey, AccountTree}; use miden_protocol::block::nullifier_tree::NullifierTree; -use miden_protocol::block::{BlockNumber, Blockchain}; +use miden_protocol::block::{BlockHeader, BlockNumber, Blockchain}; #[cfg(not(feature = "rocksdb"))] use miden_protocol::crypto::merkle::smt::MemoryStorage; use miden_protocol::crypto::merkle::smt::{LargeSmt, LargeSmtError, SmtStorage}; @@ -462,6 +462,7 @@ pub fn load_smt(storage: S) -> Result, StateInitializ /// Loads the blockchain MMR from all block headers in the database. #[instrument(target = COMPONENT, skip_all)] pub async fn load_mmr(db: &mut Db) -> Result { + let latest_header = db.select_block_header_by_block_num(None).await?; let block_commitments = db.select_all_block_header_commitments().await?; // SAFETY: We assume the loaded MMR is valid and does not have more than u32::MAX entries. @@ -469,9 +470,37 @@ pub async fn load_mmr(db: &mut Db) -> Result, +) -> Result<(), StateInitializationError> { + let Some(latest_header) = latest_header else { + return Err(StateInitializationError::GenesisBlockMissing); + }; + + let block_num = latest_header.block_num(); + let expected_chain_commitment = latest_header.chain_commitment(); + let actual_chain_commitment = chain_mmr + .peaks_at(block_num) + .map_err(|source| StateInitializationError::ChainMmrLoadError { block_num, source })? + .hash_peaks(); + + if actual_chain_commitment != expected_chain_commitment { + return Err(StateInitializationError::ChainMmrStorageDiverged { + block_num, + chain_mmr_commitment: actual_chain_commitment, + block_header_commitment: expected_chain_commitment, + }); + } + + Ok(()) +} + /// Rebuilds SMT forest with storage map and vault Merkle paths for all public accounts. #[instrument(target = COMPONENT, skip_all, fields(block.number = %block_num))] pub async fn rebuild_account_state_forest( @@ -645,6 +674,7 @@ fn verify_account_state_forest_record( #[cfg(test)] mod tests { + use diesel::{ExpressionMethods, RunQueryDsl}; use miden_protocol::account::{ AccountId, AccountStorageHeader, @@ -652,10 +682,104 @@ mod tests { StorageSlotName, StorageSlotType, }; + use miden_protocol::block::BlockHeader; + use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; + use miden_protocol::crypto::merkle::mmr::Mmr; use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE; + use miden_protocol::utils::serde::Serializable; use super::*; + fn build_headers(count: u32) -> Vec { + let mut mmr = Mmr::new(); + let mut headers = Vec::new(); + + for block_num in 0..count { + let chain_commitment = mmr.peaks().hash_peaks(); + let header = + BlockHeader::mock(block_num, Some(chain_commitment), None, &[], Word::default()); + mmr.add(header.commitment()).expect("test MMR should accept block commitment"); + headers.push(header); + } + + headers + } + + #[test] + fn chain_mmr_consistency_accepts_valid_loaded_chain() { + let headers = build_headers(5); + let mmr = Mmr::try_from_iter(headers.iter().map(BlockHeader::commitment)) + .expect("test MMR should build"); + let blockchain = Blockchain::from_mmr_unchecked(mmr); + + verify_chain_mmr_consistency(&blockchain, headers.last()) + .expect("valid loaded chain MMR should match latest header chain commitment"); + } + + #[test] + fn chain_mmr_consistency_rejects_corrupted_loaded_chain() { + let headers = build_headers(5); + let mut commitments = headers.iter().map(BlockHeader::commitment).collect::>(); + commitments[2] = Word::from([42, 0, 0, 0u32]); + + let mmr = Mmr::try_from_iter(commitments).expect("test MMR should build"); + let blockchain = Blockchain::from_mmr_unchecked(mmr); + + let error = verify_chain_mmr_consistency(&blockchain, headers.last()) + .expect_err("corrupted chain MMR should be rejected"); + + assert_matches::assert_matches!( + error, + StateInitializationError::ChainMmrStorageDiverged { + block_num, + chain_mmr_commitment, + block_header_commitment, + } if block_num == headers.last().unwrap().block_num() + && chain_mmr_commitment != block_header_commitment + && block_header_commitment == headers.last().unwrap().chain_commitment() + ); + } + + #[tokio::test] + #[miden_node_test_macro::enable_logging] + async fn load_mmr_rejects_chain_mmr_mismatch_from_database() { + let temp_dir = tempfile::tempdir().expect("temp directory should be created"); + let db_path = temp_dir.path().join("store.sqlite"); + crate::db::bootstrap_database(&db_path).expect("test database should bootstrap"); + + let headers = build_headers(5); + let signing_key = SigningKey::new(); + let mut db = crate::db::Db::load(db_path).await.expect("test database should load"); + + db.query("insert corrupted block headers", move |conn| { + for header in &headers { + let signature = signing_key.sign(header.commitment()); + crate::db::models::queries::insert_block_header(conn, header, &signature)?; + } + + diesel::update(crate::db::schema::block_headers::table) + .filter(crate::db::schema::block_headers::block_num.eq(2_i64)) + .set( + crate::db::schema::block_headers::commitment + .eq(Word::from([42, 0, 0, 0u32]).to_bytes()), + ) + .execute(conn)?; + + Ok::<_, DatabaseError>(()) + }) + .await + .expect("test block headers should be inserted"); + + let error = load_mmr(&mut db) + .await + .expect_err("startup MMR load should reject inconsistent block headers"); + + assert_matches::assert_matches!( + error, + StateInitializationError::ChainMmrStorageDiverged { .. } + ); + } + #[test] fn account_state_forest_consistency_detects_storage_map_root_mismatch() { let account_id = AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE)