From be9748bffb5e28f12f30ac4d0fe080b912400027 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Tue, 26 May 2026 12:44:13 +0200 Subject: [PATCH 1/4] feat: gate RPC network tx submission behind internal auth header Adds optional internal auth-header support to the shared gRPC client builder, enforces that header in RPC for network-account deployment submissions, and wires `ntx-builder` to send it when configured. Covers single-tx and batch submission paths, validates operator-supplied auth config cleanly at startup, and adds the future node CLI surface for RPC network-tx auth settings. --- bin/node/src/commands/rpc.rs | 9 + bin/ntx-builder/src/clients/rpc.rs | 22 ++- bin/ntx-builder/src/commands/mod.rs | 44 +++++ bin/ntx-builder/src/lib.rs | 65 ++++++- crates/proto/src/clients/mod.rs | 102 +++++++++- crates/rpc/src/server/api.rs | 51 +++-- crates/rpc/src/server/mod.rs | 7 + crates/rpc/src/tests.rs | 280 +++++++++++++++++++++++++++- 8 files changed, 552 insertions(+), 28 deletions(-) diff --git a/bin/node/src/commands/rpc.rs b/bin/node/src/commands/rpc.rs index 4236a77c5..d6c42ed8d 100644 --- a/bin/node/src/commands/rpc.rs +++ b/bin/node/src/commands/rpc.rs @@ -18,6 +18,15 @@ pub struct RpcOptions { #[arg(long = "rpc.listen", env = "MIDEN_NODE_RPC_LISTEN", value_name = "LISTEN")] pub listen: SocketAddr, + /// Optional metadata header value for internal network-transaction RPC authentication. + #[arg( + long = "rpc.network-tx-auth-header-value", + env = "MIDEN_NODE_RPC_NETWORK_TX_AUTH_HEADER_VALUE", + value_name = "VALUE", + help_heading = super::section::RPC_CONFIGURATION_HELP_HEADING + )] + pub network_tx_auth_header_value: Option, + #[command(flatten)] pub grpc: RpcGrpcOptions, diff --git a/bin/ntx-builder/src/clients/rpc.rs b/bin/ntx-builder/src/clients/rpc.rs index 1e8c0551c..3ea8ff5be 100644 --- a/bin/ntx-builder/src/clients/rpc.rs +++ b/bin/ntx-builder/src/clients/rpc.rs @@ -17,6 +17,7 @@ use miden_protocol::transaction::{AccountInputs, ProvenTransaction, TransactionI use miden_protocol::utils::serde::{Deserializable, Serializable}; use thiserror::Error; use tonic::Status; +use tonic::metadata::AsciiMetadataValue; use tracing::{info, instrument}; use url::Url; @@ -41,15 +42,28 @@ impl RpcClient { /// `backoff_initial` / `backoff_max` configure the exponential backoff schedule applied to /// `block_subscription` retries (the only operation that retries today). pub fn new(rpc_url: Url, backoff_initial: Duration, backoff_max: Duration) -> Self { + Self::new_with_auth(rpc_url, None, backoff_initial, backoff_max) + } + + /// Creates a new client with an optional metadata header for internal RPC authentication. + pub fn new_with_auth( + rpc_url: Url, + rpc_auth_header_value: Option, + backoff_initial: Duration, + backoff_max: Duration, + ) -> Self { info!(target: COMPONENT, rpc_endpoint = %rpc_url, "Initializing RPC client"); - let rpc = Builder::new(rpc_url) + let builder = Builder::new(rpc_url) .without_tls() .without_timeout() .without_metadata_version() - .without_metadata_genesis() - .with_otel_context_injection() - .connect_lazy::(); + .without_metadata_genesis(); + let builder = match rpc_auth_header_value { + Some(value) => builder.with_auth_header_value(value), + None => builder.without_auth_header(), + }; + let rpc = builder.with_otel_context_injection().connect_lazy::(); let backoff = ExponentialBuilder::default() .with_min_delay(backoff_initial) diff --git a/bin/ntx-builder/src/commands/mod.rs b/bin/ntx-builder/src/commands/mod.rs index 2f3f9832b..a6ff76d58 100644 --- a/bin/ntx-builder/src/commands/mod.rs +++ b/bin/ntx-builder/src/commands/mod.rs @@ -7,12 +7,14 @@ use anyhow::Context; use clap::Parser; use miden_node_utils::clap::duration_to_human_readable_string; use tokio::net::TcpListener; +use tonic::metadata::AsciiMetadataValue; use url::Url; const ENV_ENABLE_OTEL: &str = "MIDEN_NODE_ENABLE_OTEL"; const ENV_DATA_DIRECTORY: &str = "MIDEN_NODE_DATA_DIRECTORY"; const ENV_LISTEN: &str = "MIDEN_NODE_NTX_BUILDER_LISTEN"; const ENV_RPC_URL: &str = "MIDEN_NODE_NTX_BUILDER_RPC_URL"; +const ENV_RPC_AUTH_HEADER_VALUE: &str = "MIDEN_NODE_NTX_BUILDER_RPC_AUTH_HEADER_VALUE"; const ENV_TX_PROVER_URL: &str = "MIDEN_NODE_NTX_BUILDER_NTX_PROVER_URL"; const ENV_SCRIPT_CACHE_SIZE: &str = "MIDEN_NODE_NTX_BUILDER_SCRIPT_CACHE_SIZE"; const ENV_MAX_CYCLES: &str = "MIDEN_NODE_NTX_BUILDER_MAX_CYCLES"; @@ -35,6 +37,14 @@ pub enum NtxBuilderCommand { #[arg(long = "rpc.url", alias = "store.url", env = ENV_RPC_URL, value_name = "URL")] rpc_url: Url, + /// Optional value for the fixed `x-miden-network-tx-auth` metadata header. + #[arg( + long = "rpc.auth-header-value", + env = ENV_RPC_AUTH_HEADER_VALUE, + value_name = "VALUE" + )] + rpc_auth_header_value: Option, + /// The remote transaction prover's gRPC url. If unset, will default to running a prover /// in-process which is expensive. #[arg(long = "tx-prover.url", env = ENV_TX_PROVER_URL, value_name = "URL")] @@ -108,6 +118,7 @@ impl NtxBuilderCommand { let Self::Start { listen, rpc_url, + rpc_auth_header_value, tx_prover_url, script_cache_size, idle_timeout, @@ -131,6 +142,10 @@ impl NtxBuilderCommand { .with_max_account_crashes(max_account_crashes) .with_max_cycles(max_tx_cycles) .with_sqlite_connection_pool_size(sqlite_connection_pool_size); + let config = match rpc_auth_header_value { + Some(value) => config.with_rpc_auth_header(value), + None => config, + }; config .build() @@ -146,3 +161,32 @@ impl NtxBuilderCommand { *enable_otel } } + +#[cfg(test)] +mod tests { + use clap::Parser; + use tonic::metadata::AsciiMetadataValue; + + use super::NtxBuilderCommand; + + #[test] + fn start_command_parses_rpc_auth_header_options() { + let command = NtxBuilderCommand::try_parse_from([ + "miden-ntx-builder", + "start", + "--listen", + "127.0.0.1:8080", + "--rpc.url", + "http://127.0.0.1:57291", + "--rpc.auth-header-value", + "secret-token", + "--data-directory", + "/tmp/miden-ntx-builder", + ]) + .expect("command should parse"); + + let NtxBuilderCommand::Start { rpc_auth_header_value, .. } = command; + + assert_eq!(rpc_auth_header_value, Some(AsciiMetadataValue::from_static("secret-token"))); + } +} diff --git a/bin/ntx-builder/src/lib.rs b/bin/ntx-builder/src/lib.rs index a9658562d..907a46774 100644 --- a/bin/ntx-builder/src/lib.rs +++ b/bin/ntx-builder/src/lib.rs @@ -12,6 +12,7 @@ use futures::StreamExt; use miden_node_utils::ErrorReport; use miden_protocol::block::BlockNumber; use miden_protocol::crypto::merkle::mmr::PartialMmr; +use tonic::metadata::AsciiMetadataValue; use url::Url; use crate::committed_block::CommittedBlockEffects; @@ -96,6 +97,9 @@ pub struct NtxBuilderConfig { /// Address of the node RPC gRPC server. pub rpc_url: Url, + /// Optional auth header value injected into internal RPC requests. + pub rpc_auth_header: Option, + /// Address of the remote transaction prover. If `None`, transactions will be proven locally. pub tx_prover_url: Option, @@ -157,6 +161,7 @@ impl NtxBuilderConfig { pub fn new(rpc_url: Url, database_filepath: PathBuf) -> Self { Self { rpc_url, + rpc_auth_header: None, tx_prover_url: None, script_cache_size: DEFAULT_SCRIPT_CACHE_SIZE, max_concurrent_txs: DEFAULT_MAX_CONCURRENT_TXS, @@ -183,6 +188,13 @@ impl NtxBuilderConfig { self } + /// Sets the optional auth header value to inject into internal RPC requests. + #[must_use] + pub fn with_rpc_auth_header(mut self, value: AsciiMetadataValue) -> Self { + self.rpc_auth_header = Some(value); + self + } + /// Sets the script cache size. #[must_use] pub fn with_script_cache_size(mut self, size: NonZeroUsize) -> Self { @@ -288,6 +300,20 @@ impl NtxBuilderConfig { /// - The RPC connection fails (after retries) /// - The genesis block cannot be read from the subscription on a fresh start pub async fn build(self) -> anyhow::Result { + let rpc = match self.rpc_auth_header.clone() { + Some(rpc_auth_header_value) => RpcClient::new_with_auth( + self.rpc_url.clone(), + Some(rpc_auth_header_value), + self.request_backoff_initial, + self.request_backoff_max, + ), + None => RpcClient::new( + self.rpc_url.clone(), + self.request_backoff_initial, + self.request_backoff_max, + ), + }; + // Set up the database (bootstrap + connection pool). let db = Db::setup_with_pool_size( self.database_filepath.clone(), @@ -295,12 +321,6 @@ impl NtxBuilderConfig { ) .await?; - let rpc = RpcClient::new( - self.rpc_url.clone(), - self.request_backoff_initial, - self.request_backoff_max, - ); - // Decide where to start the subscription. On resume we load the persisted chain state; on // fresh start we begin at genesis and bootstrap inline below. let stored_chain_state = @@ -358,3 +378,36 @@ impl NtxBuilderConfig { )) } } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use tonic::metadata::AsciiMetadataValue; + use url::Url; + + use super::NtxBuilderConfig; + + #[test] + fn ntx_builder_config_default_has_no_rpc_auth_header() { + let config = NtxBuilderConfig::new( + Url::parse("http://127.0.0.1:57291").expect("valid url"), + PathBuf::from("ntx-builder.sqlite3"), + ); + + assert_eq!(config.rpc_auth_header, None); + } + + #[test] + fn ntx_builder_config_with_rpc_auth_header_stores_value() { + let secret_token = AsciiMetadataValue::from_static("secret-token"); + + let config = NtxBuilderConfig::new( + Url::parse("http://127.0.0.1:57291").expect("valid url"), + PathBuf::from("ntx-builder.sqlite3"), + ) + .with_rpc_auth_header(secret_token.clone()); + + assert_eq!(config.rpc_auth_header, Some(secret_token)); + } +} diff --git a/crates/proto/src/clients/mod.rs b/crates/proto/src/clients/mod.rs index c1d7c0035..6907337ee 100644 --- a/crates/proto/src/clients/mod.rs +++ b/crates/proto/src/clients/mod.rs @@ -17,6 +17,7 @@ //! .without_timeout() // or `.with_timeout(Duration::from_secs(10))` //! .without_metadata_version() // or `.with_metadata_version("1.0".into())` //! .without_metadata_genesis() // or `.with_metadata_genesis(genesis)` +//! .without_metadata_header() // or `.with_metadata_header("name".into(), "value".into())` //! .with_otel_context_injection() // or `.without_otel_context_injection()` //! .connect::() //! .await?; @@ -44,6 +45,7 @@ use crate::generated; pub struct Interceptor { otel: Option, accept: AsciiMetadataValue, + auth_header_value: Option, } impl Default for Interceptor { @@ -51,6 +53,7 @@ impl Default for Interceptor { Self { otel: None, accept: AsciiMetadataValue::from_static(Self::MEDIA_TYPE), + auth_header_value: None, } } } @@ -59,8 +62,14 @@ impl Interceptor { const MEDIA_TYPE: &str = "application/vnd.miden"; const VERSION: &str = "version"; const GENESIS: &str = "genesis"; - - fn new(enable_otel: bool, version: Option<&str>, genesis: Option<&str>) -> Self { + const NETWORK_TX_AUTH_HEADER_NAME: &str = "x-miden-network-tx-auth"; + + fn new( + enable_otel: bool, + version: Option<&str>, + genesis: Option<&str>, + auth_header: Option, + ) -> Self { if let Some(version) = version && !version.is_ascii() { @@ -88,6 +97,7 @@ impl Interceptor { otel: enable_otel.then_some(OtelInterceptor), // SAFETY: we checked that all values are ascii at the top of the function. accept: AsciiMetadataValue::from_str(&accept).unwrap(), + auth_header_value: auth_header, } } } @@ -100,6 +110,10 @@ impl tonic::service::Interceptor for Interceptor { request.metadata_mut().insert(ACCEPT.as_str(), self.accept.clone()); + if let Some(value) = &self.auth_header_value { + request.metadata_mut().insert(Self::NETWORK_TX_AUTH_HEADER_NAME, value.clone()); + } + Ok(request) } } @@ -334,6 +348,7 @@ impl GrpcClient for NtxBuilderClient { /// .with_timeout(Duration::from_secs(5)) // or `.without_timeout()` /// .with_metadata_version("1.0".into()) // or `.without_metadata_version()` /// .without_metadata_genesis() // or `.with_metadata_genesis(genesis)` +/// .without_metadata_header() // or `.with_metadata_header("name".into(), "value".into())` /// .with_otel_context_injection() // or `.without_otel_context_injection()` /// .connect::() /// .await?; @@ -345,6 +360,7 @@ pub struct Builder { endpoint: Endpoint, metadata_version: Option, metadata_genesis: Option, + metadata_auth_header_value: Option, enable_otel: bool, _state: PhantomData, } @@ -358,6 +374,8 @@ pub struct WantsVersion; #[derive(Copy, Clone, Debug)] pub struct WantsGenesis; #[derive(Copy, Clone, Debug)] +pub struct WantsAuthHeader; +#[derive(Copy, Clone, Debug)] pub struct WantsOTel; #[derive(Copy, Clone, Debug)] pub struct WantsConnection; @@ -369,6 +387,7 @@ impl Builder { endpoint: self.endpoint, metadata_version: self.metadata_version, metadata_genesis: self.metadata_genesis, + metadata_auth_header_value: self.metadata_auth_header_value, enable_otel: self.enable_otel, _state: PhantomData::, } @@ -386,6 +405,7 @@ impl Builder { endpoint, metadata_version: None, metadata_genesis: None, + metadata_auth_header_value: None, enable_otel: false, _state: PhantomData, } @@ -436,18 +456,44 @@ impl Builder { impl Builder { /// Do not include genesis commitment in request metadata. - pub fn without_metadata_genesis(mut self) -> Builder { + pub fn without_metadata_genesis(mut self) -> Builder { self.metadata_genesis = None; self.next_state() } /// Include a specific genesis commitment string in request metadata. - pub fn with_metadata_genesis(mut self, genesis: String) -> Builder { + pub fn with_metadata_genesis(mut self, genesis: String) -> Builder { self.metadata_genesis = Some(genesis); self.next_state() } } +impl Builder { + /// Do not include any additional metadata header in request metadata. + pub fn without_auth_header(mut self) -> Builder { + self.metadata_auth_header_value = None; + self.next_state() + } + + /// Include an additional ASCII metadata header in request metadata. + pub fn with_auth_header_value(mut self, value: AsciiMetadataValue) -> Builder { + self.metadata_auth_header_value = Some(value); + self.next_state() + } + + /// Enables OpenTelemetry context propagation via gRPC without adding a metadata header. + pub fn with_otel_context_injection(mut self) -> Builder { + self.enable_otel = true; + self.next_state() + } + + /// Disables OpenTelemetry context propagation without adding a metadata header. + pub fn without_otel_context_injection(mut self) -> Builder { + self.enable_otel = false; + self.next_state() + } +} + impl Builder { /// Enables OpenTelemetry context propagation via gRPC. /// @@ -493,7 +539,55 @@ impl Builder { self.enable_otel, self.metadata_version.as_deref(), self.metadata_genesis.as_deref(), + self.metadata_auth_header_value, ); T::with_interceptor(channel, interceptor) } } + +#[cfg(test)] +mod tests { + use tonic::metadata::AsciiMetadataValue; + use tonic::service::Interceptor as _; + use url::Url; + + use super::{Builder, Interceptor}; + + #[test] + fn interceptor_inserts_only_accept_by_default() { + let mut interceptor = Interceptor::default(); + let request = interceptor.call(tonic::Request::new(())).unwrap(); + + assert!(request.metadata().get("accept").is_some()); + assert!(request.metadata().get("x-miden-network-tx-auth").is_none()); + } + + #[test] + fn interceptor_inserts_custom_ascii_auth_metadata_when_configured() { + let mut interceptor = Interceptor::new( + false, + None, + None, + Some(AsciiMetadataValue::from_static("secret-value")), + ); + + let request = interceptor.call(tonic::Request::new(())).unwrap(); + + assert_eq!( + request.metadata().get("x-miden-network-tx-auth").unwrap().to_str().unwrap(), + "secret-value" + ); + } + + #[test] + fn interceptor_inserts_existing_post_genesis_chain_can_skip_metadata_header() { + let url = Url::parse("http://localhost:8080").unwrap(); + + let _ = Builder::new(url) + .without_tls() + .without_timeout() + .without_metadata_version() + .without_metadata_genesis() + .without_otel_context_injection(); + } +} diff --git a/crates/rpc/src/server/api.rs b/crates/rpc/src/server/api.rs index ed26ed6be..dfa81214e 100644 --- a/crates/rpc/src/server/api.rs +++ b/crates/rpc/src/server/api.rs @@ -41,11 +41,15 @@ use miden_protocol::utils::serde::{Deserializable, Serializable}; use miden_protocol::{MIN_PROOF_SECURITY_LEVEL, Word}; use miden_tx::TransactionVerifier; use miden_tx_batch_prover::LocalBatchProver; +use tonic::metadata::MetadataMap; use tonic::{IntoRequest, Request, Response, Status}; use tracing::{Span, debug, info, info_span}; use url::Url; use crate::COMPONENT; +use crate::server::NetworkTxAuth; + +const NETWORK_TX_AUTH_HEADER_NAME: &str = "x-miden-network-tx-auth"; // RPC SERVICE // ================================================================================================ @@ -55,6 +59,7 @@ pub struct RpcService { block_producer: Option, validator: ValidatorClient, ntx_builder: Option, + network_tx_auth: Option, genesis_commitment: Option, block_commitment_cache: LruCache, } @@ -66,6 +71,7 @@ impl RpcService { validator_url: Url, ntx_builder_url: Option, commitment_cache_capacity: NonZeroUsize, + network_tx_auth: Option, ) -> Self { let store = { info!(target: COMPONENT, store_endpoint = %store_url, "Initializing store client"); @@ -128,6 +134,7 @@ impl RpcService { block_producer, validator, ntx_builder, + network_tx_auth, genesis_commitment: None, block_commitment_cache: LruCache::new(commitment_cache_capacity), } @@ -270,6 +277,14 @@ impl RpcService { Ok(()) } + + fn is_authorized_network_tx(&self, metadata: &MetadataMap) -> bool { + let Some(auth) = &self.network_tx_auth else { + return false; + }; + + metadata.get(NETWORK_TX_AUTH_HEADER_NAME).is_some_and(|value| value == auth.0) + } } // API IMPLEMENTATION @@ -508,6 +523,7 @@ impl api_server::Api for RpcService { )); }; + let is_authorized_network_tx = self.is_authorized_network_tx(request.metadata()); let request = request.into_inner(); let tx = ProvenTransaction::read_from_bytes(&request.transaction).map_err(|err| { @@ -553,10 +569,14 @@ impl api_server::Api for RpcService { // Block post-deployment network-account transactions from user RPC. First-deployment txs // are exempt because the protocol-level allowlist only kicks in once the account exists, // and network accounts must be public, so private-account txs are filtered out up front. - let candidate_id = (!tx.account_update().initial_state_commitment().is_empty() - && tx.account_id().is_public()) - .then(|| tx.account_id()); - self.reject_if_any_network_accounts(candidate_id).await?; + // + // Skip this check if the client is authorized to send network transactions (ntx-builder). + if !is_authorized_network_tx { + let candidate_id = (!tx.account_update().initial_state_commitment().is_empty() + && tx.account_id().is_public()) + .then(|| tx.account_id()); + self.reject_if_any_network_accounts(candidate_id).await?; + } let tx_verifier = TransactionVerifier::new(MIN_PROOF_SECURITY_LEVEL); tx_verifier.verify(&tx).map_err(|err| { @@ -588,6 +608,7 @@ impl api_server::Api for RpcService { return Err(Status::unavailable("Batch submission not available in read-only mode")); }; + let is_authorized_network_tx = self.is_authorized_network_tx(request.metadata()); let request = request.into_inner(); let proven_batch = ProvenBatch::read_from_bytes(&request.batch_proof).map_err(|err| { @@ -633,15 +654,19 @@ impl api_server::Api for RpcService { // Same gate as `submit_proven_transaction`, applied to every post-deployment tx in the // batch. One store round-trip classifies all the non-deployment, public-account ids; any // match fails the entire batch. - let non_deployment_ids = proposed_batch - .transactions() - .iter() - .filter(|tx| { - !tx.account_update().initial_state_commitment().is_empty() - && tx.account_id().is_public() - }) - .map(|tx| tx.account_id()); - self.reject_if_any_network_accounts(non_deployment_ids).await?; + // + // Skip this check if the client is authorized to send network transactions (ntx-builder). + if !is_authorized_network_tx { + let non_deployment_ids = proposed_batch + .transactions() + .iter() + .filter(|tx| { + !tx.account_update().initial_state_commitment().is_empty() + && tx.account_id().is_public() + }) + .map(|tx| tx.account_id()); + self.reject_if_any_network_accounts(non_deployment_ids).await?; + } // Verify batch transaction proofs. // diff --git a/crates/rpc/src/server/mod.rs b/crates/rpc/src/server/mod.rs index 6a8c3acc9..20db387c9 100644 --- a/crates/rpc/src/server/mod.rs +++ b/crates/rpc/src/server/mod.rs @@ -11,6 +11,7 @@ use miden_node_utils::panic::{CatchPanicLayer, catch_panic_layer_fn}; use miden_node_utils::tracing::grpc::grpc_trace_fn; use tokio::net::TcpListener; use tokio_stream::wrappers::TcpListenerStream; +use tonic::metadata::AsciiMetadataValue; use tonic_reflection::server; use tonic_web::GrpcWebLayer; use tower_http::classify::{GrpcCode, GrpcErrorsAsFailures, SharedClassifier}; @@ -37,8 +38,13 @@ pub struct Rpc { pub validator_url: Url, pub ntx_builder_url: Option, pub grpc_options: GrpcOptionsExternal, + pub network_tx_auth: Option, } +#[derive(Clone, Debug)] +/// Shared secret value expected in the fixed `x-miden-network-tx-auth` metadata header. +pub struct NetworkTxAuth(pub AsciiMetadataValue); + impl Rpc { /// Serves the RPC API. /// @@ -51,6 +57,7 @@ impl Rpc { self.validator_url, self.ntx_builder_url.clone(), NonZeroUsize::new(1_000_000).unwrap(), + self.network_tx_auth, ); let genesis = api diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index 75c67500c..9fbda7631 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -1,5 +1,6 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::num::{NonZeroU32, NonZeroU64}; +use std::sync::Arc; use std::time::Duration; use http::header::{ACCEPT, CONTENT_TYPE}; @@ -29,9 +30,18 @@ use miden_protocol::account::{ AccountIdVersion, AccountType, }; +use miden_protocol::batch::{BatchAccountUpdate, BatchId, ProposedBatch, ProvenBatch}; +use miden_protocol::block::BlockHeader; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; use miden_protocol::testing::noop_auth_component::NoopAuthComponent; -use miden_protocol::transaction::{ProvenTransaction, TxAccountUpdate}; +use miden_protocol::transaction::{ + InputNotes, + OrderedTransactionHeaders, + PartialBlockchain, + ProvenTransaction, + TransactionHeader, + TxAccountUpdate, +}; use miden_protocol::utils::serde::Serializable; use miden_protocol::vm::ExecutionProof; use miden_standards::account::wallets::BasicWallet; @@ -40,6 +50,7 @@ use tokio::net::TcpListener; use tokio::runtime::{self, Runtime}; use tokio::task; use tokio::time::sleep; +use tonic::metadata::AsciiMetadataValue; use url::Url; use crate::Rpc; @@ -588,6 +599,161 @@ async fn rpc_server_rejects_tx_submissions_without_genesis() { ); } +#[tokio::test] +async fn rpc_server_rejects_network_tx_without_internal_auth_header() { + let secret_key = AsciiMetadataValue::from_static("secret-key"); + let (_, rpc_addr, store_listener) = start_rpc_with_network_tx_auth(Some(secret_key)).await; + let store = TestStore::start(store_listener).await; + let genesis = store.genesis_commitment(); + + let mut rpc_client = connect_rpc_for_tx_submission( + Url::parse(&format!("http://{rpc_addr}")).unwrap(), + genesis, + None, + ); + + // Seed a row marking a known AccountId as a network account directly in the store's SQLite DB. + // The store uses WAL mode so a secondary connection is safe. + let network_account_id = + AccountId::dummy([7u8; 15], AccountIdVersion::Version1, AccountType::Public); + miden_node_store::test_support::seed_network_account( + &store.data_directory_path().join("miden-store.sqlite3"), + network_account_id, + ); + + // Build a non-deployment tx for that account. + let (account, account_delta) = build_test_account([9; 32]); + let tx = build_test_proven_tx_with_id(network_account_id, &account, &account_delta, genesis); + let request = proto::transaction::ProvenTransaction { + transaction: tx.to_bytes(), + transaction_inputs: None, + }; + + let response = rpc_client.submit_proven_tx(request).await; + + assert!(response.is_err()); + assert_eq!( + response.as_ref().unwrap_err().message(), + "Network transactions may not be submitted by users yet" + ); +} + +#[tokio::test] +async fn rpc_server_accepts_network_tx_with_internal_auth_header() { + let secret_key = AsciiMetadataValue::from_static("secret-key"); + let (_, rpc_addr, store_listener) = + start_rpc_with_network_tx_auth(Some(secret_key.clone())).await; + let store = TestStore::start(store_listener).await; + let genesis = store.genesis_commitment(); + + let mut rpc_client = connect_rpc_for_tx_submission( + Url::parse(&format!("http://{rpc_addr}")).unwrap(), + genesis, + Some(secret_key), + ); + + // Seed a row marking a known AccountId as a network account directly in the store's SQLite DB. + // The store uses WAL mode so a secondary connection is safe. + let network_account_id = + AccountId::dummy([7u8; 15], AccountIdVersion::Version1, AccountType::Public); + miden_node_store::test_support::seed_network_account( + &store.data_directory_path().join("miden-store.sqlite3"), + network_account_id, + ); + + // Build a non-deployment tx for that account. + let (account, account_delta) = build_test_account([10; 32]); + let tx = build_test_proven_tx_with_id(network_account_id, &account, &account_delta, genesis); + let request = proto::transaction::ProvenTransaction { + transaction: tx.to_bytes(), + transaction_inputs: None, + }; + + let response = rpc_client.submit_proven_tx(request).await; + + assert!(response.is_err()); + assert_ne!( + response.as_ref().unwrap_err().message(), + "Network transactions may not be submitted by users yet" + ); +} + +#[tokio::test] +async fn rpc_server_rejects_network_tx_batch_without_internal_auth_header() { + let secret_key = AsciiMetadataValue::from_static("secret-key"); + let (_, rpc_addr, store_listener) = + start_rpc_with_network_tx_auth(Some(secret_key.clone())).await; + let store = TestStore::start(store_listener).await; + let genesis = store.genesis_commitment(); + + let mut rpc_client = connect_rpc_for_tx_submission( + Url::parse(&format!("http://{rpc_addr}")).unwrap(), + genesis, + None, + ); + let genesis_header = fetch_genesis_header(&mut rpc_client).await; + + // Seed a row marking a known AccountId as a network account directly in the store's SQLite DB. + // The store uses WAL mode so a secondary connection is safe. + let network_account_id = + AccountId::dummy([7u8; 15], AccountIdVersion::Version1, AccountType::Public); + miden_node_store::test_support::seed_network_account( + &store.data_directory_path().join("miden-store.sqlite3"), + network_account_id, + ); + + // Build a non-deployment tx for that account. + let (account, account_delta) = build_test_account([10; 32]); + let tx = build_test_proven_tx_with_id(network_account_id, &account, &account_delta, genesis); + let request = build_test_network_tx_batch_request(&tx, genesis_header); + + let response = rpc_client.submit_proven_tx_batch(request).await; + + assert!(response.is_err()); + assert_eq!( + response.as_ref().unwrap_err().message(), + "Network transactions may not be submitted by users yet" + ); +} + +#[tokio::test] +async fn rpc_server_accepts_network_tx_batch_with_internal_auth_header() { + let secret_key = AsciiMetadataValue::from_static("secret-key"); + let (_, rpc_addr, store_listener) = + start_rpc_with_network_tx_auth(Some(secret_key.clone())).await; + let store = TestStore::start(store_listener).await; + let genesis = store.genesis_commitment(); + + let mut rpc_client = connect_rpc_for_tx_submission( + Url::parse(&format!("http://{rpc_addr}")).unwrap(), + genesis, + Some(secret_key), + ); + let genesis_header = fetch_genesis_header(&mut rpc_client).await; + + // Seed a row marking a known AccountId as a network account directly in the store's SQLite DB. + // The store uses WAL mode so a secondary connection is safe. + let network_account_id = + AccountId::dummy([7u8; 15], AccountIdVersion::Version1, AccountType::Public); + miden_node_store::test_support::seed_network_account( + &store.data_directory_path().join("miden-store.sqlite3"), + network_account_id, + ); + + // Build a non-deployment tx for that account. + let (account, account_delta) = build_test_account([10; 32]); + let tx = build_test_proven_tx_with_id(network_account_id, &account, &account_delta, genesis); + let request = build_test_network_tx_batch_request(&tx, genesis_header); + + let response = rpc_client.submit_proven_tx_batch(request).await; + + assert!(response.is_err()); + assert_ne!( + response.as_ref().unwrap_err().message(), + "Network transactions may not be submitted by users yet" + ); +} + /// Sends an arbitrary / irrelevant request to the RPC. async fn send_request( rpc_client: &mut RpcClient, @@ -617,6 +783,19 @@ async fn send_request_until_success( } } +async fn fetch_genesis_header(rpc_client: &mut RpcClient) -> BlockHeader { + let response = rpc_client + .get_block_header_by_number(proto::rpc::BlockHeaderByNumberRequest { + block_num: Some(0), + include_mmr_proof: None, + }) + .await + .expect("genesis header request should succeed"); + let header = response.into_inner().block_header.expect("genesis header should be present"); + + BlockHeader::try_from(header).expect("genesis header should deserialize") +} + async fn connect_rpc(url: Url, local_address: Option) -> RpcClient { let mut endpoint = tonic::transport::Endpoint::from_shared(url.to_string()) .expect("Url type always results in valid endpoint") @@ -629,6 +808,26 @@ async fn connect_rpc(url: Url, local_address: Option) -> RpcClient { RpcClient::with_interceptor(channel, interceptor) } +fn connect_rpc_for_tx_submission( + url: Url, + genesis: Word, + auth: Option, +) -> RpcClient { + let builder = Builder::new(url) + .without_tls() + .with_timeout(Duration::from_secs(5)) + .without_metadata_version() + .with_metadata_genesis(genesis.to_hex()); + let builder = match auth { + Some(value) => builder.with_auth_header_value(value), + None => builder.without_auth_header(), + }; + + builder + .without_otel_context_injection() + .connect_lazy::() +} + /// Binds a socket on an available port, runs the RPC server on it, and returns a client to talk to /// the server, along with the socket address. async fn start_rpc() -> (RpcClient, std::net::SocketAddr, TcpListener) { @@ -677,6 +876,7 @@ async fn start_rpc_with_options( validator_url, ntx_builder_url: None, grpc_options, + network_tx_auth: None, } .serve() .await @@ -690,6 +890,84 @@ async fn start_rpc_with_options( (rpc_client, rpc_addr, store_listener) } +async fn start_rpc_with_network_tx_auth( + expected_auth_header_value: Option, +) -> (RpcClient, std::net::SocketAddr, TcpListener) { + let network_tx_auth = expected_auth_header_value.map(crate::server::NetworkTxAuth); + + let store_listener = TcpListener::bind("127.0.0.1:0").await.expect("store should bind a port"); + let store_addr = store_listener.local_addr().expect("store should get a local address"); + let block_producer_addr = { + let block_producer_listener = + TcpListener::bind("127.0.0.1:0").await.expect("Failed to bind block-producer"); + block_producer_listener + .local_addr() + .expect("Failed to get block-producer address") + }; + + let rpc_listener = TcpListener::bind("127.0.0.1:0").await.expect("Failed to bind rpc"); + let rpc_addr = rpc_listener.local_addr().expect("Failed to get rpc address"); + task::spawn(async move { + let store_url = Url::parse(&format!("http://{store_addr}")).unwrap(); + let block_producer_url = Url::parse(&format!("http://{block_producer_addr}")).unwrap(); + let validator_url = Url::parse("http://127.0.0.1:0").unwrap(); + Rpc { + listener: rpc_listener, + store_url, + block_producer_url: Some(block_producer_url), + validator_url, + ntx_builder_url: None, + grpc_options: GrpcOptionsExternal::test(), + network_tx_auth, + } + .serve() + .await + .expect("Failed to start serving store"); + }); + + let url = Url::parse(format!("http://{}", &rpc_addr).as_str()).unwrap(); + let rpc_client = connect_rpc(url, None).await; + + (rpc_client, rpc_addr, store_listener) +} + +fn build_test_network_tx_batch_request( + tx: &ProvenTransaction, + genesis_header: BlockHeader, +) -> proto::transaction::TransactionBatch { + let proven_batch = build_mock_proven_batch(tx, &genesis_header); + let proposed_batch = ProposedBatch::new( + vec![Arc::new(tx.clone())], + genesis_header, + PartialBlockchain::default(), + std::collections::BTreeMap::new(), + ) + .expect("test proposed batch should be valid"); + + proto::transaction::TransactionBatch { + batch_proof: proven_batch.to_bytes(), + proposed_batch: Some(proposed_batch.to_bytes()), + transaction_inputs: vec![Vec::new()], + } +} + +fn build_mock_proven_batch(tx: &ProvenTransaction, genesis_header: &BlockHeader) -> ProvenBatch { + let mut account_updates = std::collections::BTreeMap::new(); + account_updates.insert(tx.account_id(), BatchAccountUpdate::from_transaction(tx)); + + ProvenBatch::new_unchecked( + BatchId::from_transactions([tx].into_iter()), + genesis_header.commitment(), + genesis_header.block_num(), + account_updates, + InputNotes::new_unchecked(tx.input_notes().iter().cloned().collect()), + tx.output_notes().iter().cloned().collect(), + miden_protocol::block::BlockNumber::MAX, + OrderedTransactionHeaders::new_unchecked(vec![TransactionHeader::from(tx)]), + ) + .expect("mock proven batch should be valid") +} + #[tokio::test] async fn get_limits_endpoint() { let (mut rpc_client, _rpc_addr, _store) = start_rpc_and_store_ready().await; From 452915a2cec2a34521f36e070ec3ed9cfa0b460d Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Tue, 26 May 2026 14:35:47 +0200 Subject: [PATCH 2/4] chore: update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 575e6b3b6..7897fab72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ - [BREAKING] Renamed `ExplorerStatusDetails` fields in the network monitor's `/status` payload from `number_of_*` to `total_*` (`total_transactions`, `total_nullifiers`, `total_notes`, `total_account_updates`). The values now represent network-wide cumulative totals from the explorer's `overviewStats` query instead of last-block counts. - [BREAKING] Removed `--wallet-filepath` / `--counter-filepath` flags and the `MIDEN_MONITOR_WALLET_FILEPATH` / `MIDEN_MONITOR_COUNTER_FILEPATH` env vars from the network monitor. The monitor now keeps wallet and counter accounts fully in memory and regenerates them on every startup; the dashboard's counter value resets to zero on restart. - Added `--counter-pending-unhealthy-threshold` (env `MIDEN_MONITOR_COUNTER_PENDING_UNHEALTHY_THRESHOLD`, default `5`) to the network monitor: the Network Transactions card now flips unhealthy when the gap between expected and observed counter values stays above the threshold for three consecutive polls. +- Allowed network transaction submission conditionally via the gRPC `SubmitProvenTx` and `SubmitProvenTxBatch` endpoints: the NTX builder can now send a key in the `x-miden-network-tx-auth` header that enables submitting network transactions ([#2131](https://github.com/0xMiden/node/issues/2131)). + ## v0.14.11 (TBD) - Replaced blocking-in-async operations in the validator, remote prover, and ntx-builder with `spawn_blocking` to avoid starving the Tokio runtime ([#2041](https://github.com/0xMiden/node/pull/2041)). From 97dfa789504f6361d530e52f3958660da54f567f Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Wed, 27 May 2026 11:44:49 +0200 Subject: [PATCH 3/4] fixup! fix: protocol update follow-ups (#2132) --- crates/proto/src/clients/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/proto/src/clients/mod.rs b/crates/proto/src/clients/mod.rs index 6907337ee..93944e77f 100644 --- a/crates/proto/src/clients/mod.rs +++ b/crates/proto/src/clients/mod.rs @@ -17,7 +17,7 @@ //! .without_timeout() // or `.with_timeout(Duration::from_secs(10))` //! .without_metadata_version() // or `.with_metadata_version("1.0".into())` //! .without_metadata_genesis() // or `.with_metadata_genesis(genesis)` -//! .without_metadata_header() // or `.with_metadata_header("name".into(), "value".into())` +//! .without_metadata_header() // or `.with_auth_header_value(AsciiMetadataValue::from_static("value"))` //! .with_otel_context_injection() // or `.without_otel_context_injection()` //! .connect::() //! .await?; @@ -348,7 +348,7 @@ impl GrpcClient for NtxBuilderClient { /// .with_timeout(Duration::from_secs(5)) // or `.without_timeout()` /// .with_metadata_version("1.0".into()) // or `.without_metadata_version()` /// .without_metadata_genesis() // or `.with_metadata_genesis(genesis)` -/// .without_metadata_header() // or `.with_metadata_header("name".into(), "value".into())` +/// .without_auth_header() // or `.with_metadata_header("name".into(), "value".into())` /// .with_otel_context_injection() // or `.without_otel_context_injection()` /// .connect::() /// .await?; From 09c3c73e7256ffef62c5b7b5256d681bd235c041 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Wed, 27 May 2026 12:39:14 +0200 Subject: [PATCH 4/4] fixup! feat: gate RPC network tx submission behind internal auth header --- crates/proto/src/clients/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/proto/src/clients/mod.rs b/crates/proto/src/clients/mod.rs index 531f30340..9e9db833a 100644 --- a/crates/proto/src/clients/mod.rs +++ b/crates/proto/src/clients/mod.rs @@ -17,7 +17,7 @@ //! .without_timeout() // or `.with_timeout(Duration::from_secs(10))` //! .without_metadata_version() // or `.with_metadata_version("1.0".into())` //! .without_metadata_genesis() // or `.with_metadata_genesis(genesis)` -//! .without_metadata_header() // or `.with_auth_header_value(AsciiMetadataValue::from_static("value"))` +//! .without_auth_header() // or `.with_auth_header_value(AsciiMetadataValue::from_static("value"))` //! .with_otel_context_injection() // or `.without_otel_context_injection()` //! .connect::() //! .await?; @@ -324,7 +324,7 @@ impl GrpcClient for NtxBuilderClient { /// .with_timeout(Duration::from_secs(5)) // or `.without_timeout()` /// .with_metadata_version("1.0".into()) // or `.without_metadata_version()` /// .without_metadata_genesis() // or `.with_metadata_genesis(genesis)` -/// .without_auth_header() // or `.with_metadata_header("name".into(), "value".into())` +/// .without_auth_header() // or `.with_auth_header_value(AsciiMetadataValue::from_static("value"))` /// .with_otel_context_injection() // or `.without_otel_context_injection()` /// .connect::() /// .await?;