diff --git a/bin/node/src/commands/modes.rs b/bin/node/src/commands/modes.rs index 17ccf20169..5861512df6 100644 --- a/bin/node/src/commands/modes.rs +++ b/bin/node/src/commands/modes.rs @@ -13,10 +13,17 @@ use miden_node_proto::clients::{ ValidatorClient, WantsConnection, }; -use miden_node_rpc::{PreAuthSubmission, Rpc, RpcMode, SequencerInternal, ValidatorClients}; +use miden_node_rpc::{ + AccountAdmission, + PreAuthSubmission, + Rpc, + RpcMode, + SequencerInternal, + ValidatorClients, +}; use miden_node_store::allowlist::AccountAllowlist; use miden_node_store::{BlockWriter, DataDirectory, ProofWriter, State, WriterTask}; -use miden_node_tracing::info; +use miden_node_tracing::{info, warn}; use miden_node_utils::clap::duration_to_human_readable_string; use miden_node_utils::formatting::format_endpoint; use miden_node_utils::shutdown::CancellationToken; @@ -59,6 +66,10 @@ pub struct SequencerCommand { /// Require external authentication and network isolation. #[arg(long = "admin.listen", env = "MIDEN_NODE_ADMIN_LISTEN", value_name = "IP:PORT")] pub admin_listen: Option, + + /// Allow unrestricted account creation. Use only on development networks. + #[arg(long, env = "MIDEN_NODE_DISABLE_ACCOUNT_ALLOWLIST")] + pub disable_account_allowlist: bool, } impl SequencerCommand { @@ -76,6 +87,12 @@ impl SequencerCommand { let block_prover_monitor = remote_prover_monitor(self.block_producer.block_prover.url.as_ref())?; let allowlist = Arc::new(self.load_allowlist()?); + let account_admission = if self.disable_account_allowlist { + warn!(target: crate::LOG_TARGET, "Account allowlist enforcement is disabled"); + AccountAdmission::disabled(Arc::clone(&allowlist)) + } else { + AccountAdmission::enabled(Arc::clone(&allowlist)) + }; let (state, block_writer, proof_writer, writer_task) = load_state(&runtime, shutdown.clone()).await?; let _disk_monitor = state.spawn_disk_monitor(shutdown.clone()); @@ -106,7 +123,7 @@ impl SequencerCommand { mode: RpcMode::sequencer( block_producer.clone(), validator_clients, - Arc::clone(&allowlist), + account_admission.clone(), ), ntx_builder: Some(ntx_builder_client), grpc_options: runtime.grpc_options, @@ -151,6 +168,7 @@ impl SequencerCommand { listener: bind_rpc(internal_listen).await?, state, block_producer, + account_admission, grpc_options: runtime.grpc_options, }; tasks.spawn("sequencer internal server", sequencer_internal.serve(shutdown.clone())); diff --git a/compose/node.yml b/compose/node.yml index 6552ea39a7..19fbff121c 100644 --- a/compose/node.yml +++ b/compose/node.yml @@ -26,6 +26,7 @@ services: - --ntx-builder.url=http://ntx-builder:50301 - --rpc.network-tx-auth-header-value=secret_value environment: + MIDEN_NODE_DISABLE_ACCOUNT_ALLOWLIST: "${MIDEN_NODE_DISABLE_ACCOUNT_ALLOWLIST:-true}" OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317 OTEL_RESOURCE_ATTRIBUTES: service.instance.id=sequencer ports: diff --git a/crates/rpc/Cargo.toml b/crates/rpc/Cargo.toml index 24b7e1cd7c..42f75cce87 100644 --- a/crates/rpc/Cargo.toml +++ b/crates/rpc/Cargo.toml @@ -30,6 +30,7 @@ miden-node-tracing = { workspace = true } miden-node-utils = { workspace = true } miden-objects = { workspace = true } miden-protocol = { default-features = true, workspace = true } +miden-standards = { workspace = true } miden-tx-batch = { workspace = true } rand = { workspace = true } semver = { workspace = true } @@ -49,7 +50,6 @@ fs-err = { workspace = true } miden-node-tracing = { features = ["tracing-forest"], workspace = true } miden-node-utils = { features = ["testing-prover"], workspace = true } miden-protocol = { default-features = true, features = ["testing"], workspace = true } -miden-standards = { workspace = true } miden-testing = { workspace = true } miden-tx = { features = ["concurrent", "testing"], workspace = true } reqwest = { workspace = true } diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs index c7efea1f41..a8e77e0abf 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -5,7 +5,14 @@ mod server; #[cfg(test)] mod tests; -pub use server::{PreAuthSubmission, Rpc, RpcMode, SequencerInternal, ValidatorClients}; +pub use server::{ + AccountAdmission, + PreAuthSubmission, + Rpc, + RpcMode, + SequencerInternal, + ValidatorClients, +}; // CONSTANTS // ================================================================================================= diff --git a/crates/rpc/src/server/admission.rs b/crates/rpc/src/server/admission.rs new file mode 100644 index 0000000000..5be80dd1b0 --- /dev/null +++ b/crates/rpc/src/server/admission.rs @@ -0,0 +1,63 @@ +use std::sync::Arc; + +use miden_node_store::allowlist::AccountAllowlist; +use miden_node_tracing::{error, miden_instrument}; +use miden_protocol::account::{Account, AccountUpdateDetails}; +use miden_protocol::transaction::TxAccountUpdate; +use miden_standards::account::auth::NetworkAccount; +use tonic::Status; + +use crate::{COMPONENT, LOG_TARGET}; + +/// Account creation policy shared by the public and internal sequencer APIs. +#[derive(Clone)] +pub struct AccountAdmission { + pub(crate) allowlist: Arc, + disabled: bool, +} + +impl AccountAdmission { + pub fn enabled(allowlist: Arc) -> Self { + Self { allowlist, disabled: false } + } + + pub fn disabled(allowlist: Arc) -> Self { + Self { allowlist, disabled: true } + } + + /// Rejects the submission if it creates an unregistered, non-network account. + #[miden_instrument( + target = COMPONENT, + name = "account_admission.check", + fields(account.id = update.account_id()), + err, + )] + pub(crate) async fn check(&self, update: &TxAccountUpdate) -> tonic::Result<()> { + if self.disabled || !update.initial_state_commitment().is_empty() { + return Ok(()); + } + + // New public accounts include their full state. Use the store's network-account + // classification rule before the account exists on chain. + if let AccountUpdateDetails::Public(patch) = update.details() { + let account = Account::try_from(patch) + .map_err(|error| Status::invalid_argument(error.to_string()))?; + if NetworkAccount::new(account).is_ok() { + return Ok(()); + } + } + + let account_id = update.account_id(); + let registered = self.allowlist.contains_account(account_id).await.map_err(|err| { + error!(err, target: LOG_TARGET, "Account allowlist lookup failed"); + Status::internal("account allowlist lookup failed") + })?; + if !registered { + return Err(Status::permission_denied(format!( + "account {account_id} is not registered" + ))); + } + + Ok(()) + } +} diff --git a/crates/rpc/src/server/api.rs b/crates/rpc/src/server/api.rs index b09ea0bde5..5de57036f4 100644 --- a/crates/rpc/src/server/api.rs +++ b/crates/rpc/src/server/api.rs @@ -31,7 +31,7 @@ use tonic::metadata::MetadataMap; use tonic::{IntoRequest, Request, Status}; use crate::server::api::subscription::{IpBanList, MAX_REPLICA_SUBSCRIPTIONS}; -use crate::server::{NetworkTxAuth, RpcBackend}; +use crate::server::{AccountAdmission, NetworkTxAuth, RpcBackend}; use crate::{COMPONENT, LOG_TARGET}; // VALIDATOR FAN-OUT @@ -275,6 +275,7 @@ impl RpcService { pub(crate) struct SequencerInternalService { pub(crate) state: Arc, pub(crate) block_producer: BlockProducerApi, + pub(crate) account_admission: AccountAdmission, } // HELPERS diff --git a/crates/rpc/src/server/api/register_account.rs b/crates/rpc/src/server/api/register_account.rs index e4015a09e5..b81ee4689a 100644 --- a/crates/rpc/src/server/api/register_account.rs +++ b/crates/rpc/src/server/api/register_account.rs @@ -39,19 +39,20 @@ impl proto::server::rpc_api::RegisterAccount for RpcService { .map_err(|error| Status::invalid_argument(error.to_string()))?; match &self.backend { - RpcBackend::Sequencer { allowlist, .. } => { - allowlist.register_account(invitation, account_id).await.map(|_| ()).map_err( - |error| { - let code = match &error { - AllowlistError::InvitationNotFound => Code::NotFound, - AllowlistError::InvitationAlreadyUsed - | AllowlistError::AccountAlreadyRegistered(_) => Code::AlreadyExists, - AllowlistError::Database(_) => Code::Internal, - }; - Status::new(code, error.as_report()) - }, - ) - }, + RpcBackend::Sequencer { account_admission, .. } => account_admission + .allowlist + .register_account(invitation, account_id) + .await + .map(|_| ()) + .map_err(|error| { + let code = match &error { + AllowlistError::InvitationNotFound => Code::NotFound, + AllowlistError::InvitationAlreadyUsed + | AllowlistError::AccountAlreadyRegistered(_) => Code::AlreadyExists, + AllowlistError::Database(_) => Code::Internal, + }; + Status::new(code, error.as_report()) + }), RpcBackend::FullNode { source_rpc, .. } => { let mut request = Request::new(request); if let Some(accept) = metadata.get(http::header::ACCEPT.as_str()) { diff --git a/crates/rpc/src/server/api/submit_auth_tx.rs b/crates/rpc/src/server/api/submit_auth_tx.rs index a3c5ecfec3..a4caa05781 100644 --- a/crates/rpc/src/server/api/submit_auth_tx.rs +++ b/crates/rpc/src/server/api/submit_auth_tx.rs @@ -27,6 +27,10 @@ impl sequencer_api::SubmitAuthenticatedTx for SequencerInternalService { _metadata: &tonic::metadata::MetadataMap, _extensions: &tonic::codegen::http::Extensions, ) -> tonic::Result { + self.account_admission + .check(tx.raw_proven_transaction().account_update()) + .await?; + let (block_num, commitment) = tx.reference_block(); let reference_header = self .state diff --git a/crates/rpc/src/server/api/submit_auth_tx_batch.rs b/crates/rpc/src/server/api/submit_auth_tx_batch.rs index f36e266bdd..7ae04f9eb0 100644 --- a/crates/rpc/src/server/api/submit_auth_tx_batch.rs +++ b/crates/rpc/src/server/api/submit_auth_tx_batch.rs @@ -36,6 +36,10 @@ impl sequencer_api::SubmitAuthenticatedTxBatch for SequencerInternalService { Status::internal(format!("authenticated batch decoding task failed: {err}")) })??; + for tx in batch.transactions() { + self.account_admission.check(tx.account_update()).await?; + } + self.block_producer .submit_authenticated_tx_batch(batch, inputs) .await diff --git a/crates/rpc/src/server/api/submit_proven_tx.rs b/crates/rpc/src/server/api/submit_proven_tx.rs index acd4908ef9..3ddb1e635e 100644 --- a/crates/rpc/src/server/api/submit_proven_tx.rs +++ b/crates/rpc/src/server/api/submit_proven_tx.rs @@ -66,6 +66,10 @@ impl proto::server::rpc_api::SubmitProvenTx for RpcService { debug!(target: LOG_TARGET, "Submitting transaction"); + if let RpcBackend::Sequencer { account_admission, .. } = &self.backend { + account_admission.check(tx.account_update()).await?; + } + // Verify the reference block is actually part of the chain. let reference_header = self .verify_reference_commitment(tx.ref_block_num(), tx.ref_block_commitment()) diff --git a/crates/rpc/src/server/api/submit_proven_tx_batch.rs b/crates/rpc/src/server/api/submit_proven_tx_batch.rs index b1732b2836..92ccabf1c4 100644 --- a/crates/rpc/src/server/api/submit_proven_tx_batch.rs +++ b/crates/rpc/src/server/api/submit_proven_tx_batch.rs @@ -79,6 +79,12 @@ impl proto::server::rpc_api::SubmitProvenTxBatch for RpcService { debug!(target: LOG_TARGET, "Submitting transaction batch"); + if let RpcBackend::Sequencer { account_admission, .. } = &self.backend { + for tx in proposed_batch.transactions() { + account_admission.check(tx.account_update()).await?; + } + } + // Verify the reference block is actually part of the chain. self.verify_reference_commitment( proven_batch.reference_block_num(), diff --git a/crates/rpc/src/server/mod.rs b/crates/rpc/src/server/mod.rs index a6b037ddac..aed7c27a9f 100644 --- a/crates/rpc/src/server/mod.rs +++ b/crates/rpc/src/server/mod.rs @@ -13,7 +13,6 @@ use miden_node_proto::clients::{ }; use miden_node_proto::server::{rpc_api, sequencer_api}; use miden_node_proto_build::rpc_api_descriptor; -use miden_node_store::allowlist::AccountAllowlist; use miden_node_store::state::{BlockWriter, ProofWriter, State}; use miden_node_tracing::grpc::grpc_trace_fn; use miden_node_tracing::info; @@ -38,9 +37,12 @@ use crate::server::api::SequencerInternalService; use crate::server::health::HealthCheckLayer; mod accept; +mod admission; pub(crate) mod api; mod health; +pub use admission::AccountAdmission; + /// The RPC server component. /// /// On startup, binds to the provided listener and starts serving the RPC API. @@ -74,7 +76,7 @@ pub enum RpcMode { Sequencer { block_producer: Box, validators: ValidatorClients, - allowlist: Arc, + account_admission: AccountAdmission, }, /// Full-node RPC. /// @@ -106,7 +108,7 @@ pub(crate) enum RpcBackend { Sequencer { block_producer: Box, validators: ValidatorClients, - allowlist: Arc, + account_admission: AccountAdmission, }, FullNode { source_rpc: Box, @@ -122,12 +124,12 @@ impl RpcBackend { pub(crate) fn sequencer( block_producer: BlockProducerApi, validators: ValidatorClients, - allowlist: Arc, + account_admission: AccountAdmission, ) -> Self { Self::Sequencer { block_producer: Box::new(block_producer), validators, - allowlist, + account_admission, } } @@ -211,12 +213,12 @@ impl RpcMode { pub fn sequencer( block_producer: BlockProducerApi, validators: ValidatorClients, - allowlist: Arc, + account_admission: AccountAdmission, ) -> Self { Self::Sequencer { block_producer: Box::new(block_producer), validators, - allowlist, + account_admission, } } @@ -247,10 +249,14 @@ impl RpcMode { /// [`RpcService`](api::RpcService). fn backend(&self) -> RpcBackend { match self { - Self::Sequencer { block_producer, validators, allowlist } => RpcBackend::Sequencer { + Self::Sequencer { + block_producer, + validators, + account_admission, + } => RpcBackend::Sequencer { block_producer: block_producer.clone(), validators: validators.clone(), - allowlist: Arc::clone(allowlist), + account_admission: account_admission.clone(), }, Self::FullNode { source_rpc, pre_auth, .. } => RpcBackend::FullNode { source_rpc: source_rpc.clone(), @@ -459,6 +465,8 @@ pub struct SequencerInternal { pub state: Arc, /// The in-process block producer API submissions are forwarded to. pub block_producer: BlockProducerApi, + /// Account creation policy shared with the public RPC API. + pub account_admission: AccountAdmission, /// gRPC server options for internal services (timeouts). pub grpc_options: GrpcOptions, } @@ -482,6 +490,7 @@ impl SequencerInternal { let service = SequencerInternalService { state: self.state, block_producer: self.block_producer, + account_admission: self.account_admission, }; // Note: deliberately no accept-header / auth layers; this is a private, trusted interface diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index dac5795acd..78ab86d4ea 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -95,7 +95,9 @@ use url::Url; use crate::server::RpcBackend; use crate::server::api::{RpcService, SequencerInternalService}; -use crate::{PreAuthSubmission, Rpc, RpcMode, ValidatorClients}; +use crate::{AccountAdmission, PreAuthSubmission, Rpc, RpcMode, ValidatorClients}; + +mod allowlist; /// Global registry of temp directories. Held for the lifetime of the test binary so that `RocksDB` /// can always flush on drop regardless of test outcome or drop ordering. @@ -640,6 +642,7 @@ async fn sequencer_authenticated_rpc_rejects_transactions_without_fees() { let service = SequencerInternalService { state: Arc::clone(&store.state), block_producer: block_producer.clone(), + account_admission: AccountAdmission::enabled(store.bootstrap_allowlist()), }; let status = service @@ -735,7 +738,7 @@ async fn rpc_server_forwards_valid_deferred_proofs_and_rejects_missing_witnesses RpcBackend::sequencer( block_producer, ValidatorClients::new(vec![validator]).unwrap(), - allowlist, + AccountAdmission::enabled(allowlist), ), None, NonZeroUsize::new(1_000_000).unwrap(), @@ -1007,7 +1010,7 @@ async fn start_source_rpc_with_genesis( RpcBackend::sequencer( block_producer, ValidatorClients::new(vec![validator]).unwrap(), - allowlist, + AccountAdmission::enabled(allowlist), ), Some(ntx_builder), NonZeroUsize::new(1_000_000).unwrap(), @@ -1478,6 +1481,7 @@ async fn authenticated_batch_defers_validation_to_async_handler() { let service = SequencerInternalService { state: Arc::clone(&store.state), block_producer, + account_admission: AccountAdmission::enabled(store.bootstrap_allowlist()), }; let error = ::handle( &service, @@ -1595,7 +1599,7 @@ async fn start_rpc() -> (RpcClient, std::net::SocketAddr, TestStore, TestServerG mode: RpcMode::sequencer( block_producer, ValidatorClients::new(vec![validator]).unwrap(), - allowlist, + AccountAdmission::enabled(allowlist), ), ntx_builder: None, grpc_options, diff --git a/crates/rpc/src/tests/allowlist.rs b/crates/rpc/src/tests/allowlist.rs new file mode 100644 index 0000000000..619ec03a0c --- /dev/null +++ b/crates/rpc/src/tests/allowlist.rs @@ -0,0 +1,210 @@ +use std::collections::BTreeMap; + +use miden_node_proto::generated::submission::SealedTransactionInputs; +use miden_protocol::batch::{ProposedBatch, ProvenBatch}; +use miden_standards::account::auth::NetworkAccount; +use miden_standards::account::fees::{BasicConstantFeePolicy, FeePolicyManager}; + +use super::*; + +impl TestStore { + async fn with_account_creation_batch() -> (Self, ProposedBatch) { + let mut builder = MockChainBuilder::new(); + let accounts = [ + builder.create_new_wallet(Auth::IncrNonce).unwrap(), + builder.create_new_wallet(Auth::IncrNonce).unwrap(), + ]; + let chain = builder.build().unwrap(); + let store = + Self::start_from_mock_genesis(&chain.latest_block(), chain.protocol_config()).await; + let mut transactions = Vec::new(); + // Batch decoding verifies each transaction proof before the admission check. + for account in accounts { + let context = chain.build_transaction(account).build().unwrap(); + let executed = Box::pin(context.execute()).await.unwrap(); + let inputs = executed.tx_inputs().clone(); + let proven = spawn_blocking_in_current_span(move || { + LocalTransactionProver::default().prove(inputs) + }) + .await + .unwrap() + .unwrap(); + transactions.push(Arc::new(proven)); + } + let batch = ProposedBatch::new_unverified( + transactions, + chain.latest_block_header(), + chain.latest_partial_blockchain(), + BTreeMap::new(), + ) + .unwrap(); + (store, batch) + } + + fn account_transaction(&self, account: &Account, is_new: bool) -> ProvenTransaction { + let patch = AccountPatch::try_from(account.clone()).unwrap(); + let details = if account.is_public() { + AccountUpdateDetails::Public(patch.clone()) + } else { + AccountUpdateDetails::Private + }; + let update = TxAccountUpdate::new( + account.id(), + if is_new { + Word::empty() + } else { + Word::from([1u32, 2, 3, 4]) + }, + account.to_commitment(), + patch.to_commitment(), + details, + ) + .unwrap(); + ProvenTransaction::new( + update, + Vec::::new(), + Vec::::new(), + 0.into(), + self.genesis_commitment(), + u32::MAX.into(), + miden_protocol::testing::dummy_execution_proof(), + ) + .unwrap() + } +} + +#[tokio::test] +async fn account_admission_only_restricts_new_non_network_accounts() { + let store = TestStore::start().await; + let allowlist = store.bootstrap_allowlist(); + let admission = AccountAdmission::enabled(Arc::clone(&allowlist)); + let disabled = AccountAdmission::disabled(Arc::clone(&allowlist)); + + for account_type in [AccountType::Public, AccountType::Private] { + let account = AccountBuilder::new([1; 32]) + .account_type(account_type) + .with_component(BasicWallet) + .with_component(NoopAuthComponent) + .build_existing() + .unwrap(); + let creation = store.account_transaction(&account, true); + let existing = store.account_transaction(&account, false); + + let error = admission.check(creation.account_update()).await.unwrap_err(); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + admission.check(existing.account_update()).await.unwrap(); + disabled.check(creation.account_update()).await.unwrap(); + assert!(!allowlist.contains_account(creation.account_id()).await.unwrap()); + + allowlist.add_account(creation.account_id()).await.unwrap(); + admission.check(creation.account_update()).await.unwrap(); + } + + let network_account = NetworkAccount::builder( + [2; 32], + [miden_protocol::note::NoteScriptRoot::from_array([1, 2, 3, 4])].into(), + FeePolicyManager::builder() + .fee_faucet_id(FungibleAsset::mock_issuer()) + .active_fee_policy(BasicConstantFeePolicy::new().into()) + .build(), + ) + .unwrap() + .with_component(BasicWallet) + .build_existing() + .unwrap(); + let creation = store.account_transaction(&network_account, true); + admission.check(creation.account_update()).await.unwrap(); + assert!(!allowlist.contains_account(creation.account_id()).await.unwrap()); +} + +#[tokio::test] +async fn submission_endpoints_reject_unregistered_creation_without_partial_batch_admission() { + let (store, batch) = TestStore::with_account_creation_batch().await; + let transactions = batch.transactions(); + let allowlist = store.bootstrap_allowlist(); + let admission = AccountAdmission::enabled(Arc::clone(&allowlist)); + let guard = TestServerGuard(CancellationToken::new()); + let block_producer = BlockProducerApi::new( + Arc::clone(&store.state), + 0.into(), + BlockProducerApiConfig::default(), + guard.0.clone(), + ); + let public = RpcService::new( + Arc::clone(&store.state), + RpcBackend::sequencer( + block_producer.clone(), + ValidatorClients::new(vec![dummy_client::()]).unwrap(), + admission.clone(), + ), + None, + NonZeroUsize::new(10).unwrap(), + None, + ); + let internal = SequencerInternalService { + state: Arc::clone(&store.state), + block_producer, + account_admission: admission, + }; + + allowlist.add_account(transactions[0].account_id()).await.unwrap(); + + let header = batch.reference_block_header(); + let proven_batch = ProvenBatch::new_unchecked( + batch.id(), + header.commitment(), + header.block_num(), + batch.account_updates().clone(), + batch.input_notes().clone(), + batch.output_notes().to_vec(), + batch.batch_expiration_block_num(), + batch.transaction_headers(), + miden_protocol::testing::dummy_execution_proof(), + ) + .unwrap(); + let tx = proto::sequencer::AuthenticatedTransaction { + transaction: Some(transactions[1].as_ref().into()), + ..Default::default() + }; + let authenticated_batch = proto::sequencer::AuthenticatedTransactionBatch { + proposed_batch: Some((&batch).into()), + auth_inputs: transactions + .iter() + .map(|tx| proto::sequencer::AuthInputs { + account_id: Some(tx.account_id().into()), + ..Default::default() + }) + .collect(), + }; + + for result in [ + public + .submit_proven_tx(Request::new(proto::submission::ProvenTransactionSubmission { + transaction: Some(transactions[1].as_ref().into()), + sealed_transaction_inputs: None, + })) + .await, + public + .submit_proven_tx_batch(Request::new(proto::submission::TransactionBatch { + batch: Some((&proven_batch).into()), + proposed_batch: Some((&batch).into()), + sealed_transaction_inputs: vec![SealedTransactionInputs::default(); 2], + })) + .await, + internal.submit_authenticated_tx(Request::new(tx)).await, + internal + .submit_authenticated_tx_batch(Request::new(authenticated_batch.clone())) + .await, + ] { + let status = result.unwrap_err(); + assert_eq!(status.code(), tonic::Code::PermissionDenied); + assert!(status.message().contains(&transactions[1].account_id().to_string())); + } + + // The retry must not conflict with a partially admitted transaction from the rejected batch. + allowlist.add_account(transactions[1].account_id()).await.unwrap(); + internal + .submit_authenticated_tx_batch(Request::new(authenticated_batch)) + .await + .unwrap(); +} diff --git a/crates/store/src/allowlist/mod.rs b/crates/store/src/allowlist/mod.rs index ea3639eb3c..2aeecee309 100644 --- a/crates/store/src/allowlist/mod.rs +++ b/crates/store/src/allowlist/mod.rs @@ -6,11 +6,11 @@ use std::path::Path; use miden_node_db::sqlite::{DbReader, DbWriter, WriteTx}; -use miden_node_tracing::{error, miden_instrument}; +use miden_node_tracing::miden_instrument; use miden_protocol::account::AccountId; use thiserror::Error; -use crate::{COMPONENT, DatabaseError, LOG_TARGET}; +use crate::{COMPONENT, DatabaseError}; mod invitation; mod migrations; @@ -76,6 +76,7 @@ impl AccountAllowlistReader { target = COMPONENT, name = "store.allowlist.allowlisted_at", fields(account.id = account_id), + err, )] pub async fn allowlisted_at( &self, @@ -85,11 +86,10 @@ impl AccountAllowlistReader { .read("allowlist.allowlisted_at", move |tx| queries::allowlisted_at(tx, account_id)) .await .map_err(DatabaseError::DatabaseError) - .inspect_err(|err| error!(err, target: LOG_TARGET, "Account allowlist query failed")) } /// Returns the invitation's registration and allowlist entry timestamp, if it exists. - #[miden_instrument(target = COMPONENT, name = "store.allowlist.invitation_info")] + #[miden_instrument(target = COMPONENT, name = "store.allowlist.invitation_info", err)] pub async fn invitation_info( &self, invitation_code: InvitationCode, @@ -100,7 +100,6 @@ impl AccountAllowlistReader { }) .await .map_err(DatabaseError::DatabaseError) - .inspect_err(|err| error!(err, target: LOG_TARGET, "Account allowlist query failed")) } /// Returns whether the registry contains the account. @@ -108,6 +107,7 @@ impl AccountAllowlistReader { target = COMPONENT, name = "store.allowlist.contains_account", fields(account.id = account_id), + err, )] pub async fn contains_account(&self, account_id: AccountId) -> Result { self.db @@ -116,11 +116,10 @@ impl AccountAllowlistReader { }) .await .map_err(DatabaseError::DatabaseError) - .inspect_err(|err| error!(err, target: LOG_TARGET, "Account allowlist query failed")) } /// Returns the registration state of the invitation code. - #[miden_instrument(target = COMPONENT, name = "store.allowlist.invitation_status")] + #[miden_instrument(target = COMPONENT, name = "store.allowlist.invitation_status", err)] pub async fn invitation_status( &self, invitation_code: InvitationCode, @@ -131,7 +130,6 @@ impl AccountAllowlistReader { }) .await .map_err(DatabaseError::DatabaseError) - .inspect_err(|err| error!(err, target: LOG_TARGET, "Account allowlist query failed")) } } @@ -214,13 +212,13 @@ impl AccountAllowlist { target = COMPONENT, name = "store.allowlist.import_invitation", fields(account.id = entry.account_id), + err, )] pub async fn import_invitation(&self, entry: InvitationEntry) -> Result { self.transact("allowlist.import_invitation", move |tx| { queries::import_invitation(tx, &entry) }) .await - .inspect_err(|err| error!(err, target: LOG_TARGET, "Account allowlist import failed")) } /// Adds an account without an invitation code. Returns true if the registration is new. @@ -230,13 +228,13 @@ impl AccountAllowlist { target = COMPONENT, name = "store.allowlist.add_account", fields(account.id = account_id), + err, )] pub async fn add_account(&self, account_id: AccountId) -> Result { self.writer .write("allowlist.add_account", move |tx| queries::add_account(tx, account_id)) .await .map_err(DatabaseError::DatabaseError) - .inspect_err(|err| error!(err, target: LOG_TARGET, "Account allowlist update failed")) } /// Registers an unused invitation code to an account in one transaction. @@ -247,6 +245,7 @@ impl AccountAllowlist { target = COMPONENT, name = "store.allowlist.register_account", fields(account.id = account_id), + err, )] pub async fn register_account( &self, @@ -257,7 +256,6 @@ impl AccountAllowlist { queries::register_account(tx, &invitation_code, account_id) }) .await - .inspect_err(|err| error!(err, target: LOG_TARGET, "Account registration failed")) } async fn transact( diff --git a/docker-compose.yml b/docker-compose.yml index cd401b9f51..786d9122b6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,6 @@ # Environment variables: +# - MIDEN_NODE_DISABLE_ACCOUNT_ALLOWLIST: allow unrestricted account creation. +# Defaults to true for local development. Set to false to require registration. # - MIDEN_REMOTE_PROVER_URL: remote transaction prover URL. Defaults to the # bundled miden-remote-prover service at http://tx-prover:50051. # - MIDEN_VALIDATOR_1_SIGNING_KEY, MIDEN_VALIDATOR_2_SIGNING_KEY, and diff --git a/docs/external/src/network-operator/sequencer.md b/docs/external/src/network-operator/sequencer.md index 3869368342..ebc1646d5e 100644 --- a/docs/external/src/network-operator/sequencer.md +++ b/docs/external/src/network-operator/sequencer.md @@ -37,6 +37,13 @@ For larger deployments, prefer serving public RPC through full nodes so the sequ ## Allowlist Administration +The sequencer checks new, non-network accounts against the allowlist on both public and internal submission APIs. +Existing-account transactions and network-account creation do not require registration. + +For development networks, use `--disable-account-allowlist` or set `MIDEN_NODE_DISABLE_ACCOUNT_ALLOWLIST=true` to allow +unrestricted account creation. Enforcement is enabled by default. The flag does not disable registration or the +administration API. + The sequencer can serve a private JSON administration API. The listener is disabled by default. Configure its address to enable it: diff --git a/docs/external/src/rpc/public-api.md b/docs/external/src/rpc/public-api.md index 36cce5c697..5b14824ed9 100644 --- a/docs/external/src/rpc/public-api.md +++ b/docs/external/src/rpc/public-api.md @@ -45,6 +45,10 @@ invitation codes over a network. Do not log invitation codes. Full nodes forward ## Transaction Submission +The sequencer requires registration before a transaction creates a non-network account. Transactions for existing +accounts and network-account creation do not require registration. An unregistered creation returns `PERMISSION_DENIED`. +If a batch contains an unregistered creation, the sequencer rejects the entire batch. + | Method | Purpose | | ----------------------------- | ------------------------------------------------------------------------------------------- | | `GetTransactionEncryptionKey` | Returns the transaction encryption public key, attested by a validator's signing key. | diff --git a/scripts/run-node.sh b/scripts/run-node.sh index 5e66ec699c..ae3c8cab73 100755 --- a/scripts/run-node.sh +++ b/scripts/run-node.sh @@ -234,6 +234,7 @@ sleep 2 echo "Starting sequencer..." OTEL_RESOURCE_ATTRIBUTES="$(node_resource_attributes sequencer)" \ + MIDEN_NODE_DISABLE_ACCOUNT_ALLOWLIST="${MIDEN_NODE_DISABLE_ACCOUNT_ALLOWLIST:-true}" \ "$NODE_BINARY" sequencer \ --rpc.listen "0.0.0.0:$RPC_PORT" \ --rpc.network-tx-auth-header-value "$NETWORK_TX_AUTH" \