From d111117b3cbc471becd119af87bbf2b78a8c4fca Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Mon, 7 Sep 2026 15:21:57 +0200 Subject: [PATCH 1/4] feat: store and verify genesis protocol configuration --- Cargo.lock | 1 + bin/node/src/commands/lifecycle.rs | 9 +- bin/ntx-builder/src/commands/mod.rs | 9 +- bin/ntx-builder/src/lib.rs | 33 +++- bin/validator/src/commands/bootstrap.rs | 55 +++++- bin/validator/src/commands/dkg.rs | 2 +- bin/validator/src/commands/dkg/tests.rs | 3 +- bin/validator/src/commands/genesis.rs | 2 +- crates/rpc/src/tests.rs | 40 ++-- .../db/migrations/006_protocol_configs.sql | 4 + crates/store/src/db/migrations/tests/mod.rs | 3 +- crates/store/src/db/mod.rs | 50 ++++- crates/store/src/db/models/queries/mod.rs | 2 + .../src/db/models/queries/protocol_configs.rs | 179 ++++++++++++++++++ crates/store/src/db/schema.rs | 8 + crates/store/src/db/tests.rs | 55 +++++- crates/store/src/errors.rs | 8 + crates/store/src/genesis/mod.rs | 42 +--- crates/store/src/state/lifecycle.rs | 110 +++++++++++ crates/store/src/state/view/mod.rs | 8 +- .../store/src/state/view/protocol_config.rs | 15 ++ crates/utils/Cargo.toml | 4 + crates/utils/src/genesis.rs | 98 +++++++++- crates/utils/src/genesis/tests.rs | 126 ++++++++++++ docs/external/src/full-node/bootstrap.md | 5 + .../network-operator/bootstrap-and-genesis.md | 7 + 26 files changed, 778 insertions(+), 100 deletions(-) create mode 100644 crates/store/src/db/migrations/006_protocol_configs.sql create mode 100644 crates/store/src/db/models/queries/protocol_configs.rs create mode 100644 crates/store/src/state/view/protocol_config.rs create mode 100644 crates/utils/src/genesis/tests.rs diff --git a/Cargo.lock b/Cargo.lock index d427366fdb..1e9fdb592e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4380,6 +4380,7 @@ dependencies = [ "miden-testing", "miden-tx", "reqwest", + "tempfile", "thiserror 2.0.20", "tokio", "tokio-util", diff --git a/bin/node/src/commands/lifecycle.rs b/bin/node/src/commands/lifecycle.rs index 1dc7cde392..d6d3b34e9b 100644 --- a/bin/node/src/commands/lifecycle.rs +++ b/bin/node/src/commands/lifecycle.rs @@ -72,12 +72,11 @@ async fn read_bootstrap_genesis_block( genesis_block_file: Option<&Path>, network: Option, ) -> anyhow::Result { - let signed_block = match (genesis_block_file, network) { - (Some(path), None) => read_genesis_block(path)?, - (None, Some(network)) => fetch_genesis_block(network).await?, + match (genesis_block_file, network) { + (Some(path), None) => read_genesis_block(path), + (None, Some(network)) => fetch_genesis_block(network).await, _ => unreachable!("clap requires exactly one genesis block source"), - }; - GenesisBlock::try_from(signed_block) + } } // MIGRATE diff --git a/bin/ntx-builder/src/commands/mod.rs b/bin/ntx-builder/src/commands/mod.rs index b3bcf50360..ce1bc8e84f 100644 --- a/bin/ntx-builder/src/commands/mod.rs +++ b/bin/ntx-builder/src/commands/mod.rs @@ -328,10 +328,9 @@ async fn read_bootstrap_genesis_block( genesis_block_file: Option<&Path>, network: Option, ) -> anyhow::Result { - let signed_block = match (genesis_block_file, network) { - (Some(path), None) => read_genesis_block(path)?, - (None, Some(network)) => fetch_genesis_block(network).await?, + match (genesis_block_file, network) { + (Some(path), None) => read_genesis_block(path), + (None, Some(network)) => fetch_genesis_block(network).await, _ => unreachable!("clap requires exactly one genesis block source"), - }; - GenesisBlock::try_from(signed_block) + } } diff --git a/bin/ntx-builder/src/lib.rs b/bin/ntx-builder/src/lib.rs index e05972fda6..9e8344db70 100644 --- a/bin/ntx-builder/src/lib.rs +++ b/bin/ntx-builder/src/lib.rs @@ -60,14 +60,34 @@ pub fn migrate(database_filepath: impl AsRef) -> anyhow::Result<()> { #[cfg(test)] mod bootstrap_tests { - use miden_node_store::genesis::GenesisBlock; + use miden_node_store::genesis::{GenesisBlock, GenesisState}; + use miden_node_utils::fee::{test_fee_params, test_protocol_config}; use miden_protocol::block::{BlockSignatures, SignedBlock}; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; - #[test] - fn genesis_block_accepts_unsigned_block() { - let block = crate::test_utils::mock_genesis_block(); - GenesisBlock::try_from(block).expect("unsigned genesis block should validate"); + #[tokio::test] + async fn bootstrap_accepts_genesis_artifact_with_protocol_config() { + use miden_node_utils::genesis::read_genesis_block; + use miden_protocol::utils::serde::Serializable; + + let genesis = GenesisState::new( + Vec::new(), + test_fee_params(), + 1, + 0, + crate::test_utils::mock_genesis_block().header().validator_config().clone(), + test_protocol_config(), + ) + .into_block() + .unwrap(); + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("genesis.dat"); + std::fs::write(&path, genesis.to_bytes()).unwrap(); + let decoded = read_genesis_block(&path).unwrap(); + assert_eq!(decoded.protocol_config(), genesis.protocol_config()); + let database_path = root.path().join("ntx.sqlite3"); + super::bootstrap(database_path.clone(), &decoded).await.unwrap(); + assert!(database_path.is_file()); } #[test] @@ -77,7 +97,8 @@ mod bootstrap_tests { let signatures = BlockSignatures::new(vec![signature]).unwrap(); let block = SignedBlock::new_unchecked(header, body, signatures); - let err = GenesisBlock::try_from(block).expect_err("signed genesis block should fail"); + let err = GenesisBlock::new(block, test_protocol_config()) + .expect_err("signed genesis block should fail"); assert!(err.to_string().contains("must not carry signatures"), "unexpected error: {err}"); } diff --git a/bin/validator/src/commands/bootstrap.rs b/bin/validator/src/commands/bootstrap.rs index a45ec16ef2..610ff5226e 100644 --- a/bin/validator/src/commands/bootstrap.rs +++ b/bin/validator/src/commands/bootstrap.rs @@ -3,7 +3,6 @@ use std::path::Path; use anyhow::Context; use miden_node_store::BlockStore; -use miden_node_store::genesis::GenesisBlock; use miden_node_tracing::info; use miden_node_utils::fs::ensure_empty_directory; use miden_node_utils::genesis::read_genesis_block; @@ -13,8 +12,8 @@ use miden_validator::DataDirectory; /// produced by the `genesis` command. /// /// The genesis block is the chain's trust root and carries no signatures; it must come from a -/// trusted source. This command verifies the block (via [`GenesisBlock::try_from`]) and persists -/// it as the chain tip. +/// trusted source. This command verifies the block and its protocol configuration. It persists +/// the block as the chain tip. pub async fn bootstrap( data_directory: &Path, sqlite_connection_pool_size: NonZeroUsize, @@ -33,10 +32,8 @@ pub async fn bootstrap( let dirs = DataDirectory::load(data_directory.to_path_buf()) .context("failed to load the data directory")?; - let signed_block = - read_genesis_block(genesis_block_file).context("failed to read genesis block file")?; let genesis_block = - GenesisBlock::try_from(signed_block).context("genesis block validation failed")?; + read_genesis_block(genesis_block_file).context("failed to read genesis block file")?; let genesis_commitment = genesis_block.inner().header().commitment(); let _ = BlockStore::bootstrap(dirs.block_store_dir(), &genesis_block)?; @@ -62,8 +59,9 @@ pub async fn bootstrap( #[cfg(test)] mod tests { + use miden_protocol::block::BlockNumber; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; - use miden_protocol::utils::serde::Deserializable; + use miden_protocol::utils::serde::{Deserializable, Serializable}; use super::*; @@ -95,6 +93,24 @@ mod tests { assert!(genesis_directory.join("genesis.dat").is_file()); assert!(data_directory.join("validator.sqlite3").is_file()); + + let genesis = read_genesis_block(&genesis_directory.join("genesis.dat")).unwrap(); + let config = genesis.protocol_config().clone(); + let commitment = genesis.inner().header().protocol_config_commitment(); + let block_bytes = genesis.inner().to_bytes(); + assert_eq!(config.to_commitment(), commitment); + let node_directory = root.path().join("node"); + fs_err::create_dir(&node_directory).unwrap(); + miden_node_store::State::bootstrap(genesis, &node_directory).unwrap(); + let directories = miden_node_store::DataDirectory::load(node_directory).unwrap(); + let block_store = BlockStore::load(directories.block_store_dir()).unwrap(); + assert_eq!(block_store.load_block(BlockNumber::GENESIS).await.unwrap(), Some(block_bytes)); + let db = miden_node_store::Db::load(directories.database_path()).await.unwrap(); + assert_eq!( + db.select_protocol_config_by_commitment(commitment).await.unwrap(), + Some(config) + ); + assert!( fs_err::read_dir(&accounts_directory) .expect("accounts directory should be readable") @@ -107,4 +123,29 @@ mod tests { "genesis should write the generated native faucet account file", ); } + + #[tokio::test] + async fn bootstrap_rejects_block_only_genesis_before_creating_database() { + use miden_node_store::genesis::GenesisState; + use miden_node_utils::fee::{test_fee_params, test_protocol_config}; + use miden_protocol::block::ValidatorConfig; + + let root = tempfile::tempdir().unwrap(); + let key = SigningKey::read_from_bytes(&[7; 32]).unwrap().public_key(); + let genesis = GenesisState::new( + Vec::new(), + test_fee_params(), + 1, + 0, + ValidatorConfig::new(vec![key], 1).unwrap(), + test_protocol_config(), + ) + .into_block() + .unwrap(); + let path = root.path().join("genesis.dat"); + fs_err::write(&path, genesis.inner().to_bytes()).unwrap(); + let data_directory = root.path().join("data"); + assert!(bootstrap(&data_directory, NonZeroUsize::new(2).unwrap(), &path).await.is_err()); + assert!(!data_directory.join("validator.sqlite3").exists()); + } } diff --git a/bin/validator/src/commands/dkg.rs b/bin/validator/src/commands/dkg.rs index 98e16ba305..3bbe6efd27 100644 --- a/bin/validator/src/commands/dkg.rs +++ b/bin/validator/src/commands/dkg.rs @@ -1570,7 +1570,7 @@ fn take_scalar(bytes: &mut &[u8]) -> anyhow::Result { /// Reads and validates the trusted genesis block used by the ceremony. fn read_trusted_genesis(path: &Path) -> anyhow::Result { - GenesisBlock::try_from(read_genesis_block(path)?).context("failed to validate genesis block") + read_genesis_block(path).context("failed to validate genesis block") } /// Commits a validator signature to one genesis-bound DKG identity registration. diff --git a/bin/validator/src/commands/dkg/tests.rs b/bin/validator/src/commands/dkg/tests.rs index 08e114e86d..85ddc5873c 100644 --- a/bin/validator/src/commands/dkg/tests.rs +++ b/bin/validator/src/commands/dkg/tests.rs @@ -78,8 +78,7 @@ fn write_genesis_with_validator_count( Some(&config_path), validators, )?; - let genesis = - GenesisBlock::try_from(read_genesis_block(&genesis_directory.join("genesis.dat"))?)?; + let genesis = read_genesis_block(&genesis_directory.join("genesis.dat"))?; Ok(TestGenesis { path: genesis_directory.join("genesis.dat"), signing_keys, diff --git a/bin/validator/src/commands/genesis.rs b/bin/validator/src/commands/genesis.rs index fe0b1e74e0..4f931d09dd 100644 --- a/bin/validator/src/commands/genesis.rs +++ b/bin/validator/src/commands/genesis.rs @@ -64,7 +64,7 @@ pub fn generate( let genesis_block = genesis_state.into_block().context("failed to build the genesis block")?; let genesis_block_path = genesis_block_directory.join(GENESIS_BLOCK_FILE_NAME); - fs_err::write(&genesis_block_path, genesis_block.inner().to_bytes()) + fs_err::write(&genesis_block_path, genesis_block.to_bytes()) .context("failed to write genesis block")?; println!("Genesis block written to {}.", genesis_block_path.display()); diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index ee70b92192..e0d8829083 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -67,6 +67,7 @@ use miden_protocol::block::{ ValidatorConfig, }; use miden_protocol::note::NoteType; +use miden_protocol::protocol_config::ProtocolConfig; use miden_protocol::testing::account_id::{ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, ACCOUNT_ID_SENDER}; use miden_protocol::testing::noop_auth_component::NoopAuthComponent; use miden_protocol::transaction::{ @@ -150,9 +151,13 @@ impl TestStore { } } - async fn start_from_mock_genesis(genesis_block: &ProvenBlock) -> Self { + async fn start_from_mock_genesis( + genesis_block: &ProvenBlock, + protocol_config: &ProtocolConfig, + ) -> Self { let data_directory = new_tempdir(); - let genesis_commitment = Self::bootstrap_from_mock_genesis(&data_directory, genesis_block); + let genesis_commitment = + Self::bootstrap_from_mock_genesis(&data_directory, genesis_block, protocol_config); let (state, ..) = State::for_tests(&data_directory).await; Self { state, @@ -183,7 +188,11 @@ impl TestStore { genesis_commitment } - fn bootstrap_from_mock_genesis(path: &std::path::Path, genesis_block: &ProvenBlock) -> Word { + fn bootstrap_from_mock_genesis( + path: &std::path::Path, + genesis_block: &ProvenBlock, + protocol_config: &ProtocolConfig, + ) -> Word { let signatures = BlockSignatures::new(Vec::new()).unwrap(); let signed_block = SignedBlock::new( genesis_block.header().clone(), @@ -191,7 +200,7 @@ impl TestStore { signatures, ) .expect("mock genesis header and body should be consistent"); - let genesis_block = GenesisBlock::try_from(signed_block) + let genesis_block = GenesisBlock::new(signed_block, protocol_config.clone()) .expect("mock genesis should become a store genesis block after stripping signatures"); let genesis_commitment = genesis_block.inner().header().commitment(); @@ -326,6 +335,7 @@ fn replace_transaction_proof( struct ValidBatchFixture { request: proto::submission::TransactionBatch, genesis_block: ProvenBlock, + protocol_config: ProtocolConfig, } async fn build_valid_batch_fixture() -> ValidBatchFixture { @@ -349,6 +359,7 @@ async fn build_valid_batch_fixture() -> ValidBatchFixture { .unwrap(); let mock_chain = mock_chain_builder.build().unwrap(); let genesis_block = mock_chain.latest_block(); + let protocol_config = mock_chain.protocol_config().clone(); let tx_context = mock_chain .build_transaction(account.id()) @@ -388,7 +399,7 @@ async fn build_valid_batch_fixture() -> ValidBatchFixture { sealed_transaction_inputs: vec![proto::submission::SealedTransactionInputs::default()], }; - ValidBatchFixture { request, genesis_block } + ValidBatchFixture { request, genesis_block, protocol_config } } fn assert_beyond_tip(status: &tonic::Status, endpoint: &str) { @@ -928,16 +939,22 @@ async fn start_source_rpc( async fn start_source_rpc_with_genesis( ntx_builder: NtxBuilderClient, validator: ValidatorClient, - genesis_block: Option<&ProvenBlock>, + genesis_block: Option<(&ProvenBlock, &ProtocolConfig)>, ) -> (RpcClient, TestStore, TestServerGuard) { let store = match genesis_block { - Some(genesis_block) => TestStore::start_from_mock_genesis(genesis_block).await, + Some((genesis_block, protocol_config)) => { + TestStore::start_from_mock_genesis(genesis_block, protocol_config).await + }, None => TestStore::start().await, }; let block_producer_dir = new_tempdir(); match genesis_block { - Some(genesis_block) => { - TestStore::bootstrap_from_mock_genesis(&block_producer_dir, genesis_block); + Some((genesis_block, protocol_config)) => { + TestStore::bootstrap_from_mock_genesis( + &block_producer_dir, + genesis_block, + protocol_config, + ); }, None => { TestStore::bootstrap(&block_producer_dir); @@ -1389,10 +1406,11 @@ async fn full_node_forwards_complete_transaction_batch_to_source_rpc() { let (source_rpc, _source_store, _source_server) = start_source_rpc_with_genesis( dummy_client::(), validator, - Some(&fixture.genesis_block), + Some((&fixture.genesis_block, &fixture.protocol_config)), ) .await; - let local_store = TestStore::start_from_mock_genesis(&fixture.genesis_block).await; + let local_store = + TestStore::start_from_mock_genesis(&fixture.genesis_block, &fixture.protocol_config).await; let full_node = RpcService::new( Arc::clone(&local_store.state), RpcBackend::full_node(source_rpc, None), diff --git a/crates/store/src/db/migrations/006_protocol_configs.sql b/crates/store/src/db/migrations/006_protocol_configs.sql new file mode 100644 index 0000000000..d9ecc50bd8 --- /dev/null +++ b/crates/store/src/db/migrations/006_protocol_configs.sql @@ -0,0 +1,4 @@ +CREATE TABLE protocol_configs ( + commitment BLOB NOT NULL PRIMARY KEY CHECK (length(commitment) = 32), + protocol_config BLOB NOT NULL +) WITHOUT ROWID; diff --git a/crates/store/src/db/migrations/tests/mod.rs b/crates/store/src/db/migrations/tests/mod.rs index 9584a32f70..076c1bf8c7 100644 --- a/crates/store/src/db/migrations/tests/mod.rs +++ b/crates/store/src/db/migrations/tests/mod.rs @@ -10,12 +10,13 @@ use super::*; use crate::db::models::queries::VALID_FOREVER; use crate::db::schema; -const EXPECTED_SCHEMA_HASHES: [SchemaHash; 5] = [ +const EXPECTED_SCHEMA_HASHES: [SchemaHash; 6] = [ SchemaHash::from_hex("cc92cb332410e6f63036b52cf953acb446c142d5c0fbbdbd6d3b4f466510b210"), SchemaHash::from_hex("7c783947d0bb2c9745d28f4bdcf329f84ad970c36aa07ea85441e62718d8bbbb"), SchemaHash::from_hex("e026a70464e897ae9a217f45c80d72341b1bfb757200e57e41145348473a9961"), SchemaHash::from_hex("a581a13b00e4aa1d4539459e2b351c0585fad33c5a876f830c9b943adac92dea"), SchemaHash::from_hex("34bd293251a2647715dd91fa245bcd98d635e8070871b4f8335b3a3db364fc1e"), + SchemaHash::from_hex("303f71c67f038e46bd5b3234b0f5c84c37bbc1ce9f5ba65b2368ebbb0f24319b"), ]; #[test] diff --git a/crates/store/src/db/mod.rs b/crates/store/src/db/mod.rs index 5e0ec15c66..3d56c96633 100644 --- a/crates/store/src/db/mod.rs +++ b/crates/store/src/db/mod.rs @@ -34,6 +34,7 @@ use miden_protocol::note::{ NoteScript, Nullifier, }; +use miden_protocol::protocol_config::ProtocolConfig; use miden_protocol::transaction::TransactionHeader; use miden_protocol::utils::serde::Deserializable; @@ -102,6 +103,20 @@ pub struct Db { db: miden_node_db::Db, } +fn insert_genesis(conn: &mut SqliteConnection, genesis: GenesisBlock) -> Result<()> { + let (genesis_block, protocol_config) = genesis.into_parts(); + conn.transaction(move |conn| { + models::queries::insert_protocol_config(conn, &protocol_config)?; + models::queries::apply_block( + conn, + &genesis_block, + &[], + &PrecomputedPublicAccountStates::new(), + ) + })?; + Ok(()) +} + impl Deref for Db { type Target = miden_node_db::Db; @@ -223,16 +238,7 @@ impl Db { miden_node_db::configure_connection_on_creation(&mut conn)?; // Insert genesis block data. - let genesis_block = genesis.into_inner(); - conn.transaction(move |conn| { - models::queries::apply_block( - conn, - &genesis_block, - &[], - &PrecomputedPublicAccountStates::new(), - ) - }) - .context("failed to insert genesis block")?; + insert_genesis(&mut conn, genesis).context("failed to insert genesis block")?; Ok(()) } @@ -267,6 +273,22 @@ impl Db { Ok(Self { db }) } + /// Selects a protocol configuration by its commitment. + #[miden_instrument( + level = "debug", + target = COMPONENT, + err, + )] + pub async fn select_protocol_config_by_commitment( + &self, + commitment: Word, + ) -> Result> { + self.transact("protocol config by commitment", move |conn| { + queries::select_protocol_config(conn, commitment) + }) + .await + } + /// Applies all pending migrations to an existing DB. #[miden_instrument( target = COMPONENT, @@ -347,6 +369,14 @@ impl Db { .await } + /// Selects the genesis block header for state initialization. + pub(crate) async fn select_genesis_block_header(&self) -> Result> { + self.transact("genesis block header", |conn| { + queries::select_block_header_by_block_num(conn, Some(BlockNumber::GENESIS)) + }) + .await + } + /// Search for a [`BlockHeader`] and its [`BlockSignatures`] from the database by its /// `block_num`. #[miden_instrument( diff --git a/crates/store/src/db/models/queries/mod.rs b/crates/store/src/db/models/queries/mod.rs index 377a20e94d..d3872cf37c 100644 --- a/crates/store/src/db/models/queries/mod.rs +++ b/crates/store/src/db/models/queries/mod.rs @@ -43,6 +43,8 @@ pub use nullifiers::NullifiersPage; pub(crate) use nullifiers::*; mod notes; pub(crate) use notes::*; +mod protocol_configs; +pub(crate) use protocol_configs::*; /// Apply a new block to the state. /// diff --git a/crates/store/src/db/models/queries/protocol_configs.rs b/crates/store/src/db/models/queries/protocol_configs.rs new file mode 100644 index 0000000000..cabfb889ee --- /dev/null +++ b/crates/store/src/db/models/queries/protocol_configs.rs @@ -0,0 +1,179 @@ +use diesel::{ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl, SqliteConnection}; +use miden_protocol::Word; +use miden_protocol::protocol_config::ProtocolConfig; +use miden_protocol::utils::serde::{ByteReader, Deserializable, Serializable, SliceReader}; + +use crate::db::schema::protocol_configs; +use crate::errors::DatabaseError; + +/// Inserts a protocol configuration by its commitment. +pub(crate) fn insert_protocol_config( + conn: &mut SqliteConnection, + protocol_config: &ProtocolConfig, +) -> Result { + diesel::insert_into(protocol_configs::table) + .values(( + protocol_configs::commitment.eq(protocol_config.to_commitment().to_bytes()), + protocol_configs::protocol_config.eq(protocol_config.to_bytes()), + )) + .execute(conn) + .map_err(Into::into) +} + +/// Selects a protocol configuration and verifies its commitment. +pub(crate) fn select_protocol_config( + conn: &mut SqliteConnection, + commitment: Word, +) -> Result, DatabaseError> { + let bytes = protocol_configs::table + .filter(protocol_configs::commitment.eq(commitment.to_bytes())) + .select(protocol_configs::protocol_config) + .get_result::>(conn) + .optional()?; + + let Some(bytes) = bytes else { + return Ok(None); + }; + + let mut reader = SliceReader::new(&bytes); + let protocol_config = ProtocolConfig::read_from(&mut reader)?; + if reader.has_more_bytes() { + return Err(DatabaseError::DataCorrupted(format!( + "protocol config {commitment} has trailing bytes" + ))); + } + let calculated = protocol_config.to_commitment(); + if calculated != commitment { + return Err(DatabaseError::ProtocolConfigCommitmentMismatch { + expected: commitment, + calculated, + }); + } + + Ok(Some(protocol_config)) +} + +#[cfg(test)] +mod tests { + use diesel::{ExpressionMethods, RunQueryDsl, SqliteConnection}; + use miden_node_utils::fee::test_protocol_config; + use miden_protocol::Word; + use miden_protocol::protocol_config::ProtocolConfig; + use miden_protocol::utils::serde::Serializable; + + use super::{insert_protocol_config, select_protocol_config}; + use crate::db::schema::protocol_configs; + use crate::errors::DatabaseError; + + fn connection() -> SqliteConnection { + crate::db::migrations::test_connection() + } + + #[test] + fn inserts_and_selects_protocol_config() { + let mut conn = connection(); + let config = test_protocol_config(); + let commitment = config.to_commitment(); + + insert_protocol_config(&mut conn, &config).unwrap(); + + assert_eq!(select_protocol_config(&mut conn, commitment).unwrap(), Some(config)); + } + + #[test] + fn returns_none_for_unknown_commitment() { + let mut conn = connection(); + + assert_eq!(select_protocol_config(&mut conn, Word::empty()).unwrap(), None); + } + + #[test] + fn selects_multiple_protocol_configs_by_commitment() { + use miden_protocol::asset::AssetId; + use miden_protocol::testing::account_id::{ + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1, + }; + + let mut conn = connection(); + let first = ProtocolConfig::current(AssetId::new_fungible( + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap(), + )) + .unwrap(); + let second = ProtocolConfig::current(AssetId::new_fungible( + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1.try_into().unwrap(), + )) + .unwrap(); + + insert_protocol_config(&mut conn, &first).unwrap(); + insert_protocol_config(&mut conn, &second).unwrap(); + + assert_eq!(select_protocol_config(&mut conn, first.to_commitment()).unwrap(), Some(first)); + assert_eq!( + select_protocol_config(&mut conn, second.to_commitment()).unwrap(), + Some(second) + ); + } + + #[test] + fn rejects_a_row_stored_under_the_wrong_commitment() { + let mut conn = connection(); + let config = test_protocol_config(); + let expected = Word::empty(); + let calculated = config.to_commitment(); + diesel::insert_into(protocol_configs::table) + .values(( + protocol_configs::commitment.eq(expected.to_bytes()), + protocol_configs::protocol_config.eq(config.to_bytes()), + )) + .execute(&mut conn) + .unwrap(); + + assert!(matches!( + select_protocol_config(&mut conn, expected), + Err(DatabaseError::ProtocolConfigCommitmentMismatch { + expected: actual_expected, + calculated: actual_calculated, + }) if actual_expected == expected && actual_calculated == calculated + )); + } + + #[test] + fn rejects_invalid_serialized_protocol_config() { + let mut conn = connection(); + let commitment = Word::empty(); + diesel::insert_into(protocol_configs::table) + .values(( + protocol_configs::commitment.eq(commitment.to_bytes()), + protocol_configs::protocol_config.eq(vec![0xff]), + )) + .execute(&mut conn) + .unwrap(); + + assert!(matches!( + select_protocol_config(&mut conn, commitment), + Err(DatabaseError::DeserializationError(_)) + )); + } + + #[test] + fn rejects_trailing_serialized_bytes() { + let mut conn = connection(); + let config = test_protocol_config(); + let commitment = config.to_commitment(); + let mut bytes = config.to_bytes(); + bytes.push(0xff); + diesel::insert_into(protocol_configs::table) + .values(( + protocol_configs::commitment.eq(commitment.to_bytes()), + protocol_configs::protocol_config.eq(bytes), + )) + .execute(&mut conn) + .unwrap(); + + assert!(matches!( + select_protocol_config(&mut conn, commitment), + Err(DatabaseError::DataCorrupted(_)) + )); + } +} diff --git a/crates/store/src/db/schema.rs b/crates/store/src/db/schema.rs index ebeeffaede..9693f5e281 100644 --- a/crates/store/src/db/schema.rs +++ b/crates/store/src/db/schema.rs @@ -89,6 +89,13 @@ diesel::table! { } } +diesel::table! { + protocol_configs (commitment) { + commitment -> Binary, + protocol_config -> Binary, + } +} + diesel::table! { prune_progress (id) { id -> Integer, @@ -118,6 +125,7 @@ diesel::allow_tables_to_appear_in_same_query!( note_scripts, notes, nullifiers, + protocol_configs, prune_progress, transactions, ); diff --git a/crates/store/src/db/tests.rs b/crates/store/src/db/tests.rs index 88783e4b9d..370815e31d 100644 --- a/crates/store/src/db/tests.rs +++ b/crates/store/src/db/tests.rs @@ -1,7 +1,7 @@ use std::sync::{Arc, LazyLock, Mutex}; use assert_matches::assert_matches; -use diesel::{Connection, ExpressionMethods, RunQueryDsl, SqliteConnection}; +use diesel::{Connection, ExpressionMethods, QueryDsl, RunQueryDsl, SqliteConnection}; use miden_node_proto::domain::account::{AccountSummary, StorageMapEntries}; use miden_node_utils::fee::{test_fee_params, test_protocol_config}; use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment}; @@ -101,6 +101,59 @@ fn create_db() -> SqliteConnection { crate::db::migrations::test_connection() } +fn empty_genesis_block() -> crate::genesis::GenesisBlock { + use crate::genesis::GenesisState; + + let signer = random_secret_key(); + GenesisState::new( + Vec::new(), + test_fee_params(), + 1, + 0, + ValidatorConfig::new(vec![signer.public_key()], 1).unwrap(), + test_protocol_config(), + ) + .into_block() + .unwrap() +} + +#[tokio::test] +async fn bootstrap_stores_protocol_config_across_reopen() { + let temp_dir = tempdir().unwrap(); + let db_path = temp_dir.path().join("store.sqlite"); + let genesis = empty_genesis_block(); + let commitment = genesis.protocol_config().to_commitment(); + let expected = genesis.protocol_config().clone(); + + super::Db::bootstrap(db_path.clone(), genesis).unwrap(); + let db = super::Db::load(db_path).await.unwrap(); + + assert_eq!( + db.select_protocol_config_by_commitment(commitment).await.unwrap(), + Some(expected) + ); +} + +#[test] +fn bootstrap_rolls_back_protocol_config_when_genesis_insert_fails() { + let temp_dir = tempdir().unwrap(); + let db_path = temp_dir.path().join("store.sqlite"); + crate::db::migrations::bootstrap_database(&db_path).unwrap(); + let mut conn = SqliteConnection::establish(db_path.to_str().unwrap()).unwrap(); + let first = empty_genesis_block(); + let commitment = first.protocol_config().to_commitment(); + super::insert_genesis(&mut conn, first).unwrap(); + diesel::delete( + crate::db::schema::protocol_configs::table + .filter(crate::db::schema::protocol_configs::commitment.eq(commitment.to_bytes())), + ) + .execute(&mut conn) + .unwrap(); + + assert!(super::insert_genesis(&mut conn, empty_genesis_block()).is_err()); + assert_eq!(queries::select_protocol_config(&mut conn, commitment).unwrap(), None); +} + fn block_account_update( account_id: AccountId, final_state_commitment: Word, diff --git a/crates/store/src/errors.rs b/crates/store/src/errors.rs index ef36cea1a2..fd7dfe2e91 100644 --- a/crates/store/src/errors.rs +++ b/crates/store/src/errors.rs @@ -88,6 +88,10 @@ pub enum DatabaseError { // --------------------------------------------------------------------------------------------- #[error("account commitment mismatch (expected {expected}, but calculated is {calculated})")] AccountCommitmentsMismatch { expected: Word, calculated: Word }, + #[error( + "protocol config commitment mismatch (expected {expected}, but calculated is {calculated})" + )] + ProtocolConfigCommitmentMismatch { expected: Word, calculated: Word }, #[error("account {0} not found")] AccountNotFoundInDb(AccountId), #[error("accounts {0:?} not found")] @@ -183,6 +187,10 @@ pub enum StateInitializationError { AccountToDeltaConversionFailed(String), #[error("genesis block missing. The database should be bootstrapped first.")] GenesisBlockMissing, + #[error( + "genesis protocol config {commitment} is missing. Rebootstrap the database from genesis." + )] + GenesisProtocolConfigMissing { commitment: Word }, } // ENDPOINT ERRORS diff --git a/crates/store/src/genesis/mod.rs b/crates/store/src/genesis/mod.rs index 6de7a608d6..3ba945e97b 100644 --- a/crates/store/src/genesis/mod.rs +++ b/crates/store/src/genesis/mod.rs @@ -20,6 +20,8 @@ use miden_protocol::transaction::OrderedTransactionHeaders; pub mod config; +pub use miden_node_utils::genesis::GenesisBlock; + // GENESIS STATE // ================================================================================================ @@ -33,44 +35,6 @@ pub struct GenesisState { pub protocol_config: ProtocolConfig, } -/// A type-safety wrapper ensuring that genesis block data can only be created from [`GenesisState`] -/// or validated from a [`SignedBlock`] via [`GenesisBlock::try_from`]. -#[derive(Debug)] -pub struct GenesisBlock(SignedBlock); - -impl GenesisBlock { - pub fn inner(&self) -> &SignedBlock { - &self.0 - } - - pub fn into_inner(self) -> SignedBlock { - self.0 - } -} - -impl TryFrom for GenesisBlock { - type Error = anyhow::Error; - - fn try_from(block: SignedBlock) -> anyhow::Result { - anyhow::ensure!( - block.header().block_num() == BlockNumber::GENESIS, - "expected genesis block number (0), got {}", - block.header().block_num(), - ); - - // The genesis block has no parent and is not signed: it acts as the chain's trust root and - // must be obtained from a trusted source. Its header commits to the validator set, which is - // required to sign every block after genesis. - anyhow::ensure!( - block.signatures().is_empty(), - "genesis block must not carry signatures, got {}", - block.signatures().len(), - ); - - Ok(Self(block)) - } -} - impl GenesisState { pub fn new( accounts: Vec, @@ -160,6 +124,6 @@ impl GenesisState { let signatures = BlockSignatures::new(Vec::new()) .map_err(|err| anyhow::anyhow!("failed to build empty genesis signatures: {err}"))?; - Ok(GenesisBlock(SignedBlock::new(header, body, signatures)?)) + GenesisBlock::new(SignedBlock::new(header, body, signatures)?, self.protocol_config) } } diff --git a/crates/store/src/state/lifecycle.rs b/crates/store/src/state/lifecycle.rs index e40d463885..3c93a174ba 100644 --- a/crates/store/src/state/lifecycle.rs +++ b/crates/store/src/state/lifecycle.rs @@ -161,6 +161,21 @@ impl State { .map_err(StateInitializationError::DatabaseLoadError)?, ); + let genesis_header = db + .select_genesis_block_header() + .await? + .ok_or(StateInitializationError::GenesisBlockMissing)?; + let genesis_protocol_config_commitment = genesis_header.protocol_config_commitment(); + if db + .select_protocol_config_by_commitment(genesis_protocol_config_commitment) + .await? + .is_none() + { + return Err(StateInitializationError::GenesisProtocolConfigMissing { + commitment: genesis_protocol_config_commitment, + }); + } + // The chain tip drives forest loading and the account tree history below; `load_mmr`'s // consistency check also pins the chain MMR to this header. let latest_block_num = db @@ -346,3 +361,98 @@ impl State { (state, block_writer, proof_writer) } } + +#[cfg(test)] +mod tests { + use diesel::{Connection, ExpressionMethods, QueryDsl, RunQueryDsl, SqliteConnection}; + use miden_node_utils::clap::StorageOptions; + use miden_node_utils::fee::{test_fee_params, test_protocol_config}; + use miden_protocol::block::ValidatorConfig; + use miden_protocol::testing::random_secret_key::random_secret_key; + use miden_protocol::utils::serde::Serializable; + + use super::State; + use crate::DataDirectory; + use crate::db::schema::protocol_configs; + use crate::errors::{DatabaseError, StateInitializationError}; + use crate::genesis::GenesisState; + + fn bootstrap_store(path: &std::path::Path) -> miden_protocol::Word { + let signer = random_secret_key(); + let genesis = GenesisState::new( + Vec::new(), + test_fee_params(), + 1, + 0, + ValidatorConfig::new(vec![signer.public_key()], 1).unwrap(), + test_protocol_config(), + ) + .into_block() + .unwrap(); + let commitment = genesis.protocol_config().to_commitment(); + State::bootstrap(genesis, path).unwrap(); + commitment + } + + fn database_connection(path: &std::path::Path) -> SqliteConnection { + let database_path = DataDirectory::load(path.to_path_buf()).unwrap().database_path(); + SqliteConnection::establish(database_path.to_str().unwrap()).unwrap() + } + + #[tokio::test] + async fn load_rejects_missing_genesis_protocol_config() { + let temp_dir = tempfile::tempdir().unwrap(); + let commitment = bootstrap_store(temp_dir.path()); + let mut conn = database_connection(temp_dir.path()); + diesel::delete( + protocol_configs::table.filter(protocol_configs::commitment.eq(commitment.to_bytes())), + ) + .execute(&mut conn) + .unwrap(); + + let error = State::load(temp_dir.path(), StorageOptions::default()) + .await + .err() + .expect("state load should fail"); + assert!(matches!( + error, + StateInitializationError::GenesisProtocolConfigMissing { commitment: actual } + if actual == commitment + )); + } + + #[tokio::test] + async fn load_rejects_corrupt_genesis_protocol_config() { + let temp_dir = tempfile::tempdir().unwrap(); + let commitment = bootstrap_store(temp_dir.path()); + let mut conn = database_connection(temp_dir.path()); + let mut bytes = test_protocol_config().to_bytes(); + bytes.push(0xff); + diesel::update( + protocol_configs::table.filter(protocol_configs::commitment.eq(commitment.to_bytes())), + ) + .set(protocol_configs::protocol_config.eq(bytes)) + .execute(&mut conn) + .unwrap(); + + let error = State::load(temp_dir.path(), StorageOptions::default()) + .await + .err() + .expect("state load should fail"); + assert!(matches!( + error, + StateInitializationError::DatabaseError(DatabaseError::DataCorrupted(_)) + )); + } + + #[tokio::test] + async fn state_view_returns_genesis_protocol_config() { + let temp_dir = tempfile::tempdir().unwrap(); + let commitment = bootstrap_store(temp_dir.path()); + + let loaded = State::load(temp_dir.path(), StorageOptions::default()).await.unwrap(); + let protocol_config = loaded.state.view().get_protocol_config(commitment).await.unwrap(); + + assert_eq!(protocol_config, Some(test_protocol_config())); + } +} diff --git a/crates/store/src/state/view/mod.rs b/crates/store/src/state/view/mod.rs index d94c7c3a92..542ea9e15f 100644 --- a/crates/store/src/state/view/mod.rs +++ b/crates/store/src/state/view/mod.rs @@ -5,7 +5,8 @@ //! [`scoped`] proof types). This makes it impossible to implement a read whose tree and database //! halves observe different chain tips — mid-apply, the database may already contain rows for a //! block the snapshot cannot prove yet. The only deliberately unscoped reads are the -//! content-addressed note lookups and the network-account classification. +//! content-addressed note and protocol-configuration lookups and the network-account +//! classification. //! //! The submodules hold the read endpoints, all `impl StateView`; the snapshot internals //! ([`StateSnapshot`]) are only visible within this module tree, so no other part of the store @@ -37,6 +38,7 @@ mod account; mod block; mod inclusion_proofs; mod note; +mod protocol_config; mod state_witnesses; pub use state_witnesses::StateWitnesses; mod sync; @@ -54,8 +56,8 @@ pub use transaction_inputs::TransactionInputs; /// trees), so it must not be stored in long-lived structs; leaked or slow readers are reported by /// the store's snapshot-lifetime warnings. /// -/// Reads that are technically not block-scoped (e.g. content-addressed note scripts) also live -/// here so that every read path flows through a single, consistently-scoped type. +/// Reads that are technically not block-scoped (for example, immutable content-addressed data) +/// also live here so that every read path flows through one type. pub struct StateView { snapshot: Arc, db: Arc, diff --git a/crates/store/src/state/view/protocol_config.rs b/crates/store/src/state/view/protocol_config.rs new file mode 100644 index 0000000000..6d7f89eda0 --- /dev/null +++ b/crates/store/src/state/view/protocol_config.rs @@ -0,0 +1,15 @@ +use miden_protocol::Word; +use miden_protocol::protocol_config::ProtocolConfig; + +use super::StateView; +use crate::errors::DatabaseError; + +impl StateView { + /// Returns the protocol configuration with the specified commitment. + pub async fn get_protocol_config( + &self, + commitment: Word, + ) -> Result, DatabaseError> { + self.db.select_protocol_config_by_commitment(commitment).await + } +} diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index 01876e4a46..d392e6a156 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -49,3 +49,7 @@ url = { workspace = true } # RocksDbConfig is needed due to orphan rules miden-crypto = { optional = true, workspace = true } + +[dev-dependencies] +miden-protocol = { features = ["testing"], workspace = true } +tempfile = { workspace = true } diff --git a/crates/utils/src/genesis.rs b/crates/utils/src/genesis.rs index b168f90b58..b376a9d235 100644 --- a/crates/utils/src/genesis.rs +++ b/crates/utils/src/genesis.rs @@ -2,8 +2,82 @@ use std::fmt; use std::path::Path; use anyhow::Context; -use miden_protocol::block::SignedBlock; -use miden_protocol::utils::serde::Deserializable; +use miden_protocol::block::{BlockNumber, SignedBlock}; +use miden_protocol::protocol_config::ProtocolConfig; +use miden_protocol::utils::serde::{ + ByteReader, + ByteWriter, + Deserializable, + DeserializationError, + Serializable, + SliceReader, +}; + +/// A validated genesis block and its protocol configuration. +/// +/// The block is the chain's trust root. Obtain it from a trusted source. +#[derive(Debug)] +pub struct GenesisBlock { + block: SignedBlock, + protocol_config: ProtocolConfig, +} + +impl GenesisBlock { + /// Validates the genesis block and its protocol configuration. + pub fn new(block: SignedBlock, protocol_config: ProtocolConfig) -> anyhow::Result { + anyhow::ensure!( + block.header().block_num() == BlockNumber::GENESIS, + "expected genesis block number (0), got {}", + block.header().block_num(), + ); + anyhow::ensure!( + block.signatures().is_empty(), + "genesis block must not carry signatures, got {}", + block.signatures().len(), + ); + block.validate(None).context("genesis block validation failed")?; + let expected = block.header().protocol_config_commitment(); + let actual = protocol_config.to_commitment(); + anyhow::ensure!( + actual == expected, + "genesis protocol configuration commitment mismatch: expected {expected}, got {actual}", + ); + Ok(Self { block, protocol_config }) + } + + pub fn inner(&self) -> &SignedBlock { + &self.block + } + + /// Returns the block and discards the protocol configuration. + pub fn into_inner(self) -> SignedBlock { + self.block + } + + pub fn protocol_config(&self) -> &ProtocolConfig { + &self.protocol_config + } + + pub fn into_parts(self) -> (SignedBlock, ProtocolConfig) { + (self.block, self.protocol_config) + } +} + +impl Serializable for GenesisBlock { + fn write_into(&self, target: &mut W) { + self.block.write_into(target); + self.protocol_config.write_into(target); + } +} + +impl Deserializable for GenesisBlock { + fn read_from(source: &mut R) -> Result { + let block = source.read()?; + let protocol_config = source.read()?; + Self::new(block, protocol_config) + .map_err(|err| DeserializationError::InvalidValue(err.to_string())) + } +} /// Official Miden networks with a hosted genesis block. #[derive(clap::ValueEnum, Clone, Copy, Debug, Eq, PartialEq)] @@ -31,14 +105,14 @@ impl fmt::Display for OfficialNetwork { } } -/// Reads a trusted genesis block from disk. -pub fn read_genesis_block(path: &Path) -> anyhow::Result { +/// Reads a trusted genesis block and its protocol configuration from disk. +pub fn read_genesis_block(path: &Path) -> anyhow::Result { let bytes = fs_err::read(path).context("failed to read genesis block file")?; deserialize_genesis_block(&bytes) } -/// Downloads a trusted genesis block for an official Miden network. -pub async fn fetch_genesis_block(network: OfficialNetwork) -> anyhow::Result { +/// Downloads a trusted genesis block and its protocol configuration for an official Miden network. +pub async fn fetch_genesis_block(network: OfficialNetwork) -> anyhow::Result { let url = network.genesis_block_url(); let response = reqwest::get(url.as_str()) .await @@ -53,6 +127,14 @@ pub async fn fetch_genesis_block(network: OfficialNetwork) -> anyhow::Result anyhow::Result { - SignedBlock::read_from_bytes(bytes).context("failed to deserialize genesis block; the genesis may have been produced by an incompatible node version") +fn deserialize_genesis_block(bytes: &[u8]) -> anyhow::Result { + let mut reader = SliceReader::new(bytes); + let genesis = GenesisBlock::read_from(&mut reader).context( + "failed to deserialize genesis block and protocol configuration; the genesis may have been produced by an incompatible node version; regenerate genesis.dat", + )?; + anyhow::ensure!(!reader.has_more_bytes(), "unexpected trailing bytes in genesis file"); + Ok(genesis) } + +#[cfg(test)] +mod tests; diff --git a/crates/utils/src/genesis/tests.rs b/crates/utils/src/genesis/tests.rs new file mode 100644 index 0000000000..5ea0578cb9 --- /dev/null +++ b/crates/utils/src/genesis/tests.rs @@ -0,0 +1,126 @@ +use miden_protocol::Word; +use miden_protocol::block::{ + BlockBody, + BlockHeader, + BlockSignatures, + FeeParameters, + ValidatorConfig, +}; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; +use miden_protocol::transaction::OrderedTransactionHeaders; + +use super::*; + +fn genesis(block_num: BlockNumber, config: &ProtocolConfig) -> SignedBlock { + let body = BlockBody::new_unchecked( + Vec::new(), + Vec::new(), + Vec::new(), + OrderedTransactionHeaders::new_unchecked(Vec::new()), + ); + let key = SigningKey::read_from_bytes(&[7; 32]).unwrap(); + let header = BlockHeader::new( + Word::empty(), + block_num, + Word::empty(), + Word::empty(), + Word::empty(), + body.compute_block_note_tree().root(), + body.transactions().commitment(), + ValidatorConfig::new(vec![key.public_key()], 1).unwrap(), + FeeParameters::new(0), + config.to_commitment(), + None, + 0, + ); + SignedBlock::new(header, body, BlockSignatures::new(Vec::new()).unwrap()).unwrap() +} + +#[test] +fn genesis_round_trip_preserves_block_and_config() { + let config = ProtocolConfig::mock(); + let block = genesis(BlockNumber::GENESIS, &config); + let block_bytes = block.to_bytes(); + let genesis = GenesisBlock::new(block, config.clone()).unwrap(); + let bytes = genesis.to_bytes(); + assert_eq!(bytes, [block_bytes.clone(), config.to_bytes()].concat()); + + // File downloads use the same decoder as local files. + let decoded = deserialize_genesis_block(&bytes).unwrap(); + assert_eq!(decoded.inner().to_bytes(), block_bytes); + assert_eq!(decoded.protocol_config(), &config); + + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("genesis.dat"); + fs_err::write(&path, &bytes).unwrap(); + let from_file = read_genesis_block(&path).unwrap(); + let (block, stored_config) = from_file.into_parts(); + assert_eq!(block.to_bytes(), block_bytes); + assert_eq!(stored_config, config); +} + +#[test] +fn genesis_rejects_mismatched_config() { + let config = ProtocolConfig::mock(); + let block = genesis(BlockNumber::GENESIS, &config); + let other_config = ProtocolConfig::current(miden_protocol::asset::AssetId::new_fungible( + miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1 + .try_into() + .unwrap(), + )) + .unwrap(); + let bytes = [block.to_bytes(), other_config.to_bytes()].concat(); + let error = GenesisBlock::new(block, other_config).unwrap_err(); + assert!(error.to_string().contains("commitment mismatch")); + assert!(deserialize_genesis_block(&bytes).is_err()); +} + +#[test] +fn genesis_rejects_non_genesis_and_signed_blocks() { + let config = ProtocolConfig::mock(); + let block = genesis(BlockNumber::from(1), &config); + assert!( + GenesisBlock::new(block, config.clone()) + .unwrap_err() + .to_string() + .contains("number") + ); + + let (header, body, _) = genesis(BlockNumber::GENESIS, &config).into_parts(); + let key = SigningKey::read_from_bytes(&[7; 32]).unwrap(); + let signatures = BlockSignatures::new(vec![key.sign(header.commitment())]).unwrap(); + let block = SignedBlock::new(header, body, signatures).unwrap(); + assert!( + GenesisBlock::new(block, config) + .unwrap_err() + .to_string() + .contains("must not carry signatures") + ); +} + +#[test] +fn genesis_rejects_inconsistent_body() { + let config = ProtocolConfig::mock(); + let header = BlockHeader::mock(BlockNumber::GENESIS, None, None, &[]); + let (_, body, signatures) = genesis(BlockNumber::GENESIS, &config).into_parts(); + let block = SignedBlock::new_unchecked(header, body, signatures); + assert!( + GenesisBlock::new(block, config) + .unwrap_err() + .to_string() + .contains("validation failed") + ); +} + +#[test] +fn genesis_rejects_incomplete_malformed_and_trailing_data() { + let config = ProtocolConfig::mock(); + let block = genesis(BlockNumber::GENESIS, &config); + assert!(deserialize_genesis_block(&block.to_bytes()).is_err()); + assert!(deserialize_genesis_block(&[]).is_err()); + assert!(deserialize_genesis_block(&[0xff; 32]).is_err()); + let mut bytes = GenesisBlock::new(block, config).unwrap().to_bytes(); + assert!(deserialize_genesis_block(&bytes[..bytes.len() - 1]).is_err()); + bytes.push(0); + assert!(deserialize_genesis_block(&bytes).unwrap_err().to_string().contains("trailing")); +} diff --git a/docs/external/src/full-node/bootstrap.md b/docs/external/src/full-node/bootstrap.md index da7420cd01..e75a15bff4 100644 --- a/docs/external/src/full-node/bootstrap.md +++ b/docs/external/src/full-node/bootstrap.md @@ -12,6 +12,11 @@ import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem"; A full node must perform one-time initialization by bootstrapping its local chain state to the target network's genesis block. +The genesis source must contain both the block and its full protocol configuration in the same `genesis.dat` artifact. +Bootstrap verifies the configuration commitment and stores the configuration with the genesis state. Obtain the complete +artifact from the network operator. Block-only files and databases without the configuration require a fresh bootstrap +into an empty data directory. + diff --git a/docs/external/src/network-operator/bootstrap-and-genesis.md b/docs/external/src/network-operator/bootstrap-and-genesis.md index 1a8b12417c..805333538e 100644 --- a/docs/external/src/network-operator/bootstrap-and-genesis.md +++ b/docs/external/src/network-operator/bootstrap-and-genesis.md @@ -15,6 +15,13 @@ block, it must always be obtained from a trusted source. One of the network's op from the genesis configuration. On official networks, the validators are operated by separate entities from the network operator. +The `genesis.dat` artifact contains the genesis block followed by its full protocol configuration. Bootstrap commands +verify that the configuration matches the commitment in the genesis header. The node store saves the configuration for +lookup by commitment. Distribute this complete artifact through the file or hosted URL procedure below. + +Block-only genesis files are not supported. Generate the complete artifact and bootstrap into empty data directories. +Existing databases require a fresh bootstrap; database migration does not recover a missing protocol configuration. + The genesis block is subsequently made available for official networks at ```text From bfef0a8d483b52a3ee4125b0bd3f8d40491d4557 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Tue, 8 Sep 2026 10:27:02 +0200 Subject: [PATCH 2/4] fixup! feat: store and verify genesis protocol configuration --- docs/external/src/full-node/bootstrap.md | 5 ----- .../external/src/network-operator/bootstrap-and-genesis.md | 7 ------- 2 files changed, 12 deletions(-) diff --git a/docs/external/src/full-node/bootstrap.md b/docs/external/src/full-node/bootstrap.md index e75a15bff4..da7420cd01 100644 --- a/docs/external/src/full-node/bootstrap.md +++ b/docs/external/src/full-node/bootstrap.md @@ -12,11 +12,6 @@ import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem"; A full node must perform one-time initialization by bootstrapping its local chain state to the target network's genesis block. -The genesis source must contain both the block and its full protocol configuration in the same `genesis.dat` artifact. -Bootstrap verifies the configuration commitment and stores the configuration with the genesis state. Obtain the complete -artifact from the network operator. Block-only files and databases without the configuration require a fresh bootstrap -into an empty data directory. - diff --git a/docs/external/src/network-operator/bootstrap-and-genesis.md b/docs/external/src/network-operator/bootstrap-and-genesis.md index 805333538e..1a8b12417c 100644 --- a/docs/external/src/network-operator/bootstrap-and-genesis.md +++ b/docs/external/src/network-operator/bootstrap-and-genesis.md @@ -15,13 +15,6 @@ block, it must always be obtained from a trusted source. One of the network's op from the genesis configuration. On official networks, the validators are operated by separate entities from the network operator. -The `genesis.dat` artifact contains the genesis block followed by its full protocol configuration. Bootstrap commands -verify that the configuration matches the commitment in the genesis header. The node store saves the configuration for -lookup by commitment. Distribute this complete artifact through the file or hosted URL procedure below. - -Block-only genesis files are not supported. Generate the complete artifact and bootstrap into empty data directories. -Existing databases require a fresh bootstrap; database migration does not recover a missing protocol configuration. - The genesis block is subsequently made available for official networks at ```text From 6ef8fea4dd200226762534938deb9752f30cea14 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Thu, 10 Sep 2026 11:46:26 +0200 Subject: [PATCH 3/4] fix: genesis block updates --- bin/ntx-builder/src/lib.rs | 1 - bin/validator/src/commands/bootstrap.rs | 1 - crates/rpc/src/tests.rs | 5 ++++- crates/store/src/db/tests.rs | 1 - crates/store/src/state/lifecycle.rs | 1 - 5 files changed, 4 insertions(+), 5 deletions(-) diff --git a/bin/ntx-builder/src/lib.rs b/bin/ntx-builder/src/lib.rs index 9e8344db70..4d001f64a7 100644 --- a/bin/ntx-builder/src/lib.rs +++ b/bin/ntx-builder/src/lib.rs @@ -73,7 +73,6 @@ mod bootstrap_tests { let genesis = GenesisState::new( Vec::new(), test_fee_params(), - 1, 0, crate::test_utils::mock_genesis_block().header().validator_config().clone(), test_protocol_config(), diff --git a/bin/validator/src/commands/bootstrap.rs b/bin/validator/src/commands/bootstrap.rs index 610ff5226e..d725bbf8c1 100644 --- a/bin/validator/src/commands/bootstrap.rs +++ b/bin/validator/src/commands/bootstrap.rs @@ -135,7 +135,6 @@ mod tests { let genesis = GenesisState::new( Vec::new(), test_fee_params(), - 1, 0, ValidatorConfig::new(vec![key], 1).unwrap(), test_protocol_config(), diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index e0d8829083..2355b85d87 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -698,7 +698,10 @@ async fn rpc_server_rejects_invalid_deferred_transaction_proofs() { async fn rpc_server_forwards_valid_deferred_proofs_and_rejects_missing_witnesses() { let fixture = deferred_transaction_fixture().await; let data_directory = new_tempdir(); - State::bootstrap(fixture.genesis.clone().try_into().unwrap(), &data_directory).unwrap(); + let genesis = + GenesisBlock::new(fixture.genesis.clone(), fixture.inputs.protocol_config().clone()) + .unwrap(); + State::bootstrap(genesis, &data_directory).unwrap(); let (state, ..) = State::for_tests(&data_directory).await; let submissions = Arc::new(std::sync::Mutex::new(Vec::new())); let (validator, _, _, _guard) = diff --git a/crates/store/src/db/tests.rs b/crates/store/src/db/tests.rs index 370815e31d..ee9647bbbe 100644 --- a/crates/store/src/db/tests.rs +++ b/crates/store/src/db/tests.rs @@ -108,7 +108,6 @@ fn empty_genesis_block() -> crate::genesis::GenesisBlock { GenesisState::new( Vec::new(), test_fee_params(), - 1, 0, ValidatorConfig::new(vec![signer.public_key()], 1).unwrap(), test_protocol_config(), diff --git a/crates/store/src/state/lifecycle.rs b/crates/store/src/state/lifecycle.rs index 3c93a174ba..737d3541f5 100644 --- a/crates/store/src/state/lifecycle.rs +++ b/crates/store/src/state/lifecycle.rs @@ -382,7 +382,6 @@ mod tests { let genesis = GenesisState::new( Vec::new(), test_fee_params(), - 1, 0, ValidatorConfig::new(vec![signer.public_key()], 1).unwrap(), test_protocol_config(), From b9057243b29b9144189be633bc2b73ac042b8f82 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Thu, 10 Sep 2026 11:54:14 +0200 Subject: [PATCH 4/4] fix(clippy): use fs-err --- Cargo.lock | 1 + bin/ntx-builder/Cargo.toml | 1 + bin/ntx-builder/src/lib.rs | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 1e9fdb592e..e1afcc096a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4431,6 +4431,7 @@ dependencies = [ "backon", "build-rs", "clap", + "fs-err", "futures", "humantime", "miden-node-db", diff --git a/bin/ntx-builder/Cargo.toml b/bin/ntx-builder/Cargo.toml index 79d576963e..8a2b8c1199 100644 --- a/bin/ntx-builder/Cargo.toml +++ b/bin/ntx-builder/Cargo.toml @@ -45,6 +45,7 @@ build-rs = { workspace = true } miden-node-db = { workspace = true } [dev-dependencies] +fs-err = { workspace = true } miden-node-utils = { features = ["testing"], workspace = true } miden-protocol = { default-features = true, features = ["testing"], workspace = true } miden-standards = { features = ["testing"], workspace = true } diff --git a/bin/ntx-builder/src/lib.rs b/bin/ntx-builder/src/lib.rs index 4d001f64a7..a0df593ce4 100644 --- a/bin/ntx-builder/src/lib.rs +++ b/bin/ntx-builder/src/lib.rs @@ -81,7 +81,7 @@ mod bootstrap_tests { .unwrap(); let root = tempfile::tempdir().unwrap(); let path = root.path().join("genesis.dat"); - std::fs::write(&path, genesis.to_bytes()).unwrap(); + fs_err::write(&path, genesis.to_bytes()).unwrap(); let decoded = read_genesis_block(&path).unwrap(); assert_eq!(decoded.protocol_config(), genesis.protocol_config()); let database_path = root.path().join("ntx.sqlite3");