From e8c6e6fc7501646524ecc2b08e256aeb23ae644e Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Tue, 8 Sep 2026 17:53:02 +0200 Subject: [PATCH 01/28] feat(rpc): expose protocol config over the gRPC APIs --- Cargo.lock | 1 + bin/benchmark/README.md | 6 +- bin/benchmark/src/create_proofs.rs | 49 +-- bin/benchmark/src/inclusion.rs | 1 + bin/benchmark/src/main.rs | 17 +- bin/benchmark/src/rpc_state.rs | 204 ++++++---- bin/large-account-benchmark/Cargo.toml | 3 + bin/large-account-benchmark/README.md | 3 +- bin/large-account-benchmark/src/increment.rs | 5 +- bin/large-account-benchmark/src/rpc.rs | 79 +++- bin/network-monitor/README.md | 9 +- bin/network-monitor/src/commands/start.rs | 39 -- bin/network-monitor/src/config.rs | 26 -- bin/network-monitor/src/counter.rs | 1 - bin/network-monitor/src/deploy/mod.rs | 355 +++++++++++++----- bin/network-monitor/src/funding.rs | 5 +- bin/network-monitor/src/monitor/tasks.rs | 10 +- bin/network-monitor/src/remote_prover.rs | 67 ++-- bin/node/src/commands/recover.rs | 236 ++++++++++-- bin/ntx-builder/src/actor/candidate.rs | 38 +- bin/ntx-builder/src/actor/execute.rs | 102 +++-- bin/ntx-builder/src/actor/mod.rs | 12 +- bin/ntx-builder/src/builder.rs | 107 ++++-- bin/ntx-builder/src/chain_state.rs | 165 ++++++-- bin/ntx-builder/src/clients/mod.rs | 1 + bin/ntx-builder/src/clients/rpc.rs | 332 +++++++++++++++- bin/ntx-builder/src/lib.rs | 7 +- bin/validator/src/commands/bootstrap.rs | 11 +- bin/validator/src/db/migrations.rs | 2 +- .../src/db/migrations/001_initial.sql | 6 + bin/validator/src/db/mod.rs | 207 +++++++++- bin/validator/src/db/queries/mod.rs | 3 + .../src/db/queries/protocol_config/insert.sql | 2 + .../src/db/queries/protocol_config/mod.rs | 66 ++++ .../src/db/queries/protocol_config/select.sql | 3 + .../validator_service/block_subscription.rs | 39 +- .../server/validator_service/sign_block.rs | 122 ++++-- .../src/server/validator_service/tests.rs | 294 ++++++++++++++- compose/monitor.yml | 9 +- .../block-producer/src/block_builder/mod.rs | 23 +- crates/block-producer/src/rpc_sync.rs | 12 +- crates/block-producer/src/validator/mod.rs | 3 + crates/proto/src/domain/mod.rs | 1 + crates/proto/src/domain/proof_request.rs | 1 + crates/proto/src/domain/protocol_config.rs | 63 ++++ crates/rpc/src/server/api.rs | 13 + .../server/api/get_block_header_by_number.rs | 13 +- .../rpc/src/server/api/subscription/block.rs | 48 ++- crates/rpc/src/server/api/sync_chain_mmr.rs | 31 +- crates/rpc/src/tests.rs | 213 ++++++++++- crates/store/src/db/mod.rs | 6 + .../src/db/models/queries/protocol_configs.rs | 51 +++ crates/store/src/errors.rs | 2 + crates/store/src/state/writer/apply_block.rs | 2 +- crates/store/src/state/writer/mod.rs | 12 +- crates/store/src/state/writer/worker.rs | 28 +- docker-compose.yml | 2 - .../src/network-operator/monitoring.md | 8 +- docs/external/src/rpc/public-api.md | 16 +- docs/external/src/rpc/subscriptions.md | 4 + proto/proto/internal/validator.proto | 5 + proto/proto/rpc.proto | 15 + proto/proto/types/block_proving.proto | 5 + scripts/bench-local.sh | 4 - 64 files changed, 2611 insertions(+), 614 deletions(-) create mode 100644 bin/validator/src/db/queries/protocol_config/insert.sql create mode 100644 bin/validator/src/db/queries/protocol_config/mod.rs create mode 100644 bin/validator/src/db/queries/protocol_config/select.sql create mode 100644 crates/proto/src/domain/protocol_config.rs diff --git a/Cargo.lock b/Cargo.lock index e1afcc096a..1552db8a0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3973,6 +3973,7 @@ dependencies = [ "miden-node-proto", "miden-protocol", "miden-standards", + "miden-testing", "miden-tx", "rand 0.10.2", "rand_chacha 0.10.0", diff --git a/bin/benchmark/README.md b/bin/benchmark/README.md index 391e75022c..881e81e575 100644 --- a/bin/benchmark/README.md +++ b/bin/benchmark/README.md @@ -35,12 +35,11 @@ make install-benchmark ```sh miden-benchmark create-proofs \ --rpc-url http://127.0.0.1:57291 \ - --fee-faucet-id \ --num-transactions 100 ``` -`--fee-faucet-id` identifies the native faucet whose asset pays transaction fees. Block headers commit to the protocol -configuration but do not contain its full value. `miden-validator genesis` prints this account ID. +The benchmark obtains the active protocol configuration from RPC and verifies it against the reference block before it +generates transactions. Writes the bundle to `./benchmark-proofs/`: @@ -72,7 +71,6 @@ locally: ```sh miden-benchmark create-proofs \ --rpc-url http://127.0.0.1:57291 \ - --fee-faucet-id \ --num-transactions 100 \ --remote-prover-url http://prover.example.com:50051 ``` diff --git a/bin/benchmark/src/create_proofs.rs b/bin/benchmark/src/create_proofs.rs index 3f97f04951..d5a60fa657 100644 --- a/bin/benchmark/src/create_proofs.rs +++ b/bin/benchmark/src/create_proofs.rs @@ -60,7 +60,7 @@ use rayon::prelude::*; use url::Url; use crate::prover::BenchmarkProver; -use crate::rpc_state::{fetch_chain_tip_header, fetch_partial_blockchain}; +use crate::rpc_state::fetch_chain_tip_state; use crate::summary::print_proving_summary; use crate::{ PROOFS_DIR, @@ -69,9 +69,6 @@ use crate::{ write_to_file, }; -/// Maximum attempts to observe a stable chain tip. -const MAX_TIP_FETCH_ATTEMPTS: u32 = 10; - // CONSTANTS // ================================================================================================ @@ -176,12 +173,7 @@ impl ProofCollector { reason = "single linear orchestration of genesis fetch + mint phase + consume phase; \ splitting would just shuffle locals (faucet, data_store, authenticator) around" )] -pub(crate) async fn run( - rpc_url: Url, - num_transactions: u64, - fee_faucet_id: AccountId, - remote_prover_url: Option, -) { +pub(crate) async fn run(rpc_url: Url, num_transactions: u64, remote_prover_url: Option) { let mut rpc_client = create_genesis_aware_rpc_client(&rpc_url, Duration::from_secs(10)) .await .unwrap(); @@ -195,38 +187,11 @@ pub(crate) async fn run( .block_header .expect("RPC returned no block header"); let genesis_header: BlockHeader = genesis_header_proto.try_into().unwrap(); - let protocol_config = ProtocolConfig::current(AssetId::new_fungible(fee_faucet_id)) - .expect("fee faucet should produce a valid protocol configuration"); - assert_eq!( - protocol_config.to_commitment(), - genesis_header.protocol_config_commitment(), - "--fee-faucet-id does not match the target chain's protocol configuration", - ); - - // The tip header and chain MMR come from separate RPC calls, so retry until they refer to the - // same chain tip. - let mut tip_state = None; - for _ in 0..MAX_TIP_FETCH_ATTEMPTS { - println!("Fetching chain tip header..."); - let ref_block_header = fetch_chain_tip_header(&mut rpc_client).await; - let ref_block_num = ref_block_header.block_num(); - - println!("Fetching chain MMR up to ref block..."); - let partial_blockchain = - fetch_partial_blockchain(&mut rpc_client, ref_block_num.as_u32(), &genesis_header) - .await; - - if partial_blockchain.chain_length() == ref_block_num { - tip_state = Some((ref_block_header, partial_blockchain)); - break; - } - } - let (ref_block_header, partial_blockchain) = tip_state.unwrap_or_else(|| { - panic!( - "failed to fetch a consistent tip header and chain MMR after \ - {MAX_TIP_FETCH_ATTEMPTS} attempts", - ) - }); + println!("Fetching chain tip state..."); + let (ref_block_header, protocol_config, partial_blockchain) = + fetch_chain_tip_state(&mut rpc_client, &genesis_header) + .await + .expect("failed to fetch the chain tip transaction anchor"); let ref_block_num = ref_block_header.block_num(); println!("Creating faucet..."); diff --git a/bin/benchmark/src/inclusion.rs b/bin/benchmark/src/inclusion.rs index ba4919ee9d..fe29292b93 100644 --- a/bin/benchmark/src/inclusion.rs +++ b/bin/benchmark/src/inclusion.rs @@ -215,6 +215,7 @@ pub(crate) async fn current_block_height(mut client: RpcClient) -> u32 { .get_block_header_by_number(BlockHeaderByNumberRequest { block_num: None, include_mmr_proof: None, + include_protocol_config: None, }) .await .expect("failed to fetch latest block header") diff --git a/bin/benchmark/src/main.rs b/bin/benchmark/src/main.rs index c2d3fc78c7..0c810ee21e 100644 --- a/bin/benchmark/src/main.rs +++ b/bin/benchmark/src/main.rs @@ -18,7 +18,6 @@ use miden_node_proto::domain::encryption::{ }; use miden_node_proto::generated::rpc::BlockHeaderByNumberRequest; use miden_protocol::Word; -use miden_protocol::account::AccountId; use miden_protocol::block::{BlockHeader, BlockNumber}; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey as ValidatorPublicKey; use miden_protocol::utils::serde::{Deserializable, Serializable}; @@ -58,11 +57,6 @@ pub enum Command { /// STARK proving, so start small. #[arg(long, default_value_t = 10)] num_transactions: u64, - /// Faucet account whose fungible asset the target chain uses for fees. - /// - /// Block headers commit to the protocol configuration but do not contain its preimage. - #[arg(long, value_parser = parse_account_id)] - fee_faucet_id: AccountId, /// If set, proofs are produced by the remote prover at this URL instead of locally. /// Dispatch is rate-limited: starts at 1 req/s, bumps by 1 req/s every 3 minutes up to 10 /// req/s, and freezes at the current step if the prover returns a retryable error @@ -112,11 +106,9 @@ impl Cli { Command::CreateProofs { rpc_url, num_transactions, - fee_faucet_id, remote_prover_url, } => { - create_proofs::run(rpc_url, num_transactions, fee_faucet_id, remote_prover_url) - .await; + create_proofs::run(rpc_url, num_transactions, remote_prover_url).await; }, Command::RunBenchmark { rpc_url, @@ -138,12 +130,6 @@ impl Cli { } } -fn parse_account_id(value: &str) -> std::result::Result { - AccountId::parse(value) - .map(|(account_id, _network_id)| account_id) - .map_err(|err| err.to_string()) -} - // SHARED INFRA // ================================================================================================ @@ -247,6 +233,7 @@ pub(crate) fn get_genesis_header_request() -> BlockHeaderByNumberRequest { BlockHeaderByNumberRequest { block_num: Some(BlockNumber::GENESIS.as_u32()), include_mmr_proof: None, + include_protocol_config: None, } } diff --git a/bin/benchmark/src/rpc_state.rs b/bin/benchmark/src/rpc_state.rs index 1032577590..0d411e9eff 100644 --- a/bin/benchmark/src/rpc_state.rs +++ b/bin/benchmark/src/rpc_state.rs @@ -7,89 +7,167 @@ //! a snapshot) fail with `AdviceError::MapKeyNotFound` during proof //! generation. +use anyhow::{Context, Result}; use miden_node_proto::clients::RpcClient; -use miden_node_proto::generated::rpc::{ - BlockHeaderByNumberRequest, - FinalityLevel, - SyncChainMmrRequest, -}; +use miden_node_proto::domain::protocol_config::decode_protocol_config; +use miden_node_proto::generated::rpc::{FinalityLevel, SyncChainMmrRequest, SyncChainMmrResponse}; use miden_protocol::block::BlockHeader; use miden_protocol::crypto::merkle::mmr::{MmrDelta, MmrPeaks, PartialMmr}; +use miden_protocol::protocol_config::ProtocolConfig; use miden_protocol::transaction::PartialBlockchain; -/// Fetch the header of the latest committed block from the target node. -/// -/// `get_block_header_by_number(block_num=None)` returns the chain tip per -/// the server's documented contract. -pub(crate) async fn fetch_chain_tip_header(client: &mut RpcClient) -> BlockHeader { +/// Fetches and validates the complete transaction anchor for the committed chain tip. +pub(crate) async fn fetch_chain_tip_state( + client: &mut RpcClient, + genesis_header: &BlockHeader, +) -> Result<(BlockHeader, ProtocolConfig, PartialBlockchain)> { let response = client - .get_block_header_by_number(BlockHeaderByNumberRequest { - block_num: None, - include_mmr_proof: None, + .sync_chain_mmr(SyncChainMmrRequest { + current_client_block_height: 0, + finality_level: FinalityLevel::Committed.into(), }) .await - .expect("failed to fetch chain tip header") + .context("failed to call sync_chain_mmr")? .into_inner(); - response - .block_header - .expect("chain tip response missing block_header") - .try_into() - .expect("failed to decode chain tip block header") + decode_chain_tip_state(response, genesis_header) } -/// Build a [`PartialBlockchain`] whose chain MMR matches the tip block's -/// `chain_commitment`. -/// -/// Construction is: -/// -/// - `tip_block_num == 0` → empty MMR (chain at genesis has no prior blocks committed). No RPC -/// calls. -/// - `tip_block_num >= 1` → MMR starts empty, then the genesis block's commitment is added as leaf -/// 0 (this brings the local MMR's forest to 1, matching what the server expects as the caller's -/// pre-state for `block_from = 0`). -/// - `tip_block_num >= 2` → `sync_chain_mmr(block_from = 0, upper_bound = BlockNum(tip_block_num))` -/// is called and the returned `MmrDelta` is applied, bringing the MMR's forest from 1 up to -/// `tip_block_num`. -/// -/// After this function returns, `partial_mmr.peaks().hash_peaks()` matches -/// the tip block's `chain_commitment()`. -pub(crate) async fn fetch_partial_blockchain( - client: &mut RpcClient, - tip_block_num: u32, +fn decode_chain_tip_state( + response: SyncChainMmrResponse, genesis_header: &BlockHeader, -) -> PartialBlockchain { +) -> Result<(BlockHeader, ProtocolConfig, PartialBlockchain)> { + let tip_header: BlockHeader = response + .block_header + .context("sync_chain_mmr response missing block_header")? + .try_into() + .context("failed to decode the chain tip block header")?; + let protocol_config = decode_protocol_config(response.protocol_config, &tip_header) + .context("sync_chain_mmr response missing a valid protocol configuration")?; + let delta: MmrDelta = response + .mmr_delta + .context("sync_chain_mmr response missing mmr_delta")? + .try_into() + .context("failed to decode the chain MMR delta")?; + let mut partial_mmr = PartialMmr::from_peaks(MmrPeaks::default()); - if tip_block_num == 0 { - return PartialBlockchain::new(partial_mmr, Vec::new()) - .expect("empty PartialBlockchain construction"); + if tip_header.block_num().as_u32() != 0 { + partial_mmr + .add(genesis_header.commitment(), false) + .context("failed to add the genesis commitment to the chain MMR")?; + partial_mmr.apply(delta).context("failed to apply the chain MMR delta")?; } - // Genesis is always leaf 0; this brings forest from 0 to 1. - partial_mmr - .add(genesis_header.commitment(), false) - .expect("failed to add genesis commitment to partial MMR"); + anyhow::ensure!( + partial_mmr.peaks().hash_peaks() == tip_header.chain_commitment(), + "synced MMR peaks do not match the chain commitment of block {}", + tip_header.block_num(), + ); - if tip_block_num >= 2 { - let request = SyncChainMmrRequest { - current_client_block_height: 0, - finality_level: FinalityLevel::Committed.into(), + let blockchain = PartialBlockchain::new(partial_mmr, Vec::new()) + .context("failed to construct the partial blockchain")?; + Ok((tip_header, protocol_config, blockchain)) +} + +#[cfg(test)] +mod tests { + use miden_node_proto::generated::rpc::SyncChainMmrResponse; + use miden_protocol::Word; + use miden_protocol::account::AccountId; + use miden_protocol::asset::AssetId; + use miden_protocol::block::{BlockHeader, BlockNumber}; + use miden_protocol::crypto::merkle::mmr::{Forest, MmrDelta, MmrPeaks, PartialMmr}; + use miden_protocol::protocol_config::ProtocolConfig; + use miden_protocol::testing::account_id::{ + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1, + }; + + use super::decode_chain_tip_state; + + fn protocol_config(faucet: u128) -> ProtocolConfig { + ProtocolConfig::current(AssetId::new_fungible( + AccountId::try_from(faucet).expect("test faucet ID is valid"), + )) + .expect("test protocol configuration is valid") + } + + fn header_with_config( + template: &BlockHeader, + block_num: BlockNumber, + chain_commitment: Word, + protocol_config: &ProtocolConfig, + ) -> BlockHeader { + BlockHeader::new( + template.prev_block_commitment(), + block_num, + chain_commitment, + template.account_root(), + template.nullifier_root(), + template.note_root(), + template.tx_commitment(), + template.validator_config().clone(), + template.fee_parameters().clone(), + protocol_config.to_commitment(), + None, + template.timestamp(), + ) + } + + fn response( + header: &BlockHeader, + protocol_config: &ProtocolConfig, + delta: MmrDelta, + ) -> SyncChainMmrResponse { + SyncChainMmrResponse { + block_range: None, + mmr_delta: Some(delta.into()), + block_header: Some(header.into()), + block_signatures: Vec::new(), + protocol_config: Some(protocol_config.into()), + } + } + + #[test] + fn chain_tip_response_rejects_mismatched_protocol_config() { + let expected = protocol_config(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET); + let other = protocol_config(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1); + let template = BlockHeader::mock(0, None, None, &[]); + let header = header_with_config(&template, BlockNumber::GENESIS, Word::empty(), &expected); + let delta = MmrDelta { + forest: Forest::new(0).expect("zero is a valid forest"), + data: Vec::new(), }; - let response = client - .sync_chain_mmr(request) - .await - .expect("failed to call sync_chain_mmr") - .into_inner(); - let mmr_delta_proto = - response.mmr_delta.expect("sync_chain_mmr response missing mmr_delta"); - let mmr_delta: MmrDelta = mmr_delta_proto - .try_into() - .expect("failed to decode MmrDelta from sync_chain_mmr response"); - partial_mmr.apply(mmr_delta).expect("failed to apply chain MMR delta"); + let error = decode_chain_tip_state(response(&header, &other, delta), &header) + .expect_err("the response configuration must match its header"); + + assert!(format!("{error:#}").contains("does not match header commitment")); } - PartialBlockchain::new(partial_mmr, Vec::new()) - .expect("PartialBlockchain construction from fetched chain MMR") + #[test] + fn chain_tip_response_uses_changed_target_protocol_config() { + let genesis_config = protocol_config(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET); + let target_config = protocol_config(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1); + let template = BlockHeader::mock(0, None, None, &[]); + let genesis_header = + header_with_config(&template, BlockNumber::GENESIS, Word::empty(), &genesis_config); + let mut mmr = PartialMmr::from_peaks(MmrPeaks::default()); + mmr.add(genesis_header.commitment(), false).expect("the genesis leaf is valid"); + let target_header = + header_with_config(&template, 1_u32.into(), mmr.peaks().hash_peaks(), &target_config); + let delta = MmrDelta { + forest: Forest::new(1).expect("one is a valid forest"), + data: Vec::new(), + }; + + let (_, decoded, _) = decode_chain_tip_state( + response(&target_header, &target_config, delta), + &genesis_header, + ) + .expect("the changed target configuration matches the target header"); + + assert_eq!(decoded, target_config); + } } diff --git a/bin/large-account-benchmark/Cargo.toml b/bin/large-account-benchmark/Cargo.toml index 36a55bff0a..cfd2de1181 100644 --- a/bin/large-account-benchmark/Cargo.toml +++ b/bin/large-account-benchmark/Cargo.toml @@ -31,3 +31,6 @@ rand_chacha = { workspace = true } tokio = { features = ["macros", "rt-multi-thread", "time"], workspace = true } tonic = { workspace = true } url = { workspace = true } + +[dev-dependencies] +miden-testing = { workspace = true } diff --git a/bin/large-account-benchmark/README.md b/bin/large-account-benchmark/README.md index 1dd821163a..1d9c005c9a 100644 --- a/bin/large-account-benchmark/README.md +++ b/bin/large-account-benchmark/README.md @@ -80,7 +80,8 @@ miden-large-account-benchmark verify --accounts-dir ./seeded \ It exits zero only if the counter moved, which requires every part of the chain to be working: the seeded accounts are on chain, the node accepts a transaction against the wallet, and the ntx-builder can load an account this large and -consume the network note. Anything less exits non-zero with the reason. +consume the network note. The verifier obtains the genesis protocol configuration from RPC and checks it against the +genesis header and the seeded counter's fee asset. Anything less exits non-zero with the reason. ```text baseline: counter=0 chain_tip=42 diff --git a/bin/large-account-benchmark/src/increment.rs b/bin/large-account-benchmark/src/increment.rs index 1f3fe83c97..21af85d3e2 100644 --- a/bin/large-account-benchmark/src/increment.rs +++ b/bin/large-account-benchmark/src/increment.rs @@ -110,10 +110,9 @@ impl Driver { .context("counter account is missing its fee asset ID")? .try_into() .context("counter account carries an invalid fee asset ID")?; - let protocol_config = ProtocolConfig::current(fee_asset_id) - .context("failed to construct the current protocol configuration")?; + let protocol_config = client.genesis_protocol_config().clone(); anyhow::ensure!( - protocol_config.to_commitment() == genesis_header.protocol_config_commitment(), + protocol_config.fee_asset_id() == fee_asset_id, "the counter's fee asset does not match the chain's protocol configuration", ); diff --git a/bin/large-account-benchmark/src/rpc.rs b/bin/large-account-benchmark/src/rpc.rs index 20fc2a3599..80c0f876bb 100644 --- a/bin/large-account-benchmark/src/rpc.rs +++ b/bin/large-account-benchmark/src/rpc.rs @@ -11,15 +11,21 @@ use miden_node_proto::domain::encryption::{ TrustedTransactionEncryptionState, verify_transaction_encryption_key, }; +use miden_node_proto::domain::protocol_config::decode_protocol_config; use miden_node_proto::generated::account::AccountId as ProtoAccountId; use miden_node_proto::generated::rpc::account_request::AccountDetailRequest; -use miden_node_proto::generated::rpc::{AccountRequest, BlockHeaderByNumberRequest}; +use miden_node_proto::generated::rpc::{ + AccountRequest, + BlockHeaderByNumberRequest, + BlockHeaderByNumberResponse, +}; use miden_node_proto::generated::submission::ProvenTransactionSubmission as ProtoProvenTransaction; use miden_protocol::Word; use miden_protocol::account::AccountId; use miden_protocol::block::account_tree::AccountWitness; use miden_protocol::block::{BlockHeader, BlockNumber}; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey as ValidatorPublicKey; +use miden_protocol::protocol_config::ProtocolConfig; use miden_protocol::transaction::ProvenTransaction; use miden_protocol::utils::serde::Deserializable; use tokio::sync::Mutex; @@ -29,6 +35,7 @@ use url::Url; pub struct SubmissionClient { rpc: RpcClient, genesis_header: BlockHeader, + genesis_protocol_config: ProtocolConfig, genesis_commitment: Word, trusted_validator_keys: Arc<[ValidatorPublicKey]>, sealer: Mutex>, @@ -77,7 +84,7 @@ impl SubmissionClient { .await .context("failed to connect to RPC for genesis discovery")?; - let genesis_header = genesis_block_header(&mut discovery).await?; + let (genesis_header, genesis_protocol_config) = genesis_block_state(&mut discovery).await?; let genesis_commitment = genesis_header.commitment(); // Step two: the real client, carrying the genesis commitment so writes are accepted. @@ -99,6 +106,7 @@ impl SubmissionClient { let client = Self { rpc, genesis_header, + genesis_protocol_config, genesis_commitment, trusted_validator_keys: Arc::from(trusted_keys), sealer: Mutex::new(None), @@ -116,6 +124,11 @@ impl SubmissionClient { &self.genesis_header } + /// Returns the configuration committed by the genesis header. + pub fn genesis_protocol_config(&self) -> &ProtocolConfig { + &self.genesis_protocol_config + } + /// Reads the current chain tip height. /// /// Prefers the block producer's view when the node exposes one, since that is the height blocks @@ -308,21 +321,36 @@ impl SubmissionClient { /// Reads the genesis block header, which anchors both the client metadata and transaction /// execution. -async fn genesis_block_header(rpc: &mut RpcClient) -> Result { +async fn genesis_block_state(rpc: &mut RpcClient) -> Result<(BlockHeader, ProtocolConfig)> { let response = rpc - .get_block_header_by_number(BlockHeaderByNumberRequest { - block_num: Some(BlockNumber::GENESIS.as_u32()), - include_mmr_proof: None, - }) + .get_block_header_by_number(genesis_header_request()) .await .context("failed to read the genesis block header")? .into_inner(); - response + decode_genesis_block_state(response) +} + +fn decode_genesis_block_state( + response: BlockHeaderByNumberResponse, +) -> Result<(BlockHeader, ProtocolConfig)> { + let header = response .block_header .context("RPC returned no genesis block header")? .try_into() - .context("failed to decode the genesis block header") + .context("failed to decode the genesis block header")?; + let protocol_config = decode_protocol_config(response.protocol_config, &header) + .context("RPC returned no valid genesis protocol configuration")?; + + Ok((header, protocol_config)) +} + +fn genesis_header_request() -> BlockHeaderByNumberRequest { + BlockHeaderByNumberRequest { + block_num: Some(BlockNumber::GENESIS.as_u32()), + include_mmr_proof: None, + include_protocol_config: Some(true), + } } /// True when the node rejected a submission because our sealed inputs used a stale encryption key. @@ -333,3 +361,36 @@ fn is_stale_key(err: &anyhow::Error) -> bool { .is_some_and(|status| status.code() == tonic::Code::FailedPrecondition) }) } + +#[cfg(test)] +mod tests { + use miden_node_proto::generated::rpc::BlockHeaderByNumberResponse; + use miden_protocol::account::AccountId; + use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1; + use miden_testing::MockChain; + + use super::{decode_genesis_block_state, genesis_header_request}; + + #[test] + fn genesis_response_rejects_mismatched_protocol_config() { + let chain = MockChain::builder().build().expect("chain should build"); + let other = MockChain::builder() + .fee_faucet_id( + AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1) + .expect("test faucet ID is valid"), + ) + .build() + .expect("chain should build"); + let response = BlockHeaderByNumberResponse { + block_header: Some(chain.genesis_block_header().into()), + mmr_path: None, + chain_length: None, + protocol_config: Some(other.protocol_config().into()), + }; + + let error = decode_genesis_block_state(response) + .expect_err("the genesis configuration must match its header"); + + assert!(format!("{error:#}").contains("does not match header commitment")); + } +} diff --git a/bin/network-monitor/README.md b/bin/network-monitor/README.md index 5dae8449ef..8236c1e488 100644 --- a/bin/network-monitor/README.md +++ b/bin/network-monitor/README.md @@ -24,11 +24,10 @@ configured. The monitor is an observer and test client, not a node component required for block production. Its network transaction checks create fresh in-memory accounts on startup and do not persist account state to disk. -Network transaction checks also require `MIDEN_MONITOR_FEE_FAUCET_ID` and `MIDEN_MONITOR_VALIDATOR_SIGNING_PUBLIC_KEY`. -The faucet ID identifies the native fee asset and is printed by `miden-validator genesis`. The signing key must contain -the hex-encoded validator key that signs transaction encryption key attestations. The monitor will not submit a -transaction unless it can verify the advertised encryption key. Remote transaction-prover probes also require the fee -faucet ID. +Network transaction checks require `MIDEN_MONITOR_VALIDATOR_SIGNING_PUBLIC_KEY`. The signing key must contain the +hex-encoded validator key that signs transaction encryption key attestations. The monitor will not submit a transaction +unless it can verify the advertised encryption key. The monitor obtains the active fee asset from the protocol +configuration returned by RPC and verifies it against the transaction's reference block. On a chain with a non-zero verification base fee, network transaction checks additionally require `MIDEN_MONITOR_FAUCET_URL`: the monitor funds its in-memory accounts by claiming the native fee asset from the faucet, diff --git a/bin/network-monitor/src/commands/start.rs b/bin/network-monitor/src/commands/start.rs index 5a9762bca2..d038532dfb 100644 --- a/bin/network-monitor/src/commands/start.rs +++ b/bin/network-monitor/src/commands/start.rs @@ -26,8 +26,6 @@ use crate::{COMPONENT, LOG_TARGET}; err, )] pub async fn start_monitor(config: MonitorConfig) -> Result<()> { - validate_startup_config(&config)?; - info!(target: LOG_TARGET, "Loaded configuration", port = config.port); let _otel_guard = @@ -78,40 +76,3 @@ pub async fn start_monitor(config: MonitorConfig) -> Result<()> { tasks.handle_failure().await } - -/// Validates configuration that is required before background monitoring tasks start. -fn validate_startup_config(config: &MonitorConfig) -> Result<()> { - if !config.disable_ntx_service { - config.fee_faucet_id()?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use clap::Parser; - - use super::*; - - #[test] - fn status_only_prover_monitoring_does_not_require_a_fee_faucet_at_startup() { - let config = MonitorConfig::parse_from([ - "network-monitor", - "--disable-ntx-service", - "--remote-prover-urls", - "http://127.0.0.1:50051", - ]); - - validate_startup_config(&config) - .expect("prover status discovery must start without transaction-probe configuration"); - } - - #[test] - fn ntx_monitoring_requires_a_fee_faucet_at_startup() { - let config = MonitorConfig::parse_from(["network-monitor"]); - - let error = validate_startup_config(&config) - .expect_err("NTX monitoring executes transactions and needs a fee faucet"); - assert!(error.to_string().contains("--fee-faucet-id")); - } -} diff --git a/bin/network-monitor/src/config.rs b/bin/network-monitor/src/config.rs index f13f171da7..6912f73c48 100644 --- a/bin/network-monitor/src/config.rs +++ b/bin/network-monitor/src/config.rs @@ -7,7 +7,6 @@ use std::time::Duration; use anyhow::{Context, Result}; use clap::Parser; -use miden_protocol::account::AccountId; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey as ValidatorPublicKey; use miden_protocol::utils::serde::Deserializable; use url::Url; @@ -58,18 +57,6 @@ pub struct MonitorConfig { )] pub faucet_url: Option, - /// Faucet account whose fungible asset the target chain uses for fees. - /// - /// Transaction execution checks need this value because block headers contain only the - /// protocol configuration commitment. - #[arg( - long = "fee-faucet-id", - env = "MIDEN_MONITOR_FEE_FAUCET_ID", - value_parser = parse_account_id, - help = "Fee faucet account ID (hex or Bech32) for transaction execution checks" - )] - pub fee_faucet_id: Option, - /// The interval at which to test the remote provers services. #[arg( long = "remote-prover-test-interval", @@ -215,13 +202,6 @@ pub struct MonitorConfig { } impl MonitorConfig { - /// Returns the fee faucet required by checks that execute transactions. - pub fn fee_faucet_id(&self) -> Result { - self.fee_faucet_id.context( - "--fee-faucet-id is required for remote transaction-prover or network transaction checks", - ) - } - /// Decodes the validator signing key required by transaction submission checks. pub fn trusted_validator_signing_key(&self) -> Result { let encoded = self.validator_signing_public_key.as_deref().context( @@ -233,9 +213,3 @@ impl MonitorConfig { .context("validator signing public key must be a valid K256 public key") } } - -fn parse_account_id(value: &str) -> std::result::Result { - AccountId::parse(value) - .map(|(account_id, _network_id)| account_id) - .map_err(|err| err.to_string()) -} diff --git a/bin/network-monitor/src/counter.rs b/bin/network-monitor/src/counter.rs index ac0c4c7d5f..1c0c3d7656 100644 --- a/bin/network-monitor/src/counter.rs +++ b/bin/network-monitor/src/counter.rs @@ -326,7 +326,6 @@ impl IncrementService { let accounts = Box::pin(create_and_deploy_accounts( &self.submission_client, &self.prover, - self.config.fee_faucet_id()?, self.funding.as_ref(), )) .await diff --git a/bin/network-monitor/src/deploy/mod.rs b/bin/network-monitor/src/deploy/mod.rs index b0ca119137..eec7e4452d 100644 --- a/bin/network-monitor/src/deploy/mod.rs +++ b/bin/network-monitor/src/deploy/mod.rs @@ -16,11 +16,13 @@ use miden_node_proto::domain::encryption::{ TrustedTransactionEncryptionState, verify_transaction_encryption_key, }; +use miden_node_proto::domain::protocol_config::decode_protocol_config; use miden_node_proto::generated::rpc::{ AccountRequest as ProtoAccountRequest, BlockHeaderByNumberRequest, FinalityLevel, SyncChainMmrRequest, + SyncChainMmrResponse, }; use miden_node_proto::generated::submission::ProvenTransactionSubmission as ProtoProvenTransaction; use miden_node_tracing::spawn::spawn_blocking_in_current_span; @@ -84,7 +86,13 @@ use url::Url; use crate::deploy::counter::create_counter_account; use crate::deploy::wallet::create_wallet_account; -use crate::funding::{FaucetClient, FeeFunder, counter_funding_amount, wallet_funding_amount}; +use crate::funding::{ + FaucetClient, + FeeFunder, + counter_funding_amount, + ensure_note_carries_fee_asset, + wallet_funding_amount, +}; use crate::{COMPONENT, LOG_TARGET}; pub mod counter; @@ -267,6 +275,7 @@ pub async fn create_genesis_aware_rpc_client( let block_header_request = BlockHeaderByNumberRequest { block_num: Some(BlockNumber::GENESIS.as_u32()), include_mmr_proof: None, + include_protocol_config: None, }; let response = rpc @@ -317,20 +326,19 @@ pub async fn create_genesis_aware_rpc_client( pub async fn create_and_deploy_accounts( submission_client: &TransactionSubmissionClient, prover: &LocalTransactionProver, - fee_faucet_id: AccountId, funding: Option<&FaucetClient>, ) -> Result { info!(target: LOG_TARGET, "Creating fresh monitor accounts"); let mut rpc_client = submission_client.rpc_client(); - // The genesis header is immutable, so it is fetched once and reused by every step below. - let genesis_header = fetch_genesis_block_header(&mut rpc_client).await?; - let protocol_config = ProtocolConfig::current(AssetId::new_fungible(fee_faucet_id)) - .context("failed to construct the target protocol configuration")?; - ensure_anchor_protocol_config_matches(&genesis_header, &protocol_config)?; - let mut funder = active_fee_funder(&genesis_header, funding, &rpc_client, fee_faucet_id)?; - let verification_base_fee = genesis_header.fee_parameters().verification_base_fee(); + let funding_anchor = + fetch_tip_chain_state(&mut rpc_client, submission_client.genesis_commitment).await?; + let fee_faucet_id = funding_anchor.protocol_config.fee_asset_id().faucet_id(); + let mut funder = + active_fee_funder(&funding_anchor.block_header, funding, &rpc_client, fee_faucet_id)?; + let verification_base_fee = + funding_anchor.block_header.fee_parameters().verification_base_fee(); let (wallet_account, secret_key) = create_wallet_account()?; let counter_account = @@ -353,38 +361,55 @@ pub async fn create_and_deploy_accounts( None => (None, None), }; - // The creation transaction anchors at the tip; on fee-charging chains the fee faucet is - // provisioned as committed in that block (see `fetch_foreign_account_inputs`). - let (tip_header, blockchain) = - fetch_tip_chain_state(&mut rpc_client, genesis_header.commitment()).await?; - ensure_anchor_protocol_config_matches(&tip_header, &protocol_config)?; + let creation_anchor = + fetch_tip_chain_state(&mut rpc_client, submission_client.genesis_commitment).await?; + let anchor_fee_faucet_id = creation_anchor.protocol_config.fee_asset_id().faucet_id(); + anyhow::ensure!( + anchor_fee_faucet_id == fee_faucet_id, + "the active fee asset changed while the monitor funded its accounts", + ); + anyhow::ensure!( + creation_anchor.block_header.fee_parameters().verification_base_fee() + == verification_base_fee, + "the verification base fee changed while the monitor funded its accounts", + ); + if let Some(note) = &counter_funding_note { + ensure_note_carries_fee_asset(note, anchor_fee_faucet_id)?; + } + if let Some(note) = &wallet_funding_note { + ensure_note_carries_fee_asset(note, anchor_fee_faucet_id)?; + } + let creation_fee_faucet = match funder.as_ref() { Some(_) => Some( - fetch_foreign_account_inputs(&mut rpc_client, fee_faucet_id, tip_header.block_num()) - .await - .context("failed to fetch the fee faucet's state at the reference block")?, + fetch_foreign_account_inputs( + &mut rpc_client, + anchor_fee_faucet_id, + creation_anchor.block_header.block_num(), + ) + .await + .context("failed to fetch the fee faucet's state at the reference block")?, ), None => None, }; let committed_counter = Box::pin(deploy_counter_account( &counter_account, - tip_header, - protocol_config.clone(), - blockchain, + creation_anchor.block_header, + creation_anchor.protocol_config, + creation_anchor.blockchain, counter_funding_note, creation_fee_faucet, submission_client, prover, )) .await?; - let anchored_fee_faucet_id = funder.is_some().then_some(fee_faucet_id); let counter_anchor = resolve_counter_anchor( &mut rpc_client, - &genesis_header, - &protocol_config, &committed_counter, - anchored_fee_faucet_id, + submission_client.genesis_commitment, + anchor_fee_faucet_id, + verification_base_fee, ) .await?; @@ -430,8 +455,7 @@ pub fn active_fee_funding<'a>( /// Returns a [`FeeFunder`] on fee-charging chains, `None` on zero-fee chains. /// -/// The funder binds the faucet client to the given RPC client and to the fee faucet ID from the -/// genesis fee parameters. +/// The funder binds the faucet client to the given RPC client and to the active fee faucet ID. pub fn active_fee_funder( genesis_header: &BlockHeader, funding: Option<&FaucetClient>, @@ -584,12 +608,11 @@ const ANCHOR_RESOLUTION_DELAY: Duration = Duration::from_secs(1); /// describes, since the deployment transaction needs a block to land in first. async fn resolve_counter_anchor( rpc_client: &mut RpcClient, - genesis_header: &BlockHeader, - protocol_config: &ProtocolConfig, committed_counter: &Account, - fee_faucet_id: Option, + genesis_commitment: Word, + expected_fee_faucet_id: AccountId, + expected_verification_base_fee: u32, ) -> Result { - let genesis_commitment = genesis_header.commitment(); let expected_state = committed_counter.to_commitment(); let mut last_error = None; @@ -603,8 +626,8 @@ async fn resolve_counter_anchor( committed_counter, expected_state, genesis_commitment, - protocol_config, - fee_faucet_id, + expected_fee_faucet_id, + expected_verification_base_fee, ) .await { @@ -656,11 +679,16 @@ async fn try_resolve_counter_anchor( committed_counter: &Account, expected_state: Word, genesis_commitment: Word, - protocol_config: &ProtocolConfig, - fee_faucet_id: Option, + expected_fee_faucet_id: AccountId, + expected_verification_base_fee: u32, ) -> Result> { - let (block_header, blockchain) = fetch_tip_chain_state(rpc_client, genesis_commitment).await?; - ensure_anchor_protocol_config_matches(&block_header, protocol_config)?; + let anchor = fetch_tip_chain_state(rpc_client, genesis_commitment).await?; + ensure_counter_anchor_fee_policy_matches( + &anchor, + expected_fee_faucet_id, + expected_verification_base_fee, + )?; + let block_header = anchor.block_header; let block_num = block_header.block_num(); let witness = fetch_account_witness(rpc_client, committed_counter.id(), block_num).await?; @@ -683,23 +711,40 @@ async fn try_resolve_counter_anchor( // The kernel authenticates the faucet against the anchor block's account root, so fetch it // exactly as committed there. - let fee_faucet = match fee_faucet_id { - Some(faucet_id) => { - Some(fetch_foreign_account_inputs(rpc_client, faucet_id, block_num).await?) - }, - None => None, + let fee_faucet = if block_header.fee_parameters().verification_base_fee() == 0 { + None + } else { + let faucet_id = anchor.protocol_config.fee_asset_id().faucet_id(); + Some(fetch_foreign_account_inputs(rpc_client, faucet_id, block_num).await?) }; Ok(Some(CounterAnchor { block_header, - blockchain, - protocol_config: protocol_config.clone(), + blockchain: anchor.blockchain, + protocol_config: anchor.protocol_config, counter_account: committed_counter.clone(), witness, fee_faucet, })) } +fn ensure_counter_anchor_fee_policy_matches( + anchor: &ChainState, + expected_fee_faucet_id: AccountId, + expected_verification_base_fee: u32, +) -> Result<()> { + anyhow::ensure!( + anchor.protocol_config.fee_asset_id().faucet_id() == expected_fee_faucet_id, + "the active fee asset changed while the monitor resolved its counter anchor", + ); + anyhow::ensure!( + anchor.block_header.fee_parameters().verification_base_fee() + == expected_verification_base_fee, + "the verification base fee changed while the monitor resolved its counter anchor", + ); + Ok(()) +} + /// Ensures that a transaction anchor and its protocol configuration describe the same state. fn ensure_anchor_protocol_config_matches( block_header: &BlockHeader, @@ -716,12 +761,18 @@ fn ensure_anchor_protocol_config_matches( Ok(()) } -/// Fetch the chain tip header together with a [`PartialBlockchain`] whose peaks hash to that -/// header's chain commitment, making the pair usable as a transaction reference block. +/// A verified transaction anchor for one chain state. +struct ChainState { + block_header: BlockHeader, + protocol_config: ProtocolConfig, + blockchain: PartialBlockchain, +} + +/// Fetches the chain tip header, protocol configuration, and partial blockchain. async fn fetch_tip_chain_state( rpc_client: &mut RpcClient, genesis_commitment: Word, -) -> Result<(BlockHeader, PartialBlockchain)> { +) -> Result { let response = rpc_client .sync_chain_mmr(SyncChainMmrRequest { // The MMR is seeded with the genesis block below, so the delta starts at block 1. @@ -732,12 +783,22 @@ async fn fetch_tip_chain_state( .context("failed to sync the chain MMR")? .into_inner(); + decode_chain_state(response, genesis_commitment) +} + +fn decode_chain_state( + response: SyncChainMmrResponse, + genesis_commitment: Word, +) -> Result { let tip_header: BlockHeader = response .block_header .context("sync_chain_mmr response did not include a block header")? .try_into() .context("failed to convert the sync target block header")?; + let protocol_config = decode_protocol_config(response.protocol_config, &tip_header) + .context("sync_chain_mmr response did not include a valid protocol configuration")?; + let delta: MmrDelta = response .mmr_delta .context("sync_chain_mmr response did not include an MMR delta")? @@ -764,7 +825,11 @@ async fn fetch_tip_chain_state( let blockchain = PartialBlockchain::new(mmr, Vec::new()) .context("failed to build the partial blockchain")?; - Ok((tip_header, blockchain)) + Ok(ChainState { + block_header: tip_header, + protocol_config, + blockchain, + }) } /// Fetch the account-tree witness proving an account's state in the given block. @@ -791,26 +856,6 @@ async fn fetch_account_witness( Ok(response.witness) } -/// Fetch the genesis block header from RPC. -async fn fetch_genesis_block_header(rpc_client: &mut RpcClient) -> Result { - let block_header_request = BlockHeaderByNumberRequest { - block_num: Some(BlockNumber::GENESIS.as_u32()), - include_mmr_proof: None, - }; - - let response = rpc_client - .get_block_header_by_number(block_header_request) - .await - .context("Failed to get the genesis block header from RPC")?; - - let root_block_header = response - .into_inner() - .block_header - .ok_or_else(|| anyhow::anyhow!("No block header in response"))?; - - root_block_header.try_into().context("Failed to convert block header") -} - /// Execute the counter account's genesis (creation) transaction in-memory. /// /// Builds a [`MonitorDataStore`] over the given reference block and executes the creation @@ -886,56 +931,70 @@ fn counter_creation_tx_args(counter_account: &Account) -> Result, ) -> Result { let (wallet_account, _secret_key) = create_wallet_account()?; - let (mut rpc_client, _) = + let (mut rpc_client, genesis_commitment) = create_genesis_aware_rpc_client(rpc_url, Duration::from_secs(10)).await?; - let genesis_header = fetch_genesis_block_header(&mut rpc_client).await?; - let protocol_config = ProtocolConfig::current(AssetId::new_fungible(fee_faucet_id)) - .context("failed to construct the target protocol configuration")?; - ensure_anchor_protocol_config_matches(&genesis_header, &protocol_config)?; - let mut funder = active_fee_funder(&genesis_header, funding, &rpc_client, fee_faucet_id)?; - let verification_base_fee = genesis_header.fee_parameters().verification_base_fee(); + let anchor = fetch_tip_chain_state(&mut rpc_client, genesis_commitment).await?; + let fee_faucet_id = anchor.protocol_config.fee_asset_id().faucet_id(); + let mut funder = active_fee_funder(&anchor.block_header, funding, &rpc_client, fee_faucet_id)?; + let verification_base_fee = anchor.block_header.fee_parameters().verification_base_fee(); let counter_account = create_counter_account(wallet_account.id(), fee_faucet_id, verification_base_fee)?; - let (tip_header, blockchain) = - fetch_tip_chain_state(&mut rpc_client, genesis_header.commitment()).await?; - ensure_anchor_protocol_config_matches(&tip_header, &protocol_config)?; - let (funding_note, fee_faucet) = match funder.as_mut() { + let funding_note = match funder.as_mut() { Some(funder) => { let note = funder .fund(counter_account.id(), counter_funding_amount(verification_base_fee)) .await .context("failed to fund the probe's counter account")?; - let faucet = fetch_foreign_account_inputs( + Some(note) + }, + None => None, + }; + + let execution_anchor = fetch_tip_chain_state(&mut rpc_client, genesis_commitment).await?; + let anchor_fee_faucet_id = execution_anchor.protocol_config.fee_asset_id().faucet_id(); + anyhow::ensure!( + anchor_fee_faucet_id == fee_faucet_id, + "the active fee asset changed while the monitor funded its prover probe", + ); + anyhow::ensure!( + execution_anchor.block_header.fee_parameters().verification_base_fee() + == verification_base_fee, + "the verification base fee changed while the monitor funded its prover probe", + ); + if let Some(note) = &funding_note { + ensure_note_carries_fee_asset(note, anchor_fee_faucet_id)?; + } + let fee_faucet = if funder.is_some() { + Some( + fetch_foreign_account_inputs( &mut rpc_client, - fee_faucet_id, - tip_header.block_num(), + anchor_fee_faucet_id, + execution_anchor.block_header.block_num(), ) .await - .context("failed to fetch the fee faucet's state at the reference block")?; - (Some(note), Some(faucet)) - }, - None => (None, None), + .context("failed to fetch the fee faucet's state at the reference block")?, + ) + } else { + None }; let executed_tx = execute_counter_genesis_tx( &counter_account, - tip_header, - protocol_config, - blockchain, + execution_anchor.block_header, + execution_anchor.protocol_config, + execution_anchor.blockchain, funding_note, fee_faucet, ) @@ -1170,13 +1229,24 @@ mod tests { use std::collections::BTreeSet; use std::time::Duration; + use miden_node_proto::generated::rpc::SyncChainMmrResponse; + use miden_protocol::Word; use miden_protocol::asset::{AssetId, FungibleAsset}; - use miden_protocol::crypto::merkle::mmr::{MmrPeaks, PartialMmr}; + use miden_protocol::crypto::merkle::mmr::{Forest, MmrDelta, MmrPeaks, PartialMmr}; use miden_protocol::protocol_config::ProtocolConfig; use miden_protocol::transaction::PartialBlockchain; use miden_testing::MockChain; - use super::{DataStore, FaucetClient, MonitorDataStore, active_fee_funding}; + use super::{ + ChainState, + DataStore, + FaucetClient, + MonitorDataStore, + active_fee_funding, + decode_chain_state, + decode_protocol_config, + ensure_counter_anchor_fee_policy_matches, + }; use crate::deploy::wallet::create_wallet_account; /// A fee-charging chain without a faucet must fail at startup; a zero-fee chain must not fund @@ -1216,6 +1286,103 @@ mod tests { ); } + #[test] + fn rpc_protocol_config_is_accepted_for_its_header() { + let chain = MockChain::builder().build().expect("chain should build"); + let header = chain.genesis_block_header(); + let expected = chain.protocol_config().clone(); + + let decoded = decode_protocol_config(Some((&expected).into()), &header) + .expect("the RPC configuration matches its header"); + + assert_eq!(decoded, expected); + } + + #[test] + fn counter_anchor_rejects_a_base_fee_transition() { + let chain = MockChain::builder() + .verification_base_fee(500) + .build() + .expect("chain should build"); + let anchor = ChainState { + block_header: chain.genesis_block_header(), + protocol_config: chain.protocol_config().clone(), + blockchain: PartialBlockchain::new( + PartialMmr::from_peaks(MmrPeaks::default()), + Vec::new(), + ) + .expect("empty genesis blockchain should build"), + }; + + let error = ensure_counter_anchor_fee_policy_matches(&anchor, chain.fee_faucet_id(), 0) + .expect_err("the counter was built for a different base fee"); + + assert!(error.to_string().contains("verification base fee changed")); + } + + #[test] + fn chain_state_response_requires_protocol_config() { + let chain = MockChain::builder().build().expect("chain should build"); + let response = SyncChainMmrResponse { + block_range: None, + mmr_delta: Some( + MmrDelta { + forest: Forest::new(0).expect("zero is a valid forest"), + data: Vec::new(), + } + .into(), + ), + block_header: Some(chain.genesis_block_header().into()), + block_signatures: Vec::new(), + protocol_config: None, + }; + + let error = decode_chain_state(response, Word::empty()) + .err() + .expect("a transaction anchor requires its protocol configuration"); + + assert!(format!("{error:#}").contains("protocol config is missing")); + } + + #[test] + fn chain_state_response_rejects_an_mmr_mismatch() { + let chain = MockChain::builder().build().expect("chain should build"); + let base = chain.genesis_block_header(); + let header = miden_protocol::block::BlockHeader::new( + base.prev_block_commitment(), + base.block_num(), + Word::new([1_u32.into(), 0_u32.into(), 0_u32.into(), 0_u32.into()]), + base.account_root(), + base.nullifier_root(), + base.note_root(), + base.tx_commitment(), + base.validator_config().clone(), + base.fee_parameters().clone(), + base.protocol_config_commitment(), + base.next_protocol_config().cloned(), + base.timestamp(), + ); + let response = SyncChainMmrResponse { + block_range: None, + mmr_delta: Some( + MmrDelta { + forest: Forest::new(0).expect("zero is a valid forest"), + data: Vec::new(), + } + .into(), + ), + block_header: Some(header.into()), + block_signatures: Vec::new(), + protocol_config: Some(chain.protocol_config().into()), + }; + + let error = decode_chain_state(response, Word::empty()) + .err() + .expect("the response MMR must match the target header"); + + assert!(error.to_string().contains("synced MMR peaks do not match")); + } + #[tokio::test] async fn data_store_rejects_a_protocol_config_mismatched_with_its_anchor_header() { let chain = MockChain::builder().build().expect("chain should build"); diff --git a/bin/network-monitor/src/funding.rs b/bin/network-monitor/src/funding.rs index 23a1da619f..b526a379a1 100644 --- a/bin/network-monitor/src/funding.rs +++ b/bin/network-monitor/src/funding.rs @@ -135,8 +135,7 @@ impl FaucetClient { /// Funds monitor accounts with the chain's fee asset. /// /// Binds a [`FaucetClient`] to the RPC client used to await note commitment and to the chain's -/// fee faucet ID, so callers fund an account from just an ID and an amount. Built where the -/// genesis header is known, since the fee faucet ID comes from the genesis fee parameters. +/// active fee faucet ID, so callers fund an account from just an ID and an amount. pub struct FeeFunder { faucet: FaucetClient, rpc_client: RpcClient, @@ -182,7 +181,7 @@ impl FeeFunder { } /// Checks that the note holds a non-zero amount of the fee faucet's fungible asset. -fn ensure_note_carries_fee_asset(note: &Note, fee_faucet_id: AccountId) -> Result<()> { +pub(crate) fn ensure_note_carries_fee_asset(note: &Note, fee_faucet_id: AccountId) -> Result<()> { let funded = note.assets().iter().any(|asset| { asset .as_fungible() diff --git a/bin/network-monitor/src/monitor/tasks.rs b/bin/network-monitor/src/monitor/tasks.rs index 92fc4d2256..04d56bd575 100644 --- a/bin/network-monitor/src/monitor/tasks.rs +++ b/bin/network-monitor/src/monitor/tasks.rs @@ -115,7 +115,6 @@ impl Tasks { name, prover_url.clone(), config.rpc_url.clone(), - config.fee_faucet_id, funding.clone(), config.status_check_interval, config.request_timeout, @@ -288,13 +287,8 @@ async fn bootstrap_ntx( .await?; // The faucet funds fee payments; whether it is needed is decided during deployment. let funding = FaucetClient::from_config(config); - let accounts = Box::pin(create_and_deploy_accounts( - &submission_client, - &prover, - config.fee_faucet_id()?, - funding.as_ref(), - )) - .await?; + let accounts = + Box::pin(create_and_deploy_accounts(&submission_client, &prover, funding.as_ref())).await?; let (accounts_tx, accounts_rx) = watch::channel(TrackedAccounts { wallet: accounts.wallet.clone(), diff --git a/bin/network-monitor/src/remote_prover.rs b/bin/network-monitor/src/remote_prover.rs index 382aacfe94..e26af01e1f 100644 --- a/bin/network-monitor/src/remote_prover.rs +++ b/bin/network-monitor/src/remote_prover.rs @@ -15,7 +15,6 @@ use miden_node_proto::clients::{RemoteProverClient, RemoteProverProxyStatusClien use miden_node_proto::generated as proto; use miden_node_proto::prost::Message; use miden_node_tracing::{debug, miden_instrument, warn}; -use miden_protocol::account::AccountId; use serde::{Deserialize, Serialize}; use tokio::sync::watch; use tokio::task::JoinHandle; @@ -36,11 +35,6 @@ use crate::service_status::{ Status, }; -const MISSING_FEE_FAUCET_GUIDANCE: &str = concat!( - "transaction prover probe requires --fee-faucet-id ", - "after transaction capability discovery", -); - // PROOF TYPE // ================================================================================================ @@ -106,11 +100,10 @@ struct ProbeSpawner { impl ProbeSpawner { /// Spawns a probe task and returns its handle. - fn spawn(&self, fee_faucet_id: AccountId) -> JoinHandle<()> { + fn spawn(&self) -> JoinHandle<()> { tokio::spawn(run_prover_test( self.client.clone(), self.rpc_url.clone(), - fee_faucet_id, self.funding.clone(), self.interval, self.probe_tx.clone(), @@ -130,7 +123,6 @@ pub struct ProverStatusService { request_timeout: Duration, last_status: Option, last_status_err: Option, - fee_faucet_id: Option, probe_rx: watch::Receiver, probe_spawner: ProbeSpawner, probe_handle: Option>, @@ -144,7 +136,6 @@ impl ProverStatusService { name: String, prover_url: Url, rpc_url: Url, - fee_faucet_id: Option, funding: Option, interval: Duration, request_timeout: Duration, @@ -170,7 +161,6 @@ impl ProverStatusService { request_timeout, last_status: None, last_status_err: None, - fee_faucet_id, probe_rx, probe_spawner, probe_handle: None, @@ -190,13 +180,10 @@ impl ProverStatusService { if !matches!(status.supported_proof_type, ProofType::Transaction) { return; } - let Some(fee_faucet_id) = self.fee_faucet_id else { - return; - }; match &self.probe_handle { None => { debug!(target: COMPONENT, "spawning probe task", prover = self.name); - self.probe_handle = Some(self.probe_spawner.spawn(fee_faucet_id)); + self.probe_handle = Some(self.probe_spawner.spawn()); }, Some(handle) if handle.is_finished() => { warn!( @@ -218,7 +205,7 @@ impl ProverStatusService { error: Some("probe task terminated unexpectedly; respawning".to_string()), }); }); - self.probe_handle = Some(self.probe_spawner.spawn(fee_faucet_id)); + self.probe_handle = Some(self.probe_spawner.spawn()); }, Some(_) => {}, } @@ -260,18 +247,6 @@ impl ProverStatusService { service_status = ServiceStatus::unhealthy(&self.name, err.clone(), details); } - if self.fee_faucet_id.is_none() - && matches!(status_details.supported_proof_type, ProofType::Transaction) - { - service_status.error = Some(match service_status.error { - Some(error) => format!("{error}; {MISSING_FEE_FAUCET_GUIDANCE}"), - None => MISSING_FEE_FAUCET_GUIDANCE.to_string(), - }); - if service_status.status == Status::Healthy { - service_status.status = Status::Unknown; - } - } - service_status } @@ -413,7 +388,6 @@ const PAYLOAD_RETRY_DELAY: Duration = Duration::from_secs(30); async fn run_prover_test( mut client: RemoteProverClient, rpc_url: Url, - fee_faucet_id: AccountId, funding: Option, interval: Duration, probe_tx: watch::Sender, @@ -428,7 +402,7 @@ async fn run_prover_test( ); return; } - match generate_prover_test_payload(&rpc_url, fee_faucet_id, funding.as_ref()).await { + match generate_prover_test_payload(&rpc_url, funding.as_ref()).await { Ok(payload) => break payload, Err(e) => { warn!( @@ -566,11 +540,9 @@ fn tonic_status_to_json(status: &tonic::Status) -> String { )] async fn generate_prover_test_payload( rpc_url: &Url, - fee_faucet_id: AccountId, funding: Option<&FaucetClient>, ) -> anyhow::Result { - let tx_inputs = - crate::deploy::build_probe_transaction_inputs(rpc_url, fee_faucet_id, funding).await?; + let tx_inputs = crate::deploy::build_probe_transaction_inputs(rpc_url, funding).await?; Ok(proto::remote_prover::ProofRequest { request: Some(proto::remote_prover::proof_request::Request::Transaction(tx_inputs.into())), }) @@ -595,6 +567,35 @@ fn transaction_proof_size(response: proto::remote_prover::Proof) -> Result, +} + +/// Tracks the active protocol configuration commitment for one validator stream. +#[derive(Default)] +struct ProtocolConfigStreamState { + commitment: Option, +} + +impl ProtocolConfigStreamState { + /// Accepts a stream event only if it supplies every required configuration payload. + fn accept(&mut self, commitment: Word, supplied: bool) -> anyhow::Result<()> { + match self.commitment { + None if !supplied => anyhow::bail!("stream is missing its baseline protocol config"), + Some(previous) if previous != commitment && !supplied => { + anyhow::bail!("stream is missing its changed protocol config") + }, + _ => {}, + } + self.commitment = Some(commitment); + Ok(()) + } +} + #[derive(clap::Args, Clone, Debug)] pub struct RecoverCommand { /// Directory containing the node's local data storage. @@ -178,10 +208,10 @@ async fn recover_from_validators( let coalescer = tokio::spawn(coalesce_blocks(block_streams, parent, recovery_tip, coalesced_tx)); - while let Some(block) = coalesced_rx.recv().await { - let block_num = block.header().block_num(); + while let Some(recovered) = coalesced_rx.recv().await { + let block_num = recovered.block.header().block_num(); block_writer - .apply_block(block) + .apply_block(recovered.block, recovered.protocol_config) .await .context("failed to apply recovered block")?; info!(target: LOG_TARGET, "Applied recovered block", block.number = block_num); @@ -204,8 +234,9 @@ async fn read_blocks( url: Url, mut stream: Streaming, block_count: u64, - blocks: mpsc::Sender>, + blocks: mpsc::Sender>, ) { + let mut config_state = ProtocolConfigStreamState::default(); for _ in 0..block_count { let block = stream .next() @@ -217,11 +248,24 @@ async fn read_blocks( result.with_context(|| format!("block stream of validator {url} returned an error")) }) .and_then(|event| { - event + let block: SignedBlock = event .block .context("validator block stream response is missing block")? .try_into() - .with_context(|| format!("failed to decode block from validator {url}")) + .with_context(|| format!("failed to decode block from validator {url}"))?; + let protocol_config = event + .protocol_config + .map(|config| decode_protocol_config(Some(config), block.header())) + .transpose() + .with_context(|| { + format!("failed to decode protocol config from validator {url}") + })?; + config_state + .accept(block.header().protocol_config_commitment(), protocol_config.is_some()) + .with_context(|| { + format!("invalid protocol config stream from validator {url}") + })?; + Ok(RecoveredBlock { block, protocol_config }) }); let is_err = block.is_err(); @@ -234,16 +278,16 @@ async fn read_blocks( /// Coalesces the per-validator block streams into fully signed, verified blocks and forwards them /// in block-number order for application. async fn coalesce_blocks( - mut block_streams: Vec<(Url, mpsc::Receiver>)>, + mut block_streams: Vec<(Url, mpsc::Receiver>)>, mut parent: BlockHeader, recovery_tip: BlockNumber, - coalesced: mpsc::Sender, + coalesced: mpsc::Sender, ) -> anyhow::Result<()> { for block_num in parent.block_num().child().as_u32()..=recovery_tip.as_u32() { let block = next_coalesced_block(&mut block_streams, &parent) .await .with_context(|| format!("failed to reconstruct block {block_num}"))?; - parent = block.header().clone(); + parent = block.block.header().clone(); // A send failure means the applier has shut down; its error is reported by the caller. if coalesced.send(block).await.is_err() { @@ -261,18 +305,19 @@ async fn coalesce_blocks( /// validator set that signature occupies. The signatures are therefore matched to their positions /// by verifying them against the validator keys committed to by the parent header. async fn next_coalesced_block( - block_streams: &mut [(Url, mpsc::Receiver>)], + block_streams: &mut [(Url, mpsc::Receiver>)], parent: &BlockHeader, -) -> anyhow::Result { +) -> anyhow::Result { let expected_block_num = parent.block_num().child(); let mut signatures = Vec::with_capacity(block_streams.len()); - let mut first_parts: Option<(BlockHeader, BlockBody)> = None; + let mut first_parts: Option<(BlockHeader, BlockBody, Option)> = None; for (url, blocks) in block_streams.iter_mut() { - let block = blocks + let recovered = blocks .recv() .await .with_context(|| format!("block channel of validator {url} closed unexpectedly"))??; + let RecoveredBlock { block, protocol_config } = recovered; anyhow::ensure!( block.header().block_num() == expected_block_num, @@ -290,17 +335,24 @@ async fn next_coalesced_block( }; match &first_parts { - None => first_parts = Some((header, body)), - Some((first_header, _)) => anyhow::ensure!( - header.commitment() == first_header.commitment(), - "validator {url} served a block with commitment {} which diverges from commitment {} served by the first validator", - header.commitment(), - first_header.commitment(), - ), + None => first_parts = Some((header, body, protocol_config)), + Some((first_header, _, first_protocol_config)) => { + anyhow::ensure!( + header.commitment() == first_header.commitment(), + "validator {url} served a block with commitment {} which diverges from commitment {} served by the first validator", + header.commitment(), + first_header.commitment(), + ); + anyhow::ensure!( + protocol_config == *first_protocol_config, + "validator {url} served a protocol config which diverges from the first validator", + ); + }, } signatures.push(signature); } - let (header, body) = first_parts.context("at least one validator stream is required")?; + let (header, body, protocol_config) = + first_parts.context("at least one validator stream is required")?; let signatures = coalesce_signatures(signatures, header.commitment(), parent.validator_config())?; @@ -311,7 +363,7 @@ async fn next_coalesced_block( // signatures. block.validate(Some(parent)).context("coalesced block failed validation")?; - Ok(block) + Ok(RecoveredBlock { block, protocol_config }) } /// Orders the collected per-validator signatures positionally against the validator set: the @@ -352,10 +404,28 @@ fn coalesce_signatures( #[cfg(test)] mod tests { use miden_protocol::Word; - use miden_protocol::block::ValidatorConfig; + use miden_protocol::account::AccountId; + use miden_protocol::asset::AssetId; + use miden_protocol::block::{ + BlockBody, + BlockHeader, + BlockSignatures, + FeeParameters, + SignedBlock, + ValidatorConfig, + }; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; - - use super::coalesce_signatures; + use miden_protocol::protocol_config::{KernelConfig, ProtocolConfig}; + use miden_protocol::transaction::OrderedTransactionHeaders; + use tokio::sync::mpsc; + use url::Url; + + use super::{ + ProtocolConfigStreamState, + RecoveredBlock, + coalesce_signatures, + next_coalesced_block, + }; fn signers(count: usize) -> Vec { (0..count).map(|_| SigningKey::new()).collect() @@ -369,6 +439,20 @@ mod tests { .unwrap() } + fn protocol_configs() -> (ProtocolConfig, ProtocolConfig) { + let faucet: AccountId = 0xaa00_0000_0000_bc11_0000_bc00_0000_de00u128.try_into().unwrap(); + let first = ProtocolConfig::current(AssetId::new_fungible(faucet)).unwrap(); + let second = ProtocolConfig::new( + first.fee_asset_id(), + KernelConfig::new(Word::from([42u32, 0, 0, 0]), vec![]).unwrap(), + first.batch_kernel().clone(), + first.block_kernel().clone(), + first.proof_verification().clone(), + ) + .unwrap(); + (first, second) + } + #[test] fn signatures_are_ordered_against_the_validator_set() { let commitment = Word::from([1u32, 2, 3, 4]); @@ -428,4 +512,106 @@ mod tests { let err = coalesce_signatures(with_foreign, commitment, &keys).unwrap_err(); assert!(err.to_string().contains("no unmatched signature verifies"), "{err}"); } + + #[test] + fn protocol_config_stream_requires_a_baseline() { + let mut state = ProtocolConfigStreamState::default(); + + let err = state + .accept(Word::from([1u32, 2, 3, 4]), false) + .expect_err("the first stream response must carry a protocol config"); + + assert!(err.to_string().contains("baseline protocol config"), "{err}"); + } + + #[test] + fn protocol_config_stream_requires_each_transition() { + let first = Word::from([1u32, 2, 3, 4]); + let second = Word::from([5u32, 6, 7, 8]); + let mut state = ProtocolConfigStreamState::default(); + + state.accept(first, true).unwrap(); + state.accept(first, false).unwrap(); + let err = state + .accept(second, false) + .expect_err("a changed commitment must carry a protocol config"); + + assert!(err.to_string().contains("changed protocol config"), "{err}"); + } + + #[test] + fn rejected_protocol_config_transition_does_not_advance_stream_state() { + let first = Word::from([1u32, 2, 3, 4]); + let second = Word::from([5u32, 6, 7, 8]); + let mut state = ProtocolConfigStreamState::default(); + + state.accept(first, true).unwrap(); + state.accept(second, false).unwrap_err(); + + state + .accept(first, false) + .expect("a rejected transition must preserve the previous commitment"); + } + + #[tokio::test] + async fn coalescer_rejects_protocol_config_disagreement() { + let signers = signers(2); + let validators = validator_keys(&signers); + let (first_config, second_config) = protocol_configs(); + let parent = BlockHeader::new( + Word::empty(), + 0.into(), + Word::empty(), + Word::empty(), + Word::empty(), + Word::empty(), + Word::empty(), + validators.clone(), + FeeParameters::new(0), + first_config.to_commitment(), + None, + 0, + ); + let body = BlockBody::new( + vec![], + vec![], + vec![], + OrderedTransactionHeaders::new_unchecked(vec![]), + ) + .unwrap(); + let header = BlockHeader::new( + parent.commitment(), + 1.into(), + Word::empty(), + Word::empty(), + Word::empty(), + body.compute_block_note_tree().root(), + body.transaction_commitment(), + validators, + FeeParameters::new(0), + first_config.to_commitment(), + None, + 1, + ); + let mut streams = Vec::new(); + for (index, config) in [first_config, second_config].into_iter().enumerate() { + let signature = signers[index].sign(header.commitment()); + let block = SignedBlock::new_unchecked( + header.clone(), + body.clone(), + BlockSignatures::new(vec![signature]).unwrap(), + ); + let (tx, rx) = mpsc::channel(1); + tx.send(Ok(RecoveredBlock { block, protocol_config: Some(config) })) + .await + .unwrap(); + streams.push((Url::parse(&format!("http://validator-{index}.test")).unwrap(), rx)); + } + + let err = next_coalesced_block(&mut streams, &parent) + .await + .expect_err("validators must agree on the supplied protocol config"); + + assert!(err.to_string().contains("protocol config which diverges"), "{err}"); + } } diff --git a/bin/ntx-builder/src/actor/candidate.rs b/bin/ntx-builder/src/actor/candidate.rs index a8470fc2b6..c0f7a7a62b 100644 --- a/bin/ntx-builder/src/actor/candidate.rs +++ b/bin/ntx-builder/src/actor/candidate.rs @@ -5,11 +5,11 @@ use std::sync::Arc; use miden_protocol::Word; use miden_protocol::account::Account; use miden_protocol::asset::{Asset, AssetAmount}; -use miden_protocol::block::BlockHeader; use miden_protocol::note::{Note, NoteId, Nullifier}; -use miden_protocol::transaction::PartialBlockchain; use miden_standards::note::AccountTargetNetworkNote; +use crate::chain_state::ChainState; + // SPONSORED FEATURE NOTE // ================================================================================================ @@ -97,15 +97,8 @@ pub struct TransactionCandidate { /// the account together with the sponsorships that pay its fee. pub notes: Vec, - /// The latest locally committed block header. - /// - /// This should be used as the reference block during transaction execution. - pub chain_tip_header: BlockHeader, - - /// The chain MMR, which lags behind the tip by one block. - /// - /// Wrapped in `Arc` to avoid expensive clones when reading the chain state. - pub chain_mmr: Arc, + /// The immutable chain snapshot for transaction execution. + pub chain_state: ChainState, } impl TransactionCandidate { @@ -162,22 +155,15 @@ mod tests { #[test] fn sponsor_to_feature_nullifier_covers_sponsorships_only() { let sponsored_notes = [sponsored(1, 2), sponsored(2, 0)]; - let chain_mmr = PartialBlockchain::new( - miden_protocol::crypto::merkle::mmr::PartialMmr::from_peaks( - miden_protocol::crypto::merkle::mmr::MmrPeaks::new( - miden_protocol::crypto::merkle::mmr::Forest::new(0).unwrap(), - vec![], - ) - .unwrap(), - ), - [], - ) - .unwrap(); + let chain_state = ChainState::new( + crate::test_utils::mock_block_header(0_u32.into()), + miden_protocol::crypto::merkle::mmr::PartialMmr::default(), + miden_protocol::protocol_config::ProtocolConfig::mock(), + ); let candidate = TransactionCandidate { account: Arc::new(crate::test_utils::mock_account(mock_network_account_id())), notes: sponsored_notes.to_vec(), - chain_tip_header: crate::test_utils::mock_block_header(0_u32.into()), - chain_mmr: Arc::new(chain_mmr), + chain_state, }; let map = candidate.sponsor_to_feature_nullifier(); @@ -187,6 +173,10 @@ mod tests { assert_eq!(map[&sponsorship.id()], feature_nullifier); } assert_eq!(candidate.num_notes(), 4); + assert_eq!( + candidate.chain_state.protocol_config.as_ref(), + &miden_protocol::protocol_config::ProtocolConfig::mock() + ); } /// Sponsorships are ordered largest-first, so a later truncation to the cap keeps the ones most diff --git a/bin/ntx-builder/src/actor/execute.rs b/bin/ntx-builder/src/actor/execute.rs index 0d3bce04f7..75ecb0947e 100644 --- a/bin/ntx-builder/src/actor/execute.rs +++ b/bin/ntx-builder/src/actor/execute.rs @@ -28,12 +28,7 @@ use miden_protocol::account::{ }; use miden_protocol::asset::{AssetId, AssetWitness}; use miden_protocol::block::{BlockHeader, BlockNumber}; -use miden_protocol::errors::{ - AccountError, - AssetError, - ProtocolConfigError, - TransactionInputError, -}; +use miden_protocol::errors::{AccountError, AssetError, TransactionInputError}; use miden_protocol::note::{Note, NoteId, NoteScript, NoteScriptRoot}; use miden_protocol::protocol_config::ProtocolConfig; use miden_protocol::transaction::{ @@ -88,9 +83,9 @@ pub enum NtxError { FeeAssetStorage(#[source] AccountError), #[error("invalid fee asset ID in the network account")] FeeAsset(#[source] AssetError), - #[error("invalid protocol configuration for the network account")] - ProtocolConfig(#[source] ProtocolConfigError), - #[error("network account fee asset does not match the reference block protocol configuration")] + #[error("network account fee asset does not match the protocol configuration")] + FeeAssetMismatch, + #[error("protocol configuration does not match the reference block")] ProtocolConfigCommitmentMismatch, } @@ -282,17 +277,12 @@ impl NtxContext { tx: TransactionCandidate, ) -> impl FutureMaybeSend> { let num_notes = tx.num_notes(); - let TransactionCandidate { - account, - notes, - chain_tip_header, - chain_mmr, - } = tx; + let TransactionCandidate { account, notes, chain_state } = tx; miden_span_record!( account.id = account.id(), account.id.network_prefix = account.id().prefix(), note.count = num_notes, - reference_block.number = chain_tip_header.block_num() + reference_block.number = chain_state.chain_tip_header.block_num() ); async move { @@ -308,8 +298,7 @@ impl NtxContext { spawn_blocking_in_current_span(move || { let data_store = NtxDataStore::new( account, - chain_tip_header, - chain_mmr, + chain_state, ctx.rpc.clone(), ctx.script_cache.clone(), ctx.db.clone(), @@ -715,7 +704,7 @@ struct NtxDataStore { /// The native account, shared with the actor via `Arc` to avoid a deep clone per transaction. account: Arc, reference_block: BlockHeader, - protocol_config: ProtocolConfig, + protocol_config: Arc, /// The chain MMR, wrapped in `Arc` to avoid expensive clones when reading the chain state. chain_mmr: Arc, mast_store: TransactionMastStore, @@ -748,8 +737,7 @@ impl NtxDataStore { /// Creates a new `NtxDataStore` with default cache size. fn new( account: Arc, - reference_block: BlockHeader, - chain_mmr: Arc, + chain_state: crate::chain_state::ChainState, rpc: RpcClient, script_cache: LruCache, db: NtxDbReader, @@ -757,17 +745,19 @@ impl NtxDataStore { ) -> NtxResult { let mast_store = TransactionMastStore::new(); mast_store.load_account_code(account.code()); + let (reference_block, chain_mmr, protocol_config) = chain_state.into_parts(); - let fee_asset_id = account + if protocol_config.to_commitment() != reference_block.protocol_config_commitment() { + return Err(NtxError::ProtocolConfigCommitmentMismatch); + } + let fee_asset_id: AssetId = account .storage() .get_item(FeePolicyManager::fee_asset_id_slot()) .map_err(NtxError::FeeAssetStorage)? .try_into() .map_err(NtxError::FeeAsset)?; - let protocol_config = - ProtocolConfig::current(fee_asset_id).map_err(NtxError::ProtocolConfig)?; - if protocol_config.to_commitment() != reference_block.protocol_config_commitment() { - return Err(NtxError::ProtocolConfigCommitmentMismatch); + if fee_asset_id != protocol_config.fee_asset_id() { + return Err(NtxError::FeeAssetMismatch); } Ok(Self { @@ -844,7 +834,7 @@ impl DataStore for NtxDataStore { Ok(( partial_account, self.reference_block.clone(), - self.protocol_config.clone(), + self.protocol_config.as_ref().clone(), (*self.chain_mmr).clone(), )) } @@ -1022,14 +1012,16 @@ impl MastForestStore for NtxDataStore { #[cfg(test)] mod tests { use std::collections::{BTreeSet, HashMap}; - use std::error::Error; use std::future::ready; + use std::sync::Arc; + use std::time::Duration; - use miden_protocol::errors::ProtocolConfigError; use miden_protocol::note::Note; - use miden_tx::{FailedNote, TransactionExecutorError, TransactionProverError}; + use miden_protocol::protocol_config::ProtocolConfig; + use miden_tx::{DataStore, FailedNote, TransactionExecutorError, TransactionProverError}; use super::{ + NtxDataStore, NtxError, RpcError, SponsoredFeatureNote, @@ -1041,12 +1033,51 @@ mod tests { should_record_failure, }; use crate::test_utils::{ + mock_network_account, mock_network_account_id, mock_single_target_note, mock_sponsorship_note, mock_sponsorship_note_with_amount, }; + /// Transaction inputs must use the verified snapshot config without deriving it from account + /// storage. + #[tokio::test] + async fn data_store_returns_snapshot_protocol_config() { + let (db, _dir) = crate::db::test_setup().await; + let actor_context = crate::actor::AccountActorContext::test(&db.reader()); + let allowed_root = + mock_single_target_note(mock_network_account_id(), 77).as_note().script().root(); + let account = Arc::new(mock_network_account([allowed_root])); + let block_header = crate::test_utils::mock_block_header(0_u32.into()); + let protocol_config = Arc::new(ProtocolConfig::mock()); + let chain_state = crate::chain_state::ChainState::new( + block_header.clone(), + miden_protocol::crypto::merkle::mmr::PartialMmr::default(), + protocol_config.as_ref().clone(), + ); + let data_store = NtxDataStore::new( + Arc::clone(&account), + chain_state, + actor_context.clients.rpc, + actor_context.state.script_cache, + db.reader(), + miden_node_utils::retry::exponential( + Duration::from_millis(1), + Duration::from_millis(10), + ), + ) + .unwrap(); + + let (_, returned_header, returned_config, _) = data_store + .get_transaction_inputs(account.id(), BTreeSet::from([block_header.block_num()])) + .await + .unwrap(); + + assert_eq!(returned_header, block_header); + assert_eq!(returned_config, *protocol_config); + } + fn sponsored_note_with_two_sponsorships() -> SponsoredFeatureNote { let account_id = mock_network_account_id(); let feature = mock_single_target_note(account_id, 1); @@ -1202,15 +1233,4 @@ mod tests { let err = TransactionProverError::other("remote prover unreachable"); assert!(matches!(err, TransactionProverError::Other { .. })); } - - #[test] - fn protocol_config_error_preserves_its_typed_source() { - let error = NtxError::ProtocolConfig(ProtocolConfigError::MinimumSecurityBitsMustBeNonZero); - - assert!( - error - .source() - .is_some_and(|source| source.downcast_ref::().is_some()) - ); - } } diff --git a/bin/ntx-builder/src/actor/mod.rs b/bin/ntx-builder/src/actor/mod.rs index 9298ced344..ad0f9e2eed 100644 --- a/bin/ntx-builder/src/actor/mod.rs +++ b/bin/ntx-builder/src/actor/mod.rs @@ -161,7 +161,11 @@ impl AccountActorContext { let chain_mmr = PartialMmr::from_peaks( MmrPeaks::new(Forest::new(0).expect("forest 0 is valid"), vec![]).unwrap(), ); - let chain_state = Arc::new(SharedChainState::new(block_header, chain_mmr)); + let chain_state = Arc::new(SharedChainState::new( + block_header, + chain_mmr, + miden_protocol::protocol_config::ProtocolConfig::mock(), + )); let (request_tx, _request_rx) = mpsc::channel(1); let tx_args = build_tx_args(NonZeroU16::new(30).unwrap()); @@ -610,14 +614,12 @@ impl AccountActor { return Ok((None, next_retry_block)); } - let (chain_tip_header, chain_mmr) = chain_state.into_parts(); Ok(( Some(TransactionCandidate { // Cheap: bumps the `Arc` refcount instead of deep-copying the account/storage. account: Arc::clone(account), notes: selected, - chain_tip_header, - chain_mmr, + chain_state, }), next_retry_block, )) @@ -646,7 +648,7 @@ impl AccountActor { tx_candidate: TransactionCandidate, account: &mut Arc, ) -> anyhow::Result { - let block_num = tx_candidate.chain_tip_header.block_num(); + let block_num = tx_candidate.chain_state.chain_tip_header.block_num(); // Execute the selected transaction. let context = execute::NtxContext::new( diff --git a/bin/ntx-builder/src/builder.rs b/bin/ntx-builder/src/builder.rs index 96489b24e5..614c0b6f96 100644 --- a/bin/ntx-builder/src/builder.rs +++ b/bin/ntx-builder/src/builder.rs @@ -8,13 +8,14 @@ use miden_node_utils::shutdown::CancellationToken; use miden_node_utils::tasks::Tasks; use miden_protocol::account::AccountId; use miden_protocol::block::{BlockNumber, SignedBlock}; +use miden_protocol::protocol_config::ProtocolConfig; use tokio::net::TcpListener; use tokio::sync::mpsc; use tokio_stream::StreamExt; use crate::actor::ActorRequest; -use crate::chain_state::SharedChainState; -use crate::clients::RpcError; +use crate::chain_state::{ChainState, SharedChainState}; +use crate::clients::{BlockSubscriptionEvent, RpcError}; use crate::committed_block::CommittedBlockEffects; use crate::coordinator::Coordinator; use crate::db::NtxDbWriter; @@ -25,7 +26,7 @@ use crate::{LOG_TARGET, NtxBuilderConfig}; /// `&mut self` instead of three concurrent borrows. The `Block` variant is boxed since a /// `SignedBlock` dwarfs the other two payloads. enum SteadyStateAction { - Block(Box>>), + Block(Box>>), Request(Option), Respawn(Option), Shutdown, @@ -40,7 +41,7 @@ enum SteadyStateAction { /// Boxing gives the stream a `'static` lifetime by ensuring it owns all its data, avoiding the /// complex lifetime annotations otherwise required to store `impl Stream`. pub(crate) type BlockStream = - Pin> + Send>>; + Pin> + Send>>; /// Network transaction builder component. /// @@ -135,12 +136,12 @@ impl NetworkTransactionBuilder { async fn run_event_loop(mut self, shutdown: CancellationToken) -> anyhow::Result<()> { // Phase 1: catch-up. loop { - let (block, committed_tip) = tokio::select! { + let (block, committed_tip, protocol_config) = tokio::select! { () = shutdown.cancelled() => return Ok(()), result = self.next_block() => result?, }; let local_tip = block.header().block_num(); - self.apply_committed_block(block, committed_tip).await?; + self.apply_committed_block(block, committed_tip, protocol_config).await?; if local_tip == committed_tip { self.is_synced = true; @@ -190,10 +191,11 @@ impl NetworkTransactionBuilder { match action { SteadyStateAction::Block(block) => { - let (block, committed_tip) = + let (block, committed_tip, protocol_config) = (*block).context("block stream ended")?.context("block stream failed")?; - let (effects, sponsored_accounts) = - self.apply_committed_block_with_effects(block, committed_tip).await?; + let (effects, sponsored_accounts) = self + .apply_committed_block_with_effects(block, committed_tip, protocol_config) + .await?; self.coordinator.handle_committed_block(&effects, &sponsored_accounts).await?; }, SteadyStateAction::Request(request) => { @@ -220,9 +222,9 @@ impl NetworkTransactionBuilder { } } - /// Pulls the next `(block, committed_tip)` pair from the subscription, surfacing both the - /// "stream ended" and per-item RPC errors as `anyhow::Error`. - async fn next_block(&mut self) -> anyhow::Result<(SignedBlock, BlockNumber)> { + /// Pulls the next block event from the subscription. This method returns stream and item + /// errors. + async fn next_block(&mut self) -> anyhow::Result { self.block_stream .next() .await @@ -235,8 +237,11 @@ impl NetworkTransactionBuilder { &mut self, block: SignedBlock, committed_tip: BlockNumber, + protocol_config: Option, ) -> anyhow::Result<()> { - self.apply_committed_block_with_effects(block, committed_tip).await.map(drop) + self.apply_committed_block_with_effects(block, committed_tip, protocol_config) + .await + .map(drop) } /// Applies a committed block and returns the computed `CommittedBlockEffects`, plus the @@ -254,23 +259,23 @@ impl NetworkTransactionBuilder { &mut self, block: SignedBlock, committed_tip: BlockNumber, + protocol_config: Option, ) -> anyhow::Result<(CommittedBlockEffects, Vec)> { let header = block.header().clone(); let block_num = header.block_num(); let effects = CommittedBlockEffects::from_signed_block(&block); - // Advance the in-memory chain (adds the previous tip header as an MMR leaf and prunes older - // tracked headers) before snapshotting the MMR for persistence. - self.chain.update_chain_tip(header, self.config.max_block_count); - let next_mmr = self.chain.current_mmr(); + // Build the next immutable snapshot before persistence. Do not publish it until the + // database transaction commits. + let next_chain = + self.chain + .next_chain_tip(header, protocol_config, self.config.max_block_count)?; let effects_for_db = effects.clone(); - let sponsored_accounts = self - .db - .apply_committed_block(effects_for_db, next_mmr) - .await - .context("failed to apply committed block to DB")?; + let sponsored_accounts = + persist_and_publish_chain_state(&self.db, &self.chain, effects_for_db, next_chain) + .await?; self.last_applied_block = block_num; @@ -278,6 +283,21 @@ impl NetworkTransactionBuilder { } } +async fn persist_and_publish_chain_state( + db: &NtxDbWriter, + chain: &SharedChainState, + effects: CommittedBlockEffects, + next_chain: ChainState, +) -> anyhow::Result> { + let next_mmr = next_chain.current_mmr(); + let sponsored_accounts = db + .apply_committed_block(effects, next_mmr) + .await + .context("failed to apply committed block to DB")?; + chain.publish(next_chain); + Ok(sponsored_accounts) +} + /// Handles a single actor request then acknowledges the actor. All writes go through the /// framework's single writer connection, so the actors' reads cannot starve them. async fn handle_actor_request( @@ -306,3 +326,46 @@ async fn handle_actor_request( } Ok(()) } + +#[cfg(test)] +mod protocol_config_tests { + use std::sync::Arc; + + use miden_protocol::crypto::merkle::mmr::PartialMmr; + use miden_protocol::protocol_config::ProtocolConfig; + + use super::persist_and_publish_chain_state; + use crate::chain_state::SharedChainState; + use crate::committed_block::CommittedBlockEffects; + use crate::db::test_setup; + use crate::test_utils::{mock_block_header, mock_network_account_update}; + + /// A failed database transaction must leave the published chain snapshot unchanged. + #[tokio::test] + async fn failed_database_write_does_not_publish_chain_snapshot() { + let (db, _dir) = test_setup().await; + let config = ProtocolConfig::mock(); + let chain = Arc::new(SharedChainState::new( + mock_block_header(0_u32.into()), + PartialMmr::default(), + config, + )); + let next_header = mock_block_header(1_u32.into()); + let next = chain.next_chain_tip(next_header.clone(), None, 4).unwrap(); + let (account, details) = mock_network_account_update(); + let effects = CommittedBlockEffects { + header: next_header, + network_notes: vec![], + sponsorship_notes: vec![], + nullifiers: vec![], + network_account_updates: vec![(account.id(), details)], + account_transactions: vec![], + }; + + persist_and_publish_chain_state(&db, chain.as_ref(), effects, next) + .await + .expect_err("a post-genesis account update without a transaction must fail"); + + assert_eq!(chain.get_cloned().chain_tip_header.block_num(), 0_u32.into()); + } +} diff --git a/bin/ntx-builder/src/chain_state.rs b/bin/ntx-builder/src/chain_state.rs index 811fa1f69f..719c00e98a 100644 --- a/bin/ntx-builder/src/chain_state.rs +++ b/bin/ntx-builder/src/chain_state.rs @@ -3,6 +3,7 @@ use std::sync::{Arc, RwLock}; use miden_node_tracing::debug; use miden_protocol::block::{BlockHeader, BlockNumber}; use miden_protocol::crypto::merkle::mmr::PartialMmr; +use miden_protocol::protocol_config::ProtocolConfig; use miden_protocol::transaction::PartialBlockchain; use crate::LOG_TARGET; @@ -31,23 +32,33 @@ pub struct ChainState { /// [`NtxBuilderConfig::max_block_count`](crate::NtxBuilderConfig::max_block_count) blocks /// only, since all notes are executed as unauthenticated. pub chain_mmr: Arc, + /// The active protocol configuration for the chain tip. + pub protocol_config: Arc, } impl ChainState { /// Constructs a new instance of [`ChainState`]. - pub(crate) fn new(chain_tip_header: BlockHeader, chain_mmr: PartialMmr) -> Self { + pub(crate) fn new( + chain_tip_header: BlockHeader, + chain_mmr: PartialMmr, + protocol_config: ProtocolConfig, + ) -> Self { + debug_assert_eq!( + chain_tip_header.protocol_config_commitment(), + protocol_config.to_commitment() + ); let chain_mmr = PartialBlockchain::new(chain_mmr, []) .expect("partial blockchain should build from partial mmr"); Self { chain_tip_header, chain_mmr: Arc::new(chain_mmr), + protocol_config: Arc::new(protocol_config), } } - /// Consumes the chain state and returns the chain tip header and the partial blockchain as a - /// tuple. - pub fn into_parts(self) -> (BlockHeader, Arc) { - (self.chain_tip_header, self.chain_mmr) + /// Consumes the chain state and returns its header, partial blockchain, and protocol config. + pub fn into_parts(self) -> (BlockHeader, Arc, Arc) { + (self.chain_tip_header, self.chain_mmr, self.protocol_config) } /// Returns a clone of the current partial chain MMR. @@ -55,8 +66,27 @@ impl ChainState { self.chain_mmr.mmr().clone() } - /// Updates the chain tip and prunes old blocks from the MMR. - pub(crate) fn update_chain_tip(&mut self, tip: BlockHeader, max_block_count: usize) { + /// Builds the next chain snapshot and prunes old blocks from its MMR. + pub(crate) fn next_chain_tip( + &self, + tip: BlockHeader, + protocol_config: Option, + max_block_count: usize, + ) -> anyhow::Result { + let next_config = if let Some(config) = protocol_config { + anyhow::ensure!( + config.to_commitment() == tip.protocol_config_commitment(), + "block protocol config commitment does not match its header" + ); + Arc::new(config) + } else { + anyhow::ensure!( + self.protocol_config.to_commitment() == tip.protocol_config_commitment(), + "committed block omitted a changed protocol config" + ); + Arc::clone(&self.protocol_config) + }; + // Skip blocks already reflected in the chain state. The builder may load state during // startup before receiving the same block from the committed-block subscription. if tip.block_num() <= self.chain_tip_header.block_num() { @@ -66,20 +96,25 @@ impl ChainState { block.number = tip.block_num(), tip.number = self.chain_tip_header.block_num() ); - return; + return Ok(self.clone()); } + let mut next = self.clone(); + next.protocol_config = next_config; + // Update MMR which lags by one block. - let mmr_tip = self.chain_tip_header.clone(); - Arc::make_mut(&mut self.chain_mmr).add_block(&mmr_tip, true); + let mmr_tip = next.chain_tip_header.clone(); + Arc::make_mut(&mut next.chain_mmr).add_block(&mmr_tip, true); // Set the new tip. - self.chain_tip_header = tip; + next.chain_tip_header = tip; // Keep MMR pruned. let pruned_block_height = - (self.chain_mmr.chain_length().as_usize().saturating_sub(max_block_count)) as u32; - Arc::make_mut(&mut self.chain_mmr).prune_to(..pruned_block_height.into()); + (next.chain_mmr.chain_length().as_usize().saturating_sub(max_block_count)) as u32; + Arc::make_mut(&mut next.chain_mmr).prune_to(..pruned_block_height.into()); + + Ok(next) } } @@ -89,28 +124,110 @@ impl ChainState { pub struct SharedChainState(RwLock); impl SharedChainState { - pub fn new(chain_tip_header: BlockHeader, chain_mmr: PartialMmr) -> Self { - Self(RwLock::new(ChainState::new(chain_tip_header, chain_mmr))) + pub fn new( + chain_tip_header: BlockHeader, + chain_mmr: PartialMmr, + protocol_config: ProtocolConfig, + ) -> Self { + Self(RwLock::new(ChainState::new(chain_tip_header, chain_mmr, protocol_config))) } pub(crate) fn chain_tip_block_number(&self) -> BlockNumber { self.0.read().expect("chain state lock poisoned").chain_tip_header.block_num() } - /// Returns a clone of the current partial chain MMR. Cheap enough for per-block persistence - /// since the MMR is bounded by `max_block_count` headers. - pub(crate) fn current_mmr(&self) -> PartialMmr { - self.0.read().expect("chain state lock poisoned").current_mmr() + pub(crate) fn next_chain_tip( + &self, + tip: BlockHeader, + protocol_config: Option, + max_block_count: usize, + ) -> anyhow::Result { + self.0.read().expect("chain state lock poisoned").next_chain_tip( + tip, + protocol_config, + max_block_count, + ) } - pub(crate) fn update_chain_tip(&self, tip: BlockHeader, max_block_count: usize) { - self.0 - .write() - .expect("chain state lock poisoned") - .update_chain_tip(tip, max_block_count); + /// Publishes a snapshot after its database state is durable. + pub(crate) fn publish(&self, next: ChainState) { + *self.0.write().expect("chain state lock poisoned") = next; } pub(crate) fn get_cloned(&self) -> ChainState { self.0.read().expect("chain state lock poisoned").clone() } } + +#[cfg(test)] +mod protocol_config_tests { + use miden_node_utils::fee::test_protocol_config; + use miden_protocol::block::{BlockHeader, BlockNumber}; + use miden_protocol::crypto::merkle::mmr::PartialMmr; + use miden_protocol::protocol_config::ProtocolConfig; + + use super::ChainState; + use crate::test_utils::mock_block_header; + + fn other_protocol_config() -> ProtocolConfig { + use miden_protocol::asset::AssetId; + use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1; + + ProtocolConfig::current(AssetId::new_fungible( + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1.try_into().unwrap(), + )) + .unwrap() + } + + fn header_for_config(block_num: BlockNumber, config: &ProtocolConfig) -> BlockHeader { + let header = mock_block_header(block_num); + BlockHeader::new( + header.prev_block_commitment(), + header.block_num(), + header.chain_commitment(), + header.account_root(), + header.nullifier_root(), + header.note_root(), + header.tx_commitment(), + header.validator_config().clone(), + header.fee_parameters().clone(), + config.to_commitment(), + header.next_protocol_config().cloned(), + header.timestamp(), + ) + } + + /// A transition creates a new snapshot and does not change an inflight old snapshot. + #[test] + fn next_tip_keeps_old_snapshot_immutable() { + let config = test_protocol_config(); + let next_config = other_protocol_config(); + let old = + ChainState::new(mock_block_header(0_u32.into()), PartialMmr::default(), config.clone()); + + let next = old + .next_chain_tip( + header_for_config(1_u32.into(), &next_config), + Some(next_config.clone()), + 4, + ) + .expect("a matching changed config must create the next snapshot"); + + assert_eq!(old.chain_tip_header.block_num(), 0_u32.into()); + assert_eq!(old.protocol_config.as_ref(), &config); + assert_eq!(next.chain_tip_header.block_num(), 1_u32.into()); + assert_eq!(next.protocol_config.as_ref(), &next_config); + } + + /// A missing changed configuration must not produce a next snapshot. + #[test] + fn next_tip_rejects_omitted_config_transition() { + let config = test_protocol_config(); + let next_config = other_protocol_config(); + let old = ChainState::new(mock_block_header(0_u32.into()), PartialMmr::default(), config); + let changed = header_for_config(1_u32.into(), &next_config); + + assert!(old.next_chain_tip(changed, None, 4).is_err()); + assert_eq!(old.chain_tip_header.block_num(), 0_u32.into()); + } +} diff --git a/bin/ntx-builder/src/clients/mod.rs b/bin/ntx-builder/src/clients/mod.rs index 89d128cdd4..c433ee39e7 100644 --- a/bin/ntx-builder/src/clients/mod.rs +++ b/bin/ntx-builder/src/clients/mod.rs @@ -2,4 +2,5 @@ mod prover; mod rpc; pub use prover::RemoteTransactionProver; +pub(crate) use rpc::BlockSubscriptionEvent; pub use rpc::{RpcClient, RpcError}; diff --git a/bin/ntx-builder/src/clients/rpc.rs b/bin/ntx-builder/src/clients/rpc.rs index 494c992bb9..ae18659992 100644 --- a/bin/ntx-builder/src/clients/rpc.rs +++ b/bin/ntx-builder/src/clients/rpc.rs @@ -17,10 +17,16 @@ use miden_node_proto::domain::encryption::{ TrustedTransactionEncryptionState, verify_transaction_encryption_key, }; +use miden_node_proto::domain::protocol_config::decode_protocol_config; use miden_node_proto::errors::ConversionError; use miden_node_proto::generated::rpc::account_request::account_detail_request::{StorageMapDetailRequest, StorageMapDetailRequests, StorageRequest, storage_map_detail_request}; use miden_node_proto::generated::rpc::account_request::account_detail_request::storage_map_detail_request::MapKeys; -use miden_node_proto::generated::rpc::{BlockSubscriptionRequest, BlockSubscriptionResponse}; +use miden_node_proto::generated::rpc::{ + BlockHeaderByNumberRequest, + BlockHeaderByNumberResponse, + BlockSubscriptionRequest, + BlockSubscriptionResponse, +}; use miden_node_proto::generated::{self as proto}; use miden_node_tracing::ErrorReport; use miden_node_utils::retry::{self, Retryable}; @@ -38,6 +44,7 @@ use miden_protocol::asset::{Asset, AssetVault, AssetId, AssetWitness, PartialVau use miden_protocol::block::{BlockNumber, SignedBlock}; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey as ValidatorPublicKey; use miden_protocol::note::NoteScript; +use miden_protocol::protocol_config::ProtocolConfig; use miden_protocol::transaction::{AccountInputs, ProvenTransaction, TransactionInputs}; use miden_protocol::utils::serde::Serializable; use thiserror::Error; @@ -50,8 +57,51 @@ use crate::COMPONENT; // RPC CLIENT // ================================================================================================ -/// A signed block paired with the node's committed chain tip at the moment the block was emitted. -type BlockSubscriptionItem = Result<(SignedBlock, BlockNumber), RpcError>; +/// A signed block paired with its stream metadata. +pub(crate) type BlockSubscriptionEvent = (SignedBlock, BlockNumber, Option); + +/// A decoded block-subscription event. +type BlockSubscriptionItem = Result; + +/// Tracks the active configuration within one block-subscription connection. +/// +/// The node sends a baseline with the first response. It then omits the configuration until its +/// commitment changes. A new connection creates a new tracker and requires a new baseline. +#[derive(Default)] +struct ProtocolConfigTracker { + current: Option, +} + +impl ProtocolConfigTracker { + fn validate( + &mut self, + header: &miden_protocol::block::BlockHeader, + config: Option<&ProtocolConfig>, + ) -> Result<(), RpcError> { + if let Some(config) = config { + if config.to_commitment() != header.protocol_config_commitment() { + return Err(RpcError::InvalidResponse( + "block protocol config commitment does not match its header".into(), + )); + } + self.current = Some(config.clone()); + return Ok(()); + } + + let current = self.current.as_ref().ok_or_else(|| { + RpcError::InvalidResponse( + "first block subscription response is missing protocol config".into(), + ) + })?; + if current.to_commitment() != header.protocol_config_commitment() { + return Err(RpcError::InvalidResponse( + "block subscription omitted a changed protocol config".into(), + )); + } + + Ok(()) + } +} /// Delay between block-subscription reconnect attempts, paced so a node that immediately closes the /// connection cannot spin the reconnect loop. Connection *failures* are already backed off @@ -184,13 +234,41 @@ impl RpcClient { Ok(sealer) } + /// Loads and verifies the active configuration for a persisted local header. + pub(crate) async fn protocol_config_for_header( + &self, + expected_header: &miden_protocol::block::BlockHeader, + ) -> Result { + (|| async { + let response = self + .inner + .clone() + .get_block_header_by_number(startup_header_request(expected_header.block_num())) + .await + .map_err(RpcError::GrpcClientError)? + .into_inner(); + decode_startup_header_response(response, expected_header) + }) + .retry(self.backoff) + .when(|err| matches!(err, RpcError::GrpcClientError(_))) + .notify(|err, dur| { + warn!( + err, + target: COMPONENT, + "RPC request failed while verifying the persisted chain tip, retrying", + retry.delay_ms = dur.as_millis() as u64 + ); + }) + .await + } + /// Opens a committed-block subscription starting at `block_from`, retrying indefinitely with /// the client's configured exponential backoff while the initial connection attempt fails. /// - /// Returns a stream that decodes each [`BlockSubscriptionResponse`] into a `(SignedBlock, - /// committed_chain_tip)` pair. The committed chain tip is the latest block the node believes - /// is committed at the moment the response was emitted; the ntx-builder uses it to decide - /// when it has caught up to the live tip. + /// Returns a stream that decodes each [`BlockSubscriptionResponse`] into a block, the committed + /// chain tip, and an optional protocol configuration. The configuration is present for the + /// first response and for each transition. The committed chain tip is the latest block the node + /// believes is committed when it emits the response. #[miden_instrument( target = COMPONENT, name = "rpc.client.block_subscription_with_retry", @@ -217,10 +295,18 @@ impl RpcClient { // Box the stream so its type is named and explicitly `'static` (it owns the cloned // client, borrowing nothing from `self`). This keeps the return type from capturing // `&self`, so callers like `block_subscription_reconnecting` can store it freely. - Ok(stream + let decoded = stream .map_err(RpcError::GrpcClientError) .and_then(|response| async move { decode_block_subscription_response(&response) }) - .boxed()) + .scan(ProtocolConfigTracker::default(), |tracker, item| { + let item = item.and_then(|event| { + tracker.validate(event.0.header(), event.2.as_ref())?; + Ok(event) + }); + std::future::ready(Some(item)) + }); + + Ok(decoded.boxed()) }) .retry(self.backoff) .notify(|err: &RpcError, dur| { @@ -283,11 +369,11 @@ impl RpcClient { // Each quiet poll emits a liveness log; once no block has arrived for // `STALL_TIMEOUT` the subscription is treated as stalled and reconnected. match tokio::time::timeout(BLOCK_POLL_TIMEOUT, stream.next()).await { - Ok(Some(Ok((block, committed_tip)))) => { + Ok(Some(Ok((block, committed_tip, protocol_config)))) => { next_from = block.header().block_num().child(); last_block = Instant::now(); return Some(( - Ok((block, committed_tip)), + Ok((block, committed_tip, protocol_config)), (client, next_from, inner, last_block), )); }, @@ -390,10 +476,37 @@ impl RpcClient { } } +fn startup_header_request(block_num: BlockNumber) -> BlockHeaderByNumberRequest { + BlockHeaderByNumberRequest { + block_num: Some(block_num.as_u32()), + include_mmr_proof: None, + include_protocol_config: Some(true), + } +} + +fn decode_startup_header_response( + response: BlockHeaderByNumberResponse, + expected_header: &miden_protocol::block::BlockHeader, +) -> Result { + let header: miden_protocol::block::BlockHeader = response + .block_header + .ok_or_else(|| RpcError::InvalidResponse("header response is missing block header".into()))? + .try_into() + .map_err(ConversionError::from) + .map_err(RpcError::Conversion)?; + if header.commitment() != expected_header.commitment() { + return Err(RpcError::InvalidResponse( + "remote header does not match the persisted local header".into(), + )); + } + + decode_protocol_config(response.protocol_config, &header).map_err(RpcError::Conversion) +} + fn decode_block_subscription_response( response: &BlockSubscriptionResponse, -) -> Result<(SignedBlock, BlockNumber), RpcError> { - let block = response +) -> Result { + let block: SignedBlock = response .block .clone() .ok_or_else(|| { @@ -402,8 +515,14 @@ fn decode_block_subscription_response( .try_into() .map_err(ConversionError::from) .map_err(RpcError::Conversion)?; + let protocol_config = response + .protocol_config + .clone() + .map(|config| decode_protocol_config(Some(config), block.header())) + .transpose() + .map_err(RpcError::Conversion)?; let committed_tip = BlockNumber::from(response.committed_chain_tip); - Ok((block, committed_tip)) + Ok((block, committed_tip, protocol_config)) } // ACTOR-PATH METHODS @@ -624,3 +743,188 @@ pub enum RpcError { #[error("invalid RPC response: {0}")] InvalidResponse(String), } + +#[cfg(test)] +mod protocol_config_tests { + use miden_node_proto::generated::protocol_config::ProtocolConfig as ProtoProtocolConfig; + use miden_node_proto::generated::rpc::{ + BlockHeaderByNumberResponse, + BlockSubscriptionResponse, + }; + use miden_node_store::genesis::GenesisState; + use miden_node_utils::fee::{test_fee_params, test_protocol_config}; + use miden_protocol::Word; + use miden_protocol::block::{BlockHeader, BlockNumber, FeeParameters}; + use miden_protocol::protocol_config::ProtocolConfig; + + use super::{ + ProtocolConfigTracker, + RpcError, + decode_block_subscription_response, + decode_startup_header_response, + startup_header_request, + }; + use crate::test_utils::mock_genesis_block; + + fn valid_genesis_block() -> miden_protocol::block::SignedBlock { + GenesisState::new( + Vec::new(), + test_fee_params(), + 1, + 0, + mock_genesis_block().header().validator_config().clone(), + test_protocol_config(), + ) + .into_block() + .expect("test genesis block must build") + .inner() + .clone() + } + + fn header_for_config(block_num: u32, config: &ProtocolConfig) -> BlockHeader { + let fixture = mock_genesis_block(); + BlockHeader::new( + Word::empty(), + BlockNumber::from(block_num), + Word::empty(), + Word::empty(), + Word::empty(), + Word::empty(), + Word::empty(), + fixture.header().validator_config().clone(), + FeeParameters::new(0), + config.to_commitment(), + None, + block_num, + ) + } + + fn other_protocol_config() -> ProtocolConfig { + use miden_protocol::asset::AssetId; + use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1; + + ProtocolConfig::current(AssetId::new_fungible( + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1.try_into().unwrap(), + )) + .unwrap() + } + + /// A malformed configuration must stop the stream item before the builder can apply its block. + #[test] + fn subscription_decoder_rejects_malformed_protocol_config() { + let block = valid_genesis_block(); + let response = BlockSubscriptionResponse { + block: Some(block.into()), + committed_chain_tip: 0, + protocol_config: Some(ProtoProtocolConfig::default()), + }; + + let error = decode_block_subscription_response(&response) + .expect_err("a malformed protocol config must be rejected"); + + assert!(matches!(error, RpcError::Conversion(_)), "unexpected error: {error}"); + } + + /// A valid streamed configuration must remain attached to its decoded block event. + #[test] + fn subscription_decoder_preserves_protocol_config() { + let config = test_protocol_config(); + let block = valid_genesis_block(); + let response = BlockSubscriptionResponse { + block: Some(block.into()), + committed_chain_tip: 0, + protocol_config: Some((&config).into()), + }; + + let (_, _, decoded_config) = decode_block_subscription_response(&response).unwrap(); + + assert_eq!(decoded_config, Some(config)); + } + + /// Startup must explicitly request the active configuration for the persisted tip. + #[test] + fn startup_header_request_includes_protocol_config() { + let request = startup_header_request(BlockNumber::from(42)); + + assert_eq!(request.block_num, Some(42)); + assert_eq!(request.include_protocol_config, Some(true)); + } + + /// Startup must reject a response without the requested configuration. + #[test] + fn startup_decoder_rejects_missing_protocol_config() { + let config = test_protocol_config(); + let header = header_for_config(42, &config); + let response = BlockHeaderByNumberResponse { + block_header: Some((&header).into()), + chain_length: None, + mmr_path: None, + protocol_config: None, + }; + + assert!(decode_startup_header_response(response, &header).is_err()); + } + + /// Startup must reject a configuration that does not match the persisted header commitment. + #[test] + fn startup_decoder_rejects_protocol_config_mismatch() { + let config = test_protocol_config(); + let other = other_protocol_config(); + let header = header_for_config(42, &config); + let response = BlockHeaderByNumberResponse { + block_header: Some((&header).into()), + chain_length: None, + mmr_path: None, + protocol_config: Some((&other).into()), + }; + + assert!(decode_startup_header_response(response, &header).is_err()); + } + + /// Startup must reject a valid remote bundle when its header is not the persisted local tip. + #[test] + fn startup_decoder_rejects_remote_header_mismatch() { + let config = test_protocol_config(); + let local = header_for_config(42, &config); + let remote = header_for_config(43, &config); + let response = BlockHeaderByNumberResponse { + block_header: Some((&remote).into()), + chain_length: None, + mmr_path: None, + protocol_config: Some((&config).into()), + }; + + assert!(decode_startup_header_response(response, &local).is_err()); + } + + /// A stream accepts omission only after a baseline and only while the commitment is unchanged. + #[test] + fn subscription_tracker_validates_baseline_and_transitions() { + let first = test_protocol_config(); + let second = other_protocol_config(); + let first_header = header_for_config(1, &first); + let second_header = header_for_config(2, &second); + let mut tracker = ProtocolConfigTracker::default(); + + assert!(tracker.validate(&first_header, None).is_err()); + tracker.validate(&first_header, Some(&first)).unwrap(); + tracker.validate(&first_header, None).unwrap(); + assert!(tracker.validate(&second_header, None).is_err()); + tracker.validate(&first_header, None).unwrap(); + tracker.validate(&second_header, Some(&second)).unwrap(); + tracker.validate(&second_header, None).unwrap(); + } + + /// Every reconnect starts a new stream and therefore requires a new baseline configuration. + #[test] + fn subscription_tracker_requires_baseline_after_reconnect() { + let config = test_protocol_config(); + let header = header_for_config(1, &config); + let mut connected = ProtocolConfigTracker::default(); + connected.validate(&header, Some(&config)).unwrap(); + connected.validate(&header, None).unwrap(); + + let mut reconnected = ProtocolConfigTracker::default(); + assert!(reconnected.validate(&header, None).is_err()); + } +} diff --git a/bin/ntx-builder/src/lib.rs b/bin/ntx-builder/src/lib.rs index a0df593ce4..80ba472e33 100644 --- a/bin/ntx-builder/src/lib.rs +++ b/bin/ntx-builder/src/lib.rs @@ -421,6 +421,7 @@ impl NtxBuilderConfig { /// - The DB cannot be opened or the schema verification fails /// - The DB has not been bootstrapped (no persisted chain state) /// - The RPC connection fails (after retries) + /// - The remote header or protocol config does not match the persisted chain tip pub async fn build( self, shutdown: CancellationToken, @@ -500,6 +501,10 @@ impl NtxBuilderConfig { "ntx-builder database has not been bootstrapped; \ run `miden-ntx-builder bootstrap` first", )?; + let protocol_config = rpc + .protocol_config_for_header(&header) + .await + .context("failed to verify the persisted chain tip and protocol config")?; let block_from = last_applied_block.child(); @@ -513,7 +518,7 @@ impl NtxBuilderConfig { // block that the builder has not applied. let block_stream: BlockStream = Box::pin(rpc.block_subscription_reconnecting(block_from)); - let chain = Arc::new(SharedChainState::new(header, mmr)); + let chain = Arc::new(SharedChainState::new(header, mmr, protocol_config)); let (coordinator, actor_request_rx) = self.build_coordinator(rpc, db.reader(), chain.clone(), shutdown)?; diff --git a/bin/validator/src/commands/bootstrap.rs b/bin/validator/src/commands/bootstrap.rs index d725bbf8c1..c9fea1af87 100644 --- a/bin/validator/src/commands/bootstrap.rs +++ b/bin/validator/src/commands/bootstrap.rs @@ -38,11 +38,13 @@ pub async fn bootstrap( let _ = BlockStore::bootstrap(dirs.block_store_dir(), &genesis_block)?; - let (genesis_header, ..) = genesis_block.into_inner().into_parts(); + let (genesis_block, protocol_config) = genesis_block.into_parts(); + let (genesis_header, ..) = genesis_block.into_parts(); miden_validator::db::bootstrap( dirs.database_path(), sqlite_connection_pool_size, genesis_header, + protocol_config, ) .await .context("failed to bootstrap the validator database")?; @@ -97,6 +99,13 @@ mod tests { 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 validator_db = miden_validator::db::load(data_directory.join("validator.sqlite3")) + .await + .unwrap(); + assert_eq!( + validator_db.load_protocol_config(commitment).await.unwrap(), + Some(config.clone()) + ); let block_bytes = genesis.inner().to_bytes(); assert_eq!(config.to_commitment(), commitment); let node_directory = root.path().join("node"); diff --git a/bin/validator/src/db/migrations.rs b/bin/validator/src/db/migrations.rs index 26b0baf780..59edb313dd 100644 --- a/bin/validator/src/db/migrations.rs +++ b/bin/validator/src/db/migrations.rs @@ -68,7 +68,7 @@ mod tests { use super::*; const EXPECTED_SCHEMA_HASHES: [SchemaHash; 1] = [SchemaHash::from_hex( - "f2f6af5e22d8d0273524a227417339279d1a694c3802a8f3f7cc4b31e21ee035", + "56270f7825c3d07ab37a224583b7b6294bab52761e14e9aa32014f481349fb8d", )]; #[test] diff --git a/bin/validator/src/db/migrations/001_initial.sql b/bin/validator/src/db/migrations/001_initial.sql index 10588283af..7a4564dd85 100644 --- a/bin/validator/src/db/migrations/001_initial.sql +++ b/bin/validator/src/db/migrations/001_initial.sql @@ -40,3 +40,9 @@ CREATE TABLE block_headers ( block_num BIGINT PRIMARY KEY, block_header BLOB NOT NULL ) WITHOUT ROWID; + +CREATE TABLE protocol_configs ( + commitment BLOB PRIMARY KEY, + protocol_config BLOB NOT NULL, + CHECK (length(commitment) = 32) +) WITHOUT ROWID; diff --git a/bin/validator/src/db/mod.rs b/bin/validator/src/db/mod.rs index 579dd6c215..6b12064cb9 100644 --- a/bin/validator/src/db/mod.rs +++ b/bin/validator/src/db/mod.rs @@ -5,6 +5,7 @@ use miden_node_db::DatabaseError; use miden_node_db::sqlite::{DbReader, DbWriter}; use miden_node_tracing::{info, miden_instrument}; use miden_protocol::block::{BlockHeader, BlockNumber}; +use miden_protocol::protocol_config::ProtocolConfig; use miden_protocol::transaction::TransactionId; use crate::db::migrations::{bootstrap_database, migrate_database, verify_latest_schema}; @@ -71,6 +72,16 @@ impl ValidatorDbReader { .await } + /// Loads and verifies the protocol configuration with the given commitment. + pub async fn load_protocol_config( + &self, + commitment: miden_protocol::Word, + ) -> Result, DatabaseError> { + self.reader + .read("load_protocol_config", move |tx| queries::load_protocol_config(tx, commitment)) + .await + } + /// Reads the values the server's in-memory counters start from, all within a single read /// transaction so that they describe one consistent database state. pub(crate) async fn load_initial_metrics(&self) -> Result { @@ -186,20 +197,62 @@ impl ValidatorDbWriter { .await } - /// Persists a block header, replacing any header already stored at the same height. + /// Persists a protocol configuration and its block header in one transaction. + /// + /// If `protocol_config` is absent, the configuration must already be stored. #[miden_instrument( target = COMPONENT, )] - pub(crate) async fn upsert_block_header( + pub(crate) async fn upsert_block_header_with_protocol_config( &self, header: BlockHeader, + protocol_config: Option, ) -> Result<(), DatabaseError> { self.writer - .write("upsert_block_header", move |tx| queries::upsert_block_header(tx, &header)) + .write("upsert_block_header_with_protocol_config", move |tx| { + queries::ensure_protocol_config( + tx, + header.protocol_config_commitment(), + protocol_config.as_ref(), + )?; + queries::upsert_block_header(tx, &header) + }) .await } } +/// Replaces a stored protocol configuration with test bytes. +#[cfg(test)] +pub(crate) async fn overwrite_protocol_config_for_test( + db: &ValidatorDbWriter, + commitment: miden_protocol::Word, + bytes: Vec, +) -> Result<(), DatabaseError> { + db.writer + .write("overwrite_protocol_config_for_test", move |tx| { + tx.execute( + "UPDATE protocol_configs SET protocol_config = ?2 WHERE commitment = ?1", + &[&commitment, &bytes], + )?; + Ok::<_, DatabaseError>(()) + }) + .await +} + +/// Deletes a stored protocol configuration for a test. +#[cfg(test)] +pub(crate) async fn delete_protocol_config_for_test( + db: &ValidatorDbWriter, + commitment: miden_protocol::Word, +) -> Result<(), DatabaseError> { + db.writer + .write("delete_protocol_config_for_test", move |tx| { + tx.execute("DELETE FROM protocol_configs WHERE commitment = ?1", &[&commitment])?; + Ok::<_, DatabaseError>(()) + }) + .await +} + // LIFECYCLE // ================================================================================================ @@ -259,10 +312,12 @@ pub async fn bootstrap( database_filepath: PathBuf, connection_pool_size: NonZeroUsize, genesis_header: BlockHeader, + protocol_config: ProtocolConfig, ) -> Result<(), DatabaseError> { let db = setup_with_pool_size(database_filepath, connection_pool_size).await?; - db.upsert_block_header(genesis_header).await + db.upsert_block_header_with_protocol_config(genesis_header, Some(protocol_config)) + .await } /// Applies all pending migrations to an existing DB. @@ -294,9 +349,14 @@ fn open_with_pool_size( #[cfg(test)] mod tests { + use miden_node_utils::fee::{test_fee_params, test_protocol_config}; use miden_protocol::Word; + use miden_protocol::asset::AssetId; + use miden_protocol::block::{BlockHeader, ValidatorConfig}; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; - use miden_protocol::utils::serde::Deserializable; + use miden_protocol::protocol_config::ProtocolConfig; + use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1; + use miden_protocol::utils::serde::{Deserializable, Serializable}; use rand_chacha_03::ChaCha20Rng; use rand_chacha_03::rand_core::SeedableRng; @@ -329,6 +389,22 @@ mod tests { .unwrap() } + fn genesis_header(config: &miden_protocol::protocol_config::ProtocolConfig) -> BlockHeader { + miden_node_store::GenesisState::new( + vec![], + test_fee_params(), + 1, + 0, + ValidatorConfig::new(vec![SigningKey::new().public_key()], 1).unwrap(), + config.clone(), + ) + .into_block() + .unwrap() + .inner() + .header() + .clone() + } + #[test] fn migrate_rejects_missing_database() { let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); @@ -349,6 +425,127 @@ mod tests { load(db_path).await.expect("load should accept a bootstrapped database"); } + #[tokio::test] + async fn setup_creates_protocol_config_storage() { + let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); + let db = setup(temp_dir.path().join("validator.sqlite3")).await.unwrap(); + + let row_count = db + .reader + .reader + .read("protocol_config_storage", |tx| { + Ok::<_, DatabaseError>( + tx.query("SELECT COUNT(*) FROM protocol_configs", &[], |row| { + row.get::(0) + })? + .into_iter() + .next() + .expect("COUNT always returns one row"), + ) + }) + .await + .unwrap(); + + assert_eq!(row_count, 0); + } + + #[tokio::test] + async fn block_header_and_protocol_config_are_persisted_together() { + let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); + let db = setup(temp_dir.path().join("validator.sqlite3")).await.unwrap(); + let config = test_protocol_config(); + let header = genesis_header(&config); + + db.upsert_block_header_with_protocol_config(header.clone(), Some(config.clone())) + .await + .unwrap(); + + assert_eq!(db.load_chain_tip().await.unwrap(), Some(header)); + assert_eq!(db.load_protocol_config(config.to_commitment()).await.unwrap(), Some(config)); + } + + #[tokio::test] + async fn unknown_protocol_config_rolls_back_block_header() { + let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); + let db = setup(temp_dir.path().join("validator.sqlite3")).await.unwrap(); + let config = test_protocol_config(); + let header = genesis_header(&config); + + db.upsert_block_header_with_protocol_config(header.clone(), None) + .await + .expect_err("an unknown protocol config must reject the header"); + + assert_eq!(db.load_block_header(header.block_num()).await.unwrap(), None); + assert_eq!(db.load_protocol_config(config.to_commitment()).await.unwrap(), None); + } + + #[tokio::test] + async fn protocol_config_reads_reject_trailing_bytes() { + let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); + let db = setup(temp_dir.path().join("validator.sqlite3")).await.unwrap(); + let config = test_protocol_config(); + let commitment = config.to_commitment(); + let mut bytes = config.to_bytes(); + bytes.push(0xff); + db.writer + .write("insert_corrupt_protocol_config", move |tx| { + tx.execute( + "INSERT INTO protocol_configs (commitment, protocol_config) VALUES (?1, ?2)", + &[&commitment, &bytes], + )?; + Ok::<_, DatabaseError>(()) + }) + .await + .unwrap(); + + db.load_protocol_config(commitment) + .await + .expect_err("trailing bytes must be rejected"); + } + + #[tokio::test] + async fn protocol_config_reads_reject_a_wrong_storage_key() { + let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); + let db = setup(temp_dir.path().join("validator.sqlite3")).await.unwrap(); + let config = test_protocol_config(); + let wrong_commitment = Word::empty(); + let bytes = config.to_bytes(); + db.writer + .write("insert_miskeyed_protocol_config", move |tx| { + tx.execute( + "INSERT INTO protocol_configs (commitment, protocol_config) VALUES (?1, ?2)", + &[&wrong_commitment, &bytes], + )?; + Ok::<_, DatabaseError>(()) + }) + .await + .unwrap(); + + db.load_protocol_config(wrong_commitment) + .await + .expect_err("a config stored under the wrong commitment must be rejected"); + } + + #[tokio::test] + async fn mismatched_protocol_config_rolls_back_block_header() { + let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); + let db = setup(temp_dir.path().join("validator.sqlite3")).await.unwrap(); + let expected = test_protocol_config(); + let header = genesis_header(&expected); + let mismatched = ProtocolConfig::current(AssetId::new_fungible( + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1.try_into().unwrap(), + )) + .unwrap(); + + db.upsert_block_header_with_protocol_config(header.clone(), Some(mismatched.clone())) + .await + .expect_err("a mismatched config must reject the header transaction"); + + assert_eq!(db.load_block_header(header.block_num()).await.unwrap(), None); + assert_eq!(db.load_protocol_config(expected.to_commitment()).await.unwrap(), None); + assert_eq!(db.load_protocol_config(mismatched.to_commitment()).await.unwrap(), None); + } + #[tokio::test] async fn transaction_exists_detects_validated_transactions() { let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); diff --git a/bin/validator/src/db/queries/mod.rs b/bin/validator/src/db/queries/mod.rs index 8c21e66169..90561a768c 100644 --- a/bin/validator/src/db/queries/mod.rs +++ b/bin/validator/src/db/queries/mod.rs @@ -8,6 +8,9 @@ mod private_record_row; +mod protocol_config; +pub use protocol_config::{ensure as ensure_protocol_config, load as load_protocol_config}; + mod count_signed_blocks; pub use count_signed_blocks::count_signed_blocks; diff --git a/bin/validator/src/db/queries/protocol_config/insert.sql b/bin/validator/src/db/queries/protocol_config/insert.sql new file mode 100644 index 0000000000..bd045d2d89 --- /dev/null +++ b/bin/validator/src/db/queries/protocol_config/insert.sql @@ -0,0 +1,2 @@ +INSERT OR IGNORE INTO protocol_configs (commitment, protocol_config) +VALUES (?1, ?2); diff --git a/bin/validator/src/db/queries/protocol_config/mod.rs b/bin/validator/src/db/queries/protocol_config/mod.rs new file mode 100644 index 0000000000..96d0fe8acd --- /dev/null +++ b/bin/validator/src/db/queries/protocol_config/mod.rs @@ -0,0 +1,66 @@ +//! Stores and loads protocol configurations by commitment. + +use std::io; + +use miden_node_db::DatabaseError; +use miden_node_db::sqlite::{ReadTx, WriteTx}; +use miden_protocol::Word; +use miden_protocol::protocol_config::ProtocolConfig; +use miden_protocol::utils::serde::{ByteReader, Deserializable, Serializable, SliceReader}; + +const INSERT_SQL: &str = include_str!("insert.sql"); +const SELECT_SQL: &str = include_str!("select.sql"); + +/// Loads a protocol configuration and verifies its serialized value and commitment. +pub fn load(tx: &ReadTx<'_>, commitment: Word) -> Result, DatabaseError> { + let bytes = tx + .query(SELECT_SQL, &[&commitment], |row| row.get::>(0))? + .into_iter() + .next(); + let Some(bytes) = bytes else { + return Ok(None); + }; + + let mut reader = SliceReader::new(&bytes); + let config = ProtocolConfig::read_from(&mut reader) + .map_err(|err| DatabaseError::deserialization("ProtocolConfig", err))?; + if reader.has_more_bytes() { + return Err(invalid_config(format!("protocol config {commitment} has trailing bytes"))); + } + let calculated = config.to_commitment(); + if calculated != commitment { + return Err(invalid_config(format!( + "protocol config commitment mismatch: expected {commitment}, got {calculated}" + ))); + } + + Ok(Some(config)) +} + +/// Stores a supplied configuration or verifies that the committed configuration is already known. +pub fn ensure( + tx: &WriteTx<'_>, + commitment: Word, + config: Option<&ProtocolConfig>, +) -> Result<(), DatabaseError> { + if let Some(config) = config { + let calculated = config.to_commitment(); + if calculated != commitment { + return Err(invalid_config(format!( + "protocol config commitment mismatch: expected {commitment}, got {calculated}" + ))); + } + tx.execute(INSERT_SQL, &[&commitment, &config.to_bytes()])?; + } + + load(tx, commitment)? + .ok_or_else(|| invalid_config(format!("protocol config {commitment} is not stored")))?; + Ok(()) +} + +fn invalid_config(message: String) -> DatabaseError { + DatabaseError::deserialization( + "ProtocolConfig", + io::Error::new(io::ErrorKind::InvalidData, message), + ) +} diff --git a/bin/validator/src/db/queries/protocol_config/select.sql b/bin/validator/src/db/queries/protocol_config/select.sql new file mode 100644 index 0000000000..b4b8da59d0 --- /dev/null +++ b/bin/validator/src/db/queries/protocol_config/select.sql @@ -0,0 +1,3 @@ +SELECT protocol_config +FROM protocol_configs +WHERE commitment = ?1; diff --git a/bin/validator/src/server/validator_service/block_subscription.rs b/bin/validator/src/server/validator_service/block_subscription.rs index 52f7569562..5b9d229f64 100644 --- a/bin/validator/src/server/validator_service/block_subscription.rs +++ b/bin/validator/src/server/validator_service/block_subscription.rs @@ -80,19 +80,38 @@ impl grpc::server::validator_api::BlockSubscription for ValidatorService { tokio::spawn({ let store = self.block_store.clone(); + let db = self.db.reader(); async move { + let mut previous_config_commitment = None; for block in from.as_u32()..=committed_tip.as_u32() { let response = match store.load_block(block.into()).await { - Ok(Some(block)) => SignedBlock::read_from_bytes(&block) - .map(|block| BlockSubscriptionResponse { - block: Some(block.into()), - committed_chain_tip: committed_tip.as_u32(), - }) - .map_err(|err| { - tonic::Status::internal( - err.as_report_context("failed to decode backed-up block"), - ) - }), + Ok(Some(bytes)) => match SignedBlock::read_from_bytes(&bytes) { + Ok(signed_block) => { + let commitment = signed_block.header().protocol_config_commitment(); + match db.load_protocol_config(commitment).await { + Ok(Some(config)) => { + let protocol_config = (previous_config_commitment + != Some(commitment)) + .then(|| (&config).into()); + previous_config_commitment = Some(commitment); + Ok(BlockSubscriptionResponse { + block: Some(signed_block.into()), + committed_chain_tip: committed_tip.as_u32(), + protocol_config, + }) + }, + Ok(None) => Err(tonic::Status::internal(format!( + "protocol config {commitment} not found" + ))), + Err(err) => Err(tonic::Status::internal( + err.as_report_context("failed to load protocol config"), + )), + } + }, + Err(err) => Err(tonic::Status::internal( + err.as_report_context("failed to decode backed-up block"), + )), + }, Ok(None) => { Err(tonic::Status::not_found(format!("block {block} not found"))) }, diff --git a/bin/validator/src/server/validator_service/sign_block.rs b/bin/validator/src/server/validator_service/sign_block.rs index dac25123df..0d62b1b560 100644 --- a/bin/validator/src/server/validator_service/sign_block.rs +++ b/bin/validator/src/server/validator_service/sign_block.rs @@ -1,11 +1,13 @@ use std::sync::atomic::Ordering; +use miden_node_proto::domain::protocol_config::decode_protocol_config; use miden_node_proto::{BlockProofRequest, generated as grpc}; use miden_node_tracing::spawn::spawn_blocking_in_current_span; use miden_node_tracing::{ErrorReport, Instrument, info_span, miden_instrument}; use miden_protocol::Word; -use miden_protocol::block::{BlockNumber, ProposedBlock}; +use miden_protocol::block::{BlockHeader, BlockNumber, ProposedBlock}; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{PublicKey, Signature}; +use miden_protocol::protocol_config::ProtocolConfig; use super::ValidatorService; use crate::COMPONENT; @@ -58,24 +60,47 @@ impl grpc::server::validator_api::SignBlock for ValidatorService { tonic::Status::internal(format!("sign_block semaphore closed: {err}")) })?; - let proposed_block = spawn_blocking_in_current_span(move || { - let request = BlockProofRequest::try_from(request).map_err(tonic::Status::from)?; - ProposedBlock::new_at( - request.block_inputs, - request.tx_batches.into_vec(), - request.block_header.timestamp(), - ) - .map(|block| { - block - .with_next_validator_config(request.block_header.validator_config().clone()) - .with_next_protocol_config(request.block_header.next_protocol_config().cloned()) + let (proposed_block, protocol_config, protocol_config_commitment) = + spawn_blocking_in_current_span(move || { + let mut request = request; + let supplied_protocol_config = request.protocol_config.take(); + let request = BlockProofRequest::try_from(request).map_err(tonic::Status::from)?; + let protocol_config = supplied_protocol_config + .map(|config| decode_protocol_config(Some(config), &request.block_header)) + .transpose() + .map_err(tonic::Status::from)?; + let protocol_config_commitment = request.block_header.protocol_config_commitment(); + let proposed_block = ProposedBlock::new_at( + request.block_inputs, + request.tx_batches.into_vec(), + request.block_header.timestamp(), + ) + .map(|block| { + block + .with_next_validator_config(request.block_header.validator_config().clone()) + .with_next_protocol_config( + request.block_header.next_protocol_config().cloned(), + ) + }) + .map_err(|error| tonic::Status::invalid_argument(error.to_string()))?; + Ok::<_, tonic::Status>(( + proposed_block, + protocol_config, + protocol_config_commitment, + )) }) - .map_err(|error| tonic::Status::invalid_argument(error.to_string())) - }) - .await - .map_err(|error| { - tonic::Status::internal(format!("block decoding task failed: {error}")) - })??; + .await + .map_err(|error| { + tonic::Status::internal(format!("block decoding task failed: {error}")) + })??; + let protocol_config = self + .resolve_protocol_config(protocol_config_commitment, protocol_config) + .await?; + + let block_num = proposed_block.block_num(); + let previous_backup = self.block_store.load_block(block_num).await.map_err(|err| { + tonic::Status::internal(format!("Failed to load previous block backup: {err}")) + })?; // Load the current chain tip from the database. let chain_tip = self @@ -102,9 +127,7 @@ impl grpc::server::validator_api::SignBlock for ValidatorService { // Persist the signed header. let new_block_num = header.block_num().as_u32(); - self.db.upsert_block_header(header).await.map_err(|err| { - tonic::Status::internal(format!("Failed to persist block header: {}", err.as_report())) - })?; + self.persist_signed_header(header, protocol_config, previous_backup).await?; // Update the in-memory counters after successful persistence. The block has already been // backed up to the block store by `validate_block`, so it is available to subscribers by @@ -115,3 +138,60 @@ impl grpc::server::validator_api::SignBlock for ValidatorService { Ok((signature, block_commitment, self.signer.public_key())) } } + +impl ValidatorService { + /// Resolves and validates the active configuration before the block is signed. + async fn resolve_protocol_config( + &self, + commitment: Word, + supplied: Option, + ) -> tonic::Result { + let stored = self.db.load_protocol_config(commitment).await.map_err(|err| { + tonic::Status::internal(format!("Failed to load protocol config: {}", err.as_report())) + })?; + + match (supplied, stored) { + (Some(supplied), Some(stored)) if supplied != stored => Err(tonic::Status::internal( + format!("Stored protocol config {commitment} differs from the supplied config"), + )), + (Some(supplied), _) => Ok(supplied), + (None, Some(stored)) => Ok(stored), + (None, None) => Err(tonic::Status::invalid_argument(format!( + "Protocol config {commitment} is not stored" + ))), + } + } + + /// Persists the signed header and restores an existing backup if persistence fails. + async fn persist_signed_header( + &self, + header: BlockHeader, + protocol_config: ProtocolConfig, + previous_backup: Option>, + ) -> tonic::Result<()> { + let block_num = header.block_num(); + let Err(err) = self + .db + .upsert_block_header_with_protocol_config(header, Some(protocol_config)) + .await + else { + return Ok(()); + }; + + if let Some(previous_backup) = previous_backup { + self.block_store + .save_block(block_num, &previous_backup) + .await + .map_err(|restore_err| { + tonic::Status::internal(format!( + "Failed to persist block header: {}; failed to restore block backup: {restore_err}", + err.as_report() + )) + })?; + } + Err(tonic::Status::internal(format!( + "Failed to persist block header: {}", + err.as_report() + ))) + } +} diff --git a/bin/validator/src/server/validator_service/tests.rs b/bin/validator/src/server/validator_service/tests.rs index b64b8c785e..332ce8bff6 100644 --- a/bin/validator/src/server/validator_service/tests.rs +++ b/bin/validator/src/server/validator_service/tests.rs @@ -18,19 +18,26 @@ use miden_node_utils::testing::{ use miden_protocol::Word; use miden_protocol::account::AccountUpdateDetails; use miden_protocol::account::auth::AuthScheme; -use miden_protocol::asset::{Asset, FungibleAsset}; +use miden_protocol::asset::{Asset, AssetId, FungibleAsset}; use miden_protocol::batch::OrderedBatches; use miden_protocol::block::{ BlockHeader, BlockInputs, BlockNumber, + BlockSignatures, ProposedBlock, + SignedBlock, ValidatorConfig, }; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; use miden_protocol::crypto::dsa::eddsa_25519_sha512::KeyExchangeKey; use miden_protocol::note::NoteType; -use miden_protocol::testing::account_id::{ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, ACCOUNT_ID_SENDER}; +use miden_protocol::protocol_config::{KernelConfig, ProtocolConfig}; +use miden_protocol::testing::account_id::{ + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1, + ACCOUNT_ID_SENDER, +}; use miden_protocol::testing::random_secret_key::random_secret_key; use miden_protocol::transaction::{ InputNoteCommitment, @@ -78,6 +85,7 @@ struct TestValidator { server: ValidatorService, chain: PartialBlockchain, chain_tip: BlockHeader, + protocol_config: ProtocolConfig, // Keeps the database's temp directory alive for the validator's lifetime: the reader pool opens // connections lazily, so the file must still exist when the first read runs. _temp_dir: tempfile::TempDir, @@ -89,7 +97,8 @@ impl TestValidator { async fn new() -> Self { let key = random_secret_key(); let signer = ValidatorSigner::new_local(key.clone()); - let (temp_dir, db, block_store, genesis_header) = setup_db_with_genesis(&key).await; + let (temp_dir, db, block_store, genesis_header, protocol_config) = + setup_db_with_genesis(&key).await; Self { server: ValidatorService::new( @@ -104,6 +113,7 @@ impl TestValidator { .unwrap(), chain: PartialBlockchain::default(), chain_tip: genesis_header, + protocol_config, _temp_dir: temp_dir, } } @@ -155,6 +165,16 @@ impl TestValidator { async fn call_sign_block( &self, proposed_block: &ProposedBlock, + ) -> Result { + self.call_sign_block_with_protocol_config(proposed_block, Some(&self.protocol_config)) + .await + } + + /// Calls `sign_block` with the selected active protocol configuration payload. + async fn call_sign_block_with_protocol_config( + &self, + proposed_block: &ProposedBlock, + protocol_config: Option<&ProtocolConfig>, ) -> Result { let block_inputs = BlockInputs::new( proposed_block.prev_block_header().clone(), @@ -164,14 +184,14 @@ impl TestValidator { BTreeMap::new(), ); let (block_header, _) = proposed_block.clone().into_header_and_body().unwrap(); - let request = tonic::Request::new( - BlockProofRequest { - tx_batches: OrderedBatches::new(proposed_block.batches().as_slice().to_vec()), - block_header, - block_inputs, - } - .into(), - ); + let mut request: proto::block_proving::BlockProofRequest = BlockProofRequest { + tx_batches: OrderedBatches::new(proposed_block.batches().as_slice().to_vec()), + block_header, + block_inputs, + } + .into(); + request.protocol_config = protocol_config.map(Into::into); + let request = tonic::Request::new(request); validator_api::SignBlock::full(&self.server, request).await } @@ -268,13 +288,14 @@ impl TestValidator { /// of `key`. Returns the database handle and the genesis block header. async fn setup_db_with_genesis( key: &SigningKey, -) -> (tempfile::TempDir, ValidatorDbWriter, BlockStore, BlockHeader) { +) -> (tempfile::TempDir, ValidatorDbWriter, BlockStore, BlockHeader, ProtocolConfig) { + let protocol_config = test_protocol_config(); let genesis_state = GenesisState::new( vec![], test_fee_params(), 0, ValidatorConfig::new(vec![key.public_key()], 1).unwrap(), - test_protocol_config(), + protocol_config.clone(), ); let genesis_block = genesis_state.into_block().unwrap(); let genesis_header = genesis_block.inner().header().clone(); @@ -284,9 +305,14 @@ async fn setup_db_with_genesis( let block_store = BlockStore::bootstrap(dir.path().join("blocks").clone(), &genesis_block).unwrap(); - db.upsert_block_header(genesis_header.clone()).await.unwrap(); + db.upsert_block_header_with_protocol_config( + genesis_header.clone(), + Some(protocol_config.clone()), + ) + .await + .unwrap(); - (dir, db, block_store, genesis_header) + (dir, db, block_store, genesis_header, protocol_config) } /// Builds an empty [`ProposedBlock`] that extends the given parent block header using the provided @@ -427,7 +453,7 @@ async fn proven_transaction_fixture() -> &'static ProvenTransactionFixture { async fn signing_key_mismatch_rejected() { // Seed a database whose genesis designates `genesis_key` as the validator key. let genesis_key = random_secret_key(); - let (_temp_dir, db, block_store, genesis_header) = setup_db_with_genesis(&genesis_key).await; + let (_temp_dir, db, block_store, genesis_header, _) = setup_db_with_genesis(&genesis_key).await; // Start a validator with a different key, modelling a validator configured with the wrong key. let rogue_signer = ValidatorSigner::new_local(random_secret_key()); @@ -480,6 +506,109 @@ async fn sign_block_returns_signed_commitment() { assert!(signature.verify(header.commitment(), &public_key)); } +#[tokio::test] +async fn sign_block_accepts_an_omitted_known_protocol_config() { + let tv = TestValidator::new().await; + let proposed = tv.propose_empty_block(); + + tv.call_sign_block_with_protocol_config(&proposed, None) + .await + .expect("a stored active config may be omitted"); +} + +#[tokio::test] +async fn sign_block_rejects_an_omitted_unknown_protocol_config() { + let tv = TestValidator::new().await; + let commitment = tv.protocol_config.to_commitment(); + crate::db::delete_protocol_config_for_test(&tv.server.db, commitment) + .await + .unwrap(); + let proposed = tv.propose_empty_block(); + + let status = tv + .call_sign_block_with_protocol_config(&proposed, None) + .await + .expect_err("an unknown active config cannot be omitted"); + + assert_eq!(status.code(), tonic::Code::InvalidArgument); + assert_eq!(tv.load_chain_tip().await.block_num(), BlockNumber::GENESIS); + assert_eq!(tv.call_status().await.signed_blocks_count, 0); + assert_eq!( + tv.server.block_store.load_block(1.into()).await.unwrap(), + None, + "an unknown config must be rejected before backup" + ); +} + +#[tokio::test] +async fn sign_block_rejects_a_mismatched_protocol_config_before_signing() { + let tv = TestValidator::new().await; + let proposed = tv.propose_empty_block(); + let mismatched = ProtocolConfig::current(AssetId::new_fungible( + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1.try_into().unwrap(), + )) + .unwrap(); + + let status = tv + .call_sign_block_with_protocol_config(&proposed, Some(&mismatched)) + .await + .expect_err("a config that does not match the reconstructed header must be rejected"); + + assert_eq!(status.code(), tonic::Code::InvalidArgument); + assert_eq!(tv.load_chain_tip().await.block_num(), BlockNumber::GENESIS); + assert_eq!(tv.call_status().await.signed_blocks_count, 0); +} + +#[tokio::test] +async fn sign_block_rejects_corrupt_stored_config_before_replacing_backup() { + let mut tv = TestValidator::new().await; + let genesis_header = tv.chain_tip.clone(); + let chain_at_genesis = tv.chain.clone(); + tv.apply_empty_block().await; + let original_header = tv.chain_tip.clone(); + let original_backup = tv + .server + .block_store + .load_block(original_header.block_num()) + .await + .unwrap() + .unwrap(); + + let block_inputs = BlockInputs::new( + genesis_header.clone(), + chain_at_genesis, + BTreeMap::new(), + BTreeMap::new(), + BTreeMap::new(), + ); + let replacement = + ProposedBlock::new_at(block_inputs, vec![], genesis_header.timestamp() + 1_000_000) + .unwrap(); + + let commitment = tv.protocol_config.to_commitment(); + let mut corrupt_bytes = tv.protocol_config.to_bytes(); + corrupt_bytes.push(0xff); + crate::db::overwrite_protocol_config_for_test(&tv.server.db, commitment, corrupt_bytes) + .await + .unwrap(); + + tv.call_sign_block_with_protocol_config(&replacement, Some(&tv.protocol_config)) + .await + .expect_err("corrupt stored config must reject signing"); + + assert_eq!(tv.load_chain_tip().await, original_header); + assert_eq!( + tv.server + .block_store + .load_block(original_header.block_num()) + .await + .unwrap() + .unwrap(), + original_backup, + "a rejected replacement must not overwrite the committed backup" + ); +} + /// An empty block at chain tip + 1 with the correct previous block commitment should be accepted. #[tokio::test] async fn chain_tip_plus_one_succeeds() { @@ -538,6 +667,15 @@ async fn chain_tip_replacement_succeeds() { original_header.commitment(), "chain tip should no longer be the original block" ); + assert_eq!( + tv.server + .db + .load_protocol_config(replacement_header.protocol_config_commitment()) + .await + .unwrap(), + Some(tv.protocol_config.clone()), + "replacement persistence must retain the active protocol config" + ); } /// A block at chain tip + 2 (skipping a block number) should be rejected. @@ -839,6 +977,19 @@ async fn block_subscription_replays_then_freezes_signing() { .expect("valid signed block"); assert_eq!(block.header().block_num().as_u32(), expected); assert_eq!(response.committed_chain_tip, 2); + if expected == 1 { + let config: ProtocolConfig = response + .protocol_config + .expect("the first response must carry the active protocol config") + .try_into() + .unwrap(); + assert_eq!(config, tv.protocol_config); + } else { + assert!( + response.protocol_config.is_none(), + "an unchanged protocol config must be omitted" + ); + } } // The live subscription holds the backup lock, so no new block can be signed while it is open. @@ -858,6 +1009,117 @@ async fn block_subscription_replays_then_freezes_signing() { .expect("sign_block should succeed once the subscription is dropped"); } +#[tokio::test] +async fn protocol_config_transition_is_streamed_and_used_for_next_signature() { + use std::time::Duration; + + use tokio_stream::StreamExt; + + let mut tv = TestValidator::new().await; + tv.apply_empty_block().await; + + let block_2 = tv.propose_empty_block(); + tv.call_sign_block(&block_2).await.unwrap(); + let (header_2, body_2) = block_2.into_header_and_body().unwrap(); + tv.chain.add_block(&tv.chain_tip, false); + tv.chain_tip = header_2.clone(); + + let next_config = ProtocolConfig::new( + tv.protocol_config.fee_asset_id(), + KernelConfig::new(Word::from([42u32, 0, 0, 0]), vec![]).unwrap(), + tv.protocol_config.batch_kernel().clone(), + tv.protocol_config.block_kernel().clone(), + tv.protocol_config.proof_verification().clone(), + ) + .unwrap(); + let transitioned_header = BlockHeader::new( + header_2.prev_block_commitment(), + header_2.block_num(), + header_2.chain_commitment(), + header_2.account_root(), + header_2.nullifier_root(), + header_2.note_root(), + header_2.tx_commitment(), + header_2.validator_config().clone(), + header_2.fee_parameters().clone(), + next_config.to_commitment(), + header_2.next_protocol_config().cloned(), + header_2.timestamp(), + ); + let signature = tv + .server + .signer + .sign_commitment(transitioned_header.commitment()) + .await + .unwrap(); + let transitioned_block = SignedBlock::new_unchecked( + transitioned_header.clone(), + body_2, + BlockSignatures::new(vec![signature]).unwrap(), + ); + tv.server + .block_store + .save_block(transitioned_header.block_num(), &transitioned_block.to_bytes()) + .await + .unwrap(); + tv.server + .db + .upsert_block_header_with_protocol_config( + transitioned_header.clone(), + Some(next_config.clone()), + ) + .await + .unwrap(); + tv.chain_tip = transitioned_header; + + let mut stream = tv.call_block_subscription(1).await; + let first = tokio::time::timeout(Duration::from_secs(5), stream.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let first_config: ProtocolConfig = first.protocol_config.unwrap().try_into().unwrap(); + assert_eq!(first_config, tv.protocol_config); + let transition = tokio::time::timeout(Duration::from_secs(5), stream.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let streamed_config: ProtocolConfig = transition.protocol_config.unwrap().try_into().unwrap(); + assert_eq!(streamed_config, next_config); + drop(stream); + + let block_3 = tv.propose_empty_block(); + tv.call_sign_block_with_protocol_config(&block_3, Some(&next_config)) + .await + .expect("the validator must sign with the transitioned active config"); +} + +#[tokio::test] +async fn block_subscription_rejects_a_corrupt_protocol_config_before_the_block() { + use std::time::Duration; + + use tokio_stream::StreamExt; + + let mut tv = TestValidator::new().await; + tv.apply_empty_block().await; + let commitment = tv.chain_tip.protocol_config_commitment(); + let mut bytes = tv.protocol_config.to_bytes(); + bytes.push(0xff); + crate::db::overwrite_protocol_config_for_test(&tv.server.db, commitment, bytes) + .await + .unwrap(); + + let mut stream = tv.call_block_subscription(1).await; + let status = tokio::time::timeout(Duration::from_secs(5), stream.next()) + .await + .expect("the stream must reject corrupt storage promptly") + .expect("the stream must emit the storage error") + .expect_err("the block must not be emitted before config validation"); + + assert_eq!(status.code(), tonic::Code::Internal); +} + // SERVE LOCK TESTS // ================================================================================================ // diff --git a/compose/monitor.yml b/compose/monitor.yml index 5337a3c109..6192885cd3 100644 --- a/compose/monitor.yml +++ b/compose/monitor.yml @@ -8,20 +8,15 @@ services: condition: service_started sequencer: condition: service_started - entrypoint: ["/bin/sh", "-ec"] command: - - | - fee_faucet_id="$${MIDEN_MONITOR_FEE_FAUCET_ID:-$$(cat /data/genesis/native-faucet-id)}" - exec miden-network-monitor start --fee-faucet-id "$${fee_faucet_id}" + - miden-network-monitor + - start environment: MIDEN_MONITOR_RPC_URL: http://sequencer:57291 MIDEN_MONITOR_PORT: "3001" MIDEN_MONITOR_NETWORK_NAME: Localhost - MIDEN_MONITOR_FEE_FAUCET_ID: ${MIDEN_MONITOR_FEE_FAUCET_ID:-} # Public key for validator 1's insecure default development signing key. MIDEN_MONITOR_VALIDATOR_SIGNING_PUBLIC_KEY: 031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317 - volumes: - - node-data:/data:ro ports: - "127.0.0.1:3001:3001" diff --git a/crates/block-producer/src/block_builder/mod.rs b/crates/block-producer/src/block_builder/mod.rs index 76c72845c9..9c05b5d5de 100644 --- a/crates/block-producer/src/block_builder/mod.rs +++ b/crates/block-producer/src/block_builder/mod.rs @@ -313,20 +313,31 @@ impl BlockBuilder { ) -> Result { let ProposedBlockAndInputs { proposed_block, block_inputs } = proposal; - // Concurrently build the block and validate it via the validators. + // Resolve the active configuration against the constructed header before requesting + // signatures. let build_result = spawn_blocking_in_current_span({ let proposed_block = proposed_block.clone(); move || proposed_block.into_header_and_body() }); - let responses = self - .validator - .sign_block(&proposed_block, &block_inputs) - .await - .map_err(|err| BuildBlockError::ValidateBlockFailed(err.into()))?; let (header, body) = build_result .await .map_err(|err| BuildBlockError::other(format!("task join error: {err}")))? .map_err(BuildBlockError::ProposeBlockFailed)?; + let commitment = header.protocol_config_commitment(); + let protocol_config = self + .state + .view() + .get_protocol_config(commitment) + .await + .map_err(|err| BuildBlockError::other(err.to_string()))? + .ok_or_else(|| { + BuildBlockError::other(format!("protocol config {commitment} is missing")) + })?; + let responses = self + .validator + .sign_block(&proposed_block, &block_inputs, &protocol_config) + .await + .map_err(|err| BuildBlockError::ValidateBlockFailed(err.into()))?; // Every validator and the block producer must derive the same block from the same proposed // block. Comparing the commitment each validator signed against the locally built one diff --git a/crates/block-producer/src/rpc_sync.rs b/crates/block-producer/src/rpc_sync.rs index 18fdd966fc..1e107b573c 100644 --- a/crates/block-producer/src/rpc_sync.rs +++ b/crates/block-producer/src/rpc_sync.rs @@ -236,6 +236,16 @@ impl BlockSync { .ok_or_else(|| anyhow::anyhow!("upstream block event is missing its block"))? .try_into() .context("failed to decode block from upstream")?; + let protocol_config = event + .protocol_config + .map(|config| { + miden_node_proto::domain::protocol_config::decode_protocol_config( + Some(config), + block.header(), + ) + }) + .transpose() + .context("failed to decode protocol config from upstream")?; // Each synced block gets its own root span: the surrounding `sync` span lives for the // whole subscription, so parenting under it would chain every block into one // never-exported trace. @@ -245,7 +255,7 @@ impl BlockSync { "sync_block", block.number = block.header().block_num().as_u32(), ); - self.writer.apply_block(block).instrument(block_span).await?; + self.writer.apply_block(block, protocol_config).instrument(block_span).await?; let local_tip = self.state.committed_tip(); self.readiness.update(upstream_tip, local_tip).await; diff --git a/crates/block-producer/src/validator/mod.rs b/crates/block-producer/src/validator/mod.rs index 03a665b388..9d7a22c26c 100644 --- a/crates/block-producer/src/validator/mod.rs +++ b/crates/block-producer/src/validator/mod.rs @@ -8,6 +8,7 @@ use miden_node_tracing::{info, miden_instrument}; use miden_protocol::Word; use miden_protocol::block::{BlockInputs, ProposedBlock}; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{PublicKey, Signature}; +use miden_protocol::protocol_config::ProtocolConfig; use thiserror::Error; use url::Url; @@ -92,8 +93,10 @@ impl BlockProducerValidatorClient { &self, proposed_block: &ProposedBlock, block_inputs: &BlockInputs, + protocol_config: &ProtocolConfig, ) -> Result, ValidatorError> { let message = proto::block_proving::BlockProofRequest { + protocol_config: Some(protocol_config.into()), batches: proposed_block.batches().as_slice().iter().map(Into::into).collect(), block_inputs: Some(block_inputs.into()), timestamp: proposed_block.timestamp(), diff --git a/crates/proto/src/domain/mod.rs b/crates/proto/src/domain/mod.rs index 53e99c9cdb..ef8d39e822 100644 --- a/crates/proto/src/domain/mod.rs +++ b/crates/proto/src/domain/mod.rs @@ -2,6 +2,7 @@ pub mod account; pub mod block; pub mod encryption; pub mod proof_request; +pub mod protocol_config; pub mod submission; use miden_node_tracing::{RecordAttribute, Value}; diff --git a/crates/proto/src/domain/proof_request.rs b/crates/proto/src/domain/proof_request.rs index e5fc3fdf51..d5a138be11 100644 --- a/crates/proto/src/domain/proof_request.rs +++ b/crates/proto/src/domain/proof_request.rs @@ -34,6 +34,7 @@ impl From<&BlockProofRequest> for proto::block_proving::BlockProofRequest { timestamp: value.block_header.timestamp(), next_validator_config: Some(value.block_header.validator_config().into()), next_protocol_config: value.block_header.next_protocol_config().map(Into::into), + protocol_config: None, } } } diff --git a/crates/proto/src/domain/protocol_config.rs b/crates/proto/src/domain/protocol_config.rs new file mode 100644 index 0000000000..5e8d822365 --- /dev/null +++ b/crates/proto/src/domain/protocol_config.rs @@ -0,0 +1,63 @@ +//! Protocol configuration validation at RPC boundaries. + +use miden_protocol::block::BlockHeader; +use miden_protocol::protocol_config::ProtocolConfig; + +use crate::errors::ConversionError; +use crate::generated::protocol_config::ProtocolConfig as ProtoProtocolConfig; + +/// Decodes a required configuration and verifies the header's commitment. +pub fn decode_protocol_config( + config: Option, + header: &BlockHeader, +) -> Result { + let config: ProtocolConfig = config + .ok_or_else(|| ConversionError::message("protocol config is missing"))? + .try_into() + .map_err(ConversionError::from)?; + let calculated = config.to_commitment(); + let expected = header.protocol_config_commitment(); + if calculated != expected { + return Err(ConversionError::message(format!( + "protocol config commitment {calculated} does not match header commitment {expected}" + ))); + } + Ok(config) +} + +#[cfg(test)] +mod tests { + use miden_protocol::Word; + use miden_protocol::asset::AssetId; + use miden_protocol::block::FeeParameters; + use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1; + + use super::*; + + #[test] + fn accepts_only_present_valid_matching_config() { + let config = ProtocolConfig::current(AssetId::new_fungible( + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1.try_into().unwrap(), + )) + .unwrap(); + let other_header = BlockHeader::mock(0, None, None, &[]); + let header = BlockHeader::new( + Word::empty(), + 0.into(), + Word::empty(), + Word::empty(), + Word::empty(), + Word::empty(), + Word::empty(), + other_header.validator_config().clone(), + FeeParameters::new(0), + config.to_commitment(), + None, + 0, + ); + assert!(decode_protocol_config(None, &header).is_err()); + assert!(decode_protocol_config(Some(ProtoProtocolConfig::default()), &header).is_err()); + assert_eq!(decode_protocol_config(Some((&config).into()), &header).unwrap(), config); + assert!(decode_protocol_config(Some(config.into()), &other_header).is_err()); + } +} diff --git a/crates/rpc/src/server/api.rs b/crates/rpc/src/server/api.rs index 1fad01cf04..ae64a2375f 100644 --- a/crates/rpc/src/server/api.rs +++ b/crates/rpc/src/server/api.rs @@ -34,6 +34,18 @@ use crate::server::api::subscription::{IpBanList, MAX_REPLICA_SUBSCRIPTIONS}; use crate::server::{NetworkTxAuth, RpcBackend}; use crate::{COMPONENT, LOG_TARGET}; +/// Loads the configuration committed to by a stored header. +async fn load_protocol_config( + view: &miden_node_store::state::StateView, + header: &BlockHeader, +) -> tonic::Result { + let commitment = header.protocol_config_commitment(); + view.get_protocol_config(commitment) + .await + .map_err(|err| Status::internal(err.to_string()))? + .ok_or_else(|| Status::internal(format!("protocol config {commitment} is missing"))) +} + // VALIDATOR FAN-OUT // ================================================================================================ @@ -165,6 +177,7 @@ impl RpcService { proto::rpc::BlockHeaderByNumberRequest { block_num: Some(BlockNumber::GENESIS.as_u32()), include_mmr_proof: None, + include_protocol_config: None, } .into_request(), ) diff --git a/crates/rpc/src/server/api/get_block_header_by_number.rs b/crates/rpc/src/server/api/get_block_header_by_number.rs index 0c9c6a242d..ac32a6a45b 100644 --- a/crates/rpc/src/server/api/get_block_header_by_number.rs +++ b/crates/rpc/src/server/api/get_block_header_by_number.rs @@ -41,14 +41,21 @@ impl proto::server::rpc_api::GetBlockHeaderByNumber for RpcService { ); let block_num = request.block_num.map(BlockNumber::from); - let (block_header, mmr_proof) = self - .state - .view() + let view = self.state.view(); + let (block_header, mmr_proof) = view .get_block_header(block_num, request.include_mmr_proof.unwrap_or(false)) .await .map_err(super::get_block_header_error_to_status)?; + let protocol_config = match block_header.as_ref() { + Some(header) if request.include_protocol_config.unwrap_or(false) => { + Some(super::load_protocol_config(&view, header).await?.into()) + }, + _ => None, + }; + Ok(proto::rpc::BlockHeaderByNumberResponse { + protocol_config, block_header: block_header.map(Into::into), chain_length: mmr_proof.as_ref().map(|p| p.forest().num_leaves() as u32), mmr_path: mmr_proof.map(|p| Into::into(p.merkle_path())), diff --git a/crates/rpc/src/server/api/subscription/block.rs b/crates/rpc/src/server/api/subscription/block.rs index 2fb9755771..4df36809f1 100644 --- a/crates/rpc/src/server/api/subscription/block.rs +++ b/crates/rpc/src/server/api/subscription/block.rs @@ -1,3 +1,7 @@ +use std::pin::Pin; +use std::sync::Arc; + +use futures::{Stream, TryStreamExt}; use miden_node_proto::generated as proto; use miden_node_tracing::{debug, miden_instrument}; use miden_node_utils::grpc::ClientIp; @@ -5,26 +9,21 @@ use miden_protocol::block::{BlockNumber, SignedBlock}; use miden_protocol::utils::serde::Deserializable; use super::super::{COMPONENT, RpcService}; -use super::stream::{StreamItem, SubscriptionStream}; +use super::stream::SubscriptionStream; use crate::LOG_TARGET; #[tonic::async_trait] impl proto::server::rpc_api::BlockSubscription for RpcService { type Input = BlockNumber; - type Item = StreamItem; - type ItemStream = SubscriptionStream; + type Item = proto::rpc::BlockSubscriptionResponse; + type ItemStream = Pin> + Send>>; fn decode(request: proto::rpc::BlockSubscriptionRequest) -> tonic::Result { Ok(BlockNumber::from(request.block_from)) } fn encode(event: Self::Item) -> tonic::Result { - let block = SignedBlock::read_from_bytes(&event.data) - .map_err(|err| tonic::Status::internal(format!("invalid stored block: {err}")))?; - Ok(proto::rpc::BlockSubscriptionResponse { - block: Some(block.into()), - committed_chain_tip: event.tip.as_u32(), - }) + Ok(event) } #[miden_instrument( @@ -45,7 +44,34 @@ impl proto::server::rpc_api::BlockSubscription for RpcService { debug!(target: LOG_TARGET, "Subscribing to blocks"); - let from = input; - SubscriptionStream::blocks(self, from, client_ip) + let stream = SubscriptionStream::blocks(self, input, client_ip)?; + let state = Arc::clone(&self.state); + Ok(Box::pin(futures::stream::try_unfold( + (stream, state, None), + |(mut stream, state, previous)| async move { + let Some(event) = stream.try_next().await? else { + return Ok(None); + }; + let block = SignedBlock::read_from_bytes(&event.data).map_err(|err| { + tonic::Status::internal(format!("invalid stored block: {err}")) + })?; + let commitment = block.header().protocol_config_commitment(); + let protocol_config = if previous == Some(commitment) { + None + } else { + Some( + super::super::load_protocol_config(&state.view(), block.header()) + .await? + .into(), + ) + }; + let response = proto::rpc::BlockSubscriptionResponse { + block: Some(block.into()), + committed_chain_tip: event.tip.as_u32(), + protocol_config, + }; + Ok(Some((response, (stream, state, Some(commitment))))) + }, + ))) } } diff --git a/crates/rpc/src/server/api/sync_chain_mmr.rs b/crates/rpc/src/server/api/sync_chain_mmr.rs index 47db61779c..9d0f8611d3 100644 --- a/crates/rpc/src/server/api/sync_chain_mmr.rs +++ b/crates/rpc/src/server/api/sync_chain_mmr.rs @@ -56,15 +56,34 @@ impl proto::server::rpc_api::SyncChainMmr for RpcService { } let block_range = current_client_block_height..=sync_target; + let view = self.state.view(); let (mmr_delta, block_header, block_signatures) = - self.state.view().sync_chain_mmr(block_range.clone()).await.map_err( - |err| match err { - StateSyncError::RangeBeyondTip(_) => Status::invalid_argument(err.to_string()), - _ => Status::internal(err.to_string()), - }, - )?; + view.sync_chain_mmr(block_range.clone()).await.map_err(|err| match err { + StateSyncError::RangeBeyondTip(_) => Status::invalid_argument(err.to_string()), + _ => Status::internal(err.to_string()), + })?; + + let include_config = if current_client_block_height == BlockNumber::GENESIS { + true + } else if current_client_block_height == sync_target { + false + } else { + let (start, _) = view + .get_block_header(Some(current_client_block_height), false) + .await + .map_err(super::get_block_header_error_to_status)?; + let start = + start.ok_or_else(|| Status::internal("starting block header is missing"))?; + start.protocol_config_commitment() != block_header.protocol_config_commitment() + }; + let protocol_config = if include_config { + Some(super::load_protocol_config(&view, &block_header).await?.into()) + } else { + None + }; Ok(proto::rpc::SyncChainMmrResponse { + protocol_config, block_range: Some(proto::rpc::BlockRange { block_from: block_range.start().as_u32(), block_to: block_range.end().as_u32(), diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index 2355b85d87..d915ec0fc9 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -114,6 +114,7 @@ fn new_tempdir() -> std::path::PathBuf { /// A wrapper around the loaded store state and its backing data directory. struct TestStore { state: Arc, + writer: miden_node_store::state::BlockWriter, genesis_commitment: Word, data_directory: std::path::PathBuf, } @@ -143,9 +144,10 @@ impl TestStore { let data_directory = new_tempdir(); let genesis_commitment = Self::bootstrap_with_base_fee(&data_directory, verification_base_fee); - let (state, ..) = State::for_tests(&data_directory).await; + let (state, writer, ..) = State::for_tests(&data_directory).await; Self { state, + writer, genesis_commitment, data_directory, } @@ -158,9 +160,10 @@ impl TestStore { let data_directory = new_tempdir(); let genesis_commitment = Self::bootstrap_from_mock_genesis(&data_directory, genesis_block, protocol_config); - let (state, ..) = State::for_tests(&data_directory).await; + let (state, writer, ..) = State::for_tests(&data_directory).await; Self { state, + writer, genesis_commitment, data_directory, } @@ -431,6 +434,7 @@ async fn rpc_server_accepts_requests_without_accept_header() { let request = proto::rpc::BlockHeaderByNumberRequest { block_num: Some(0), include_mmr_proof: None, + include_protocol_config: None, }; let response = rpc_client.get_block_header_by_number(request).await; @@ -1518,6 +1522,7 @@ async fn send_request( let request = proto::rpc::BlockHeaderByNumberRequest { block_num: Some(0), include_mmr_proof: None, + include_protocol_config: None, }; rpc_client.get_block_header_by_number(request).await } @@ -1671,6 +1676,7 @@ async fn get_limits_endpoint() { #[tokio::test] async fn sync_chain_mmr_returns_delta() { + use miden_protocol::block::BlockHeader; let (mut rpc_client, _rpc_addr, _store, _server) = start_rpc().await; let request = proto::rpc::SyncChainMmrRequest { @@ -1683,6 +1689,45 @@ async fn sync_chain_mmr_returns_delta() { let mmr_delta = response.mmr_delta.expect("mmr_delta should exist"); assert_eq!(mmr_delta.forest, 0); assert!(mmr_delta.update_data.is_empty()); + let config: ProtocolConfig = + response.protocol_config.expect("genesis config").try_into().unwrap(); + let header: BlockHeader = response.block_header.unwrap().try_into().unwrap(); + assert_eq!(config.to_commitment(), header.protocol_config_commitment()); +} + +#[tokio::test] +async fn header_protocol_config_is_opt_in() { + use miden_protocol::block::BlockHeader; + let (mut client, _, _store, _server) = start_rpc().await; + for include in [None, Some(false), Some(true)] { + let response = client + .get_block_header_by_number(proto::rpc::BlockHeaderByNumberRequest { + block_num: Some(0), + include_mmr_proof: Some(true), + include_protocol_config: include, + }) + .await + .unwrap() + .into_inner(); + assert_eq!(response.protocol_config.is_some(), include == Some(true)); + assert!(response.mmr_path.is_some()); + if let Some(config) = response.protocol_config { + let config: ProtocolConfig = config.try_into().unwrap(); + let header: BlockHeader = response.block_header.unwrap().try_into().unwrap(); + assert_eq!(config.to_commitment(), header.protocol_config_commitment()); + } + } + let response = client + .get_block_header_by_number(proto::rpc::BlockHeaderByNumberRequest { + block_num: Some(1), + include_mmr_proof: None, + include_protocol_config: Some(true), + }) + .await + .unwrap() + .into_inner(); + assert!(response.block_header.is_none()); + assert!(response.protocol_config.is_none()); } #[test] @@ -1744,6 +1789,170 @@ async fn sync_nullifiers_rejects_prefix_above_u16() { ); } +#[tokio::test] +async fn block_subscription_starts_with_matching_config() { + let (mut client, _, _store, _server) = start_rpc().await; + let mut stream = client + .block_subscription(proto::rpc::BlockSubscriptionRequest { block_from: 0 }) + .await + .unwrap() + .into_inner(); + let event = stream.message().await.unwrap().unwrap(); + let block: SignedBlock = event.block.unwrap().try_into().unwrap(); + let config: ProtocolConfig = event.protocol_config.expect("initial config").try_into().unwrap(); + assert_eq!(config.to_commitment(), block.header().protocol_config_commitment()); +} + +async fn config_block(store: &TestStore, config: &ProtocolConfig) -> SignedBlock { + use miden_protocol::block::{BlockBody, BlockHeader}; + use miden_protocol::crypto::merkle::mmr::Mmr; + use miden_protocol::transaction::OrderedTransactionHeaders; + let view = store.state.view(); + let (parent, _) = view.get_block_header(None, false).await.unwrap(); + let parent = parent.unwrap(); + let mut mmr = Mmr::new(); + for height in 0..=parent.block_num().as_u32() { + let (header, _) = view.get_block_header(Some(height.into()), false).await.unwrap(); + mmr.add(header.unwrap().commitment()).unwrap(); + } + let body = + BlockBody::new(vec![], vec![], vec![], OrderedTransactionHeaders::new_unchecked(vec![])) + .unwrap(); + let header = BlockHeader::new( + parent.commitment(), + parent.block_num().child(), + mmr.peaks().hash_peaks(), + parent.account_root(), + parent.nullifier_root(), + body.compute_block_note_tree().root(), + body.transaction_commitment(), + parent.validator_config().clone(), + parent.fee_parameters().clone(), + config.to_commitment(), + None, + parent.timestamp() + 1, + ); + SignedBlock::new_unchecked(header, body, BlockSignatures::new(vec![]).unwrap()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn protocol_config_transitions_follow_response_headers() { + use miden_node_proto::domain::protocol_config::decode_protocol_config; + use miden_protocol::block::BlockHeader; + use miden_protocol::protocol_config::KernelConfig; + let (mut client, _, mut store, _server) = start_rpc().await; + let (genesis, _) = store.state.view().get_block_header(Some(0.into()), false).await.unwrap(); + let genesis = genesis.unwrap(); + let a = store + .state + .view() + .get_protocol_config(genesis.protocol_config_commitment()) + .await + .unwrap() + .unwrap(); + let b = ProtocolConfig::new( + a.fee_asset_id(), + KernelConfig::new(Word::from([42u32, 0, 0, 0]), vec![]).unwrap(), + a.batch_kernel().clone(), + a.block_kernel().clone(), + a.proof_verification().clone(), + ) + .unwrap(); + for config in [&a, &b, &b, &a] { + let block = config_block(&store, config).await; + store.writer.apply_block(block, Some(config.clone())).await.unwrap(); + } + for (height, included) in [(0, true), (1, false), (2, true), (3, true), (4, false)] { + let response = client + .sync_chain_mmr(proto::rpc::SyncChainMmrRequest { + current_client_block_height: height, + finality_level: proto::rpc::FinalityLevel::Committed.into(), + }) + .await + .unwrap() + .into_inner(); + assert_eq!(response.protocol_config.is_some(), included); + let header: BlockHeader = response.block_header.unwrap().try_into().unwrap(); + assert_eq!(header.block_num(), 4.into()); + if included { + assert_eq!(decode_protocol_config(response.protocol_config, &header).unwrap(), a); + } + } + let proven = client + .sync_chain_mmr(proto::rpc::SyncChainMmrRequest { + current_client_block_height: 0, + finality_level: proto::rpc::FinalityLevel::Proven.into(), + }) + .await + .unwrap() + .into_inner(); + let header: BlockHeader = proven.block_header.unwrap().try_into().unwrap(); + assert_eq!(header.block_num(), 0.into()); + assert_eq!(decode_protocol_config(proven.protocol_config, &header).unwrap(), a); + for start in [1, 2] { + let mut stream = client + .block_subscription(proto::rpc::BlockSubscriptionRequest { block_from: start }) + .await + .unwrap() + .into_inner(); + for height in start..=4 { + let response = stream.message().await.unwrap().unwrap(); + let block: SignedBlock = response.block.unwrap().try_into().unwrap(); + assert_eq!(block.header().block_num(), height.into()); + let included = height == start || height == 2 || height == 4; + assert_eq!(response.protocol_config.is_some(), included); + if included { + let expected = if height == 2 || height == 3 { &b } else { &a }; + assert_eq!( + &decode_protocol_config(response.protocol_config, block.header()).unwrap(), + expected + ); + } + } + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn invalid_protocol_config_does_not_advance_store() { + use miden_protocol::asset::AssetId; + use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1; + let mut store = TestStore::start().await; + let (header, _) = store.state.view().get_block_header(None, false).await.unwrap(); + let header = header.unwrap(); + let initial = store + .state + .view() + .get_protocol_config(header.protocol_config_commitment()) + .await + .unwrap() + .unwrap(); + let config = ProtocolConfig::current(AssetId::new_fungible( + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1.try_into().unwrap(), + )) + .unwrap(); + assert_ne!(config.to_commitment(), initial.to_commitment()); + let block = config_block(&store, &config).await; + assert!(store.writer.apply_block(block.clone(), None).await.is_err()); + assert!(store.writer.apply_block(block.clone(), Some(initial)).await.is_err()); + assert_eq!(store.state.committed_tip(), 0.into()); + assert!(store.state.load_block(1.into()).await.unwrap().is_none()); + assert!( + store + .state + .view() + .get_protocol_config(config.to_commitment()) + .await + .unwrap() + .is_none() + ); + store.writer.apply_block(block, Some(config.clone())).await.unwrap(); + assert_eq!(store.state.committed_tip(), 1.into()); + assert_eq!( + store.state.view().get_protocol_config(config.to_commitment()).await.unwrap(), + Some(config) + ); +} + /// All paginated sync endpoints must reject a `block_to` that is greater than the chain tip. /// /// After bootstrapping, the chain tip is the genesis block (0), so a range ending at block 1 is diff --git a/crates/store/src/db/mod.rs b/crates/store/src/db/mod.rs index 3d56c96633..4dc9428f4a 100644 --- a/crates/store/src/db/mod.rs +++ b/crates/store/src/db/mod.rs @@ -637,12 +637,18 @@ impl Db { pub(crate) async fn apply_block( &self, signed_block: SignedBlock, + protocol_config: Option, notes: Vec<(NoteRecord, Option)>, precomputed_public_states: PrecomputedPublicAccountStates, unresolved_note_nullifiers: Vec, prune_tip: BlockNumber, ) -> Result> { self.transact("apply block", move |conn| { + queries::ensure_protocol_config( + conn, + signed_block.header().protocol_config_commitment(), + protocol_config.as_ref(), + )?; models::queries::apply_block(conn, &signed_block, ¬es, &precomputed_public_states)?; models::queries::prune_history(conn, prune_tip)?; diff --git a/crates/store/src/db/models/queries/protocol_configs.rs b/crates/store/src/db/models/queries/protocol_configs.rs index cabfb889ee..967232b830 100644 --- a/crates/store/src/db/models/queries/protocol_configs.rs +++ b/crates/store/src/db/models/queries/protocol_configs.rs @@ -6,6 +6,28 @@ use miden_protocol::utils::serde::{ByteReader, Deserializable, Serializable, Sli use crate::db::schema::protocol_configs; use crate::errors::DatabaseError; +/// Ensures that a block's active configuration is stored in the current transaction. +pub(crate) fn ensure_protocol_config( + conn: &mut SqliteConnection, + commitment: Word, + supplied: Option<&ProtocolConfig>, +) -> Result<(), DatabaseError> { + if let Some(config) = supplied { + let calculated = config.to_commitment(); + if calculated != commitment { + return Err(DatabaseError::ProtocolConfigCommitmentMismatch { + expected: commitment, + calculated, + }); + } + } + if select_protocol_config(conn, commitment)?.is_none() { + let config = supplied.ok_or(DatabaseError::ProtocolConfigNotFound(commitment))?; + insert_protocol_config(conn, config)?; + } + Ok(()) +} + /// Inserts a protocol configuration by its commitment. pub(crate) fn insert_protocol_config( conn: &mut SqliteConnection, @@ -80,6 +102,35 @@ mod tests { assert_eq!(select_protocol_config(&mut conn, commitment).unwrap(), Some(config)); } + #[test] + fn block_config_requires_a_known_matching_configuration() { + let mut conn = connection(); + let config = test_protocol_config(); + let commitment = config.to_commitment(); + assert!(super::ensure_protocol_config(&mut conn, commitment, None).is_err()); + assert!(super::ensure_protocol_config(&mut conn, Word::empty(), Some(&config)).is_err()); + assert_eq!(select_protocol_config(&mut conn, commitment).unwrap(), None); + super::ensure_protocol_config(&mut conn, commitment, Some(&config)).unwrap(); + super::ensure_protocol_config(&mut conn, commitment, Some(&config)).unwrap(); + super::ensure_protocol_config(&mut conn, commitment, None).unwrap(); + assert_eq!(select_protocol_config(&mut conn, commitment).unwrap(), Some(config)); + } + + #[test] + fn config_insert_rolls_back_with_the_block_transaction() { + use diesel::Connection; + let mut conn = connection(); + let config = test_protocol_config(); + let commitment = config.to_commitment(); + let result: Result<(), DatabaseError> = conn.transaction(|conn| { + super::ensure_protocol_config(conn, commitment, Some(&config))?; + assert_eq!(select_protocol_config(conn, commitment)?, Some(config.clone())); + Err(DatabaseError::DataCorrupted("block rejected".into())) + }); + assert!(result.is_err()); + assert_eq!(select_protocol_config(&mut conn, commitment).unwrap(), None); + } + #[test] fn returns_none_for_unknown_commitment() { let mut conn = connection(); diff --git a/crates/store/src/errors.rs b/crates/store/src/errors.rs index fd7dfe2e91..15f4edb531 100644 --- a/crates/store/src/errors.rs +++ b/crates/store/src/errors.rs @@ -92,6 +92,8 @@ pub enum DatabaseError { "protocol config commitment mismatch (expected {expected}, but calculated is {calculated})" )] ProtocolConfigCommitmentMismatch { expected: Word, calculated: Word }, + #[error("protocol config {0} is missing")] + ProtocolConfigNotFound(Word), #[error("account {0} not found")] AccountNotFoundInDb(AccountId), #[error("accounts {0:?} not found")] diff --git a/crates/store/src/state/writer/apply_block.rs b/crates/store/src/state/writer/apply_block.rs index bc7e651d10..87c3048bbf 100644 --- a/crates/store/src/state/writer/apply_block.rs +++ b/crates/store/src/state/writer/apply_block.rs @@ -35,7 +35,7 @@ impl BlockWriter { .await .map_err(ApplyBlockWithProvingInputsError::SaveProvingInputs)?; - self.apply_block(signed_block) + self.apply_block(signed_block, None) .await .map_err(ApplyBlockWithProvingInputsError::ApplyBlock) } diff --git a/crates/store/src/state/writer/mod.rs b/crates/store/src/state/writer/mod.rs index efbd166bd7..27925293a8 100644 --- a/crates/store/src/state/writer/mod.rs +++ b/crates/store/src/state/writer/mod.rs @@ -19,6 +19,7 @@ use std::task::{Context, Poll}; use miden_node_tracing::{ErrorReport, miden_instrument}; use miden_protocol::block::SignedBlock; +use miden_protocol::protocol_config::ProtocolConfig; use tokio::sync::{mpsc, oneshot}; pub(in crate::state) use worker::WriteWorker; @@ -89,6 +90,7 @@ impl Future for WriterTask { /// A request to apply a block, paired with a one-shot channel for the result. pub(super) struct WriteRequest { signed_block: SignedBlock, + protocol_config: Option, result_tx: oneshot::Sender>, /// Span of the `apply_block` caller. The worker runs the write under it, keeping the write path /// in the caller's trace across the channel hop. @@ -121,6 +123,9 @@ impl BlockWriter { /// Apply changes of a new block to the DB and in-memory data structures. /// + /// Supply the active configuration if its commitment is not yet stored. The configuration + /// must match the block header. New configurations are committed with the block. + /// /// Blocks are forwarded to the store's write worker task, which processes them one at a /// time. /// Readers are unaffected while a block is being applied: they keep reading from the previous @@ -129,11 +134,16 @@ impl BlockWriter { target = COMPONENT, err, )] - pub async fn apply_block(&mut self, signed_block: SignedBlock) -> Result<(), ApplyBlockError> { + pub async fn apply_block( + &mut self, + signed_block: SignedBlock, + protocol_config: Option, + ) -> Result<(), ApplyBlockError> { let (result_tx, result_rx) = oneshot::channel(); self.write_tx .send(WriteRequest { signed_block, + protocol_config, result_tx, span: miden_node_tracing::Span::current(), }) diff --git a/crates/store/src/state/writer/worker.rs b/crates/store/src/state/writer/worker.rs index 77ec551762..f57ba8658c 100644 --- a/crates/store/src/state/writer/worker.rs +++ b/crates/store/src/state/writer/worker.rs @@ -20,6 +20,7 @@ use miden_protocol::block::nullifier_tree::{NullifierMutationSet, NullifierTree} use miden_protocol::block::{BlockBody, BlockHeader, BlockNumber, Blockchain, SignedBlock}; use miden_protocol::crypto::merkle::smt::LargeSmt; use miden_protocol::note::{NoteDetails, Nullifier}; +use miden_protocol::protocol_config::ProtocolConfig; use miden_protocol::transaction::OutputNote; use miden_protocol::utils::serde::Serializable; use rayon::ThreadPool; @@ -160,7 +161,10 @@ impl WriteWorker { None => break, }, }; - let result = self.write_block(req.signed_block).instrument(req.span).await; + let result = self + .write_block(req.signed_block, req.protocol_config) + .instrument(req.span) + .await; let _ = req.result_tx.send(result); } } @@ -186,7 +190,11 @@ impl WriteWorker { target = COMPONENT, err, )] - async fn write_block(&mut self, signed_block: SignedBlock) -> Result<(), ApplyBlockError> { + async fn write_block( + &mut self, + signed_block: SignedBlock, + protocol_config: Option, + ) -> Result<(), ApplyBlockError> { let header = signed_block.header(); let body = signed_block.body(); @@ -201,6 +209,21 @@ impl WriteWorker { ); self.validate_block_header(header).await?; + let commitment = header.protocol_config_commitment(); + if let Some(config) = protocol_config.as_ref() { + let calculated = config.to_commitment(); + if calculated != commitment { + return Err(crate::errors::DatabaseError::ProtocolConfigCommitmentMismatch { + expected: commitment, + calculated, + } + .into()); + } + } + let stored = self.db.select_protocol_config_by_commitment(commitment).await?; + if stored.is_none() && protocol_config.is_none() { + return Err(crate::errors::DatabaseError::ProtocolConfigNotFound(commitment).into()); + } let block_lifecycle = lifecycle_events_enabled().then(|| BlockLifecycle::from_block_body(block_num, body)); @@ -237,6 +260,7 @@ impl WriteWorker { .db .apply_block( signed_block, + protocol_config, notes, precomputed_public_states, unresolved_note_nullifiers, diff --git a/docker-compose.yml b/docker-compose.yml index 5d659e6526..cd401b9f51 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,8 +9,6 @@ # - MIDEN_SEED_RPC_URL and MIDEN_SEED_VALIDATOR_1_SIGNING_PUBLIC_KEY through # MIDEN_SEED_VALIDATOR_3_SIGNING_PUBLIC_KEY: overrides used by the on-demand # seed service. -# - MIDEN_MONITOR_FEE_FAUCET_ID: optional override for the fee faucet used by -# monitor transaction checks. It defaults to the local genesis faucet. # - MIDEN_NODE_IMAGE, MIDEN_VALIDATOR_IMAGE, MIDEN_NTX_BUILDER_IMAGE, # MIDEN_REMOTE_PROVER_IMAGE, MIDEN_NETWORK_MONITOR_IMAGE, # MIDEN_BENCHMARK_IMAGE, MIDEN_FAUCET_IMAGE, and MIDEN_NOTE_TRANSPORT_IMAGE: diff --git a/docs/external/src/network-operator/monitoring.md b/docs/external/src/network-operator/monitoring.md index ae008f983e..296ca6e2db 100644 --- a/docs/external/src/network-operator/monitoring.md +++ b/docs/external/src/network-operator/monitoring.md @@ -17,11 +17,11 @@ configuration. configuration, it can check RPC freshness, validator health, remote prover status, faucet availability, explorer availability, note transport, and end-to-end network transaction flows. -End-to-end transaction checks require the native fee faucet ID and the validator's signing public key. Set -`MIDEN_MONITOR_FEE_FAUCET_ID` to the account ID printed by `miden-validator genesis`. Set +End-to-end transaction checks require the validator's signing public key. Set `MIDEN_MONITOR_VALIDATOR_SIGNING_PUBLIC_KEY` to the validator key's hex encoding. The monitor uses this key to verify -the validator's transaction encryption key before it submits private inputs. Remote transaction-prover probes also -require the fee faucet ID. +the validator's transaction encryption key before it submits private inputs. It obtains the active fee asset from RPC +and verifies the returned protocol configuration against each transaction's reference block. Remote transaction-prover +probes use the same RPC configuration discovery. Use the binary help output for the current configuration surface: diff --git a/docs/external/src/rpc/public-api.md b/docs/external/src/rpc/public-api.md index f2c73c72e4..5f30b65479 100644 --- a/docs/external/src/rpc/public-api.md +++ b/docs/external/src/rpc/public-api.md @@ -22,13 +22,13 @@ grpcurl rpc.testnet.miden.io:443 describe rpc.Api ## State Queries -| Method | Purpose | -| ------------------------ | -------------------------------------------------------------------------------- | -| `GetAccount` | Returns account witness data and optional details for public accounts. | -| `GetBlockByNumber` | Returns raw block data for a block number, optionally including the block proof. | -| `GetBlockHeaderByNumber` | Returns a block header and, optionally, MMR authentication data. | -| `GetNotesById` | Returns committed notes matching the requested note IDs. | -| `GetNoteScriptByRoot` | Returns a note script by script root when available. | +| Method | Purpose | +| ------------------------ | ------------------------------------------------------------------------------------ | +| `GetAccount` | Returns account witness data and optional details for public accounts. | +| `GetBlockByNumber` | Returns raw block data for a block number, optionally including the block proof. | +| `GetBlockHeaderByNumber` | Returns a block header and, optionally, MMR authentication data and protocol config. | +| `GetNotesById` | Returns committed notes matching the requested note IDs. | +| `GetNoteScriptByRoot` | Returns a note script by script root when available. | ## Transaction Submission @@ -73,7 +73,7 @@ codes returned in gRPC status details. | `SyncNullifiers` | Returns nullifiers matching specified 16-bit prefixes within a block range. | | `SyncAccountVault` | Returns public account vault updates within a block range. | | `SyncAccountStorageMaps` | Returns public account storage map updates within a block range. | -| `SyncChainMmr` | Returns MMR delta information needed to synchronize the chain MMR. | +| `SyncChainMmr` | Returns the chain MMR delta, target header, and protocol config when required. | Use `GetLimits` to discover the maximum request sizes accepted by the node before batching large sync requests. diff --git a/docs/external/src/rpc/subscriptions.md b/docs/external/src/rpc/subscriptions.md index 6e2fe37eeb..b3dccf2d9c 100644 --- a/docs/external/src/rpc/subscriptions.md +++ b/docs/external/src/rpc/subscriptions.md @@ -16,6 +16,10 @@ Each response includes: - `block`: the serialized block. - `committed_chain_tip`: the current committed chain tip observed when the item was emitted. +- `protocol_config`: the active configuration, included with the first response and when its commitment changes. + +Verify each configuration against the accompanying block header. Retain the configuration for subsequent responses that +omit it. A new subscription includes the first block's configuration, even when `block_from` is not genesis. ## ProofSubscription diff --git a/proto/proto/internal/validator.proto b/proto/proto/internal/validator.proto index 22321b7472..5f0a1a44fc 100644 --- a/proto/proto/internal/validator.proto +++ b/proto/proto/internal/validator.proto @@ -4,6 +4,7 @@ package validator; import "block.proto"; import "primitives.proto"; +import "protocol_config.proto"; import "types/block_proving.proto"; import "types/submission.proto"; import "google/protobuf/empty.proto"; @@ -62,6 +63,10 @@ message BlockSubscriptionResponse { // The signed chain tip when this item was emitted. fixed32 committed_chain_tip = 2; + + // The active configuration. Included with the first response and when its commitment changes. + // Verify its commitment against the accompanying block header. + protocol_config.ProtocolConfig protocol_config = 3; } // VALIDATOR STATUS diff --git a/proto/proto/rpc.proto b/proto/proto/rpc.proto index 65034874c6..ad0f064d93 100644 --- a/proto/proto/rpc.proto +++ b/proto/proto/rpc.proto @@ -10,6 +10,7 @@ import "block.proto"; import "block_number.proto"; import "note.proto"; import "primitives.proto"; +import "protocol_config.proto"; import "transaction.proto"; import "types/submission.proto"; @@ -140,6 +141,10 @@ message BlockSubscriptionResponse { // The committed chain tip when this item was emitted. fixed32 committed_chain_tip = 2; + + // The active configuration. Included with the first response and when its commitment changes. + // Verify its commitment against the accompanying block header. + protocol_config.ProtocolConfig protocol_config = 3; } // PROOF SUBSCRIPTION @@ -229,6 +234,8 @@ message BlockHeaderByNumberRequest { optional uint32 block_num = 1; // Whether or not to return authentication data for the block header. optional bool include_mmr_proof = 2; + // Include the active configuration for the returned header. Defaults to false. + optional bool include_protocol_config = 3; } // Represents the result of getting a block header by block number. @@ -241,6 +248,9 @@ message BlockHeaderByNumberResponse { // Current chain length. optional fixed32 chain_length = 3; + + // Included when requested and the header exists. Verify its commitment against that header. + protocol_config.ProtocolConfig protocol_config = 4; } // GET BLOCK BY NUMBER @@ -654,6 +664,11 @@ message SyncChainMmrResponse { // Validator signatures for the sync target, ordered positionally with respect to the // validator set committed to by the parent block's header. repeated primitives.Signature block_signatures = 4; + + // The target header's active configuration. Included when the client starts at genesis or + // the starting and target headers have different configuration commitments. + // Verify its commitment against the target header. + protocol_config.ProtocolConfig protocol_config = 5; } // SYNC ACCOUNT STORAGE MAP diff --git a/proto/proto/types/block_proving.proto b/proto/proto/types/block_proving.proto index 7a2dfbe7e2..3c233a8686 100644 --- a/proto/proto/types/block_proving.proto +++ b/proto/proto/types/block_proving.proto @@ -7,6 +7,7 @@ import "block.proto"; import "note.proto"; import "partial_blockchain.proto"; import "primitives.proto"; +import "protocol_config.proto"; // The requested account ID paired with the canonical account-tree witness. message AccountWitnessRecord { @@ -50,4 +51,8 @@ message BlockProofRequest { blockchain.ValidatorConfig next_validator_config = 4; // The optional next protocol configuration included in the proposed block header. optional blockchain.NextProtocolConfig next_protocol_config = 5; + + // The active configuration for signing. May be omitted if the validator already stores it. + // Proving-only callers may omit this field. + protocol_config.ProtocolConfig protocol_config = 6; } diff --git a/scripts/bench-local.sh b/scripts/bench-local.sh index 05c11af7b7..26dcb170e5 100755 --- a/scripts/bench-local.sh +++ b/scripts/bench-local.sh @@ -133,9 +133,6 @@ miden-validator genesis \ --accounts-directory "$DATA/accounts" \ --validator.key "$VALIDATOR_SIGNING_PUBLIC_KEY" \ > "$LOGS/genesis.log" 2>&1 -FEE_FAUCET_ID="$(sed -n 's/^Native faucet account id: //p' "$LOGS/genesis.log")" -[ -n "$FEE_FAUCET_ID" ] || die "genesis did not report the native faucet account ID" - say "bootstrapping validator storage from genesis" miden-validator bootstrap \ --data-directory "$DATA/validator" \ @@ -211,7 +208,6 @@ say "running create-proofs with N=$N_TXS (use_remote_prover=$USE_REMOTE_PROVER)" miden-benchmark create-proofs \ --rpc-url "http://127.0.0.1:$RPC_PORT" \ --num-transactions "$N_TXS" \ - --fee-faucet-id "$FEE_FAUCET_ID" \ $REMOTE_PROVER_ARG \ 2>&1 | tee "$LOGS/create-proofs.log" From a6e433046a6b6fc57653dcbe1bfef0d7bc91a42f Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Wed, 9 Sep 2026 14:30:39 +0200 Subject: [PATCH 02/28] Update proto/proto/rpc.proto Co-authored-by: Mirko <48352201+Mirko-von-Leipzig@users.noreply.github.com> --- proto/proto/rpc.proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proto/proto/rpc.proto b/proto/proto/rpc.proto index ad0f064d93..746889d145 100644 --- a/proto/proto/rpc.proto +++ b/proto/proto/rpc.proto @@ -234,7 +234,7 @@ message BlockHeaderByNumberRequest { optional uint32 block_num = 1; // Whether or not to return authentication data for the block header. optional bool include_mmr_proof = 2; - // Include the active configuration for the returned header. Defaults to false. + // Whether to include the active configuration for the returned header. optional bool include_protocol_config = 3; } From 66dd34e440831acdcb92c17177ff15d47c78c151 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Wed, 9 Sep 2026 14:31:04 +0200 Subject: [PATCH 03/28] Update proto/proto/rpc.proto Co-authored-by: Mirko <48352201+Mirko-von-Leipzig@users.noreply.github.com> --- proto/proto/rpc.proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proto/proto/rpc.proto b/proto/proto/rpc.proto index 746889d145..a2466691fc 100644 --- a/proto/proto/rpc.proto +++ b/proto/proto/rpc.proto @@ -250,7 +250,7 @@ message BlockHeaderByNumberResponse { optional fixed32 chain_length = 3; // Included when requested and the header exists. Verify its commitment against that header. - protocol_config.ProtocolConfig protocol_config = 4; + optional protocol_config.ProtocolConfig protocol_config = 4; } // GET BLOCK BY NUMBER From b10d7994cc5d9314d090adad017fccea0313e910 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Wed, 9 Sep 2026 14:31:20 +0200 Subject: [PATCH 04/28] Update proto/proto/rpc.proto Co-authored-by: Mirko <48352201+Mirko-von-Leipzig@users.noreply.github.com> --- proto/proto/rpc.proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proto/proto/rpc.proto b/proto/proto/rpc.proto index a2466691fc..d680be2c71 100644 --- a/proto/proto/rpc.proto +++ b/proto/proto/rpc.proto @@ -668,7 +668,7 @@ message SyncChainMmrResponse { // The target header's active configuration. Included when the client starts at genesis or // the starting and target headers have different configuration commitments. // Verify its commitment against the target header. - protocol_config.ProtocolConfig protocol_config = 5; + optional protocol_config.ProtocolConfig protocol_config = 5; } // SYNC ACCOUNT STORAGE MAP From 6f42953e309b3aa4b00f9428c5524584d8c95110 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Wed, 9 Sep 2026 14:31:32 +0200 Subject: [PATCH 05/28] Update proto/proto/types/block_proving.proto Co-authored-by: Mirko <48352201+Mirko-von-Leipzig@users.noreply.github.com> --- proto/proto/types/block_proving.proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proto/proto/types/block_proving.proto b/proto/proto/types/block_proving.proto index 3c233a8686..ad972e4bcd 100644 --- a/proto/proto/types/block_proving.proto +++ b/proto/proto/types/block_proving.proto @@ -54,5 +54,5 @@ message BlockProofRequest { // The active configuration for signing. May be omitted if the validator already stores it. // Proving-only callers may omit this field. - protocol_config.ProtocolConfig protocol_config = 6; + optional protocol_config.ProtocolConfig protocol_config = 6; } From 8c74349e5cf4e1fc06dfb67fcfd20cf1f24d22d5 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Wed, 9 Sep 2026 14:31:48 +0200 Subject: [PATCH 06/28] Update proto/proto/internal/validator.proto Co-authored-by: Mirko <48352201+Mirko-von-Leipzig@users.noreply.github.com> --- proto/proto/internal/validator.proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proto/proto/internal/validator.proto b/proto/proto/internal/validator.proto index 5f0a1a44fc..4a5937abec 100644 --- a/proto/proto/internal/validator.proto +++ b/proto/proto/internal/validator.proto @@ -66,7 +66,7 @@ message BlockSubscriptionResponse { // The active configuration. Included with the first response and when its commitment changes. // Verify its commitment against the accompanying block header. - protocol_config.ProtocolConfig protocol_config = 3; + optional protocol_config.ProtocolConfig protocol_config = 3; } // VALIDATOR STATUS From 21e93bb75bc486e73ec6fab90724f048ee18a9ca Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Thu, 10 Sep 2026 12:04:35 +0200 Subject: [PATCH 07/28] fix: compile after rebase --- bin/large-account-benchmark/src/rpc.rs | 2 +- bin/ntx-builder/src/clients/rpc.rs | 1 - bin/validator/src/db/mod.rs | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/bin/large-account-benchmark/src/rpc.rs b/bin/large-account-benchmark/src/rpc.rs index 80c0f876bb..0517b50902 100644 --- a/bin/large-account-benchmark/src/rpc.rs +++ b/bin/large-account-benchmark/src/rpc.rs @@ -369,7 +369,7 @@ mod tests { use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1; use miden_testing::MockChain; - use super::{decode_genesis_block_state, genesis_header_request}; + use super::decode_genesis_block_state; #[test] fn genesis_response_rejects_mismatched_protocol_config() { diff --git a/bin/ntx-builder/src/clients/rpc.rs b/bin/ntx-builder/src/clients/rpc.rs index ae18659992..40a1387b46 100644 --- a/bin/ntx-builder/src/clients/rpc.rs +++ b/bin/ntx-builder/src/clients/rpc.rs @@ -770,7 +770,6 @@ mod protocol_config_tests { GenesisState::new( Vec::new(), test_fee_params(), - 1, 0, mock_genesis_block().header().validator_config().clone(), test_protocol_config(), diff --git a/bin/validator/src/db/mod.rs b/bin/validator/src/db/mod.rs index 6b12064cb9..ed96d31fce 100644 --- a/bin/validator/src/db/mod.rs +++ b/bin/validator/src/db/mod.rs @@ -393,7 +393,6 @@ mod tests { miden_node_store::GenesisState::new( vec![], test_fee_params(), - 1, 0, ValidatorConfig::new(vec![SigningKey::new().public_key()], 1).unwrap(), config.clone(), From 4159caf31356d65b0d8d6303f659e28247dc67b8 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Thu, 10 Sep 2026 13:24:53 +0200 Subject: [PATCH 08/28] fix(network-monitor): simplify deployment fee checks --- bin/network-monitor/src/deploy/mod.rs | 90 +-------------------------- 1 file changed, 1 insertion(+), 89 deletions(-) diff --git a/bin/network-monitor/src/deploy/mod.rs b/bin/network-monitor/src/deploy/mod.rs index eec7e4452d..3f0cc40b71 100644 --- a/bin/network-monitor/src/deploy/mod.rs +++ b/bin/network-monitor/src/deploy/mod.rs @@ -86,13 +86,7 @@ use url::Url; use crate::deploy::counter::create_counter_account; use crate::deploy::wallet::create_wallet_account; -use crate::funding::{ - FaucetClient, - FeeFunder, - counter_funding_amount, - ensure_note_carries_fee_asset, - wallet_funding_amount, -}; +use crate::funding::{FaucetClient, FeeFunder, counter_funding_amount, wallet_funding_amount}; use crate::{COMPONENT, LOG_TARGET}; pub mod counter; @@ -364,22 +358,6 @@ pub async fn create_and_deploy_accounts( let creation_anchor = fetch_tip_chain_state(&mut rpc_client, submission_client.genesis_commitment).await?; let anchor_fee_faucet_id = creation_anchor.protocol_config.fee_asset_id().faucet_id(); - anyhow::ensure!( - anchor_fee_faucet_id == fee_faucet_id, - "the active fee asset changed while the monitor funded its accounts", - ); - anyhow::ensure!( - creation_anchor.block_header.fee_parameters().verification_base_fee() - == verification_base_fee, - "the verification base fee changed while the monitor funded its accounts", - ); - if let Some(note) = &counter_funding_note { - ensure_note_carries_fee_asset(note, anchor_fee_faucet_id)?; - } - if let Some(note) = &wallet_funding_note { - ensure_note_carries_fee_asset(note, anchor_fee_faucet_id)?; - } - let creation_fee_faucet = match funder.as_ref() { Some(_) => Some( fetch_foreign_account_inputs( @@ -408,8 +386,6 @@ pub async fn create_and_deploy_accounts( &mut rpc_client, &committed_counter, submission_client.genesis_commitment, - anchor_fee_faucet_id, - verification_base_fee, ) .await?; @@ -610,8 +586,6 @@ async fn resolve_counter_anchor( rpc_client: &mut RpcClient, committed_counter: &Account, genesis_commitment: Word, - expected_fee_faucet_id: AccountId, - expected_verification_base_fee: u32, ) -> Result { let expected_state = committed_counter.to_commitment(); let mut last_error = None; @@ -626,8 +600,6 @@ async fn resolve_counter_anchor( committed_counter, expected_state, genesis_commitment, - expected_fee_faucet_id, - expected_verification_base_fee, ) .await { @@ -679,15 +651,8 @@ async fn try_resolve_counter_anchor( committed_counter: &Account, expected_state: Word, genesis_commitment: Word, - expected_fee_faucet_id: AccountId, - expected_verification_base_fee: u32, ) -> Result> { let anchor = fetch_tip_chain_state(rpc_client, genesis_commitment).await?; - ensure_counter_anchor_fee_policy_matches( - &anchor, - expected_fee_faucet_id, - expected_verification_base_fee, - )?; let block_header = anchor.block_header; let block_num = block_header.block_num(); @@ -728,23 +693,6 @@ async fn try_resolve_counter_anchor( })) } -fn ensure_counter_anchor_fee_policy_matches( - anchor: &ChainState, - expected_fee_faucet_id: AccountId, - expected_verification_base_fee: u32, -) -> Result<()> { - anyhow::ensure!( - anchor.protocol_config.fee_asset_id().faucet_id() == expected_fee_faucet_id, - "the active fee asset changed while the monitor resolved its counter anchor", - ); - anyhow::ensure!( - anchor.block_header.fee_parameters().verification_base_fee() - == expected_verification_base_fee, - "the verification base fee changed while the monitor resolved its counter anchor", - ); - Ok(()) -} - /// Ensures that a transaction anchor and its protocol configuration describe the same state. fn ensure_anchor_protocol_config_matches( block_header: &BlockHeader, @@ -964,18 +912,6 @@ pub async fn build_probe_transaction_inputs( let execution_anchor = fetch_tip_chain_state(&mut rpc_client, genesis_commitment).await?; let anchor_fee_faucet_id = execution_anchor.protocol_config.fee_asset_id().faucet_id(); - anyhow::ensure!( - anchor_fee_faucet_id == fee_faucet_id, - "the active fee asset changed while the monitor funded its prover probe", - ); - anyhow::ensure!( - execution_anchor.block_header.fee_parameters().verification_base_fee() - == verification_base_fee, - "the verification base fee changed while the monitor funded its prover probe", - ); - if let Some(note) = &funding_note { - ensure_note_carries_fee_asset(note, anchor_fee_faucet_id)?; - } let fee_faucet = if funder.is_some() { Some( fetch_foreign_account_inputs( @@ -1238,14 +1174,12 @@ mod tests { use miden_testing::MockChain; use super::{ - ChainState, DataStore, FaucetClient, MonitorDataStore, active_fee_funding, decode_chain_state, decode_protocol_config, - ensure_counter_anchor_fee_policy_matches, }; use crate::deploy::wallet::create_wallet_account; @@ -1298,28 +1232,6 @@ mod tests { assert_eq!(decoded, expected); } - #[test] - fn counter_anchor_rejects_a_base_fee_transition() { - let chain = MockChain::builder() - .verification_base_fee(500) - .build() - .expect("chain should build"); - let anchor = ChainState { - block_header: chain.genesis_block_header(), - protocol_config: chain.protocol_config().clone(), - blockchain: PartialBlockchain::new( - PartialMmr::from_peaks(MmrPeaks::default()), - Vec::new(), - ) - .expect("empty genesis blockchain should build"), - }; - - let error = ensure_counter_anchor_fee_policy_matches(&anchor, chain.fee_faucet_id(), 0) - .expect_err("the counter was built for a different base fee"); - - assert!(error.to_string().contains("verification base fee changed")); - } - #[test] fn chain_state_response_requires_protocol_config() { let chain = MockChain::builder().build().expect("chain should build"); From 56c5460dbf351792e9aacf9fb99e3cb5315726b5 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Thu, 10 Sep 2026 13:25:10 +0200 Subject: [PATCH 09/28] test(network-monitor): remove obsolete probe startup test --- bin/network-monitor/src/remote_prover.rs | 29 ------------------------ 1 file changed, 29 deletions(-) diff --git a/bin/network-monitor/src/remote_prover.rs b/bin/network-monitor/src/remote_prover.rs index e26af01e1f..334700749f 100644 --- a/bin/network-monitor/src/remote_prover.rs +++ b/bin/network-monitor/src/remote_prover.rs @@ -567,35 +567,6 @@ fn transaction_proof_size(response: proto::remote_prover::Proof) -> Result Date: Thu, 10 Sep 2026 13:25:25 +0200 Subject: [PATCH 10/28] refactor(validator): hoist protocol config persistence checks --- bin/validator/src/db/mod.rs | 137 +++++++++++++++++- bin/validator/src/db/queries/mod.rs | 2 +- .../src/db/queries/protocol_config/mod.rs | 22 +-- 3 files changed, 137 insertions(+), 24 deletions(-) diff --git a/bin/validator/src/db/mod.rs b/bin/validator/src/db/mod.rs index ed96d31fce..b0cd76ff18 100644 --- a/bin/validator/src/db/mod.rs +++ b/bin/validator/src/db/mod.rs @@ -1,3 +1,4 @@ +use std::io; use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; @@ -210,17 +211,34 @@ impl ValidatorDbWriter { ) -> Result<(), DatabaseError> { self.writer .write("upsert_block_header_with_protocol_config", move |tx| { - queries::ensure_protocol_config( - tx, - header.protocol_config_commitment(), - protocol_config.as_ref(), - )?; + let commitment = header.protocol_config_commitment(); + if let Some(config) = protocol_config.as_ref() { + let calculated = config.to_commitment(); + if calculated != commitment { + return Err(invalid_protocol_config(format!( + "protocol config commitment mismatch: expected {commitment}, got \ + {calculated}" + ))); + } + queries::insert_protocol_config(tx, config)?; + } + + queries::load_protocol_config(tx, commitment)?.ok_or_else(|| { + invalid_protocol_config(format!("protocol config {commitment} is not stored")) + })?; queries::upsert_block_header(tx, &header) }) .await } } +fn invalid_protocol_config(message: String) -> DatabaseError { + DatabaseError::deserialization( + "ProtocolConfig", + io::Error::new(io::ErrorKind::InvalidData, message), + ) +} + /// Replaces a stored protocol configuration with test bytes. #[cfg(test)] pub(crate) async fn overwrite_protocol_config_for_test( @@ -404,6 +422,23 @@ mod tests { .clone() } + fn header_with_next_timestamp(header: &BlockHeader) -> BlockHeader { + BlockHeader::new( + header.prev_block_commitment(), + header.block_num(), + header.chain_commitment(), + header.account_root(), + header.nullifier_root(), + header.note_root(), + header.tx_commitment(), + header.validator_config().clone(), + header.fee_parameters().clone(), + header.protocol_config_commitment(), + header.next_protocol_config().cloned(), + header.timestamp() + 1, + ) + } + #[test] fn migrate_rejects_missing_database() { let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); @@ -463,6 +498,44 @@ mod tests { assert_eq!(db.load_protocol_config(config.to_commitment()).await.unwrap(), Some(config)); } + #[tokio::test] + async fn duplicate_supplied_protocol_config_is_accepted() { + let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); + let db = setup(temp_dir.path().join("validator.sqlite3")).await.unwrap(); + let config = test_protocol_config(); + let header = genesis_header(&config); + let replacement = header_with_next_timestamp(&header); + + db.upsert_block_header_with_protocol_config(header.clone(), Some(config.clone())) + .await + .unwrap(); + db.upsert_block_header_with_protocol_config(replacement.clone(), Some(config.clone())) + .await + .expect("a duplicate supplied protocol config should be accepted"); + + assert_eq!(db.load_block_header(header.block_num()).await.unwrap(), Some(replacement)); + assert_eq!(db.load_protocol_config(config.to_commitment()).await.unwrap(), Some(config)); + } + + #[tokio::test] + async fn known_protocol_config_can_be_omitted() { + let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); + let db = setup(temp_dir.path().join("validator.sqlite3")).await.unwrap(); + let config = test_protocol_config(); + let header = genesis_header(&config); + let replacement = header_with_next_timestamp(&header); + + db.upsert_block_header_with_protocol_config(header.clone(), Some(config.clone())) + .await + .unwrap(); + db.upsert_block_header_with_protocol_config(replacement.clone(), None) + .await + .expect("a stored protocol config should not need to be supplied again"); + + assert_eq!(db.load_block_header(header.block_num()).await.unwrap(), Some(replacement)); + assert_eq!(db.load_protocol_config(config.to_commitment()).await.unwrap(), Some(config)); + } + #[tokio::test] async fn unknown_protocol_config_rolls_back_block_header() { let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); @@ -545,6 +618,60 @@ mod tests { assert_eq!(db.load_protocol_config(mismatched.to_commitment()).await.unwrap(), None); } + #[tokio::test] + async fn corrupted_protocol_config_cannot_be_overwritten() { + let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); + let db = setup(temp_dir.path().join("validator.sqlite3")).await.unwrap(); + let config = test_protocol_config(); + let commitment = config.to_commitment(); + let header = genesis_header(&config); + let replacement = header_with_next_timestamp(&header); + + db.upsert_block_header_with_protocol_config(header.clone(), Some(config.clone())) + .await + .unwrap(); + overwrite_protocol_config_for_test(&db, commitment, vec![0xff]).await.unwrap(); + + db.upsert_block_header_with_protocol_config(replacement, Some(config)) + .await + .expect_err("a corrupt stored protocol config must reject the write"); + assert_eq!(db.load_block_header(header.block_num()).await.unwrap(), Some(header)); + db.load_protocol_config(commitment) + .await + .expect_err("the supplied protocol config must not overwrite the corrupt row"); + } + + #[tokio::test] + async fn block_header_insertion_failure_rolls_back_new_protocol_config() { + let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); + let db = setup(temp_dir.path().join("validator.sqlite3")).await.unwrap(); + let config = test_protocol_config(); + let commitment = config.to_commitment(); + let header = genesis_header(&config); + + db.writer + .write("reject_block_header_inserts", |tx| { + tx.execute( + "CREATE TRIGGER reject_block_header_insert + BEFORE INSERT ON block_headers + BEGIN + SELECT RAISE(ABORT, 'block header insertion rejected'); + END;", + &[], + )?; + Ok::<_, DatabaseError>(()) + }) + .await + .unwrap(); + + db.upsert_block_header_with_protocol_config(header.clone(), Some(config)) + .await + .expect_err("a block header insertion failure must reject the transaction"); + + assert_eq!(db.load_block_header(header.block_num()).await.unwrap(), None); + assert_eq!(db.load_protocol_config(commitment).await.unwrap(), None); + } + #[tokio::test] async fn transaction_exists_detects_validated_transactions() { let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); diff --git a/bin/validator/src/db/queries/mod.rs b/bin/validator/src/db/queries/mod.rs index 90561a768c..d867bbd16d 100644 --- a/bin/validator/src/db/queries/mod.rs +++ b/bin/validator/src/db/queries/mod.rs @@ -9,7 +9,7 @@ mod private_record_row; mod protocol_config; -pub use protocol_config::{ensure as ensure_protocol_config, load as load_protocol_config}; +pub use protocol_config::{insert as insert_protocol_config, load as load_protocol_config}; mod count_signed_blocks; pub use count_signed_blocks::count_signed_blocks; diff --git a/bin/validator/src/db/queries/protocol_config/mod.rs b/bin/validator/src/db/queries/protocol_config/mod.rs index 96d0fe8acd..8e421fa297 100644 --- a/bin/validator/src/db/queries/protocol_config/mod.rs +++ b/bin/validator/src/db/queries/protocol_config/mod.rs @@ -37,24 +37,10 @@ pub fn load(tx: &ReadTx<'_>, commitment: Word) -> Result, Ok(Some(config)) } -/// Stores a supplied configuration or verifies that the committed configuration is already known. -pub fn ensure( - tx: &WriteTx<'_>, - commitment: Word, - config: Option<&ProtocolConfig>, -) -> Result<(), DatabaseError> { - if let Some(config) = config { - let calculated = config.to_commitment(); - if calculated != commitment { - return Err(invalid_config(format!( - "protocol config commitment mismatch: expected {commitment}, got {calculated}" - ))); - } - tx.execute(INSERT_SQL, &[&commitment, &config.to_bytes()])?; - } - - load(tx, commitment)? - .ok_or_else(|| invalid_config(format!("protocol config {commitment} is not stored")))?; +/// Inserts a protocol configuration if its commitment is not stored. +pub fn insert(tx: &WriteTx<'_>, config: &ProtocolConfig) -> Result<(), DatabaseError> { + let commitment = config.to_commitment(); + tx.execute(INSERT_SQL, &[&commitment, &config.to_bytes()])?; Ok(()) } From 1fe9eeb5a14d8bc4781cf6f9d2a0046042890a6b Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Thu, 10 Sep 2026 13:25:37 +0200 Subject: [PATCH 11/28] refactor(store): validate protocol configs in the block writer --- crates/store/src/db/mod.rs | 10 +- .../src/db/models/queries/protocol_configs.rs | 51 ---- crates/store/src/state/writer/worker.rs | 242 +++++++++++++++++- 3 files changed, 245 insertions(+), 58 deletions(-) diff --git a/crates/store/src/db/mod.rs b/crates/store/src/db/mod.rs index 4dc9428f4a..e258d1d5e8 100644 --- a/crates/store/src/db/mod.rs +++ b/crates/store/src/db/mod.rs @@ -637,18 +637,16 @@ impl Db { pub(crate) async fn apply_block( &self, signed_block: SignedBlock, - protocol_config: Option, + new_protocol_config: Option, notes: Vec<(NoteRecord, Option)>, precomputed_public_states: PrecomputedPublicAccountStates, unresolved_note_nullifiers: Vec, prune_tip: BlockNumber, ) -> Result> { self.transact("apply block", move |conn| { - queries::ensure_protocol_config( - conn, - signed_block.header().protocol_config_commitment(), - protocol_config.as_ref(), - )?; + if let Some(protocol_config) = new_protocol_config.as_ref() { + queries::insert_protocol_config(conn, protocol_config)?; + } models::queries::apply_block(conn, &signed_block, ¬es, &precomputed_public_states)?; models::queries::prune_history(conn, prune_tip)?; diff --git a/crates/store/src/db/models/queries/protocol_configs.rs b/crates/store/src/db/models/queries/protocol_configs.rs index 967232b830..cabfb889ee 100644 --- a/crates/store/src/db/models/queries/protocol_configs.rs +++ b/crates/store/src/db/models/queries/protocol_configs.rs @@ -6,28 +6,6 @@ use miden_protocol::utils::serde::{ByteReader, Deserializable, Serializable, Sli use crate::db::schema::protocol_configs; use crate::errors::DatabaseError; -/// Ensures that a block's active configuration is stored in the current transaction. -pub(crate) fn ensure_protocol_config( - conn: &mut SqliteConnection, - commitment: Word, - supplied: Option<&ProtocolConfig>, -) -> Result<(), DatabaseError> { - if let Some(config) = supplied { - let calculated = config.to_commitment(); - if calculated != commitment { - return Err(DatabaseError::ProtocolConfigCommitmentMismatch { - expected: commitment, - calculated, - }); - } - } - if select_protocol_config(conn, commitment)?.is_none() { - let config = supplied.ok_or(DatabaseError::ProtocolConfigNotFound(commitment))?; - insert_protocol_config(conn, config)?; - } - Ok(()) -} - /// Inserts a protocol configuration by its commitment. pub(crate) fn insert_protocol_config( conn: &mut SqliteConnection, @@ -102,35 +80,6 @@ mod tests { assert_eq!(select_protocol_config(&mut conn, commitment).unwrap(), Some(config)); } - #[test] - fn block_config_requires_a_known_matching_configuration() { - let mut conn = connection(); - let config = test_protocol_config(); - let commitment = config.to_commitment(); - assert!(super::ensure_protocol_config(&mut conn, commitment, None).is_err()); - assert!(super::ensure_protocol_config(&mut conn, Word::empty(), Some(&config)).is_err()); - assert_eq!(select_protocol_config(&mut conn, commitment).unwrap(), None); - super::ensure_protocol_config(&mut conn, commitment, Some(&config)).unwrap(); - super::ensure_protocol_config(&mut conn, commitment, Some(&config)).unwrap(); - super::ensure_protocol_config(&mut conn, commitment, None).unwrap(); - assert_eq!(select_protocol_config(&mut conn, commitment).unwrap(), Some(config)); - } - - #[test] - fn config_insert_rolls_back_with_the_block_transaction() { - use diesel::Connection; - let mut conn = connection(); - let config = test_protocol_config(); - let commitment = config.to_commitment(); - let result: Result<(), DatabaseError> = conn.transaction(|conn| { - super::ensure_protocol_config(conn, commitment, Some(&config))?; - assert_eq!(select_protocol_config(conn, commitment)?, Some(config.clone())); - Err(DatabaseError::DataCorrupted("block rejected".into())) - }); - assert!(result.is_err()); - assert_eq!(select_protocol_config(&mut conn, commitment).unwrap(), None); - } - #[test] fn returns_none_for_unknown_commitment() { let mut conn = connection(); diff --git a/crates/store/src/state/writer/worker.rs b/crates/store/src/state/writer/worker.rs index f57ba8658c..27d5a40682 100644 --- a/crates/store/src/state/writer/worker.rs +++ b/crates/store/src/state/writer/worker.rs @@ -224,6 +224,7 @@ impl WriteWorker { if stored.is_none() && protocol_config.is_none() { return Err(crate::errors::DatabaseError::ProtocolConfigNotFound(commitment).into()); } + let new_protocol_config = if stored.is_none() { protocol_config } else { None }; let block_lifecycle = lifecycle_events_enabled().then(|| BlockLifecycle::from_block_body(block_num, body)); @@ -260,7 +261,7 @@ impl WriteWorker { .db .apply_block( signed_block, - protocol_config, + new_protocol_config, notes, precomputed_public_states, unresolved_note_nullifiers, @@ -612,3 +613,242 @@ fn raise_thread_priority() { }); } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use assert_matches::assert_matches; + use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl}; + use miden_node_utils::clap::StorageOptions; + use miden_node_utils::fee::{test_fee_params, test_protocol_config}; + use miden_node_utils::shutdown::CancellationToken; + use miden_protocol::asset::AssetId; + use miden_protocol::block::{ + BlockBody, + BlockHeader, + BlockSignatures, + SignedBlock, + ValidatorConfig, + }; + use miden_protocol::crypto::merkle::mmr::Mmr; + use miden_protocol::protocol_config::ProtocolConfig; + use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1; + use miden_protocol::testing::random_secret_key::random_secret_key; + use miden_protocol::transaction::OrderedTransactionHeaders; + use miden_protocol::utils::serde::Serializable; + use tempfile::TempDir; + + use crate::db::schema::protocol_configs; + use crate::errors::{ApplyBlockError, DatabaseError}; + use crate::genesis::GenesisState; + use crate::state::{BlockWriter, State, WriterTask}; + + async fn start_store() -> (TempDir, Arc, BlockWriter, WriterTask, ProtocolConfig) { + let temp_dir = tempfile::tempdir().expect("test directory should be created"); + let protocol_config = test_protocol_config(); + let signer = random_secret_key(); + let genesis = GenesisState::new( + Vec::new(), + test_fee_params(), + 0, + ValidatorConfig::new(vec![signer.public_key()], 1).unwrap(), + protocol_config.clone(), + ) + .into_block() + .expect("genesis block should be created"); + State::bootstrap(genesis, temp_dir.path()).expect("store should bootstrap"); + + let (state, block_writer, _proof_writer, writer_task) = + State::load(temp_dir.path(), StorageOptions::default()) + .await + .expect("state should load") + .start(CancellationToken::new()); + + (temp_dir, state, block_writer, writer_task, protocol_config) + } + + fn alternate_protocol_config() -> ProtocolConfig { + ProtocolConfig::current(AssetId::new_fungible( + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1.try_into().unwrap(), + )) + .unwrap() + } + + async fn empty_block(state: &State, protocol_config: &ProtocolConfig) -> SignedBlock { + let view = state.view(); + let (parent, _) = view.get_block_header(None, false).await.unwrap(); + let parent = parent.expect("chain should have a parent block"); + let mut mmr = Mmr::new(); + for height in 0..=parent.block_num().as_u32() { + let (header, _) = view.get_block_header(Some(height.into()), false).await.unwrap(); + mmr.add(header.expect("block header should exist").commitment()).unwrap(); + } + let body = BlockBody::new( + Vec::new(), + Vec::new(), + Vec::new(), + OrderedTransactionHeaders::new_unchecked(Vec::new()), + ) + .unwrap(); + let header = BlockHeader::new( + parent.commitment(), + parent.block_num().child(), + mmr.peaks().hash_peaks(), + parent.account_root(), + parent.nullifier_root(), + body.compute_block_note_tree().root(), + body.transaction_commitment(), + parent.validator_config().clone(), + parent.fee_parameters().clone(), + protocol_config.to_commitment(), + None, + parent.timestamp() + 1, + ); + SignedBlock::new_unchecked(header, body, BlockSignatures::new(Vec::new()).unwrap()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn writer_inserts_a_new_protocol_config_with_its_block() { + let (_temp_dir, state, mut writer, writer_task, _genesis_config) = start_store().await; + let protocol_config = alternate_protocol_config(); + let block = empty_block(&state, &protocol_config).await; + + writer.apply_block(block, Some(protocol_config.clone())).await.unwrap(); + + assert_eq!(state.committed_tip(), 1.into()); + assert_eq!( + state.view().get_protocol_config(protocol_config.to_commitment()).await.unwrap(), + Some(protocol_config) + ); + writer.stop(writer_task).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn writer_reuses_a_known_protocol_config_without_a_supplied_config() { + let (_temp_dir, state, mut writer, writer_task, protocol_config) = start_store().await; + let block = empty_block(&state, &protocol_config).await; + + writer.apply_block(block, None).await.unwrap(); + + assert_eq!(state.committed_tip(), 1.into()); + writer.stop(writer_task).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn writer_reuses_a_known_protocol_config_when_it_is_supplied() { + let (_temp_dir, state, mut writer, writer_task, protocol_config) = start_store().await; + let block = empty_block(&state, &protocol_config).await; + + writer.apply_block(block, Some(protocol_config)).await.unwrap(); + + assert_eq!(state.committed_tip(), 1.into()); + writer.stop(writer_task).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn writer_rejects_an_unknown_protocol_config() { + let (_temp_dir, state, mut writer, writer_task, _genesis_config) = start_store().await; + let protocol_config = alternate_protocol_config(); + let commitment = protocol_config.to_commitment(); + let block = empty_block(&state, &protocol_config).await; + + let error = writer.apply_block(block, None).await.unwrap_err(); + + assert_matches!( + error, + ApplyBlockError::DatabaseError(DatabaseError::ProtocolConfigNotFound(actual)) + if actual == commitment + ); + assert_eq!(state.committed_tip(), 0.into()); + writer.stop(writer_task).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn writer_rejects_a_mismatched_supplied_protocol_config() { + let (_temp_dir, state, mut writer, writer_task, genesis_config) = start_store().await; + let protocol_config = alternate_protocol_config(); + let expected = protocol_config.to_commitment(); + let calculated = genesis_config.to_commitment(); + let block = empty_block(&state, &protocol_config).await; + + let error = writer.apply_block(block, Some(genesis_config)).await.unwrap_err(); + + assert_matches!( + error, + ApplyBlockError::DatabaseError(DatabaseError::ProtocolConfigCommitmentMismatch { + expected: actual_expected, + calculated: actual_calculated, + }) if actual_expected == expected && actual_calculated == calculated + ); + assert_eq!(state.committed_tip(), 0.into()); + writer.stop(writer_task).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn writer_rejects_a_corrupt_persisted_protocol_config() { + let (_temp_dir, state, mut writer, writer_task, protocol_config) = start_store().await; + let commitment = protocol_config.to_commitment(); + let block = empty_block(&state, &protocol_config).await; + let mut bytes = protocol_config.to_bytes(); + bytes.push(0xff); + state + .db + .query("corrupt protocol config", move |conn| { + diesel::update( + protocol_configs::table + .filter(protocol_configs::commitment.eq(commitment.to_bytes())), + ) + .set(protocol_configs::protocol_config.eq(bytes)) + .execute(conn)?; + Ok::<_, DatabaseError>(()) + }) + .await + .unwrap(); + + let error = writer.apply_block(block, Some(protocol_config)).await.unwrap_err(); + + assert_matches!(error, ApplyBlockError::DatabaseError(DatabaseError::DataCorrupted(_))); + assert_eq!(state.committed_tip(), 0.into()); + writer.stop(writer_task).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn protocol_config_insertion_rolls_back_when_the_block_write_fails() { + let (_temp_dir, state, mut writer, writer_task, _genesis_config) = start_store().await; + let protocol_config = alternate_protocol_config(); + let commitment = protocol_config.to_commitment(); + let block = empty_block(&state, &protocol_config).await; + state + .db + .query("reject block inserts", |conn| { + diesel::sql_query( + "CREATE TRIGGER reject_block_insert BEFORE INSERT ON block_headers \ + BEGIN SELECT RAISE(ABORT, 'test block rejection'); END", + ) + .execute(conn)?; + Ok::<_, DatabaseError>(()) + }) + .await + .unwrap(); + + assert_matches!( + writer.apply_block(block, Some(protocol_config)).await, + Err(ApplyBlockError::DbUpdateTaskFailed(_)) + ); + + assert_eq!(state.committed_tip(), 0.into()); + assert_eq!(state.view().get_protocol_config(commitment).await.unwrap(), None); + assert_eq!( + state + .db + .select_block_header_by_block_num(Some( + crate::state::ScopedBlockNum::new_unchecked(1.into()), + )) + .await + .unwrap(), + None + ); + writer.stop(writer_task).await; + } +} From f4a201caa4c7a5792797576b638e4f6bf3fd1e6a Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Thu, 10 Sep 2026 14:09:35 +0200 Subject: [PATCH 12/28] fix(validator): migrate protocol config storage separately --- bin/validator/src/db/migrations.rs | 7 ++-- .../src/db/migrations/001_initial.sql | 6 --- .../db/migrations/002_protocol_configs.sql | 5 +++ bin/validator/src/db/mod.rs | 41 +++++++++++++++++++ 4 files changed, 50 insertions(+), 9 deletions(-) create mode 100644 bin/validator/src/db/migrations/002_protocol_configs.sql diff --git a/bin/validator/src/db/migrations.rs b/bin/validator/src/db/migrations.rs index 59edb313dd..30dc7c5a30 100644 --- a/bin/validator/src/db/migrations.rs +++ b/bin/validator/src/db/migrations.rs @@ -67,9 +67,10 @@ mod tests { use super::*; - const EXPECTED_SCHEMA_HASHES: [SchemaHash; 1] = [SchemaHash::from_hex( - "56270f7825c3d07ab37a224583b7b6294bab52761e14e9aa32014f481349fb8d", - )]; + const EXPECTED_SCHEMA_HASHES: [SchemaHash; 2] = [ + SchemaHash::from_hex("f2f6af5e22d8d0273524a227417339279d1a694c3802a8f3f7cc4b31e21ee035"), + SchemaHash::from_hex("56270f7825c3d07ab37a224583b7b6294bab52761e14e9aa32014f481349fb8d"), + ]; #[test] fn migration_schema_hashes_are_stable() -> Result<()> { diff --git a/bin/validator/src/db/migrations/001_initial.sql b/bin/validator/src/db/migrations/001_initial.sql index 7a4564dd85..10588283af 100644 --- a/bin/validator/src/db/migrations/001_initial.sql +++ b/bin/validator/src/db/migrations/001_initial.sql @@ -40,9 +40,3 @@ CREATE TABLE block_headers ( block_num BIGINT PRIMARY KEY, block_header BLOB NOT NULL ) WITHOUT ROWID; - -CREATE TABLE protocol_configs ( - commitment BLOB PRIMARY KEY, - protocol_config BLOB NOT NULL, - CHECK (length(commitment) = 32) -) WITHOUT ROWID; diff --git a/bin/validator/src/db/migrations/002_protocol_configs.sql b/bin/validator/src/db/migrations/002_protocol_configs.sql new file mode 100644 index 0000000000..52c2a28e44 --- /dev/null +++ b/bin/validator/src/db/migrations/002_protocol_configs.sql @@ -0,0 +1,5 @@ +CREATE TABLE protocol_configs ( + commitment BLOB PRIMARY KEY, + protocol_config BLOB NOT NULL, + CHECK (length(commitment) = 32) +) WITHOUT ROWID; diff --git a/bin/validator/src/db/mod.rs b/bin/validator/src/db/mod.rs index b0cd76ff18..be0a0ae856 100644 --- a/bin/validator/src/db/mod.rs +++ b/bin/validator/src/db/mod.rs @@ -450,6 +450,47 @@ mod tests { assert!(!db_path.exists()); } + #[tokio::test] + async fn migration_preserves_headers_and_private_records() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("validator.sqlite3"); + miden_node_db::migration::Migrator::builder() + .unwrap() + .push_sql("001_initial", include_str!("migrations/001_initial.sql")) + .unwrap() + .build() + .unwrap() + .bootstrap(&db_path) + .unwrap(); + + let config = test_protocol_config(); + let header = genesis_header(&config); + let transaction_id = TransactionId::from_raw(Word::from([1u32, 2, 3, 4])); + let record = private_record(transaction_id, 1); + let db = open_with_pool_size(&db_path, NonZeroUsize::new(2).unwrap()).unwrap(); + let stored_header = header.clone(); + db.writer + .write("seed legacy header", move |tx| queries::upsert_block_header(tx, &stored_header)) + .await + .unwrap(); + db.insert_validated_private_transaction(record.clone()).await.unwrap(); + drop(db); + + assert!(load(db_path.clone()).await.is_err(), "the old schema requires migration"); + migrate(&db_path).unwrap(); + migrate(&db_path).expect("migration should also accept the latest schema"); + + let db = load(db_path).await.unwrap(); + assert_eq!(db.load_chain_tip().await.unwrap(), Some(header.clone())); + assert_eq!(db.load_all_transactions().await.unwrap(), vec![record]); + assert_eq!(db.load_protocol_config(config.to_commitment()).await.unwrap(), None); + + db.upsert_block_header_with_protocol_config(header, Some(config.clone())) + .await + .unwrap(); + assert_eq!(db.load_protocol_config(config.to_commitment()).await.unwrap(), Some(config)); + } + #[tokio::test] async fn setup_creates_database_that_load_accepts() { let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); From 914b4e8caf2013d1ef88d8c8d7d4b41239f9ae84 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Thu, 10 Sep 2026 14:46:42 +0200 Subject: [PATCH 13/28] fix(db): implement FromSqlValue and ToSqlValue for ProtocolConfig --- bin/validator/src/db/mod.rs | 49 +------------------ .../src/db/queries/protocol_config/mod.rs | 36 ++------------ crates/db/src/sqlite/codec.rs | 1 + 3 files changed, 6 insertions(+), 80 deletions(-) diff --git a/bin/validator/src/db/mod.rs b/bin/validator/src/db/mod.rs index be0a0ae856..8abf2a2d75 100644 --- a/bin/validator/src/db/mod.rs +++ b/bin/validator/src/db/mod.rs @@ -374,7 +374,7 @@ mod tests { use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; use miden_protocol::protocol_config::ProtocolConfig; use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1; - use miden_protocol::utils::serde::{Deserializable, Serializable}; + use miden_protocol::utils::serde::Deserializable; use rand_chacha_03::ChaCha20Rng; use rand_chacha_03::rand_core::SeedableRng; @@ -592,53 +592,6 @@ mod tests { assert_eq!(db.load_protocol_config(config.to_commitment()).await.unwrap(), None); } - #[tokio::test] - async fn protocol_config_reads_reject_trailing_bytes() { - let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); - let db = setup(temp_dir.path().join("validator.sqlite3")).await.unwrap(); - let config = test_protocol_config(); - let commitment = config.to_commitment(); - let mut bytes = config.to_bytes(); - bytes.push(0xff); - db.writer - .write("insert_corrupt_protocol_config", move |tx| { - tx.execute( - "INSERT INTO protocol_configs (commitment, protocol_config) VALUES (?1, ?2)", - &[&commitment, &bytes], - )?; - Ok::<_, DatabaseError>(()) - }) - .await - .unwrap(); - - db.load_protocol_config(commitment) - .await - .expect_err("trailing bytes must be rejected"); - } - - #[tokio::test] - async fn protocol_config_reads_reject_a_wrong_storage_key() { - let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); - let db = setup(temp_dir.path().join("validator.sqlite3")).await.unwrap(); - let config = test_protocol_config(); - let wrong_commitment = Word::empty(); - let bytes = config.to_bytes(); - db.writer - .write("insert_miskeyed_protocol_config", move |tx| { - tx.execute( - "INSERT INTO protocol_configs (commitment, protocol_config) VALUES (?1, ?2)", - &[&wrong_commitment, &bytes], - )?; - Ok::<_, DatabaseError>(()) - }) - .await - .unwrap(); - - db.load_protocol_config(wrong_commitment) - .await - .expect_err("a config stored under the wrong commitment must be rejected"); - } - #[tokio::test] async fn mismatched_protocol_config_rolls_back_block_header() { let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); diff --git a/bin/validator/src/db/queries/protocol_config/mod.rs b/bin/validator/src/db/queries/protocol_config/mod.rs index 8e421fa297..497d9ed417 100644 --- a/bin/validator/src/db/queries/protocol_config/mod.rs +++ b/bin/validator/src/db/queries/protocol_config/mod.rs @@ -1,52 +1,24 @@ //! Stores and loads protocol configurations by commitment. -use std::io; - use miden_node_db::DatabaseError; use miden_node_db::sqlite::{ReadTx, WriteTx}; use miden_protocol::Word; use miden_protocol::protocol_config::ProtocolConfig; -use miden_protocol::utils::serde::{ByteReader, Deserializable, Serializable, SliceReader}; const INSERT_SQL: &str = include_str!("insert.sql"); const SELECT_SQL: &str = include_str!("select.sql"); /// Loads a protocol configuration and verifies its serialized value and commitment. pub fn load(tx: &ReadTx<'_>, commitment: Word) -> Result, DatabaseError> { - let bytes = tx - .query(SELECT_SQL, &[&commitment], |row| row.get::>(0))? + Ok(tx + .query(SELECT_SQL, &[&commitment], |row| row.get::(0))? .into_iter() - .next(); - let Some(bytes) = bytes else { - return Ok(None); - }; - - let mut reader = SliceReader::new(&bytes); - let config = ProtocolConfig::read_from(&mut reader) - .map_err(|err| DatabaseError::deserialization("ProtocolConfig", err))?; - if reader.has_more_bytes() { - return Err(invalid_config(format!("protocol config {commitment} has trailing bytes"))); - } - let calculated = config.to_commitment(); - if calculated != commitment { - return Err(invalid_config(format!( - "protocol config commitment mismatch: expected {commitment}, got {calculated}" - ))); - } - - Ok(Some(config)) + .next()) } /// Inserts a protocol configuration if its commitment is not stored. pub fn insert(tx: &WriteTx<'_>, config: &ProtocolConfig) -> Result<(), DatabaseError> { let commitment = config.to_commitment(); - tx.execute(INSERT_SQL, &[&commitment, &config.to_bytes()])?; + tx.execute(INSERT_SQL, &[&commitment, &config])?; Ok(()) } - -fn invalid_config(message: String) -> DatabaseError { - DatabaseError::deserialization( - "ProtocolConfig", - io::Error::new(io::ErrorKind::InvalidData, message), - ) -} diff --git a/crates/db/src/sqlite/codec.rs b/crates/db/src/sqlite/codec.rs index 4f2c9ef4e1..384fc6a45a 100644 --- a/crates/db/src/sqlite/codec.rs +++ b/crates/db/src/sqlite/codec.rs @@ -365,6 +365,7 @@ impl_blob_codec!( miden_protocol::note::NoteScript, miden_protocol::note::NoteStorage, miden_protocol::note::Nullifier, + miden_protocol::protocol_config::ProtocolConfig, miden_protocol::crypto::merkle::SparseMerklePath, miden_protocol::crypto::merkle::mmr::PartialMmr, miden_protocol::Word, From 3bc0bcc92b7e959bce16229416b6327413fcbcd2 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Thu, 10 Sep 2026 16:02:49 +0200 Subject: [PATCH 14/28] fix: remove bogus test --- .../src/server/validator_service/tests.rs | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/bin/validator/src/server/validator_service/tests.rs b/bin/validator/src/server/validator_service/tests.rs index 332ce8bff6..5bdfe94662 100644 --- a/bin/validator/src/server/validator_service/tests.rs +++ b/bin/validator/src/server/validator_service/tests.rs @@ -1095,31 +1095,6 @@ async fn protocol_config_transition_is_streamed_and_used_for_next_signature() { .expect("the validator must sign with the transitioned active config"); } -#[tokio::test] -async fn block_subscription_rejects_a_corrupt_protocol_config_before_the_block() { - use std::time::Duration; - - use tokio_stream::StreamExt; - - let mut tv = TestValidator::new().await; - tv.apply_empty_block().await; - let commitment = tv.chain_tip.protocol_config_commitment(); - let mut bytes = tv.protocol_config.to_bytes(); - bytes.push(0xff); - crate::db::overwrite_protocol_config_for_test(&tv.server.db, commitment, bytes) - .await - .unwrap(); - - let mut stream = tv.call_block_subscription(1).await; - let status = tokio::time::timeout(Duration::from_secs(5), stream.next()) - .await - .expect("the stream must reject corrupt storage promptly") - .expect("the stream must emit the storage error") - .expect_err("the block must not be emitted before config validation"); - - assert_eq!(status.code(), tonic::Code::Internal); -} - // SERVE LOCK TESTS // ================================================================================================ // From f108bf06e9b1b278d08f88cf3fca20dbd1939667 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Thu, 10 Sep 2026 16:31:56 +0200 Subject: [PATCH 15/28] fix: remove more tests --- bin/validator/src/db/mod.rs | 41 --------------- .../src/server/validator_service/tests.rs | 50 ------------------- 2 files changed, 91 deletions(-) diff --git a/bin/validator/src/db/mod.rs b/bin/validator/src/db/mod.rs index 8abf2a2d75..8d8cb55561 100644 --- a/bin/validator/src/db/mod.rs +++ b/bin/validator/src/db/mod.rs @@ -239,24 +239,6 @@ fn invalid_protocol_config(message: String) -> DatabaseError { ) } -/// Replaces a stored protocol configuration with test bytes. -#[cfg(test)] -pub(crate) async fn overwrite_protocol_config_for_test( - db: &ValidatorDbWriter, - commitment: miden_protocol::Word, - bytes: Vec, -) -> Result<(), DatabaseError> { - db.writer - .write("overwrite_protocol_config_for_test", move |tx| { - tx.execute( - "UPDATE protocol_configs SET protocol_config = ?2 WHERE commitment = ?1", - &[&commitment, &bytes], - )?; - Ok::<_, DatabaseError>(()) - }) - .await -} - /// Deletes a stored protocol configuration for a test. #[cfg(test)] pub(crate) async fn delete_protocol_config_for_test( @@ -612,29 +594,6 @@ mod tests { assert_eq!(db.load_protocol_config(mismatched.to_commitment()).await.unwrap(), None); } - #[tokio::test] - async fn corrupted_protocol_config_cannot_be_overwritten() { - let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); - let db = setup(temp_dir.path().join("validator.sqlite3")).await.unwrap(); - let config = test_protocol_config(); - let commitment = config.to_commitment(); - let header = genesis_header(&config); - let replacement = header_with_next_timestamp(&header); - - db.upsert_block_header_with_protocol_config(header.clone(), Some(config.clone())) - .await - .unwrap(); - overwrite_protocol_config_for_test(&db, commitment, vec![0xff]).await.unwrap(); - - db.upsert_block_header_with_protocol_config(replacement, Some(config)) - .await - .expect_err("a corrupt stored protocol config must reject the write"); - assert_eq!(db.load_block_header(header.block_num()).await.unwrap(), Some(header)); - db.load_protocol_config(commitment) - .await - .expect_err("the supplied protocol config must not overwrite the corrupt row"); - } - #[tokio::test] async fn block_header_insertion_failure_rolls_back_new_protocol_config() { let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); diff --git a/bin/validator/src/server/validator_service/tests.rs b/bin/validator/src/server/validator_service/tests.rs index 5bdfe94662..815b1f22ec 100644 --- a/bin/validator/src/server/validator_service/tests.rs +++ b/bin/validator/src/server/validator_service/tests.rs @@ -559,56 +559,6 @@ async fn sign_block_rejects_a_mismatched_protocol_config_before_signing() { assert_eq!(tv.call_status().await.signed_blocks_count, 0); } -#[tokio::test] -async fn sign_block_rejects_corrupt_stored_config_before_replacing_backup() { - let mut tv = TestValidator::new().await; - let genesis_header = tv.chain_tip.clone(); - let chain_at_genesis = tv.chain.clone(); - tv.apply_empty_block().await; - let original_header = tv.chain_tip.clone(); - let original_backup = tv - .server - .block_store - .load_block(original_header.block_num()) - .await - .unwrap() - .unwrap(); - - let block_inputs = BlockInputs::new( - genesis_header.clone(), - chain_at_genesis, - BTreeMap::new(), - BTreeMap::new(), - BTreeMap::new(), - ); - let replacement = - ProposedBlock::new_at(block_inputs, vec![], genesis_header.timestamp() + 1_000_000) - .unwrap(); - - let commitment = tv.protocol_config.to_commitment(); - let mut corrupt_bytes = tv.protocol_config.to_bytes(); - corrupt_bytes.push(0xff); - crate::db::overwrite_protocol_config_for_test(&tv.server.db, commitment, corrupt_bytes) - .await - .unwrap(); - - tv.call_sign_block_with_protocol_config(&replacement, Some(&tv.protocol_config)) - .await - .expect_err("corrupt stored config must reject signing"); - - assert_eq!(tv.load_chain_tip().await, original_header); - assert_eq!( - tv.server - .block_store - .load_block(original_header.block_num()) - .await - .unwrap() - .unwrap(), - original_backup, - "a rejected replacement must not overwrite the committed backup" - ); -} - /// An empty block at chain tip + 1 with the correct previous block commitment should be accepted. #[tokio::test] async fn chain_tip_plus_one_succeeds() { From aeacafb02db3686cc327341ced392e033d62211a Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Thu, 10 Sep 2026 16:34:06 +0200 Subject: [PATCH 16/28] fix: rename to config_required --- crates/rpc/src/server/api/sync_chain_mmr.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/rpc/src/server/api/sync_chain_mmr.rs b/crates/rpc/src/server/api/sync_chain_mmr.rs index 9d0f8611d3..0092f91f0d 100644 --- a/crates/rpc/src/server/api/sync_chain_mmr.rs +++ b/crates/rpc/src/server/api/sync_chain_mmr.rs @@ -63,7 +63,7 @@ impl proto::server::rpc_api::SyncChainMmr for RpcService { _ => Status::internal(err.to_string()), })?; - let include_config = if current_client_block_height == BlockNumber::GENESIS { + let config_required = if current_client_block_height == BlockNumber::GENESIS { true } else if current_client_block_height == sync_target { false @@ -76,7 +76,8 @@ impl proto::server::rpc_api::SyncChainMmr for RpcService { start.ok_or_else(|| Status::internal("starting block header is missing"))?; start.protocol_config_commitment() != block_header.protocol_config_commitment() }; - let protocol_config = if include_config { + + let protocol_config = if config_required { Some(super::load_protocol_config(&view, &block_header).await?.into()) } else { None From b4252c70aae3618058de1190d3f585435fb32038 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Thu, 10 Sep 2026 18:29:08 +0200 Subject: [PATCH 17/28] refactor: apply block tests --- crates/rpc/src/tests.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index d915ec0fc9..477dd91c9d 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -1803,10 +1803,14 @@ async fn block_subscription_starts_with_matching_config() { assert_eq!(config.to_commitment(), block.header().protocol_config_commitment()); } -async fn config_block(store: &TestStore, config: &ProtocolConfig) -> SignedBlock { +async fn next_block_with_protocol_config( + store: &TestStore, + config: &ProtocolConfig, +) -> SignedBlock { use miden_protocol::block::{BlockBody, BlockHeader}; use miden_protocol::crypto::merkle::mmr::Mmr; use miden_protocol::transaction::OrderedTransactionHeaders; + let view = store.state.view(); let (parent, _) = view.get_block_header(None, false).await.unwrap(); let parent = parent.unwrap(); @@ -1818,6 +1822,7 @@ async fn config_block(store: &TestStore, config: &ProtocolConfig) -> SignedBlock let body = BlockBody::new(vec![], vec![], vec![], OrderedTransactionHeaders::new_unchecked(vec![])) .unwrap(); + let header = BlockHeader::new( parent.commitment(), parent.block_num().child(), @@ -1840,6 +1845,7 @@ async fn protocol_config_transitions_follow_response_headers() { use miden_node_proto::domain::protocol_config::decode_protocol_config; use miden_protocol::block::BlockHeader; use miden_protocol::protocol_config::KernelConfig; + let (mut client, _, mut store, _server) = start_rpc().await; let (genesis, _) = store.state.view().get_block_header(Some(0.into()), false).await.unwrap(); let genesis = genesis.unwrap(); @@ -1858,10 +1864,12 @@ async fn protocol_config_transitions_follow_response_headers() { a.proof_verification().clone(), ) .unwrap(); + for config in [&a, &b, &b, &a] { - let block = config_block(&store, config).await; + let block = next_block_with_protocol_config(&store, config).await; store.writer.apply_block(block, Some(config.clone())).await.unwrap(); } + for (height, included) in [(0, true), (1, false), (2, true), (3, true), (4, false)] { let response = client .sync_chain_mmr(proto::rpc::SyncChainMmrRequest { @@ -1878,6 +1886,7 @@ async fn protocol_config_transitions_follow_response_headers() { assert_eq!(decode_protocol_config(response.protocol_config, &header).unwrap(), a); } } + let proven = client .sync_chain_mmr(proto::rpc::SyncChainMmrRequest { current_client_block_height: 0, @@ -1889,6 +1898,7 @@ async fn protocol_config_transitions_follow_response_headers() { let header: BlockHeader = proven.block_header.unwrap().try_into().unwrap(); assert_eq!(header.block_num(), 0.into()); assert_eq!(decode_protocol_config(proven.protocol_config, &header).unwrap(), a); + for start in [1, 2] { let mut stream = client .block_subscription(proto::rpc::BlockSubscriptionRequest { block_from: start }) @@ -1916,6 +1926,7 @@ async fn protocol_config_transitions_follow_response_headers() { async fn invalid_protocol_config_does_not_advance_store() { use miden_protocol::asset::AssetId; use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1; + let mut store = TestStore::start().await; let (header, _) = store.state.view().get_block_header(None, false).await.unwrap(); let header = header.unwrap(); @@ -1931,7 +1942,10 @@ async fn invalid_protocol_config_does_not_advance_store() { )) .unwrap(); assert_ne!(config.to_commitment(), initial.to_commitment()); - let block = config_block(&store, &config).await; + + let block = next_block_with_protocol_config(&store, &config).await; + + // Try applying without a matching protocol config assert!(store.writer.apply_block(block.clone(), None).await.is_err()); assert!(store.writer.apply_block(block.clone(), Some(initial)).await.is_err()); assert_eq!(store.state.committed_tip(), 0.into()); @@ -1945,6 +1959,8 @@ async fn invalid_protocol_config_does_not_advance_store() { .unwrap() .is_none() ); + + // Then apply with the matching protocol config store.writer.apply_block(block, Some(config.clone())).await.unwrap(); assert_eq!(store.state.committed_tip(), 1.into()); assert_eq!( From 385177550c9fccd498cede831ef7a16a51ec67c0 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Fri, 11 Sep 2026 14:02:41 +0200 Subject: [PATCH 18/28] fix: compile after rebase --- bin/node/src/commands/recover.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/node/src/commands/recover.rs b/bin/node/src/commands/recover.rs index 83d6710482..4a12008f09 100644 --- a/bin/node/src/commands/recover.rs +++ b/bin/node/src/commands/recover.rs @@ -556,7 +556,7 @@ mod tests { #[tokio::test] async fn coalescer_rejects_protocol_config_disagreement() { let signers = signers(2); - let validators = validator_keys(&signers); + let validators = validator_config(&signers); let (first_config, second_config) = protocol_configs(); let parent = BlockHeader::new( Word::empty(), From 090ceb0d66a33221ef20c057fd224af8513c423a Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Fri, 11 Sep 2026 14:51:19 +0200 Subject: [PATCH 19/28] fix(db): protocol config is not verified when loading from db --- bin/validator/src/db/queries/protocol_config/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/validator/src/db/queries/protocol_config/mod.rs b/bin/validator/src/db/queries/protocol_config/mod.rs index 497d9ed417..d73b87e51f 100644 --- a/bin/validator/src/db/queries/protocol_config/mod.rs +++ b/bin/validator/src/db/queries/protocol_config/mod.rs @@ -8,7 +8,7 @@ use miden_protocol::protocol_config::ProtocolConfig; const INSERT_SQL: &str = include_str!("insert.sql"); const SELECT_SQL: &str = include_str!("select.sql"); -/// Loads a protocol configuration and verifies its serialized value and commitment. +/// Loads a protocol configuration given its commitment. pub fn load(tx: &ReadTx<'_>, commitment: Word) -> Result, DatabaseError> { Ok(tx .query(SELECT_SQL, &[&commitment], |row| row.get::(0))? From 9b208623f00e31939033c57b513e99f3a27a333d Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Fri, 11 Sep 2026 17:07:46 +0200 Subject: [PATCH 20/28] refactor(validator/block_subscription): load protocol config only if the commitment changed --- .../validator_service/block_subscription.rs | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/bin/validator/src/server/validator_service/block_subscription.rs b/bin/validator/src/server/validator_service/block_subscription.rs index 5b9d229f64..99663e60ef 100644 --- a/bin/validator/src/server/validator_service/block_subscription.rs +++ b/bin/validator/src/server/validator_service/block_subscription.rs @@ -88,25 +88,29 @@ impl grpc::server::validator_api::BlockSubscription for ValidatorService { Ok(Some(bytes)) => match SignedBlock::read_from_bytes(&bytes) { Ok(signed_block) => { let commitment = signed_block.header().protocol_config_commitment(); - match db.load_protocol_config(commitment).await { - Ok(Some(config)) => { - let protocol_config = (previous_config_commitment - != Some(commitment)) - .then(|| (&config).into()); - previous_config_commitment = Some(commitment); - Ok(BlockSubscriptionResponse { - block: Some(signed_block.into()), - committed_chain_tip: committed_tip.as_u32(), - protocol_config, - }) - }, - Ok(None) => Err(tonic::Status::internal(format!( - "protocol config {commitment} not found" - ))), - Err(err) => Err(tonic::Status::internal( - err.as_report_context("failed to load protocol config"), - )), - } + let protocol_config = if previous_config_commitment + == Some(commitment) + { + Ok(None) + } else { + match db.load_protocol_config(commitment).await { + Ok(Some(config)) => { + previous_config_commitment = Some(commitment); + Ok(Some((&config).into())) + }, + Ok(None) => Err(tonic::Status::internal(format!( + "protocol config {commitment} not found" + ))), + Err(err) => Err(tonic::Status::internal( + err.as_report_context("failed to load protocol config"), + )), + } + }; + protocol_config.map(|protocol_config| BlockSubscriptionResponse { + block: Some(signed_block.into()), + committed_chain_tip: committed_tip.as_u32(), + protocol_config, + }) }, Err(err) => Err(tonic::Status::internal( err.as_report_context("failed to decode backed-up block"), From 9f3c57eaaac93c6e46eaea03bfec81978192a13a Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Sun, 13 Sep 2026 11:14:58 +0200 Subject: [PATCH 21/28] refactor(store): add activation block number for protocol configs --- crates/rpc/src/server/api/sync_chain_mmr.rs | 17 ++-- .../db/migrations/006_protocol_configs.sql | 8 +- crates/store/src/db/migrations/tests/mod.rs | 2 +- crates/store/src/db/mod.rs | 25 ++++- .../src/db/models/queries/protocol_configs.rs | 92 ++++++++++++++++--- crates/store/src/db/schema.rs | 3 +- crates/store/src/db/tests.rs | 5 +- .../store/src/state/view/protocol_config.rs | 17 +++- crates/store/src/state/writer/worker.rs | 77 ++++++++++++++-- 9 files changed, 206 insertions(+), 40 deletions(-) diff --git a/crates/rpc/src/server/api/sync_chain_mmr.rs b/crates/rpc/src/server/api/sync_chain_mmr.rs index 0092f91f0d..8529b4f442 100644 --- a/crates/rpc/src/server/api/sync_chain_mmr.rs +++ b/crates/rpc/src/server/api/sync_chain_mmr.rs @@ -63,21 +63,22 @@ impl proto::server::rpc_api::SyncChainMmr for RpcService { _ => Status::internal(err.to_string()), })?; - let config_required = if current_client_block_height == BlockNumber::GENESIS { + let protocol_config_required = if current_client_block_height == BlockNumber::GENESIS { true } else if current_client_block_height == sync_target { false } else { - let (start, _) = view - .get_block_header(Some(current_client_block_height), false) + let commitment_at_start = view + .get_protocol_config_commitment_at(current_client_block_height) .await - .map_err(super::get_block_header_error_to_status)?; - let start = - start.ok_or_else(|| Status::internal("starting block header is missing"))?; - start.protocol_config_commitment() != block_header.protocol_config_commitment() + .map_err(|err| Status::internal(err.to_string()))?; + let commitment_at_start = commitment_at_start.ok_or_else(|| { + Status::internal("starting protocol configuration activation is missing") + })?; + commitment_at_start != block_header.protocol_config_commitment() }; - let protocol_config = if config_required { + let protocol_config = if protocol_config_required { Some(super::load_protocol_config(&view, &block_header).await?.into()) } else { None diff --git a/crates/store/src/db/migrations/006_protocol_configs.sql b/crates/store/src/db/migrations/006_protocol_configs.sql index d9ecc50bd8..14f1290cbc 100644 --- a/crates/store/src/db/migrations/006_protocol_configs.sql +++ b/crates/store/src/db/migrations/006_protocol_configs.sql @@ -1,4 +1,8 @@ CREATE TABLE protocol_configs ( - commitment BLOB NOT NULL PRIMARY KEY CHECK (length(commitment) = 32), - protocol_config BLOB NOT NULL + commitment BLOB NOT NULL CHECK (length(commitment) = 32), + block_number BIGINT NOT NULL CHECK (block_number BETWEEN 0 AND 0xFFFFFFFF), + protocol_config BLOB NOT NULL, + PRIMARY KEY (commitment, block_number) ) WITHOUT ROWID; + +CREATE UNIQUE INDEX protocol_configs_block_number ON protocol_configs (block_number); diff --git a/crates/store/src/db/migrations/tests/mod.rs b/crates/store/src/db/migrations/tests/mod.rs index 076c1bf8c7..2f60c484f1 100644 --- a/crates/store/src/db/migrations/tests/mod.rs +++ b/crates/store/src/db/migrations/tests/mod.rs @@ -16,7 +16,7 @@ const EXPECTED_SCHEMA_HASHES: [SchemaHash; 6] = [ SchemaHash::from_hex("e026a70464e897ae9a217f45c80d72341b1bfb757200e57e41145348473a9961"), SchemaHash::from_hex("a581a13b00e4aa1d4539459e2b351c0585fad33c5a876f830c9b943adac92dea"), SchemaHash::from_hex("34bd293251a2647715dd91fa245bcd98d635e8070871b4f8335b3a3db364fc1e"), - SchemaHash::from_hex("303f71c67f038e46bd5b3234b0f5c84c37bbc1ce9f5ba65b2368ebbb0f24319b"), + SchemaHash::from_hex("cce37dcaef2f20597016e89e8b3e109b486149a66f1590137c5f9b7ccc8e3ad4"), ]; #[test] diff --git a/crates/store/src/db/mod.rs b/crates/store/src/db/mod.rs index e258d1d5e8..362a2fb870 100644 --- a/crates/store/src/db/mod.rs +++ b/crates/store/src/db/mod.rs @@ -106,7 +106,7 @@ pub struct 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::insert_protocol_config(conn, &protocol_config, BlockNumber::GENESIS)?; models::queries::apply_block( conn, &genesis_block, @@ -284,7 +284,18 @@ impl Db { commitment: Word, ) -> Result> { self.transact("protocol config by commitment", move |conn| { - queries::select_protocol_config(conn, commitment) + queries::select_protocol_config_by_commitment(conn, commitment) + }) + .await + } + + /// Selects the configuration commitment active at the specified block. + pub async fn select_protocol_config_commitment_at( + &self, + block_number: ScopedBlockNum, + ) -> Result> { + self.transact("protocol config commitment at block", move |conn| { + queries::select_protocol_config_commitment_at(conn, *block_number) }) .await } @@ -637,15 +648,19 @@ impl Db { pub(crate) async fn apply_block( &self, signed_block: SignedBlock, - new_protocol_config: Option, + activated_protocol_config: Option, notes: Vec<(NoteRecord, Option)>, precomputed_public_states: PrecomputedPublicAccountStates, unresolved_note_nullifiers: Vec, prune_tip: BlockNumber, ) -> Result> { self.transact("apply block", move |conn| { - if let Some(protocol_config) = new_protocol_config.as_ref() { - queries::insert_protocol_config(conn, protocol_config)?; + if let Some(protocol_config) = activated_protocol_config.as_ref() { + queries::insert_protocol_config( + conn, + protocol_config, + signed_block.header().block_num(), + )?; } models::queries::apply_block(conn, &signed_block, ¬es, &precomputed_public_states)?; models::queries::prune_history(conn, prune_tip)?; diff --git a/crates/store/src/db/models/queries/protocol_configs.rs b/crates/store/src/db/models/queries/protocol_configs.rs index cabfb889ee..7859c3dc08 100644 --- a/crates/store/src/db/models/queries/protocol_configs.rs +++ b/crates/store/src/db/models/queries/protocol_configs.rs @@ -1,18 +1,21 @@ use diesel::{ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl, SqliteConnection}; use miden_protocol::Word; +use miden_protocol::block::BlockNumber; 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. +/// Inserts a protocol configuration at its activation block. pub(crate) fn insert_protocol_config( conn: &mut SqliteConnection, protocol_config: &ProtocolConfig, + block_number: BlockNumber, ) -> Result { diesel::insert_into(protocol_configs::table) .values(( + protocol_configs::block_number.eq(i64::from(block_number.as_u32())), protocol_configs::commitment.eq(protocol_config.to_commitment().to_bytes()), protocol_configs::protocol_config.eq(protocol_config.to_bytes()), )) @@ -20,15 +23,16 @@ pub(crate) fn insert_protocol_config( .map_err(Into::into) } -/// Selects a protocol configuration and verifies its commitment. -pub(crate) fn select_protocol_config( +/// Selects a protocol configuration by its commitment. +pub(crate) fn select_protocol_config_by_commitment( 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) + .order(protocol_configs::block_number.asc()) + .first::>(conn) .optional()?; let Some(bytes) = bytes else { @@ -53,6 +57,20 @@ pub(crate) fn select_protocol_config( Ok(Some(protocol_config)) } +/// Selects the configuration commitment active at the specified block. +pub(crate) fn select_protocol_config_commitment_at( + conn: &mut SqliteConnection, + block_number: BlockNumber, +) -> Result, DatabaseError> { + let bytes = protocol_configs::table + .filter(protocol_configs::block_number.le(i64::from(block_number.as_u32()))) + .order(protocol_configs::block_number.desc()) + .select(protocol_configs::commitment) + .first::>(conn) + .optional()?; + bytes.map(|bytes| Word::read_from_bytes(&bytes).map_err(Into::into)).transpose() +} + #[cfg(test)] mod tests { use diesel::{ExpressionMethods, RunQueryDsl, SqliteConnection}; @@ -61,7 +79,7 @@ mod tests { use miden_protocol::protocol_config::ProtocolConfig; use miden_protocol::utils::serde::Serializable; - use super::{insert_protocol_config, select_protocol_config}; + use super::{insert_protocol_config, select_protocol_config_by_commitment}; use crate::db::schema::protocol_configs; use crate::errors::DatabaseError; @@ -75,16 +93,19 @@ mod tests { let config = test_protocol_config(); let commitment = config.to_commitment(); - insert_protocol_config(&mut conn, &config).unwrap(); + insert_protocol_config(&mut conn, &config, 0.into()).unwrap(); - assert_eq!(select_protocol_config(&mut conn, commitment).unwrap(), Some(config)); + assert_eq!( + select_protocol_config_by_commitment(&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); + assert_eq!(select_protocol_config_by_commitment(&mut conn, Word::empty()).unwrap(), None); } #[test] @@ -105,16 +126,50 @@ mod tests { )) .unwrap(); - insert_protocol_config(&mut conn, &first).unwrap(); - insert_protocol_config(&mut conn, &second).unwrap(); + insert_protocol_config(&mut conn, &first, 0.into()).unwrap(); + insert_protocol_config(&mut conn, &second, 1.into()).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(), + select_protocol_config_by_commitment(&mut conn, first.to_commitment()).unwrap(), + Some(first) + ); + assert_eq!( + select_protocol_config_by_commitment(&mut conn, second.to_commitment()).unwrap(), Some(second) ); } + #[test] + fn selects_commitment_at_activation_boundaries() { + let mut conn = connection(); + let first = test_protocol_config(); + let second = ProtocolConfig::current(miden_protocol::asset::AssetId::new_fungible( + miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1 + .try_into() + .unwrap(), + )) + .unwrap(); + assert_ne!(first.to_commitment(), second.to_commitment()); + assert_eq!(super::select_protocol_config_commitment_at(&mut conn, 0.into()).unwrap(), None); + for (height, config) in [(0, &first), (3, &second), (7, &first)] { + insert_protocol_config(&mut conn, config, height.into()).unwrap(); + } + for (height, config) in + [(0, &first), (2, &first), (3, &second), (6, &second), (7, &first), (9, &first)] + { + assert_eq!( + super::select_protocol_config_commitment_at(&mut conn, height.into()).unwrap(), + Some(config.to_commitment()) + ); + } + assert_eq!( + select_protocol_config_by_commitment(&mut conn, first.to_commitment()).unwrap(), + Some(first.clone()) + ); + assert!(insert_protocol_config(&mut conn, &first, 7.into()).is_err()); + assert!(insert_protocol_config(&mut conn, &second, 7.into()).is_err()); + } + #[test] fn rejects_a_row_stored_under_the_wrong_commitment() { let mut conn = connection(); @@ -123,6 +178,7 @@ mod tests { let calculated = config.to_commitment(); diesel::insert_into(protocol_configs::table) .values(( + protocol_configs::block_number.eq(0_i64), protocol_configs::commitment.eq(expected.to_bytes()), protocol_configs::protocol_config.eq(config.to_bytes()), )) @@ -130,7 +186,7 @@ mod tests { .unwrap(); assert!(matches!( - select_protocol_config(&mut conn, expected), + select_protocol_config_by_commitment(&mut conn, expected), Err(DatabaseError::ProtocolConfigCommitmentMismatch { expected: actual_expected, calculated: actual_calculated, @@ -144,14 +200,19 @@ mod tests { let commitment = Word::empty(); diesel::insert_into(protocol_configs::table) .values(( + protocol_configs::block_number.eq(0_i64), protocol_configs::commitment.eq(commitment.to_bytes()), protocol_configs::protocol_config.eq(vec![0xff]), )) .execute(&mut conn) .unwrap(); + assert_eq!( + super::select_protocol_config_commitment_at(&mut conn, 0.into()).unwrap(), + Some(commitment) + ); assert!(matches!( - select_protocol_config(&mut conn, commitment), + select_protocol_config_by_commitment(&mut conn, commitment), Err(DatabaseError::DeserializationError(_)) )); } @@ -165,6 +226,7 @@ mod tests { bytes.push(0xff); diesel::insert_into(protocol_configs::table) .values(( + protocol_configs::block_number.eq(0_i64), protocol_configs::commitment.eq(commitment.to_bytes()), protocol_configs::protocol_config.eq(bytes), )) @@ -172,7 +234,7 @@ mod tests { .unwrap(); assert!(matches!( - select_protocol_config(&mut conn, commitment), + select_protocol_config_by_commitment(&mut conn, commitment), Err(DatabaseError::DataCorrupted(_)) )); } diff --git a/crates/store/src/db/schema.rs b/crates/store/src/db/schema.rs index 9693f5e281..d841c72d63 100644 --- a/crates/store/src/db/schema.rs +++ b/crates/store/src/db/schema.rs @@ -90,7 +90,8 @@ diesel::table! { } diesel::table! { - protocol_configs (commitment) { + protocol_configs (commitment, block_number) { + block_number -> BigInt, commitment -> Binary, protocol_config -> Binary, } diff --git a/crates/store/src/db/tests.rs b/crates/store/src/db/tests.rs index ee9647bbbe..c382554688 100644 --- a/crates/store/src/db/tests.rs +++ b/crates/store/src/db/tests.rs @@ -150,7 +150,10 @@ fn bootstrap_rolls_back_protocol_config_when_genesis_insert_fails() { .unwrap(); assert!(super::insert_genesis(&mut conn, empty_genesis_block()).is_err()); - assert_eq!(queries::select_protocol_config(&mut conn, commitment).unwrap(), None); + assert_eq!( + queries::select_protocol_config_by_commitment(&mut conn, commitment).unwrap(), + None + ); } fn block_account_update( diff --git a/crates/store/src/state/view/protocol_config.rs b/crates/store/src/state/view/protocol_config.rs index 6d7f89eda0..6bc54a230e 100644 --- a/crates/store/src/state/view/protocol_config.rs +++ b/crates/store/src/state/view/protocol_config.rs @@ -1,10 +1,25 @@ use miden_protocol::Word; +use miden_protocol::block::BlockNumber; use miden_protocol::protocol_config::ProtocolConfig; use super::StateView; -use crate::errors::DatabaseError; +use crate::errors::{DatabaseError, RangeBeyondTip}; impl StateView { + /// Returns the configuration commitment active at the specified block. + /// + /// Returns an error if the block exceeds this view's tip. + pub async fn get_protocol_config_commitment_at( + &self, + block_number: BlockNumber, + ) -> Result, DatabaseError> { + let scoped = self.scope_block(block_number).ok_or(RangeBeyondTip { + chain_tip: *self.tip(), + block_to: block_number, + })?; + self.db.select_protocol_config_commitment_at(scoped).await + } + /// Returns the protocol configuration with the specified commitment. pub async fn get_protocol_config( &self, diff --git a/crates/store/src/state/writer/worker.rs b/crates/store/src/state/writer/worker.rs index 27d5a40682..3b61ff37a3 100644 --- a/crates/store/src/state/writer/worker.rs +++ b/crates/store/src/state/writer/worker.rs @@ -208,7 +208,8 @@ impl WriteWorker { block.transaction.count = num_transactions ); - self.validate_block_header(header).await?; + let previous_config_commitment = self.validate_block_header(header).await?; + let commitment = header.protocol_config_commitment(); if let Some(config) = protocol_config.as_ref() { let calculated = config.to_commitment(); @@ -220,11 +221,16 @@ impl WriteWorker { .into()); } } + let stored = self.db.select_protocol_config_by_commitment(commitment).await?; if stored.is_none() && protocol_config.is_none() { return Err(crate::errors::DatabaseError::ProtocolConfigNotFound(commitment).into()); } - let new_protocol_config = if stored.is_none() { protocol_config } else { None }; + let activated_protocol_config = if previous_config_commitment != commitment { + stored.or(protocol_config) + } else { + None + }; let block_lifecycle = lifecycle_events_enabled().then(|| BlockLifecycle::from_block_body(block_num, body)); @@ -261,7 +267,7 @@ impl WriteWorker { .db .apply_block( signed_block, - new_protocol_config, + activated_protocol_config, notes, precomputed_public_states, unresolved_note_nullifiers, @@ -442,7 +448,7 @@ impl WriteWorker { target = COMPONENT, err, )] - async fn validate_block_header(&self, header: &BlockHeader) -> Result<(), ApplyBlockError> { + async fn validate_block_header(&self, header: &BlockHeader) -> Result { let block_num = header.block_num(); // Validate that the applied block is the next block in sequence. @@ -463,7 +469,7 @@ impl WriteWorker { return Err(InvalidBlockError::NewBlockInvalidPrevCommitment.into()); } - Ok(()) + Ok(prev_block.protocol_config_commitment()) } /// Computes nullifier and account tree mutations, validating roots against the block header. @@ -724,6 +730,55 @@ mod tests { writer.stop(writer_task).await; } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn writer_records_reactivations_and_scopes_history() { + let (_temp_dir, state, mut writer, writer_task, first) = start_store().await; + let second = alternate_protocol_config(); + let pinned = state.view(); + for (config, supplied) in [ + (&first, false), + (&second, true), + (&second, false), + (&first, false), + (&second, true), + ] { + let block = empty_block(&state, config).await; + writer.apply_block(block, supplied.then(|| config.clone())).await.unwrap(); + } + assert_eq!( + pinned.get_protocol_config_commitment_at(0.into()).await.unwrap(), + Some(first.to_commitment()) + ); + assert!(matches!( + pinned.get_protocol_config_commitment_at(1.into()).await, + Err(DatabaseError::RangeBeyondTip(_)) + )); + let view = state.view(); + for (height, config) in + [(0, &first), (1, &first), (2, &second), (3, &second), (4, &first), (5, &second)] + { + assert_eq!( + view.get_protocol_config_commitment_at(height.into()).await.unwrap(), + Some(config.to_commitment()) + ); + } + let activations: Vec = state + .db + .query("activation history", |conn| { + protocol_configs::table + .select(protocol_configs::block_number) + .order(protocol_configs::block_number.asc()) + .load(conn) + .map_err(DatabaseError::from) + }) + .await + .unwrap(); + assert_eq!(activations, vec![0, 2, 4, 5]); + drop(view); + drop(pinned); + writer.stop(writer_task).await; + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn writer_reuses_a_known_protocol_config_without_a_supplied_config() { let (_temp_dir, state, mut writer, writer_task, protocol_config) = start_store().await; @@ -815,7 +870,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn protocol_config_insertion_rolls_back_when_the_block_write_fails() { - let (_temp_dir, state, mut writer, writer_task, _genesis_config) = start_store().await; + let (_temp_dir, state, mut writer, writer_task, genesis_config) = start_store().await; let protocol_config = alternate_protocol_config(); let commitment = protocol_config.to_commitment(); let block = empty_block(&state, &protocol_config).await; @@ -839,6 +894,16 @@ mod tests { assert_eq!(state.committed_tip(), 0.into()); assert_eq!(state.view().get_protocol_config(commitment).await.unwrap(), None); + assert_eq!( + state + .db + .select_protocol_config_commitment_at(crate::state::ScopedBlockNum::new_unchecked( + 1.into() + ),) + .await + .unwrap(), + Some(genesis_config.to_commitment()) + ); assert_eq!( state .db From a23a51e225c3dad5e79f5618b17eb53df9495d0d Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Sun, 13 Sep 2026 11:34:20 +0200 Subject: [PATCH 22/28] refactor: rename decode_protocol_config --- bin/benchmark/src/rpc_state.rs | 7 +++-- bin/large-account-benchmark/src/rpc.rs | 7 +++-- bin/network-monitor/src/deploy/mod.rs | 14 +++++---- bin/node/src/commands/recover.rs | 9 ++++-- bin/ntx-builder/src/clients/rpc.rs | 9 ++++-- .../server/validator_service/sign_block.rs | 9 ++++-- crates/block-producer/src/rpc_sync.rs | 2 +- crates/proto/src/domain/protocol_config.rs | 29 +++++++++++++++---- crates/rpc/src/tests.rs | 18 +++++++++--- 9 files changed, 74 insertions(+), 30 deletions(-) diff --git a/bin/benchmark/src/rpc_state.rs b/bin/benchmark/src/rpc_state.rs index 0d411e9eff..ad59dd8799 100644 --- a/bin/benchmark/src/rpc_state.rs +++ b/bin/benchmark/src/rpc_state.rs @@ -9,7 +9,7 @@ use anyhow::{Context, Result}; use miden_node_proto::clients::RpcClient; -use miden_node_proto::domain::protocol_config::decode_protocol_config; +use miden_node_proto::domain::protocol_config::ensure_protocol_config_is_present_and_matches_header; use miden_node_proto::generated::rpc::{FinalityLevel, SyncChainMmrRequest, SyncChainMmrResponse}; use miden_protocol::block::BlockHeader; use miden_protocol::crypto::merkle::mmr::{MmrDelta, MmrPeaks, PartialMmr}; @@ -42,8 +42,9 @@ fn decode_chain_tip_state( .context("sync_chain_mmr response missing block_header")? .try_into() .context("failed to decode the chain tip block header")?; - let protocol_config = decode_protocol_config(response.protocol_config, &tip_header) - .context("sync_chain_mmr response missing a valid protocol configuration")?; + let protocol_config = + ensure_protocol_config_is_present_and_matches_header(response.protocol_config, &tip_header) + .context("sync_chain_mmr response missing a valid protocol configuration")?; let delta: MmrDelta = response .mmr_delta .context("sync_chain_mmr response missing mmr_delta")? diff --git a/bin/large-account-benchmark/src/rpc.rs b/bin/large-account-benchmark/src/rpc.rs index 0517b50902..601a18d959 100644 --- a/bin/large-account-benchmark/src/rpc.rs +++ b/bin/large-account-benchmark/src/rpc.rs @@ -11,7 +11,7 @@ use miden_node_proto::domain::encryption::{ TrustedTransactionEncryptionState, verify_transaction_encryption_key, }; -use miden_node_proto::domain::protocol_config::decode_protocol_config; +use miden_node_proto::domain::protocol_config::ensure_protocol_config_is_present_and_matches_header; use miden_node_proto::generated::account::AccountId as ProtoAccountId; use miden_node_proto::generated::rpc::account_request::AccountDetailRequest; use miden_node_proto::generated::rpc::{ @@ -339,8 +339,9 @@ fn decode_genesis_block_state( .context("RPC returned no genesis block header")? .try_into() .context("failed to decode the genesis block header")?; - let protocol_config = decode_protocol_config(response.protocol_config, &header) - .context("RPC returned no valid genesis protocol configuration")?; + let protocol_config = + ensure_protocol_config_is_present_and_matches_header(response.protocol_config, &header) + .context("RPC returned no valid genesis protocol configuration")?; Ok((header, protocol_config)) } diff --git a/bin/network-monitor/src/deploy/mod.rs b/bin/network-monitor/src/deploy/mod.rs index 3f0cc40b71..dbce4eecb2 100644 --- a/bin/network-monitor/src/deploy/mod.rs +++ b/bin/network-monitor/src/deploy/mod.rs @@ -16,7 +16,7 @@ use miden_node_proto::domain::encryption::{ TrustedTransactionEncryptionState, verify_transaction_encryption_key, }; -use miden_node_proto::domain::protocol_config::decode_protocol_config; +use miden_node_proto::domain::protocol_config::ensure_protocol_config_is_present_and_matches_header; use miden_node_proto::generated::rpc::{ AccountRequest as ProtoAccountRequest, BlockHeaderByNumberRequest, @@ -744,8 +744,9 @@ fn decode_chain_state( .try_into() .context("failed to convert the sync target block header")?; - let protocol_config = decode_protocol_config(response.protocol_config, &tip_header) - .context("sync_chain_mmr response did not include a valid protocol configuration")?; + let protocol_config = + ensure_protocol_config_is_present_and_matches_header(response.protocol_config, &tip_header) + .context("sync_chain_mmr response did not include a valid protocol configuration")?; let delta: MmrDelta = response .mmr_delta @@ -1179,7 +1180,7 @@ mod tests { MonitorDataStore, active_fee_funding, decode_chain_state, - decode_protocol_config, + ensure_protocol_config_is_present_and_matches_header, }; use crate::deploy::wallet::create_wallet_account; @@ -1226,8 +1227,9 @@ mod tests { let header = chain.genesis_block_header(); let expected = chain.protocol_config().clone(); - let decoded = decode_protocol_config(Some((&expected).into()), &header) - .expect("the RPC configuration matches its header"); + let decoded = + ensure_protocol_config_is_present_and_matches_header(Some((&expected).into()), &header) + .expect("the RPC configuration matches its header"); assert_eq!(decoded, expected); } diff --git a/bin/node/src/commands/recover.rs b/bin/node/src/commands/recover.rs index 4a12008f09..f2cf773c9d 100644 --- a/bin/node/src/commands/recover.rs +++ b/bin/node/src/commands/recover.rs @@ -4,7 +4,7 @@ use std::time::Duration; use anyhow::Context; use miden_node_proto::clients::{Builder, ValidatorClient}; -use miden_node_proto::domain::protocol_config::decode_protocol_config; +use miden_node_proto::domain::protocol_config::ensure_protocol_config_is_present_and_matches_header; use miden_node_proto::generated::validator::{BlockSubscriptionRequest, BlockSubscriptionResponse}; use miden_node_store::{BlockWriter, State, WriterTask}; use miden_node_tracing::info; @@ -255,7 +255,12 @@ async fn read_blocks( .with_context(|| format!("failed to decode block from validator {url}"))?; let protocol_config = event .protocol_config - .map(|config| decode_protocol_config(Some(config), block.header())) + .map(|config| { + ensure_protocol_config_is_present_and_matches_header( + Some(config), + block.header(), + ) + }) .transpose() .with_context(|| { format!("failed to decode protocol config from validator {url}") diff --git a/bin/ntx-builder/src/clients/rpc.rs b/bin/ntx-builder/src/clients/rpc.rs index 40a1387b46..0fbffa60a3 100644 --- a/bin/ntx-builder/src/clients/rpc.rs +++ b/bin/ntx-builder/src/clients/rpc.rs @@ -17,7 +17,7 @@ use miden_node_proto::domain::encryption::{ TrustedTransactionEncryptionState, verify_transaction_encryption_key, }; -use miden_node_proto::domain::protocol_config::decode_protocol_config; +use miden_node_proto::domain::protocol_config::ensure_protocol_config_is_present_and_matches_header; use miden_node_proto::errors::ConversionError; use miden_node_proto::generated::rpc::account_request::account_detail_request::{StorageMapDetailRequest, StorageMapDetailRequests, StorageRequest, storage_map_detail_request}; use miden_node_proto::generated::rpc::account_request::account_detail_request::storage_map_detail_request::MapKeys; @@ -500,7 +500,8 @@ fn decode_startup_header_response( )); } - decode_protocol_config(response.protocol_config, &header).map_err(RpcError::Conversion) + ensure_protocol_config_is_present_and_matches_header(response.protocol_config, &header) + .map_err(RpcError::Conversion) } fn decode_block_subscription_response( @@ -518,7 +519,9 @@ fn decode_block_subscription_response( let protocol_config = response .protocol_config .clone() - .map(|config| decode_protocol_config(Some(config), block.header())) + .map(|config| { + ensure_protocol_config_is_present_and_matches_header(Some(config), block.header()) + }) .transpose() .map_err(RpcError::Conversion)?; let committed_tip = BlockNumber::from(response.committed_chain_tip); diff --git a/bin/validator/src/server/validator_service/sign_block.rs b/bin/validator/src/server/validator_service/sign_block.rs index 0d62b1b560..5958a215e8 100644 --- a/bin/validator/src/server/validator_service/sign_block.rs +++ b/bin/validator/src/server/validator_service/sign_block.rs @@ -1,6 +1,6 @@ use std::sync::atomic::Ordering; -use miden_node_proto::domain::protocol_config::decode_protocol_config; +use miden_node_proto::domain::protocol_config::ensure_protocol_config_is_present_and_matches_header; use miden_node_proto::{BlockProofRequest, generated as grpc}; use miden_node_tracing::spawn::spawn_blocking_in_current_span; use miden_node_tracing::{ErrorReport, Instrument, info_span, miden_instrument}; @@ -66,7 +66,12 @@ impl grpc::server::validator_api::SignBlock for ValidatorService { let supplied_protocol_config = request.protocol_config.take(); let request = BlockProofRequest::try_from(request).map_err(tonic::Status::from)?; let protocol_config = supplied_protocol_config - .map(|config| decode_protocol_config(Some(config), &request.block_header)) + .map(|config| { + ensure_protocol_config_is_present_and_matches_header( + Some(config), + &request.block_header, + ) + }) .transpose() .map_err(tonic::Status::from)?; let protocol_config_commitment = request.block_header.protocol_config_commitment(); diff --git a/crates/block-producer/src/rpc_sync.rs b/crates/block-producer/src/rpc_sync.rs index 1e107b573c..ac03cc3f6e 100644 --- a/crates/block-producer/src/rpc_sync.rs +++ b/crates/block-producer/src/rpc_sync.rs @@ -239,7 +239,7 @@ impl BlockSync { let protocol_config = event .protocol_config .map(|config| { - miden_node_proto::domain::protocol_config::decode_protocol_config( + miden_node_proto::domain::protocol_config::ensure_protocol_config_is_present_and_matches_header( Some(config), block.header(), ) diff --git a/crates/proto/src/domain/protocol_config.rs b/crates/proto/src/domain/protocol_config.rs index 5e8d822365..d60f0686a5 100644 --- a/crates/proto/src/domain/protocol_config.rs +++ b/crates/proto/src/domain/protocol_config.rs @@ -6,8 +6,9 @@ use miden_protocol::protocol_config::ProtocolConfig; use crate::errors::ConversionError; use crate::generated::protocol_config::ProtocolConfig as ProtoProtocolConfig; -/// Decodes a required configuration and verifies the header's commitment. -pub fn decode_protocol_config( +/// Ensures protocol configuration is present and matches the protocol configuration commitment in +/// the header. +pub fn ensure_protocol_config_is_present_and_matches_header( config: Option, header: &BlockHeader, ) -> Result { @@ -55,9 +56,25 @@ mod tests { None, 0, ); - assert!(decode_protocol_config(None, &header).is_err()); - assert!(decode_protocol_config(Some(ProtoProtocolConfig::default()), &header).is_err()); - assert_eq!(decode_protocol_config(Some((&config).into()), &header).unwrap(), config); - assert!(decode_protocol_config(Some(config.into()), &other_header).is_err()); + assert!(ensure_protocol_config_is_present_and_matches_header(None, &header).is_err()); + assert!( + ensure_protocol_config_is_present_and_matches_header( + Some(ProtoProtocolConfig::default()), + &header + ) + .is_err() + ); + assert_eq!( + ensure_protocol_config_is_present_and_matches_header(Some((&config).into()), &header) + .unwrap(), + config + ); + assert!( + ensure_protocol_config_is_present_and_matches_header( + Some(config.into()), + &other_header + ) + .is_err() + ); } } diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index 477dd91c9d..38398be400 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -1842,7 +1842,7 @@ async fn next_block_with_protocol_config( #[tokio::test(flavor = "multi_thread")] async fn protocol_config_transitions_follow_response_headers() { - use miden_node_proto::domain::protocol_config::decode_protocol_config; + use miden_node_proto::domain::protocol_config::ensure_protocol_config_is_present_and_matches_header; use miden_protocol::block::BlockHeader; use miden_protocol::protocol_config::KernelConfig; @@ -1883,7 +1883,10 @@ async fn protocol_config_transitions_follow_response_headers() { let header: BlockHeader = response.block_header.unwrap().try_into().unwrap(); assert_eq!(header.block_num(), 4.into()); if included { - assert_eq!(decode_protocol_config(response.protocol_config, &header).unwrap(), a); + assert_eq!( + ensure_protocol_config_is_present_and_matches_header(response.protocol_config, &header).unwrap( + a + ) a); } } @@ -1897,7 +1900,10 @@ async fn protocol_config_transitions_follow_response_headers() { .into_inner(); let header: BlockHeader = proven.block_header.unwrap().try_into().unwrap(); assert_eq!(header.block_num(), 0.into()); - assert_eq!(decode_protocol_config(proven.protocol_config, &header).unwrap(), a); + assert_eq!( + ensure_protocol_config_is_present_and_matches_header(proven.protocol_config, &header).unwrap( + a + ) a); for start in [1, 2] { let mut stream = client @@ -1914,7 +1920,11 @@ async fn protocol_config_transitions_follow_response_headers() { if included { let expected = if height == 2 || height == 3 { &b } else { &a }; assert_eq!( - &decode_protocol_config(response.protocol_config, block.header()).unwrap(), + &ensure_protocol_config_is_present_and_matches_header( + response.protocol_config, + block.header() + ) + .unwrap(), expected ); } From 8302aa3202fd6e0aa660a6398d9f0d9a086664a0 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Sun, 13 Sep 2026 11:35:20 +0200 Subject: [PATCH 23/28] refactor(validator/db): remove unnecessary DB load --- bin/validator/src/db/mod.rs | 15 ++++++++++----- crates/rpc/src/tests.rs | 13 +++++++++---- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/bin/validator/src/db/mod.rs b/bin/validator/src/db/mod.rs index 8d8cb55561..e4bde5c829 100644 --- a/bin/validator/src/db/mod.rs +++ b/bin/validator/src/db/mod.rs @@ -73,7 +73,7 @@ impl ValidatorDbReader { .await } - /// Loads and verifies the protocol configuration with the given commitment. + /// Loads the protocol configuration with the given commitment. pub async fn load_protocol_config( &self, commitment: miden_protocol::Word, @@ -200,7 +200,8 @@ impl ValidatorDbWriter { /// Persists a protocol configuration and its block header in one transaction. /// - /// If `protocol_config` is absent, the configuration must already be stored. + /// If `protocol_config` is absent, the configuration must already be stored + /// otherwise an error is returned. #[miden_instrument( target = COMPONENT, )] @@ -221,11 +222,15 @@ impl ValidatorDbWriter { ))); } queries::insert_protocol_config(tx, config)?; + } else { + // Ensure that the configuration is already stored. + queries::load_protocol_config(tx, commitment)?.ok_or_else(|| { + invalid_protocol_config(format!( + "protocol config {commitment} is not stored" + )) + })?; } - queries::load_protocol_config(tx, commitment)?.ok_or_else(|| { - invalid_protocol_config(format!("protocol config {commitment} is not stored")) - })?; queries::upsert_block_header(tx, &header) }) .await diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index 38398be400..e28e2814cb 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -1884,9 +1884,13 @@ async fn protocol_config_transitions_follow_response_headers() { assert_eq!(header.block_num(), 4.into()); if included { assert_eq!( - ensure_protocol_config_is_present_and_matches_header(response.protocol_config, &header).unwrap( + ensure_protocol_config_is_present_and_matches_header( + response.protocol_config, + &header + ) + .unwrap(), a - ) a); + ); } } @@ -1901,9 +1905,10 @@ async fn protocol_config_transitions_follow_response_headers() { let header: BlockHeader = proven.block_header.unwrap().try_into().unwrap(); assert_eq!(header.block_num(), 0.into()); assert_eq!( - ensure_protocol_config_is_present_and_matches_header(proven.protocol_config, &header).unwrap( + ensure_protocol_config_is_present_and_matches_header(proven.protocol_config, &header) + .unwrap(), a - ) a); + ); for start in [1, 2] { let mut stream = client From d5a78241ceae76c13ec4bf207d3787776b01375e Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Sun, 13 Sep 2026 11:38:47 +0200 Subject: [PATCH 24/28] refactor(rpc): move helper function to helper section --- crates/rpc/src/server/api.rs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/rpc/src/server/api.rs b/crates/rpc/src/server/api.rs index ae64a2375f..63c4ac3de2 100644 --- a/crates/rpc/src/server/api.rs +++ b/crates/rpc/src/server/api.rs @@ -34,18 +34,6 @@ use crate::server::api::subscription::{IpBanList, MAX_REPLICA_SUBSCRIPTIONS}; use crate::server::{NetworkTxAuth, RpcBackend}; use crate::{COMPONENT, LOG_TARGET}; -/// Loads the configuration committed to by a stored header. -async fn load_protocol_config( - view: &miden_node_store::state::StateView, - header: &BlockHeader, -) -> tonic::Result { - let commitment = header.protocol_config_commitment(); - view.get_protocol_config(commitment) - .await - .map_err(|err| Status::internal(err.to_string()))? - .ok_or_else(|| Status::internal(format!("protocol config {commitment} is missing"))) -} - // VALIDATOR FAN-OUT // ================================================================================================ @@ -314,6 +302,18 @@ fn invalid_block_range_to_status(RpcInvalidBlockRange(err): RpcInvalidBlockRange Status::invalid_argument(err.to_string()) } +/// Loads the configuration committed to by a stored header. +async fn load_protocol_config( + view: &miden_node_store::state::StateView, + header: &BlockHeader, +) -> tonic::Result { + let commitment = header.protocol_config_commitment(); + view.get_protocol_config(commitment) + .await + .map_err(|err| Status::internal(err.to_string()))? + .ok_or_else(|| Status::internal(format!("protocol config {commitment} is missing"))) +} + // LIMIT HELPERS // ================================================================================================ From b32eb69e691de521a2bcf304f683a260e8164904 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Mon, 14 Sep 2026 09:45:28 +0200 Subject: [PATCH 25/28] fix: clippy --- crates/store/src/state/writer/worker.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/store/src/state/writer/worker.rs b/crates/store/src/state/writer/worker.rs index 3b61ff37a3..23d542170c 100644 --- a/crates/store/src/state/writer/worker.rs +++ b/crates/store/src/state/writer/worker.rs @@ -226,10 +226,10 @@ impl WriteWorker { if stored.is_none() && protocol_config.is_none() { return Err(crate::errors::DatabaseError::ProtocolConfigNotFound(commitment).into()); } - let activated_protocol_config = if previous_config_commitment != commitment { - stored.or(protocol_config) - } else { + let activated_protocol_config = if previous_config_commitment == commitment { None + } else { + stored.or(protocol_config) }; let block_lifecycle = From 41f6e863e43625b583195a2962d313f3f2263d08 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Mon, 14 Sep 2026 09:58:20 +0200 Subject: [PATCH 26/28] fix: diesel schema ordering fix --- crates/store/src/db/schema.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/store/src/db/schema.rs b/crates/store/src/db/schema.rs index d841c72d63..6eb11d729f 100644 --- a/crates/store/src/db/schema.rs +++ b/crates/store/src/db/schema.rs @@ -91,8 +91,8 @@ diesel::table! { diesel::table! { protocol_configs (commitment, block_number) { - block_number -> BigInt, commitment -> Binary, + block_number -> BigInt, protocol_config -> Binary, } } From 843100f281227c1cfa71c49f68455dda2c68a99c Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Mon, 14 Sep 2026 10:09:45 +0200 Subject: [PATCH 27/28] fix: document when protocol configuration needs to be present --- crates/store/src/state/writer/mod.rs | 2 ++ proto/proto/types/block_proving.proto | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/store/src/state/writer/mod.rs b/crates/store/src/state/writer/mod.rs index 27925293a8..b08c676ac8 100644 --- a/crates/store/src/state/writer/mod.rs +++ b/crates/store/src/state/writer/mod.rs @@ -125,6 +125,8 @@ impl BlockWriter { /// /// Supply the active configuration if its commitment is not yet stored. The configuration /// must match the block header. New configurations are committed with the block. + /// If the configuration is omitted and its commitment is not stored, this method returns + /// `DatabaseError::ProtocolConfigNotFound` through `ApplyBlockError`. /// /// Blocks are forwarded to the store's write worker task, which processes them one at a /// time. diff --git a/proto/proto/types/block_proving.proto b/proto/proto/types/block_proving.proto index ad972e4bcd..a50549448d 100644 --- a/proto/proto/types/block_proving.proto +++ b/proto/proto/types/block_proving.proto @@ -52,7 +52,9 @@ message BlockProofRequest { // The optional next protocol configuration included in the proposed block header. optional blockchain.NextProtocolConfig next_protocol_config = 5; - // The active configuration for signing. May be omitted if the validator already stores it. - // Proving-only callers may omit this field. + // The active configuration for signing. The block producer always supplies this field. + // Other callers may omit this field if the validator already stores the configuration. + // The validator returns INVALID_ARGUMENT if the field is omitted and the configuration is not stored. + // Proving requests do not require this field. optional protocol_config.ProtocolConfig protocol_config = 6; } From 39de88eddaf5b3fb4b9bb9ad2f2ec6dfc25b5084 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Mon, 14 Sep 2026 11:18:47 +0200 Subject: [PATCH 28/28] refactor(validator): add activation block number to protocol_configs --- bin/validator/src/db/migrations.rs | 2 +- .../db/migrations/002_protocol_configs.sql | 7 +- bin/validator/src/db/mod.rs | 25 +++- bin/validator/src/db/queries/mod.rs | 6 +- .../src/db/queries/protocol_config/insert.sql | 7 +- .../src/db/queries/protocol_config/mod.rs | 25 +++- .../src/db/queries/protocol_config/select.sql | 4 +- .../queries/protocol_config/select_before.sql | 5 + .../src/db/tests/protocol_config_history.rs | 125 ++++++++++++++++++ .../src/server/validator_service/tests.rs | 5 +- 10 files changed, 194 insertions(+), 17 deletions(-) create mode 100644 bin/validator/src/db/queries/protocol_config/select_before.sql create mode 100644 bin/validator/src/db/tests/protocol_config_history.rs diff --git a/bin/validator/src/db/migrations.rs b/bin/validator/src/db/migrations.rs index 30dc7c5a30..0a1c847118 100644 --- a/bin/validator/src/db/migrations.rs +++ b/bin/validator/src/db/migrations.rs @@ -69,7 +69,7 @@ mod tests { const EXPECTED_SCHEMA_HASHES: [SchemaHash; 2] = [ SchemaHash::from_hex("f2f6af5e22d8d0273524a227417339279d1a694c3802a8f3f7cc4b31e21ee035"), - SchemaHash::from_hex("56270f7825c3d07ab37a224583b7b6294bab52761e14e9aa32014f481349fb8d"), + SchemaHash::from_hex("5e91b956814dcd627a69417f48231de70e01a8aa97b32e7d3a1db67c077cb4f3"), ]; #[test] diff --git a/bin/validator/src/db/migrations/002_protocol_configs.sql b/bin/validator/src/db/migrations/002_protocol_configs.sql index 52c2a28e44..14f1290cbc 100644 --- a/bin/validator/src/db/migrations/002_protocol_configs.sql +++ b/bin/validator/src/db/migrations/002_protocol_configs.sql @@ -1,5 +1,8 @@ CREATE TABLE protocol_configs ( - commitment BLOB PRIMARY KEY, + commitment BLOB NOT NULL CHECK (length(commitment) = 32), + block_number BIGINT NOT NULL CHECK (block_number BETWEEN 0 AND 0xFFFFFFFF), protocol_config BLOB NOT NULL, - CHECK (length(commitment) = 32) + PRIMARY KEY (commitment, block_number) ) WITHOUT ROWID; + +CREATE UNIQUE INDEX protocol_configs_block_number ON protocol_configs (block_number); diff --git a/bin/validator/src/db/mod.rs b/bin/validator/src/db/mod.rs index e4bde5c829..5d3bffc564 100644 --- a/bin/validator/src/db/mod.rs +++ b/bin/validator/src/db/mod.rs @@ -198,7 +198,11 @@ impl ValidatorDbWriter { .await } - /// Persists a protocol configuration and its block header in one transaction. + /// Persists a block header and its configuration activation in one transaction. + /// + /// Records an activation if the configuration differs from the preceding activation. + /// A replacement at the current tip must retain its active configuration. + /// Callers must validate block order before this method runs. /// /// If `protocol_config` is absent, the configuration must already be stored /// otherwise an error is returned. @@ -213,7 +217,7 @@ impl ValidatorDbWriter { self.writer .write("upsert_block_header_with_protocol_config", move |tx| { let commitment = header.protocol_config_commitment(); - if let Some(config) = protocol_config.as_ref() { + let config = if let Some(config) = protocol_config { let calculated = config.to_commitment(); if calculated != commitment { return Err(invalid_protocol_config(format!( @@ -221,14 +225,19 @@ impl ValidatorDbWriter { {calculated}" ))); } - queries::insert_protocol_config(tx, config)?; + config } else { - // Ensure that the configuration is already stored. queries::load_protocol_config(tx, commitment)?.ok_or_else(|| { invalid_protocol_config(format!( "protocol config {commitment} is not stored" )) - })?; + })? + }; + + let block_number = header.block_num(); + let previous = queries::load_protocol_config_commitment_before(tx, block_number)?; + if previous != Some(commitment) { + queries::insert_protocol_config(tx, &config, block_number)?; } queries::upsert_block_header(tx, &header) @@ -354,6 +363,8 @@ fn open_with_pool_size( #[cfg(test)] mod tests { + mod protocol_config_history; + use miden_node_utils::fee::{test_fee_params, test_protocol_config}; use miden_protocol::Word; use miden_protocol::asset::AssetId; @@ -475,6 +486,10 @@ mod tests { db.upsert_block_header_with_protocol_config(header, Some(config.clone())) .await .unwrap(); + assert_eq!( + protocol_config_history::history(&db).await, + vec![(0, config.to_commitment(), config.clone())] + ); assert_eq!(db.load_protocol_config(config.to_commitment()).await.unwrap(), Some(config)); } diff --git a/bin/validator/src/db/queries/mod.rs b/bin/validator/src/db/queries/mod.rs index d867bbd16d..f75ba6f08b 100644 --- a/bin/validator/src/db/queries/mod.rs +++ b/bin/validator/src/db/queries/mod.rs @@ -9,7 +9,11 @@ mod private_record_row; mod protocol_config; -pub use protocol_config::{insert as insert_protocol_config, load as load_protocol_config}; +pub use protocol_config::{ + insert as insert_protocol_config, + load as load_protocol_config, + load_before as load_protocol_config_commitment_before, +}; mod count_signed_blocks; pub use count_signed_blocks::count_signed_blocks; diff --git a/bin/validator/src/db/queries/protocol_config/insert.sql b/bin/validator/src/db/queries/protocol_config/insert.sql index bd045d2d89..4559dda32e 100644 --- a/bin/validator/src/db/queries/protocol_config/insert.sql +++ b/bin/validator/src/db/queries/protocol_config/insert.sql @@ -1,2 +1,5 @@ -INSERT OR IGNORE INTO protocol_configs (commitment, protocol_config) -VALUES (?1, ?2); +INSERT INTO protocol_configs (commitment, block_number, protocol_config) +VALUES (?1, ?2, ?3) +ON CONFLICT (block_number) DO UPDATE SET + commitment = excluded.commitment, + protocol_config = excluded.protocol_config; diff --git a/bin/validator/src/db/queries/protocol_config/mod.rs b/bin/validator/src/db/queries/protocol_config/mod.rs index d73b87e51f..5131f2bb0f 100644 --- a/bin/validator/src/db/queries/protocol_config/mod.rs +++ b/bin/validator/src/db/queries/protocol_config/mod.rs @@ -1,12 +1,14 @@ -//! Stores and loads protocol configurations by commitment. +//! Stores protocol configuration activations and loads configurations by commitment. use miden_node_db::DatabaseError; use miden_node_db::sqlite::{ReadTx, WriteTx}; use miden_protocol::Word; +use miden_protocol::block::BlockNumber; use miden_protocol::protocol_config::ProtocolConfig; const INSERT_SQL: &str = include_str!("insert.sql"); const SELECT_SQL: &str = include_str!("select.sql"); +const SELECT_BEFORE_SQL: &str = include_str!("select_before.sql"); /// Loads a protocol configuration given its commitment. pub fn load(tx: &ReadTx<'_>, commitment: Word) -> Result, DatabaseError> { @@ -16,9 +18,24 @@ pub fn load(tx: &ReadTx<'_>, commitment: Word) -> Result, .next()) } -/// Inserts a protocol configuration if its commitment is not stored. -pub fn insert(tx: &WriteTx<'_>, config: &ProtocolConfig) -> Result<(), DatabaseError> { +/// Stores an activation and replaces any activation at the same height. +pub fn insert( + tx: &WriteTx<'_>, + config: &ProtocolConfig, + block_number: BlockNumber, +) -> Result<(), DatabaseError> { let commitment = config.to_commitment(); - tx.execute(INSERT_SQL, &[&commitment, &config])?; + tx.execute(INSERT_SQL, &[&commitment, &block_number, &config])?; Ok(()) } + +/// Loads the commitment of the latest activation strictly before the given height. +pub fn load_before( + tx: &ReadTx<'_>, + block_number: BlockNumber, +) -> Result, DatabaseError> { + Ok(tx + .query(SELECT_BEFORE_SQL, &[&block_number], |row| row.get(0))? + .into_iter() + .next()) +} diff --git a/bin/validator/src/db/queries/protocol_config/select.sql b/bin/validator/src/db/queries/protocol_config/select.sql index b4b8da59d0..bda1a5227b 100644 --- a/bin/validator/src/db/queries/protocol_config/select.sql +++ b/bin/validator/src/db/queries/protocol_config/select.sql @@ -1,3 +1,5 @@ SELECT protocol_config FROM protocol_configs -WHERE commitment = ?1; +WHERE commitment = ?1 +ORDER BY block_number ASC +LIMIT 1; diff --git a/bin/validator/src/db/queries/protocol_config/select_before.sql b/bin/validator/src/db/queries/protocol_config/select_before.sql new file mode 100644 index 0000000000..34af08e2b6 --- /dev/null +++ b/bin/validator/src/db/queries/protocol_config/select_before.sql @@ -0,0 +1,5 @@ +SELECT commitment +FROM protocol_configs +WHERE block_number < ?1 +ORDER BY block_number DESC +LIMIT 1; diff --git a/bin/validator/src/db/tests/protocol_config_history.rs b/bin/validator/src/db/tests/protocol_config_history.rs new file mode 100644 index 0000000000..5820aba9b4 --- /dev/null +++ b/bin/validator/src/db/tests/protocol_config_history.rs @@ -0,0 +1,125 @@ +use super::*; + +fn config(seed: u32) -> ProtocolConfig { + use miden_protocol::protocol_config::KernelConfig; + + let base = test_protocol_config(); + ProtocolConfig::new( + base.fee_asset_id(), + KernelConfig::new(Word::from([seed, 0, 0, 0]), vec![]).unwrap(), + base.batch_kernel().clone(), + base.block_kernel().clone(), + base.proof_verification().clone(), + ) + .unwrap() +} + +fn header_at(base: &BlockHeader, height: u32, config: &ProtocolConfig) -> BlockHeader { + BlockHeader::new( + base.commitment(), + height.into(), + base.chain_commitment(), + base.account_root(), + base.nullifier_root(), + base.note_root(), + base.tx_commitment(), + base.validator_config().clone(), + base.fee_parameters().clone(), + config.to_commitment(), + base.next_protocol_config().cloned(), + base.timestamp() + 1, + ) +} + +pub(super) async fn history(db: &ValidatorDbWriter) -> Vec<(i64, Word, ProtocolConfig)> { + db.reader.reader.read("activation_history", |tx| { + tx.query( + "SELECT block_number, commitment, protocol_config FROM protocol_configs ORDER BY block_number", + &[], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + }).await.unwrap() +} + +#[tokio::test] +async fn activations_include_reused_configs_and_survive_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("validator.sqlite3"); + let first = test_protocol_config(); + let second = config(42); + let genesis = genesis_header(&first); + bootstrap(path.clone(), NonZeroUsize::new(2).unwrap(), genesis.clone(), first.clone()) + .await + .unwrap(); + let db = load(path.clone()).await.unwrap(); + let mut parent = genesis; + for (height, active, supplied) in + [(1, &first, true), (2, &second, true), (3, &second, false), (4, &first, false)] + { + let header = header_at(&parent, height, active); + db.upsert_block_header_with_protocol_config( + header.clone(), + supplied.then(|| active.clone()), + ) + .await + .unwrap(); + parent = header; + } + let expected = vec![ + (0, first.to_commitment(), first.clone()), + (2, second.to_commitment(), second), + (4, first.to_commitment(), first.clone()), + ]; + assert_eq!(history(&db).await, expected); + assert_eq!(db.load_protocol_config(first.to_commitment()).await.unwrap(), Some(first)); + drop(db); + let db = load(path).await.unwrap(); + assert_eq!(history(&db).await, expected); + assert_eq!(db.load_chain_tip().await.unwrap(), Some(parent)); +} + +#[tokio::test] +async fn repeated_tip_writes_preserve_activation() { + let dir = tempfile::tempdir().unwrap(); + let db = setup(dir.path().join("validator.sqlite3")).await.unwrap(); + let first = test_protocol_config(); + let second = config(42); + let genesis = genesis_header(&first); + db.upsert_block_header_with_protocol_config(genesis.clone(), Some(first.clone())) + .await + .unwrap(); + let tip = header_at(&genesis, 1, &second); + db.upsert_block_header_with_protocol_config(tip.clone(), Some(second.clone())) + .await + .unwrap(); + db.upsert_block_header_with_protocol_config(tip.clone(), None).await.unwrap(); + let tip = header_with_next_timestamp(&tip); + db.upsert_block_header_with_protocol_config(tip.clone(), Some(second.clone())) + .await + .unwrap(); + assert_eq!( + history(&db).await, + vec![(0, first.to_commitment(), first.clone()), (1, second.to_commitment(), second),] + ); + assert_eq!(db.load_chain_tip().await.unwrap(), Some(tip)); +} + +#[tokio::test] +async fn invalid_configs_preserve_existing_history() { + let dir = tempfile::tempdir().unwrap(); + let db = setup(dir.path().join("validator.sqlite3")).await.unwrap(); + let first = test_protocol_config(); + let second = config(42); + let genesis = genesis_header(&first); + db.upsert_block_header_with_protocol_config(genesis.clone(), Some(first.clone())) + .await + .unwrap(); + let before = history(&db).await; + for supplied in [None, Some(first)] { + db.upsert_block_header_with_protocol_config(header_at(&genesis, 1, &second), supplied) + .await + .unwrap_err(); + assert_eq!(history(&db).await, before); + assert_eq!(db.load_chain_tip().await.unwrap(), Some(genesis.clone())); + } +} diff --git a/bin/validator/src/server/validator_service/tests.rs b/bin/validator/src/server/validator_service/tests.rs index 815b1f22ec..6e0c6ea282 100644 --- a/bin/validator/src/server/validator_service/tests.rs +++ b/bin/validator/src/server/validator_service/tests.rs @@ -604,6 +604,9 @@ async fn chain_tip_replacement_succeeds() { let result = tv.call_sign_block(&replacement).await; assert!(result.is_ok(), "chain tip replacement should succeed, got: {:?}", result.err()); + tv.call_sign_block_with_protocol_config(&replacement, None) + .await + .expect("repeated signing must retain the stored configuration"); // Verify that the chain tip in the database is now the replacement block, not the original. let new_chain_tip = tv.load_chain_tip().await; @@ -1040,7 +1043,7 @@ async fn protocol_config_transition_is_streamed_and_used_for_next_signature() { drop(stream); let block_3 = tv.propose_empty_block(); - tv.call_sign_block_with_protocol_config(&block_3, Some(&next_config)) + tv.call_sign_block_with_protocol_config(&block_3, None) .await .expect("the validator must sign with the transitioned active config"); }