From ecc8ccb617ce7f8969e8c04a0b3c2efbf8bfc7d9 Mon Sep 17 00:00:00 2001 From: Mirko von Leipzig <48352201+Mirko-von-Leipzig@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:45:20 +0200 Subject: [PATCH 1/3] Create and deploy fee collector accounts --- Cargo.lock | 4 + bin/benchmark/README.md | 5 + bin/node/Cargo.toml | 2 + bin/node/src/commands/fee_collector.rs | 187 +++++++++ bin/node/src/commands/fee_collector/tests.rs | 62 +++ bin/node/src/commands/mod.rs | 17 +- compose/bootstrap.yml | 31 ++ compose/node.yml | 2 +- crates/block-producer/Cargo.toml | 2 + .../block-producer/src/batch_builder/mod.rs | 47 +-- .../src/batch_builder/remote_prover.rs | 47 ++- .../block-producer/src/block_builder/mod.rs | 114 +++--- crates/block-producer/src/block_prover.rs | 2 +- crates/block-producer/src/fee_collector.rs | 141 +++++++ .../block-producer/src/fee_collector/tests.rs | 341 +++++++++++++++++ .../src/fee_collector/transaction.rs | 355 ++++++++++++++++++ crates/block-producer/src/lib.rs | 2 + .../block-producer/src/test_utils/account.rs | 23 +- crates/block-producer/src/test_utils/mod.rs | 2 +- crates/block-producer/src/validator/mod.rs | 56 ++- crates/store/src/data_directory.rs | 4 + .../network-operator/bootstrap-and-genesis.md | 2 + .../external/src/network-operator/recovery.md | 3 +- .../src/network-operator/sequencer.md | 24 ++ scripts/bench-local.sh | 7 + scripts/run-node.sh | 16 +- 26 files changed, 1378 insertions(+), 120 deletions(-) create mode 100644 bin/node/src/commands/fee_collector.rs create mode 100644 bin/node/src/commands/fee_collector/tests.rs create mode 100644 crates/block-producer/src/fee_collector.rs create mode 100644 crates/block-producer/src/fee_collector/tests.rs create mode 100644 crates/block-producer/src/fee_collector/transaction.rs diff --git a/Cargo.lock b/Cargo.lock index 79bf0dac56..3948cfae61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4151,6 +4151,8 @@ dependencies = [ "miden-node-tracing", "miden-node-utils", "miden-protocol", + "miden-standards", + "rand 0.10.2", "serde", "serde_json", "tempfile", @@ -4177,6 +4179,8 @@ dependencies = [ "miden-node-utils", "miden-protocol", "miden-standards", + "miden-testing", + "miden-tx", "miden-tx-batch", "pretty_assertions", "rand 0.10.2", diff --git a/bin/benchmark/README.md b/bin/benchmark/README.md index 881e81e575..b4cd76f4c6 100644 --- a/bin/benchmark/README.md +++ b/bin/benchmark/README.md @@ -201,6 +201,11 @@ nohup miden-validator start \ --encryption-key.hex "" \ > logs/validator.log 2>&1 & +miden-node fee-collector create --data-directory "$DATA/node" +miden-node fee-collector deploy \ + --data-directory "$DATA/node" \ + --validator.url http://127.0.0.1:50101 + # The ntx-builder needs a transaction prover, so start one regardless. nohup miden-remote-prover \ --port 50051 \ diff --git a/bin/node/Cargo.toml b/bin/node/Cargo.toml index 12f6e670c3..1beb5234ab 100644 --- a/bin/node/Cargo.toml +++ b/bin/node/Cargo.toml @@ -30,6 +30,8 @@ miden-node-store = { workspace = true } miden-node-tracing = { workspace = true } miden-node-utils = { workspace = true } miden-protocol = { workspace = true } +miden-standards = { workspace = true } +rand = { workspace = true } serde = { features = ["derive"], workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/bin/node/src/commands/fee_collector.rs b/bin/node/src/commands/fee_collector.rs new file mode 100644 index 0000000000..20bedb1c77 --- /dev/null +++ b/bin/node/src/commands/fee_collector.rs @@ -0,0 +1,187 @@ +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::Context; +use miden_node_block_producer::{DEFAULT_VALIDATOR_TIMEOUT, deploy_fee_collector}; +use miden_node_store::{DataDirectory, State}; +use miden_node_tracing::info; +use miden_node_utils::clap::duration_to_human_readable_string; +use miden_node_utils::shutdown::CancellationToken; +use miden_protocol::account::auth::AuthSecretKey; +use miden_protocol::account::{AccountBuilder, AccountFile, AccountType}; +use miden_protocol::utils::serde::Serializable; +use miden_standards::account::auth::AuthTxFeeCollector; +use miden_standards::account::wallets::BasicWallet; +use url::Url; + +use super::ENV_DATA_DIRECTORY; +use super::store::StoreOptions; + +#[cfg(test)] +mod tests; + +#[derive(clap::Subcommand, Debug)] +pub enum FeeCollectorCommand { + /// Create a fee collector account and save its signing key. + /// + /// Writes fee-collector.mac in the existing data directory. Refuses to overwrite an existing + /// file. Creation is offline. Keep the file private because it contains the signing key. + /// + /// Use `miden-node fee-collector deploy` to deploy this account before collecting batch fees. + Create(CreateCommand), + + /// Deploy a fee collector account in a dedicated block. + /// + /// Loads fee-collector.mac from the data directory, or the file specified by + /// --fee-collector-account. + /// + /// Stop any node process that uses the data directory. All validators must be running to + /// validate the transaction and sign the deployment block. + /// + /// Generates the transaction, batch, and block proofs locally. Deployment requires no funds + /// and pays no transaction fee. If the matching account is already deployed, the command + /// succeeds without creating another block. + /// + /// Keep the account file and its signing key for fee collection. + Deploy(Box), +} + +impl FeeCollectorCommand { + pub async fn handle(self, shutdown: CancellationToken) -> anyhow::Result<()> { + match self { + Self::Create(command) => command.handle(), + Self::Deploy(command) => command.handle(shutdown).await, + } + } +} + +#[derive(clap::Args, Debug)] +pub struct CreateCommand { + /// Existing directory in which to create fee-collector.mac. The file must not exist. + #[arg(long, env = ENV_DATA_DIRECTORY, value_name = "DIR")] + data_directory: PathBuf, +} + +impl CreateCommand { + fn handle(self) -> anyhow::Result<()> { + let output = DataDirectory::load(self.data_directory)?.fee_collector_account_path(); + let secret_key = AuthSecretKey::new_falcon512_poseidon2(); + let account = AccountBuilder::new(rand::random()) + .account_type(AccountType::Public) + .with_component(AuthTxFeeCollector::from_public_key(secret_key.public_key())) + .with_component(BasicWallet) + .build()?; + let account_file = AccountFile::new(account, vec![secret_key]); + let mut options = fs_err::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use fs_err::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = + options.open(&output).context("failed to create fee collector account file")?; + file.write_all(&account_file.to_bytes())?; + file.sync_all()?; + info!( + target: crate::LOG_TARGET, + "Saved new fee collector account", + account.id = account_file.account.id(), + account.file = output.as_path() + ); + Ok(()) + } +} + +#[derive(clap::Args, Clone, Debug)] +pub struct FeeCollectorAccountOptions { + /// Fee collector account file, including its signing key. Defaults to fee-collector.mac in the + /// data directory. + #[arg( + long = "fee-collector-account", + env = "MIDEN_NODE_FEE_COLLECTOR_ACCOUNT", + value_name = "FILE" + )] + account: Option, +} + +impl FeeCollectorAccountOptions { + pub fn read(&self, data_directory: &Path) -> anyhow::Result { + let path = match &self.account { + Some(path) => path.clone(), + None => DataDirectory::load(data_directory.to_path_buf())?.fee_collector_account_path(), + }; + AccountFile::read(&path).with_context(|| { + format!("failed to read fee collector account from {}", path.display()) + }) + } +} + +#[derive(clap::Args, Debug)] +pub struct DeployCommand { + /// Directory containing the node's local data storage. + #[arg(long, env = ENV_DATA_DIRECTORY, value_name = "DIR")] + data_directory: PathBuf, + + #[command(flatten)] + fee_collector: FeeCollectorAccountOptions, + + /// URLs of all validators in the current validator set. Repeat this option for each validator. + #[arg( + long = "validator.url", + env = "MIDEN_NODE_VALIDATOR_URL", + value_name = "URL", + value_delimiter = ',', + required = true + )] + validator_urls: Vec, + + /// Request timeout for calls to the validator services. + #[arg( + long = "validator.timeout", + env = "MIDEN_NODE_VALIDATOR_TIMEOUT", + default_value = duration_to_human_readable_string(DEFAULT_VALIDATOR_TIMEOUT), + value_parser = humantime::parse_duration, + value_name = "DURATION" + )] + validator_timeout: Duration, + + #[command(flatten)] + store: StoreOptions, +} + +impl DeployCommand { + async fn handle(self, shutdown: CancellationToken) -> anyhow::Result<()> { + let account = self.fee_collector.read(&self.data_directory)?; + let loaded = State::load_with_database_options( + &self.data_directory, + self.store.storage.into(), + self.store.sqlite.database_options(), + ) + .await + .context("failed to load node state")?; + let (state, mut block_writer, mut proof_writer, writer_task) = + loaded.start(CancellationToken::new()); + let result = async { + anyhow::ensure!( + state.proven_tip() == state.committed_tip(), + "sync all committed block proofs before deploying a fee collector", + ); + tokio::select! { + () = shutdown.cancelled() => anyhow::bail!("fee collector deployment cancelled"), + result = Box::pin(deploy_fee_collector( + &state, + &mut block_writer, + &mut proof_writer, + account, + self.validator_urls, + self.validator_timeout, + )) => result, + } + } + .await; + block_writer.stop(writer_task).await; + result + } +} diff --git a/bin/node/src/commands/fee_collector/tests.rs b/bin/node/src/commands/fee_collector/tests.rs new file mode 100644 index 0000000000..258281e0f9 --- /dev/null +++ b/bin/node/src/commands/fee_collector/tests.rs @@ -0,0 +1,62 @@ +use super::*; + +#[test] +fn saves_the_collector_signing_key_without_overwriting_existing_files() -> anyhow::Result<()> { + let directory = tempfile::tempdir()?; + let path = directory.path().join("fee-collector.mac"); + CreateCommand { + data_directory: directory.path().to_path_buf(), + } + .handle()?; + let account_file = AccountFile::read(&path)?; + let loaded = FeeCollectorAccountOptions { account: None }.read(directory.path())?; + assert_eq!(loaded.to_bytes(), account_file.to_bytes()); + assert!(account_file.account.is_new()); + assert!(account_file.account.is_public()); + assert!(account_file.account.vault().is_empty()); + assert_eq!(account_file.auth_secret_keys.len(), 1); + assert_eq!( + account_file.account.storage().get_item(AuthTxFeeCollector::public_key_slot())?, + miden_protocol::Word::from(account_file.auth_secret_keys[0].public_key().to_commitment()), + ); + let contents = fs_err::read(&path)?; + assert!( + CreateCommand { + data_directory: directory.path().to_path_buf() + } + .handle() + .is_err() + ); + assert_eq!(fs_err::read(&path)?, contents); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(fs_err::metadata(&path)?.permissions().mode() & 0o777, 0o600); + } + Ok(()) +} + +#[test] +fn explicit_account_file_overrides_the_data_directory_default() -> anyhow::Result<()> { + let directory = tempfile::tempdir()?; + let other_directory = tempfile::tempdir()?; + CreateCommand { + data_directory: directory.path().to_path_buf(), + } + .handle()?; + CreateCommand { + data_directory: other_directory.path().to_path_buf(), + } + .handle()?; + let default_path = directory.path().join("fee-collector.mac"); + let custom_path = other_directory.path().join("fee-collector.mac"); + let default_contents = fs_err::read(&default_path)?; + let custom_contents = fs_err::read(&custom_path)?; + let account = + FeeCollectorAccountOptions { account: Some(custom_path.clone()) }.read(directory.path())?; + assert_eq!(account.to_bytes(), custom_contents); + assert_ne!(account.account.id(), AccountFile::read(&default_path)?.account.id()); + assert_eq!(fs_err::read(default_path)?, default_contents); + assert_eq!(fs_err::read(custom_path)?, custom_contents); + Ok(()) +} diff --git a/bin/node/src/commands/mod.rs b/bin/node/src/commands/mod.rs index f5d6551a2d..6e0c6e1625 100644 --- a/bin/node/src/commands/mod.rs +++ b/bin/node/src/commands/mod.rs @@ -1,4 +1,5 @@ mod block_producer; +mod fee_collector; mod lifecycle; mod modes; mod recover; @@ -8,6 +9,7 @@ pub(crate) mod section; mod store; use clap::Subcommand; +pub use fee_collector::FeeCollectorCommand; pub use lifecycle::{BootstrapCommand, MigrateCommand}; use miden_node_tracing::OpenTelemetry; use miden_node_utils::shutdown::CancellationToken; @@ -40,6 +42,13 @@ pub enum Command { /// initialized before the node can be started. Bootstrap(BootstrapCommand), + /// Create or deploy the sequencer's fee collector account. + /// + /// The immutable collector combines transaction fees into P2ID notes for the batch builder's + /// wallet. + #[command(subcommand)] + FeeCollector(FeeCollectorCommand), + /// Apply pending migrations to the node's storage. /// /// Migrates the node's data storage from its current schema version to the version required by @@ -78,15 +87,17 @@ impl Command { Command::Full(_) => OpenTelemetry::from_env() .with_name("node") .with_attribute("miden.node.role", "full"), - Command::Bootstrap(_) | Command::Migrate(_) | Command::Recover(_) => { - OpenTelemetry::Disabled - }, + Command::Bootstrap(_) + | Command::FeeCollector(_) + | Command::Migrate(_) + | Command::Recover(_) => OpenTelemetry::Disabled, } } pub(crate) async fn execute(self, shutdown: CancellationToken) -> anyhow::Result<()> { match self { Command::Bootstrap(bootstrap_command) => bootstrap_command.handle().await, + Command::FeeCollector(command) => command.handle(shutdown).await, Command::Migrate(migrate_command) => migrate_command.handle(), Command::Sequencer(sequencer_command) => sequencer_command.handle(shutdown).await, Command::Full(full_node_command) => full_node_command.handle(shutdown).await, diff --git a/compose/bootstrap.yml b/compose/bootstrap.yml index 4571113eee..00a0c07d77 100644 --- a/compose/bootstrap.yml +++ b/compose/bootstrap.yml @@ -215,6 +215,37 @@ services: touch /data/node/.bootstrapped + deploy-fee-collector: + image: ${MIDEN_NODE_IMAGE:-miden-node} + pull_policy: missing + volumes: + - node-data:/data + depends_on: + bootstrap-node: + condition: service_completed_successfully + validator-1: + condition: service_started + validator-2: + condition: service_started + validator-3: + condition: service_started + entrypoint: ["/bin/sh", "-c"] + command: + - | + set -e + if [ -f /data/node/.fee-collector-deployed ]; then + exit 0 + fi + if [ ! -f /data/node/fee-collector.mac ]; then + miden-node fee-collector create --data-directory /data/node + fi + miden-node fee-collector deploy \ + --data-directory /data/node \ + --validator.url http://validator-1:50101 \ + --validator.url http://validator-2:50101 \ + --validator.url http://validator-3:50101 + touch /data/node/.fee-collector-deployed + bootstrap-ntx-builder: image: ${MIDEN_NTX_BUILDER_IMAGE:-miden-ntx-builder} pull_policy: missing diff --git a/compose/node.yml b/compose/node.yml index 19fbff121c..f57ccef8dd 100644 --- a/compose/node.yml +++ b/compose/node.yml @@ -5,7 +5,7 @@ services: volumes: - node-data:/data depends_on: - bootstrap-node: + deploy-fee-collector: condition: service_completed_successfully otel-collector: condition: service_started diff --git a/crates/block-producer/Cargo.toml b/crates/block-producer/Cargo.toml index 35d54971d6..7111626d74 100644 --- a/crates/block-producer/Cargo.toml +++ b/crates/block-producer/Cargo.toml @@ -32,6 +32,7 @@ miden-node-tracing = { workspace = true } miden-node-utils = { features = ["testing"], workspace = true } miden-protocol = { default-features = true, workspace = true } miden-standards = { workspace = true } +miden-tx = { workspace = true } miden-tx-batch = { workspace = true } rand = { workspace = true } thiserror = { workspace = true } @@ -47,6 +48,7 @@ miden-node-tracing = { features = ["testing"], workspace = true } miden-node-utils = { features = ["testing"], workspace = true } miden-protocol = { default-features = true, features = ["testing"], workspace = true } miden-standards = { features = ["testing"], workspace = true } +miden-testing = { workspace = true } pretty_assertions = { workspace = true } rand_chacha = { default-features = false, workspace = true } serial_test = { workspace = true } diff --git a/crates/block-producer/src/batch_builder/mod.rs b/crates/block-producer/src/batch_builder/mod.rs index f49e73e69d..61c0395621 100644 --- a/crates/block-producer/src/batch_builder/mod.rs +++ b/crates/block-producer/src/batch_builder/mod.rs @@ -7,7 +7,6 @@ use std::time::Duration; use futures::TryFutureExt; use miden_node_proto::domain::sequencer::AuthenticatedTransaction; use miden_node_store::state::State; -use miden_node_tracing::spawn::spawn_blocking_in_current_span; use miden_node_tracing::{ ErrorSpanExt, Instrument, @@ -21,7 +20,6 @@ use miden_protocol::MIN_PROOF_SECURITY_LEVEL; use miden_protocol::batch::{BatchId, ProposedBatch, ProvenBatch}; use miden_protocol::note::NoteId; use miden_protocol::transaction::TransactionId; -use miden_tx_batch::BatchExecutor; use tokio::task::{JoinError, JoinSet}; use tokio::time::{Instant, MissedTickBehavior}; use url::Url; @@ -32,7 +30,7 @@ use crate::mempool::SharedMempool; use crate::{COMPONENT, LOG_TARGET}; mod remote_prover; -use remote_prover::BatchProver; +pub(crate) use remote_prover::BatchProver; pub use remote_prover::RemoteProverError; // BATCH BUILDER @@ -274,8 +272,8 @@ impl BatchJob { batch.output_note.count = telemetry.output_notes_count ); }) - .and_then(|proposed| self.prove_batch(proposed)) - .and_then(|proven_batch| async { self.commit_batch(proven_batch) }) + .and_then(|proposed| self.batch_prover.prove(proposed)) + .and_then(|proven_batch| async { self.commit_batch(Arc::new(proven_batch)) }) // Handle errors by propagating the error to the root span and rolling back the batch. .inspect_err(|err| Span::current().set_error(err)) .instrument(Span::current()) @@ -353,45 +351,6 @@ impl BatchJob { .map_err(BuildBatchError::ProposeBatchError) } - #[miden_instrument( - target = COMPONENT, - name = "batch_builder.prove_batch", - err, - )] - async fn prove_batch( - &self, - proposed_batch: ProposedBatch, - ) -> Result, BuildBatchError> { - miden_span_record!(prover.kind = self.batch_prover.kind()); - - let proven_batch = match &self.batch_prover { - BatchProver::Remote(prover) => prover - .prove(proposed_batch) - .await - .map_err(BuildBatchError::RemoteProverClientError), - BatchProver::Local(prover) => { - let prover = prover.clone(); - spawn_blocking_in_current_span(move || { - let executed_batch = BatchExecutor::new() - .execute(proposed_batch) - .map_err(BuildBatchError::ProveBatchError)?; - prover.prove(executed_batch).map_err(BuildBatchError::ProveBatchError) - }) - .await - .map_err(BuildBatchError::JoinError)? - }, - }?; - - if proven_batch.proof_security_level() < MIN_PROOF_SECURITY_LEVEL { - Err(BuildBatchError::SecurityLevelTooLow( - proven_batch.proof_security_level(), - MIN_PROOF_SECURITY_LEVEL, - )) - } else { - Ok(Arc::new(proven_batch)) - } - } - #[miden_instrument( target = COMPONENT, name = "batch_builder.commit_batch", diff --git a/crates/block-producer/src/batch_builder/remote_prover.rs b/crates/block-producer/src/batch_builder/remote_prover.rs index 8148fd3b37..9f128d9aec 100644 --- a/crates/block-producer/src/batch_builder/remote_prover.rs +++ b/crates/block-producer/src/batch_builder/remote_prover.rs @@ -2,10 +2,16 @@ use miden_node_proto::DecodeMessageExt; use miden_node_proto::clients::{Builder, RemoteProverClient}; use miden_node_proto::generated::remote_prover::ProofRequest; use miden_node_proto::generated::remote_prover::proof_request::Request; +use miden_node_tracing::spawn::spawn_blocking_in_current_span; +use miden_node_tracing::{miden_instrument, miden_span_record}; +use miden_protocol::MIN_PROOF_SECURITY_LEVEL; use miden_protocol::batch::{ProposedBatch, ProvenBatch}; -use miden_tx_batch::LocalBatchProver; +use miden_tx_batch::{BatchExecutor, LocalBatchProver}; use url::Url; +use crate::COMPONENT; +use crate::errors::BuildBatchError; + /// Errors returned by [`RemoteBatchProver`]. #[derive(Debug, thiserror::Error)] pub enum RemoteProverError { @@ -20,12 +26,45 @@ pub enum RemoteProverError { /// Represents a batch prover which can be either local or remote. #[derive(Clone)] -pub(super) enum BatchProver { +pub(crate) enum BatchProver { Local(LocalBatchProver), Remote(Box), } impl BatchProver { + #[miden_instrument(target = COMPONENT, name = "batch_builder.prove_batch", err)] + pub(crate) async fn prove( + &self, + proposed_batch: ProposedBatch, + ) -> Result { + miden_span_record!(prover.kind = self.kind()); + let proven_batch = match self { + Self::Remote(prover) => prover + .prove(proposed_batch) + .await + .map_err(BuildBatchError::RemoteProverClientError), + Self::Local(prover) => { + let prover = prover.clone(); + spawn_blocking_in_current_span(move || { + let executed_batch = BatchExecutor::new() + .execute(proposed_batch) + .map_err(BuildBatchError::ProveBatchError)?; + prover.prove(executed_batch).map_err(BuildBatchError::ProveBatchError) + }) + .await + .map_err(BuildBatchError::JoinError)? + }, + }?; + if proven_batch.proof_security_level() < MIN_PROOF_SECURITY_LEVEL { + Err(BuildBatchError::SecurityLevelTooLow( + proven_batch.proof_security_level(), + MIN_PROOF_SECURITY_LEVEL, + )) + } else { + Ok(proven_batch) + } + } + pub(super) const fn kind(&self) -> &'static str { match self { BatchProver::Local(_) => "local", @@ -33,7 +72,7 @@ impl BatchProver { } } - pub(super) fn local() -> Self { + pub(crate) fn local() -> Self { Self::Local(LocalBatchProver::default()) } @@ -50,7 +89,7 @@ impl BatchProver { /// The connection is lazy: the underlying channel connects on first use and is shared (cheaply /// cloned) across all subsequent calls. #[derive(Clone)] -pub(super) struct RemoteBatchProver { +pub(crate) struct RemoteBatchProver { client: RemoteProverClient, } diff --git a/crates/block-producer/src/block_builder/mod.rs b/crates/block-producer/src/block_builder/mod.rs index 9c05b5d5de..08aa1f8926 100644 --- a/crates/block-producer/src/block_builder/mod.rs +++ b/crates/block-producer/src/block_builder/mod.rs @@ -102,17 +102,7 @@ impl BlockBuilder { } } - /// Run the block building stages and add open-telemetry trace information where applicable. - /// - /// A failure in any stage will result in that block being rolled back. - /// - /// ## Telemetry - /// - /// - Creates a new root span which means each block gets its own complete trace. - /// - Important telemetry fields are added to the root span with the `block.xxx` prefix. - /// - Each stage has its own child span and are free to add further field data. - /// - A failed stage will emit an error event, and both its own span and the root span will be - /// marked as errors. + /// Selects and builds a block from the mempool. Rolls back the selection on failure. #[miden_instrument( parent = None, target = COMPONENT, @@ -122,46 +112,11 @@ impl BlockBuilder { use futures::TryFutureExt; let selected = Self::select_block(mempool)?; - let telemetry = selected.telemetry(); - miden_span_record!( - block.number = telemetry.block_number, - block.batch.count = telemetry.batches_count, - block.batch.ids = telemetry.batch_ids, - block.transaction.ids = telemetry.transaction_ids, - block.transaction.count = telemetry.transactions_count - ); let block_num = selected.block_number; - - // The stages run inside one async block so that its borrows are sequential: the combinator - // chain's shared borrows of `self` end at its `.await`, after which `commit_block` may take - // `&mut self` (the block-write capability). The `?` exits only this block, so the error - // handling below still sees failures from every stage. async { - let block_commit = self - .get_block_inputs(selected) - .inspect_ok(|inputs| { - let telemetry = inputs.telemetry(); - miden_span_record!( - block.updated_account.count = telemetry.updated_accounts_count, - block.erased_note_proof.count = telemetry.erased_note_proofs_count - ); - }) - .and_then(|inputs| self.propose_block(inputs)) - .inspect_ok(|proposed_block| { - let telemetry = proposed_block_telemetry(&proposed_block.proposed_block); - miden_span_record!( - block.nullifier.count = telemetry.nullifiers_count, - block.output_note.count = telemetry.output_notes_count, - block.batch.output_note.count = telemetry.batch_output_notes_count, - block.erased_note.count = telemetry.erased_notes_count - ); - }) - .and_then(|proposed_block| self.build_and_validate_block(proposed_block)) - .await?; - - self.commit_block(mempool, block_commit).await + let block = Self::prepare_block(&self.state, &self.validator, selected).await?; + self.commit_block(mempool, block).await } - // Handle errors by propagating the error to the root span and rolling back the block. .inspect_err(|err| Span::current().set_error(err)) .or_else(|err| async { Self::rollback_block(mempool, block_num)?; @@ -170,6 +125,47 @@ impl BlockBuilder { .await } + /// Builds a block from the selected batches and obtains validator signatures. + #[miden_instrument(target = COMPONENT, name = "block_builder.prepare_block", err)] + pub(crate) async fn prepare_block( + state: &State, + validator: &BlockProducerValidatorClient, + selected: SelectedBlock, + ) -> Result { + use futures::TryFutureExt; + + let telemetry = selected.telemetry(); + miden_span_record!( + block.number = telemetry.block_number, + block.batch.count = telemetry.batches_count, + block.batch.ids = telemetry.batch_ids, + block.transaction.ids = telemetry.transaction_ids, + block.transaction.count = telemetry.transactions_count + ); + Self::get_block_inputs(state, selected) + .inspect_ok(|inputs| { + let telemetry = inputs.telemetry(); + miden_span_record!( + block.updated_account.count = telemetry.updated_accounts_count, + block.erased_note_proof.count = telemetry.erased_note_proofs_count + ); + }) + .and_then(Self::propose_block) + .inspect_ok(|proposed_block| { + let telemetry = proposed_block_telemetry(&proposed_block.proposed_block); + miden_span_record!( + block.nullifier.count = telemetry.nullifiers_count, + block.output_note.count = telemetry.output_notes_count, + block.batch.output_note.count = telemetry.batch_output_notes_count, + block.erased_note.count = telemetry.erased_notes_count + ); + }) + .and_then(|proposed_block| { + Self::build_and_validate_block(state, validator, proposed_block) + }) + .await + } + #[miden_instrument( target = COMPONENT, name = "block_builder.select_block", @@ -200,7 +196,7 @@ impl BlockBuilder { err, )] async fn get_block_inputs( - &self, + state: &State, selected_block: SelectedBlock, ) -> Result { let SelectedBlock { block_number, batches } = selected_block; @@ -231,7 +227,7 @@ impl BlockBuilder { let created_nullifiers = created_nullifiers_iter.collect::>(); let mut block_numbers: BTreeSet<_> = block_references_iter.collect(); let note_ids = unauthenticated_notes_iter.collect(); - let view = self.state.view(); + let view = state.view(); let reference_block = *view.tip(); // The reference block must be the chain tip. Its account and nullifier roots must match the @@ -289,7 +285,6 @@ impl BlockBuilder { err, )] async fn propose_block( - &self, batches_inputs: BlockBatchesAndInputs, ) -> Result { let BlockBatchesAndInputs { batches, inputs } = batches_inputs; @@ -308,7 +303,8 @@ impl BlockBuilder { err, )] async fn build_and_validate_block( - &self, + state: &State, + validator: &BlockProducerValidatorClient, proposal: ProposedBlockAndInputs, ) -> Result { let ProposedBlockAndInputs { proposed_block, block_inputs } = proposal; @@ -324,8 +320,7 @@ impl BlockBuilder { .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 + let protocol_config = state .view() .get_protocol_config(commitment) .await @@ -333,8 +328,7 @@ impl BlockBuilder { .ok_or_else(|| { BuildBlockError::other(format!("protocol config {commitment} is missing")) })?; - let responses = self - .validator + let responses = validator .sign_block(&proposed_block, &block_inputs, &protocol_config) .await .map_err(|err| BuildBlockError::ValidateBlockFailed(err.into()))?; @@ -495,10 +489,10 @@ struct ProposedBlockAndInputs { } /// Data needed to commit a signed block and persist its proving inputs. -struct BlockCommit { - ordered_batches: OrderedBatches, - block_inputs: BlockInputs, - signed_block: SignedBlock, +pub(crate) struct BlockCommit { + pub(crate) ordered_batches: OrderedBatches, + pub(crate) block_inputs: BlockInputs, + pub(crate) signed_block: SignedBlock, } struct BlockInputsTelemetry { diff --git a/crates/block-producer/src/block_prover.rs b/crates/block-producer/src/block_prover.rs index 024e2f07be..08d70c6615 100644 --- a/crates/block-producer/src/block_prover.rs +++ b/crates/block-producer/src/block_prover.rs @@ -43,7 +43,7 @@ pub enum RemoteProverError { /// Block prover which allows for proving via either local or remote backend. /// -/// The local proving variant is intended for development and testing purposes. +/// The local proving variant supports one-time deployments, development, and testing. /// The remote proving variant is intended for production use. pub enum BlockProver { Local(LocalBlockProver), diff --git a/crates/block-producer/src/fee_collector.rs b/crates/block-producer/src/fee_collector.rs new file mode 100644 index 0000000000..b412fcd04f --- /dev/null +++ b/crates/block-producer/src/fee_collector.rs @@ -0,0 +1,141 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Context; +use miden_node_proto::domain::account::AccountRequest; +use miden_node_store::state::{BlockWriter, ProofWriter, State}; +use miden_node_tracing::spawn::spawn_blocking_in_current_span; +use miden_node_tracing::{info, miden_instrument, miden_span_record}; +use miden_protocol::account::{Account, AccountFile}; +use miden_protocol::batch::ProposedBatch; +use miden_protocol::block::BlockNumber; +use miden_protocol::{MIN_PROOF_SECURITY_LEVEL, ONE}; +use url::Url; + +use crate::batch_builder::BatchProver; +use crate::block_builder::{BlockBuilder, SelectedBlock}; +use crate::block_prover::BlockProver; +use crate::validator::BlockProducerValidatorClient; +use crate::{COMPONENT, LOG_TARGET}; + +#[cfg(test)] +mod tests; + +mod transaction; +pub(crate) use transaction::PassThroughTransactionBuilder; + +/// Deploys a new collector in one block and proves the transaction, batch, and block locally. +/// +/// The deployment requires no funds and pays no fee. The sequencer must be stopped. +/// Returns without creating a block if the matching collector is already deployed. +#[miden_instrument(target = COMPONENT, name = "deploy_fee_collector", err)] +pub async fn deploy_fee_collector( + state: &State, + block_writer: &mut BlockWriter, + proof_writer: &mut ProofWriter, + account_file: AccountFile, + validator_urls: Vec, + validator_timeout: Duration, +) -> anyhow::Result<()> { + miden_span_record!(account.id = account_file.account.id()); + anyhow::ensure!( + account_file.account.is_new(), + "fee collector deployment requires a new account", + ); + anyhow::ensure!( + state.proven_tip() == state.committed_tip(), + "sync all committed block proofs before deploying a fee collector", + ); + let mut deployed_account = account_file.account.clone(); + deployed_account.set_nonce(ONE)?; + // Deployment creates no output note, so the recipient is not used. + let builder = PassThroughTransactionBuilder::new(account_file.account.id(), account_file)?; + if collector_is_deployed(state, &deployed_account).await? { + info!(target: LOG_TARGET, "Fee collector is already deployed"); + return Ok(()); + } + let validator = BlockProducerValidatorClient::new(validator_urls, validator_timeout)?; + + let (header, config, blockchain, genesis) = state + .with_view(async |view| { + let tip = *view.tip(); + let (_, header, _) = view.sync_chain_mmr(tip..=tip).await?; + let config = view + .get_protocol_config(header.protocol_config_commitment()) + .await? + .context("protocol configuration is missing")?; + let blockchain = view.get_block_inclusion_proofs(tip, BTreeSet::new()).await?; + let genesis = view + .get_block_header(Some(BlockNumber::GENESIS), false) + .await? + .0 + .context("genesis block header is missing")? + .commitment(); + anyhow::Ok((header, config, blockchain, genesis)) + }) + .await?; + let executed = builder.execute(Vec::new(), header.clone(), config, blockchain.clone()).await?; + let inputs = executed.tx_inputs().clone(); + let transaction = + spawn_blocking_in_current_span(move || PassThroughTransactionBuilder::prove(executed)) + .await??; + miden_span_record!(transaction.id = transaction.id()); + validator + .validate_transaction(&transaction, &inputs, genesis, header.validator_config()) + .await?; + let block_number = header.block_num().child(); + let batch = ProposedBatch::new( + vec![Arc::new(transaction)], + header, + blockchain, + BTreeMap::new(), + MIN_PROOF_SECURITY_LEVEL, + )?; + let proof = BatchProver::local().prove(batch).await?; + let block = BlockBuilder::prepare_block( + state, + &validator, + SelectedBlock { + block_number, + batches: vec![Arc::new(proof)], + }, + ) + .await?; + let proof = BlockProver::local() + .prove( + block.ordered_batches.clone(), + block.block_inputs.clone(), + block.signed_block.header(), + ) + .await?; + block_writer + .apply_block_with_proving_inputs( + block.ordered_batches, + block.block_inputs, + block.signed_block, + ) + .await?; + proof_writer.apply_proof(block_number, proof.to_bytes()).await?; + info!(target: LOG_TARGET, "Deployed batch builder collection account", block.number = block_number); + Ok(()) +} + +async fn collector_is_deployed(state: &State, account: &Account) -> anyhow::Result { + let response = state + .view() + .get_account(AccountRequest { + account_id: account.id(), + block_num: None, + details: None, + }) + .await?; + if response.witness.state_commitment().is_empty() { + return Ok(false); + } + anyhow::ensure!( + response.witness.state_commitment() == account.to_commitment(), + "fee collector account file does not match the deployed account", + ); + Ok(true) +} diff --git a/crates/block-producer/src/fee_collector/tests.rs b/crates/block-producer/src/fee_collector/tests.rs new file mode 100644 index 0000000000..e6e2f67129 --- /dev/null +++ b/crates/block-producer/src/fee_collector/tests.rs @@ -0,0 +1,341 @@ +use std::collections::BTreeSet; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use miden_node_proto::domain::encryption::{ + TransactionEncryptionKeyInfo, + TransactionEncryptionScheme, + transaction_inputs_associated_data, +}; +use miden_node_proto::generated::server::validator_api; +use miden_node_proto::{BuildUnchecked, DecodeMessage, generated as proto}; +use miden_node_store::GenesisState; +use miden_node_store::state::State; +use miden_node_utils::clap::StorageOptions; +use miden_node_utils::fee::test_protocol_config; +use miden_node_utils::shutdown::CancellationToken; +use miden_protocol::Word; +use miden_protocol::block::{FeeParameters, ValidatorConfig}; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; +use miden_protocol::crypto::dsa::eddsa_25519_sha512::KeyExchangeKey; +use miden_protocol::crypto::ies::{SealedMessage, UnsealingKey}; +use miden_protocol::transaction::{TransactionId, TransactionInputs, TransactionVerifier}; +use miden_protocol::utils::serde::{Deserializable, Serializable}; +use tokio::net::TcpListener; +use tokio_stream::wrappers::TcpListenerStream; +use tonic::codegen::http::Extensions; +use tonic::metadata::MetadataMap; + +use super::*; +use crate::test_utils::mock_collection_account; + +#[tokio::test(flavor = "multi_thread")] +async fn collector_deployment_proves_the_block_and_supports_a_new_collector() { + let directory = tempfile::tempdir().unwrap(); + let signer = SigningKey::new(); + let genesis = GenesisState::new( + vec![], + FeeParameters::new(1), + 1, + ValidatorConfig::new(vec![signer.public_key()], 1).unwrap(), + test_protocol_config(), + ) + .into_block() + .unwrap(); + let validator = Validator { + signer, + genesis: genesis.inner().header().commitment(), + encryption_secret: KeyExchangeKey::read_from_bytes(&[7; 32]).unwrap(), + transactions: Arc::new(Mutex::new(BTreeSet::new())), + reject_transaction: Arc::new(AtomicBool::new(true)), + }; + State::bootstrap(genesis, directory.path()).unwrap(); + let shutdown = CancellationToken::new(); + let (state, mut writer, mut proof_writer, writer_task) = + State::load(directory.path(), StorageOptions::default()) + .await + .unwrap() + .start(shutdown.clone()); + let (url, server) = validator.clone().serve(shutdown.clone()).await; + let validator_urls = vec![url]; + let account_file = mock_collection_account(); + let mut account = account_file.clone(); + account.account.set_nonce(ONE).unwrap(); + assert!(!collector_is_deployed(&state, &account.account).await.unwrap()); + + assert!( + Box::pin(deploy_fee_collector( + &state, + &mut writer, + &mut proof_writer, + account_file.clone(), + validator_urls.clone(), + Duration::from_secs(30) + )) + .await + .is_err() + ); + assert_eq!(state.committed_tip(), BlockNumber::GENESIS); + assert_eq!(state.proven_tip(), BlockNumber::GENESIS); + + validator.reject_transaction.store(false, Ordering::SeqCst); + Box::pin(deploy_fee_collector( + &state, + &mut writer, + &mut proof_writer, + account_file.clone(), + validator_urls.clone(), + Duration::from_secs(30), + )) + .await + .unwrap(); + assert_eq!(state.committed_tip(), BlockNumber::GENESIS.child()); + assert_eq!(state.proven_tip(), state.committed_tip()); + assert!(state.load_proof(state.proven_tip()).await.unwrap().is_some()); + assert!(collector_is_deployed(&state, &account.account).await.unwrap()); + assert_eq!(account.account.nonce(), ONE); + assert!(account.account.vault().is_empty()); + assert_eq!(validator.transactions.lock().unwrap().len(), 1); + validator.reject_transaction.store(true, Ordering::SeqCst); + Box::pin(deploy_fee_collector( + &state, + &mut writer, + &mut proof_writer, + account_file, + validator_urls.clone(), + Duration::from_secs(30), + )) + .await + .unwrap(); + assert_eq!(state.committed_tip(), BlockNumber::GENESIS.child()); + assert_eq!(state.proven_tip(), state.committed_tip()); + assert_eq!(validator.transactions.lock().unwrap().len(), 1); + validator.reject_transaction.store(false, Ordering::SeqCst); + let mut replacement = mock_collection_account(); + assert_ne!(replacement.account.id(), account.account.id()); + Box::pin(deploy_fee_collector( + &state, + &mut writer, + &mut proof_writer, + replacement.clone(), + validator_urls, + Duration::from_secs(30), + )) + .await + .unwrap(); + assert_eq!(state.committed_tip(), BlockNumber::GENESIS.child().child()); + assert_eq!(state.proven_tip(), state.committed_tip()); + assert!(state.load_proof(state.proven_tip()).await.unwrap().is_some()); + replacement.account.set_nonce(ONE).unwrap(); + assert!(collector_is_deployed(&state, &replacement.account).await.unwrap()); + assert!(collector_is_deployed(&state, &account.account).await.unwrap()); + assert_eq!(validator.transactions.lock().unwrap().len(), 2); + + shutdown.cancel(); + server.await.unwrap(); + writer.stop(writer_task).await; +} + +/// Signs blocks only after it accepts their transactions and decrypts their execution inputs. +#[derive(Clone)] +struct Validator { + signer: SigningKey, + genesis: Word, + encryption_secret: KeyExchangeKey, + transactions: Arc>>, + reject_transaction: Arc, +} + +impl Validator { + async fn serve(self, shutdown: CancellationToken) -> (Url, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(validator_api::service(self)) + .serve_with_incoming_shutdown( + TcpListenerStream::new(listener), + shutdown.cancelled_owned(), + ) + .await + .unwrap(); + }); + (format!("http://{address}").parse().unwrap(), server) + } +} + +#[tonic::async_trait] +impl validator_api::GetTransactionEncryptionKey for Validator { + type Input = (); + type Output = proto::submission::TransactionEncryptionKey; + + fn decode(input: ()) -> tonic::Result { + Ok(input) + } + + fn encode(output: Self::Output) -> tonic::Result { + Ok(output) + } + + async fn handle( + &self, + (): Self::Input, + _metadata: &MetadataMap, + _extensions: &Extensions, + ) -> tonic::Result { + let mut key = proto::submission::TransactionEncryptionKey { + scheme: TransactionEncryptionScheme::X25519XChaCha20Poly1305.as_i32(), + key_id: vec![1], + public_key: self.encryption_secret.public_key().to_bytes(), + attestations: vec![], + next_key: None, + }; + let info = TransactionEncryptionKeyInfo { + scheme: TransactionEncryptionScheme::X25519XChaCha20Poly1305, + key_id: key.key_id.clone(), + public_key: key.public_key.clone(), + next_key: None, + }; + key.attestations.push(proto::submission::ValidatorKeyAttestation { + validator_public_key: Some(self.signer.public_key().into()), + signature: Some(self.signer.sign(info.attestation_commitment(self.genesis)).into()), + }); + Ok(key) + } +} + +#[tonic::async_trait] +impl validator_api::SubmitProvenTransaction for Validator { + type Input = proto::submission::ProvenTransactionSubmission; + type Output = (); + + fn decode(input: Self::Input) -> tonic::Result { + Ok(input) + } + + fn encode(output: Self::Output) -> tonic::Result { + Ok(output) + } + + async fn handle( + &self, + input: Self::Input, + _metadata: &MetadataMap, + _extensions: &Extensions, + ) -> tonic::Result { + if self.reject_transaction.load(Ordering::SeqCst) { + return Err(tonic::Status::invalid_argument("transaction rejected")); + } + let submission = input.decode_fields().unwrap().build_unchecked().unwrap(); + let transaction = submission.transaction; + let sealed = submission.sealed_transaction_inputs; + let associated_data = transaction_inputs_associated_data( + TransactionEncryptionScheme::X25519XChaCha20Poly1305.as_u32(), + &sealed.key_id, + self.genesis, + transaction.id(), + ); + let plaintext = UnsealingKey::X25519XChaCha20Poly1305(self.encryption_secret.clone()) + .unseal_bytes_with_associated_data( + SealedMessage::read_from_bytes(&sealed.ciphertext).unwrap(), + &associated_data, + ) + .unwrap(); + let inputs = TransactionInputs::read_from_bytes(&plaintext).unwrap(); + assert!(inputs.account().is_new()); + assert_eq!(inputs.account().id(), transaction.account_id()); + assert!(transaction.input_notes().is_empty()); + assert!(transaction.output_notes().is_empty()); + let outcome = + TransactionVerifier::new(MIN_PROOF_SECURITY_LEVEL).verify(&transaction).unwrap(); + assert!(outcome.is_complete()); + self.transactions.lock().unwrap().insert(transaction.id()); + Ok(()) + } +} + +#[tonic::async_trait] +impl validator_api::SignBlock for Validator { + type Input = proto::validator::SignBlockRequest; + type Output = proto::validator::SignBlockResponse; + + fn decode(input: Self::Input) -> tonic::Result { + Ok(input) + } + + fn encode(output: Self::Output) -> tonic::Result { + Ok(output) + } + + async fn handle( + &self, + input: Self::Input, + _metadata: &MetadataMap, + _extensions: &Extensions, + ) -> tonic::Result { + let proposal = input.decode_fields().unwrap().build_unchecked().unwrap(); + let transactions = self.transactions.lock().unwrap(); + let txs = proposal + .tx_batches + .as_slice() + .iter() + .flat_map(|batch| batch.transactions().as_slice()); + for tx in txs { + assert!(transactions.contains(&tx.id()), "block contains an unvalidated transaction"); + } + let commitment = proposal.block_header.commitment(); + Ok(proto::validator::SignBlockResponse { + signature: Some(self.signer.sign(commitment).into()), + block_commitment: Some(commitment.into()), + public_key: Some(self.signer.public_key().into()), + }) + } +} + +#[tonic::async_trait] +impl validator_api::Status for Validator { + type Input = (); + type Output = proto::validator::ValidatorStatus; + + fn decode(input: ()) -> tonic::Result { + Ok(input) + } + + fn encode(output: Self::Output) -> tonic::Result { + Ok(output) + } + + async fn handle( + &self, + (): Self::Input, + _metadata: &MetadataMap, + _extensions: &Extensions, + ) -> tonic::Result { + Err(tonic::Status::unimplemented("unused")) + } +} + +#[tonic::async_trait] +impl validator_api::BlockSubscription for Validator { + type Input = proto::validator::BlockSubscriptionRequest; + type Item = proto::validator::BlockSubscriptionResponse; + type ItemStream = tokio_stream::Empty>; + + fn decode(input: Self::Input) -> tonic::Result { + Ok(input) + } + + fn encode(output: Self::Item) -> tonic::Result { + Ok(output) + } + + async fn handle( + &self, + _input: Self::Input, + _metadata: &MetadataMap, + _extensions: &Extensions, + ) -> tonic::Result { + Err(tonic::Status::unimplemented("unused")) + } +} diff --git a/crates/block-producer/src/fee_collector/transaction.rs b/crates/block-producer/src/fee_collector/transaction.rs new file mode 100644 index 0000000000..0e5b069f60 --- /dev/null +++ b/crates/block-producer/src/fee_collector/transaction.rs @@ -0,0 +1,355 @@ +use std::collections::BTreeSet; +use std::num::NonZeroU16; + +use miden_protocol::Word; +use miden_protocol::account::{ + Account, + AccountFile, + AccountId, + PartialAccount, + StorageMapKey, + StorageMapWitness, +}; +use miden_protocol::asset::{Asset, AssetId, AssetWitness}; +use miden_protocol::block::{BlockHeader, BlockNumber}; +use miden_protocol::note::{Note, NoteAssets, NoteScript, NoteScriptRoot, NoteType}; +use miden_protocol::protocol_config::ProtocolConfig; +use miden_protocol::transaction::{ + AccountInputs, + ExecutedTransaction, + InputNotes, + PartialBlockchain, + ProvenTransaction, + TransactionArgs, +}; +use miden_protocol::vm::{AdviceMap, FutureMaybeSend}; +use miden_standards::account::auth::AuthTxFeeCollector; +use miden_standards::note::P2idNoteStorage; +use miden_standards::tx_script::ExpirationTransactionScript; +use miden_tx::auth::BasicAuthenticator; +use miden_tx::{ + DataStore, + DataStoreError, + LoadedMastForest, + LocalTransactionProver, + MastForestStore, + TransactionExecutor, + TransactionMastStore, +}; + +/// Builds transactions that deploy the fee collector or convert fee notes into one P2ID note. +#[derive(Clone)] +pub(crate) struct PassThroughTransactionBuilder { + account: Account, + target: AccountId, + authenticator: BasicAuthenticator, +} + +impl PassThroughTransactionBuilder { + pub(crate) fn new(target: AccountId, account_file: AccountFile) -> anyhow::Result { + let AccountFile { account, auth_secret_keys } = account_file; + let auth_root = AuthTxFeeCollector::code() + .procedure_roots() + .next() + .expect("the fee collector exports its authentication procedure"); + anyhow::ensure!( + account.code().procedures().first() == Some(&auth_root), + "pass-through account must use AuthTxFeeCollector", + ); + anyhow::ensure!( + account.vault().is_empty(), + "pass-through account must have an empty vault", + ); + let public_key = account.storage().get_item(AuthTxFeeCollector::public_key_slot())?; + let signature_scheme = + account.storage().get_item(AuthTxFeeCollector::signature_scheme_slot())?; + anyhow::ensure!( + auth_secret_keys.iter().any(|key| { + Word::from(key.public_key().to_commitment()) == public_key + && Word::from([key.auth_scheme().as_u8(), 0, 0, 0]) == signature_scheme + }), + "pass-through account file must contain its signing key", + ); + let authenticator = BasicAuthenticator::new(&auth_secret_keys); + + Ok(Self { account, target, authenticator }) + } + + pub(crate) async fn execute( + &self, + notes: Vec, + reference_block_header: BlockHeader, + protocol_config: ProtocolConfig, + partial_blockchain: PartialBlockchain, + ) -> anyhow::Result { + let asset_ids = notes + .iter() + .flat_map(|note| note.assets().iter()) + .map(Asset::id) + .collect::>(); + anyhow::ensure!( + asset_ids.len() <= NoteAssets::MAX_NUM_ASSETS, + "pass-through transaction names {} assets but at most {} fit into one note", + asset_ids.len(), + NoteAssets::MAX_NUM_ASSETS, + ); + + let notes = InputNotes::from_unauthenticated_notes(notes)?; + let auth_args = AuthTxFeeCollector::auth_args(self.target, NoteType::Public); + let serial_number = AuthTxFeeCollector::derive_serial_number(auth_args, notes.commitment()); + let mut tx_args = TransactionArgs::new(AdviceMap::default()).with_auth_args(auth_args); + if self.account.is_new() { + let script = ExpirationTransactionScript::new(NonZeroU16::new(30).unwrap()); + tx_args = tx_args.with_tx_script_and_args(script.into(), script.tx_script_args()); + } + let output_note_recipient = P2idNoteStorage::new(self.target).into_recipient(serial_number); + tx_args.extend_advice_map(output_note_recipient.to_advice_map_entries()); + let data_store = PassThroughDataStore::new( + self.account.clone(), + reference_block_header, + protocol_config, + partial_blockchain, + ); + + Ok(TransactionExecutor::new(&data_store) + .with_authenticator(&self.authenticator) + .execute_transaction( + self.account.id(), + data_store.reference_block_header.block_num(), + notes, + tx_args, + ) + .await?) + } + + pub(crate) fn prove(transaction: ExecutedTransaction) -> anyhow::Result { + Ok(LocalTransactionProver::default().prove(transaction)?) + } +} + +struct PassThroughDataStore { + account: Account, + reference_block_header: BlockHeader, + protocol_config: ProtocolConfig, + partial_blockchain: PartialBlockchain, + mast_store: TransactionMastStore, +} + +impl PassThroughDataStore { + fn new( + account: Account, + reference_block_header: BlockHeader, + protocol_config: ProtocolConfig, + partial_blockchain: PartialBlockchain, + ) -> Self { + let mast_store = TransactionMastStore::new(); + mast_store.load_account_code(account.code()); + + Self { + account, + reference_block_header, + protocol_config, + partial_blockchain, + mast_store, + } + } +} + +impl DataStore for PassThroughDataStore { + fn get_transaction_inputs( + &self, + account_id: AccountId, + ref_blocks: BTreeSet, + ) -> impl FutureMaybeSend< + Result<(PartialAccount, BlockHeader, ProtocolConfig, PartialBlockchain), DataStoreError>, + > { + async move { + if account_id != self.account.id() + || !ref_blocks.contains(&self.reference_block_header.block_num()) + { + return Err(DataStoreError::other("invalid pass-through transaction inputs")); + } + + Ok(( + PartialAccount::from(&self.account), + self.reference_block_header.clone(), + self.protocol_config.clone(), + self.partial_blockchain.clone(), + )) + } + } + + fn get_foreign_account_inputs( + &self, + _foreign_account_id: AccountId, + _ref_block: BlockNumber, + ) -> impl FutureMaybeSend> { + async { + Err(DataStoreError::other("pass-through transactions do not use foreign accounts")) + } + } + + fn get_vault_asset_witnesses( + &self, + account_id: AccountId, + vault_root: Word, + asset_ids: BTreeSet, + ) -> impl FutureMaybeSend, DataStoreError>> { + async move { + if account_id != self.account.id() || vault_root != self.account.vault().root() { + return Err(DataStoreError::other("invalid pass-through account vault")); + } + + Ok(asset_ids + .into_iter() + .map(|asset_id| self.account.vault().open(asset_id)) + .collect()) + } + } + + fn get_storage_map_witness( + &self, + _account_id: AccountId, + _map_root: Word, + _map_key: StorageMapKey, + ) -> impl FutureMaybeSend> { + async { Err(DataStoreError::other("pass-through transactions do not use storage maps")) } + } + + fn get_note_script( + &self, + _script_root: NoteScriptRoot, + ) -> impl FutureMaybeSend, DataStoreError>> { + async { Ok(None) } + } +} + +impl MastForestStore for PassThroughDataStore { + fn get(&self, procedure_hash: &Word) -> Option { + self.mast_store.get(procedure_hash) + } +} + +#[cfg(test)] +mod tests { + use miden_protocol::account::auth::AuthSecretKey; + use miden_protocol::asset::FungibleAsset; + use miden_protocol::testing::account_id::{ + ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE, + ACCOUNT_ID_SENDER, + }; + use miden_protocol::transaction::{OutputNote, TransactionVerifier}; + use miden_standards::note::TxFeeNote; + use miden_testing::{Auth, MockChain}; + + use super::*; + use crate::test_utils::mock_collection_account; + + #[tokio::test] + async fn deploys_without_funds_and_collects_fee_notes_without_changing_account_state() + -> anyhow::Result<()> { + let mut chain = MockChain::builder().verification_base_fee(1).build()?; + let target = ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE.try_into()?; + let mut builder = PassThroughTransactionBuilder::new(target, mock_collection_account())?; + assert!(builder.account.is_new()); + assert!(builder.account.vault().is_empty()); + let executed = builder + .execute( + Vec::new(), + chain.latest_block_header(), + chain.protocol_config().clone(), + chain.latest_partial_blockchain(), + ) + .await?; + let deployment = PassThroughTransactionBuilder::prove(executed)?; + let outcome = TransactionVerifier::new(miden_protocol::MIN_PROOF_SECURITY_LEVEL) + .verify(&deployment)?; + assert!(outcome.is_complete()); + assert_eq!(deployment.account_update().initial_state_commitment(), Word::empty()); + assert_eq!(deployment.input_notes().num_notes(), 0); + assert_eq!(deployment.output_notes().num_notes(), 0); + assert_eq!(deployment.expiration_block_num(), chain.latest_block_header().block_num() + 30); + builder.account.set_nonce(miden_protocol::ONE)?; + assert_eq!( + deployment.account_update().final_state_commitment(), + builder.account.to_commitment() + ); + chain.add_pending_proven_transaction(deployment); + chain.prove_next_block()?; + + for amounts in [vec![10, 20], vec![0]] { + let notes = amounts + .iter() + .enumerate() + .map(|(index, amount)| { + TxFeeNote::builder() + .sender(ACCOUNT_ID_SENDER.try_into().unwrap()) + .serial_number(Word::from([u32::try_from(index).unwrap(), 2, 3, 4])) + .asset(FungibleAsset::mock(*amount)) + .build() + .map(Note::from) + }) + .collect::, _>>()?; + let serial_number = AuthTxFeeCollector::derive_serial_number( + AuthTxFeeCollector::auth_args(target, NoteType::Public), + InputNotes::from_unauthenticated_notes(notes.clone())?.commitment(), + ); + let executed = builder + .execute( + notes, + chain.latest_block_header(), + chain.protocol_config().clone(), + chain.latest_partial_blockchain(), + ) + .await?; + let transaction = PassThroughTransactionBuilder::prove(executed)?; + let outcome = TransactionVerifier::new(miden_protocol::MIN_PROOF_SECURITY_LEVEL) + .verify(&transaction)?; + assert!(outcome.is_complete()); + + assert_eq!(transaction.account_id(), builder.account.id()); + assert_eq!( + transaction.account_update().initial_state_commitment(), + transaction.account_update().final_state_commitment(), + ); + assert_eq!(usize::from(transaction.input_notes().num_notes()), amounts.len()); + assert_eq!(transaction.output_notes().num_notes(), 1); + + let OutputNote::Public(output_note) = transaction.output_notes().get_note(0) else { + panic!("the batch builder output note must be public"); + }; + let expected_recipient = P2idNoteStorage::new(target).into_recipient(serial_number); + assert_eq!(output_note.recipient().digest(), expected_recipient.digest()); + assert_eq!( + output_note.assets().iter().copied().collect::>(), + vec![FungibleAsset::mock(amounts.iter().sum())], + ); + } + + Ok(()) + } + + #[test] + fn rejects_missing_or_mismatched_signing_keys() -> anyhow::Result<()> { + let account = mock_collection_account().account; + let target = ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE.try_into()?; + for keys in [vec![], vec![AuthSecretKey::new_falcon512_poseidon2()]] { + let result = + PassThroughTransactionBuilder::new(target, AccountFile::new(account.clone(), keys)); + let error = result.err().expect("the collector must require its own signing key"); + assert!(error.to_string().contains("signing key")); + } + + Ok(()) + } + + #[test] + fn rejects_an_ordinary_wallet_as_the_collector() -> anyhow::Result<()> { + let account = MockChain::builder().add_existing_wallet(Auth::basic_ecdsa())?; + let target = ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE.try_into()?; + let result = PassThroughTransactionBuilder::new(target, AccountFile::new(account, vec![])); + let error = result.err().expect("an ordinary wallet must not collect batch fees"); + assert!(error.to_string().contains("AuthTxFeeCollector")); + Ok(()) + } +} diff --git a/crates/block-producer/src/lib.rs b/crates/block-producer/src/lib.rs index 0392980f1c..83dad9fdff 100644 --- a/crates/block-producer/src/lib.rs +++ b/crates/block-producer/src/lib.rs @@ -12,6 +12,7 @@ mod batch_builder; mod block_builder; mod block_prover; mod domain; +mod fee_collector; mod mempool; mod proof_scheduler; mod rpc_sync; @@ -26,6 +27,7 @@ mod errors; pub mod server; pub use domain::transaction::ensure_transaction_has_fee; pub use errors::MempoolSubmissionError; +pub use fee_collector::deploy_fee_collector; pub use proof_scheduler::DEFAULT_MAX_CONCURRENT_PROOFS; pub use rpc_sync::{RpcReadiness, RpcSync}; pub use server::{ diff --git a/crates/block-producer/src/test_utils/account.rs b/crates/block-producer/src/test_utils/account.rs index fe13de01b4..1871a049eb 100644 --- a/crates/block-producer/src/test_utils/account.rs +++ b/crates/block-producer/src/test_utils/account.rs @@ -1,8 +1,29 @@ use std::collections::HashMap; use std::sync::LazyLock; -use miden_protocol::account::{AccountId, AccountIdVersion, AccountType, AssetCallbackFlag}; +use miden_protocol::account::auth::AuthSecretKey; +use miden_protocol::account::{ + AccountBuilder, + AccountFile, + AccountId, + AccountIdVersion, + AccountType, + AssetCallbackFlag, +}; use miden_protocol::{Hasher, Word}; +use miden_standards::account::auth::AuthTxFeeCollector; +use miden_standards::account::wallets::BasicWallet; + +pub fn mock_collection_account() -> AccountFile { + let key = AuthSecretKey::new_falcon512_poseidon2(); + let account = AccountBuilder::new(rand::random()) + .account_type(AccountType::Public) + .with_component(AuthTxFeeCollector::from_public_key(key.public_key())) + .with_component(BasicWallet) + .build() + .unwrap(); + AccountFile::new(account, vec![key]) +} pub static MOCK_ACCOUNTS: LazyLock>> = LazyLock::new(Default::default); diff --git a/crates/block-producer/src/test_utils/mod.rs b/crates/block-producer/src/test_utils/mod.rs index 62dd1b15ee..ef9a80922b 100644 --- a/crates/block-producer/src/test_utils/mod.rs +++ b/crates/block-producer/src/test_utils/mod.rs @@ -14,7 +14,7 @@ pub use authenticated_tx::MockAuthenticatedTxBuilder; mod account; -pub use account::{MockPrivateAccount, mock_account_id}; +pub use account::{MockPrivateAccount, mock_account_id, mock_collection_account}; pub mod batch; diff --git a/crates/block-producer/src/validator/mod.rs b/crates/block-producer/src/validator/mod.rs index db3520607c..dd621c9217 100644 --- a/crates/block-producer/src/validator/mod.rs +++ b/crates/block-producer/src/validator/mod.rs @@ -1,12 +1,21 @@ use std::time::Duration; +use anyhow::Context; use miden_node_proto::clients::{Builder, ValidatorClient}; +use miden_node_proto::domain::encryption::{ + TransactionInputsSealer, + TrustedTransactionEncryptionState, +}; use miden_node_proto::domain::validator::SignBlockResponse; use miden_node_proto::errors::ConversionError; -use miden_node_proto::{DecodeMessageExt, generated as proto}; +use miden_node_proto::{DecodeMessageExt, VerifyWith, generated as proto}; use miden_node_tracing::{info, miden_instrument}; -use miden_protocol::block::{BlockInputs, ProposedBlock}; +use miden_node_utils::retry::{self, Retryable}; +use miden_protocol::Word; +use miden_protocol::block::{BlockInputs, ProposedBlock, ValidatorConfig}; use miden_protocol::protocol_config::ProtocolConfig; +use miden_protocol::transaction::{ProvenTransaction, TransactionInputs}; +use miden_protocol::utils::serde::Serializable; use thiserror::Error; use url::Url; @@ -64,6 +73,49 @@ impl BlockProducerValidatorClient { Ok(Self { clients }) } + /// Validates a transaction with every validator before it can appear in a signed block. + #[miden_instrument(target = COMPONENT, name = "validator.client.validate_transaction", err)] + pub(crate) async fn validate_transaction( + &self, + transaction: &ProvenTransaction, + inputs: &TransactionInputs, + genesis: Word, + validators: &ValidatorConfig, + ) -> anyhow::Result<()> { + let client = self.clients.first().context("collector deployment requires a validator")?; + let key = (|| async { client.clone().get_transaction_encryption_key(()).await }) + .retry(retry::exponential_bounded( + Duration::from_millis(100), + Duration::from_secs(2), + 10, + )) + .when(|error| error.code() == tonic::Code::Unavailable) + .await? + .into_inner() + .verify_with(TrustedTransactionEncryptionState::new(genesis, validators.keys()))?; + let sealed = + TransactionInputsSealer::new(key).seal(transaction.id(), &inputs.to_bytes())?; + let request = proto::submission::ProvenTransactionSubmission { + transaction: Some(transaction.into()), + sealed_transaction_inputs: Some(sealed), + }; + futures::future::try_join_all(self.clients.iter().map(|client| { + let request = request.clone(); + async move { + (|| async { client.clone().submit_proven_transaction(request.clone()).await }) + .retry(retry::exponential_bounded( + Duration::from_millis(100), + Duration::from_secs(2), + 10, + )) + .when(|error| error.code() == tonic::Code::Unavailable) + .await + } + })) + .await?; + Ok(()) + } + /// Signs the proposed block via every validator concurrently, returning each validator's /// signature, the block commitment it reports having signed (for cross-checking against the /// locally built block), and its public key (so the caller can place the signature at the diff --git a/crates/store/src/data_directory.rs b/crates/store/src/data_directory.rs index 975dbeabb2..b3a8e0bded 100644 --- a/crates/store/src/data_directory.rs +++ b/crates/store/src/data_directory.rs @@ -33,6 +33,10 @@ impl DataDirectory { self.0.join("miden-allowlist.sqlite3") } + pub fn fee_collector_account_path(&self) -> PathBuf { + self.0.join("fee-collector.mac") + } + pub fn display(&self) -> std::path::Display<'_> { self.0.display() } diff --git a/docs/external/src/network-operator/bootstrap-and-genesis.md b/docs/external/src/network-operator/bootstrap-and-genesis.md index e0fd9f715f..6eed58caef 100644 --- a/docs/external/src/network-operator/bootstrap-and-genesis.md +++ b/docs/external/src/network-operator/bootstrap-and-genesis.md @@ -25,6 +25,8 @@ which provides an easy method to obtain this data. This is directly supported by `--network testnet` or `--network devnet`. Bootstrap commands also support passing a file directly to cover custom networks, or if the official URLs are not trusted. +Before starting the sequencer, create and deploy its [fee collector account](./sequencer.md#fee-collection). + ## Bootstrap Flow diff --git a/docs/external/src/network-operator/recovery.md b/docs/external/src/network-operator/recovery.md index f7d6db3cf9..826c53c037 100644 --- a/docs/external/src/network-operator/recovery.md +++ b/docs/external/src/network-operator/recovery.md @@ -49,4 +49,5 @@ be imported or re-proven separately as part of recovery before the node resumes tip, it reports that there is nothing to recover and exits successfully. 4. Commission proofs for the recovered blocks. -5. Restart the node as a sequencer. See [Sequencer](/network-operator/sequencer). +5. Restore or replace the [fee collector account](./sequencer.md#fee-collector-account). +6. Restart the node as a sequencer. See [Sequencer](/network-operator/sequencer). diff --git a/docs/external/src/network-operator/sequencer.md b/docs/external/src/network-operator/sequencer.md index ebc1646d5e..40c15e713b 100644 --- a/docs/external/src/network-operator/sequencer.md +++ b/docs/external/src/network-operator/sequencer.md @@ -8,6 +8,21 @@ sidebar_position: 4 The sequencer is centralized network infrastructure operated by the network operator. It runs `miden-node sequencer`, produces blocks, serves public RPC, and connects to the validator and network transaction builder. +## Fee Collection + +A dedicated immutable fee collector account combines transaction fees into a single P2ID note targeting the batch +builder's wallet account. + +This process will need to change when we support fees paid in non-native tokens. For now, it provides a simple way to +collect fees while avoiding race conditions on the receiving wallet account. + +Use `miden-node fee-collector create` to create the account and `miden-node fee-collector deploy` to deploy it. +Deployment creates a dedicated block and therefore the validators must be running to sign this block. + +The collector account is fairly low-risk. It only needs to exist and is immutable once deployed. Keep the generated +signing key to authorize transactions. A new collector can be trivially created and redeployed so backup isn't a strong +requirement. + ## Start ```bash @@ -102,6 +117,15 @@ committed by the sequencer but not yet replicated to the promoted full node may The validator also retains a copy of the blocks it validated and signed, and can be used to recover missing committed block data when this occurs. See [Recovery](/network-operator/recovery) for the procedure. +### Fee Collector Account + +Copy the existing `fee-collector.mac` file to the replacement node's data directory before starting it as a sequencer. +This file contains the collector's signing key and is not replicated with chain state. The account is already deployed +and does not need to be deployed again. + +If the file is lost, complete chain recovery, then create and deploy a new collector with the same +[Fee Collection](#fee-collection) procedure. Keep the replacement node stopped until deployment completes. + ## Common Configuration | Option | Purpose | diff --git a/scripts/bench-local.sh b/scripts/bench-local.sh index 26dcb170e5..a8d6736407 100755 --- a/scripts/bench-local.sh +++ b/scripts/bench-local.sh @@ -163,6 +163,13 @@ start_bg validator miden-validator start \ --encryption-key.hex "$ENCRYPTION_KEY_HEX" wait_for_port "$VALIDATOR_PORT" validator +say "deploying fee collector" +miden-node fee-collector create --data-directory "$DATA/node" +miden-node fee-collector deploy \ + --data-directory "$DATA/node" \ + --validator.url "http://127.0.0.1:$VALIDATOR_PORT" \ + > "$LOGS/deploy-fee-collector.log" 2>&1 + # The ntx-builder always needs a transaction prover, so start one regardless of # USE_REMOTE_PROVER (which only governs whether create-proofs offloads here too). start_bg remote-prover miden-remote-prover \ diff --git a/scripts/run-node.sh b/scripts/run-node.sh index ae3c8cab73..b4d6293570 100755 --- a/scripts/run-node.sh +++ b/scripts/run-node.sh @@ -229,8 +229,20 @@ echo "Starting validator 2..." "${KMS_START_ARGS_2[@]}" & PIDS+=($!) -# Give the validators a moment to bind before the sequencer starts producing blocks. -sleep 2 +if [[ ! -f "$NODE_DIR/fee-collector.mac" ]]; then + echo "Creating fee collector account..." + "$NODE_BINARY" fee-collector create --data-directory "$NODE_DIR" +fi + +echo "Waiting for validators before deploying the fee collector..." +wait_for_port "$VALIDATOR_1_PORT" +wait_for_port "$VALIDATOR_2_PORT" + +echo "Deploying fee collector..." +"$NODE_BINARY" fee-collector deploy \ + --data-directory "$NODE_DIR" \ + --validator.url "http://127.0.0.1:$VALIDATOR_1_PORT" \ + --validator.url "http://127.0.0.1:$VALIDATOR_2_PORT" echo "Starting sequencer..." OTEL_RESOURCE_ATTRIBUTES="$(node_resource_attributes sequencer)" \ From abea398388dda2c4eef9fa8932427562090d473b Mon Sep 17 00:00:00 2001 From: Mirko von Leipzig <48352201+Mirko-von-Leipzig@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:10:42 +0200 Subject: [PATCH 2/3] Address fee collector deployment review feedback --- bin/node/src/commands/fee_collector.rs | 2 +- crates/block-producer/src/fee_collector.rs | 4 ++-- crates/block-producer/src/fee_collector/tests.rs | 4 ++-- .../block-producer/src/fee_collector/transaction.rs | 12 ++++-------- crates/block-producer/src/test_utils/account.rs | 2 +- 5 files changed, 10 insertions(+), 14 deletions(-) diff --git a/bin/node/src/commands/fee_collector.rs b/bin/node/src/commands/fee_collector.rs index 20bedb1c77..6aded03058 100644 --- a/bin/node/src/commands/fee_collector.rs +++ b/bin/node/src/commands/fee_collector.rs @@ -66,7 +66,7 @@ pub struct CreateCommand { impl CreateCommand { fn handle(self) -> anyhow::Result<()> { let output = DataDirectory::load(self.data_directory)?.fee_collector_account_path(); - let secret_key = AuthSecretKey::new_falcon512_poseidon2(); + let secret_key = AuthSecretKey::new_ecdsa_k256_keccak(); let account = AccountBuilder::new(rand::random()) .account_type(AccountType::Public) .with_component(AuthTxFeeCollector::from_public_key(secret_key.public_key())) diff --git a/crates/block-producer/src/fee_collector.rs b/crates/block-producer/src/fee_collector.rs index b412fcd04f..b679de46e3 100644 --- a/crates/block-producer/src/fee_collector.rs +++ b/crates/block-producer/src/fee_collector.rs @@ -49,12 +49,12 @@ pub async fn deploy_fee_collector( ); let mut deployed_account = account_file.account.clone(); deployed_account.set_nonce(ONE)?; - // Deployment creates no output note, so the recipient is not used. - let builder = PassThroughTransactionBuilder::new(account_file.account.id(), account_file)?; if collector_is_deployed(state, &deployed_account).await? { info!(target: LOG_TARGET, "Fee collector is already deployed"); return Ok(()); } + // Deployment creates no output note, so the recipient is not used. + let builder = PassThroughTransactionBuilder::new(account_file.account.id(), account_file)?; let validator = BlockProducerValidatorClient::new(validator_urls, validator_timeout)?; let (header, config, blockchain, genesis) = state diff --git a/crates/block-producer/src/fee_collector/tests.rs b/crates/block-producer/src/fee_collector/tests.rs index e6e2f67129..894c2fec5c 100644 --- a/crates/block-producer/src/fee_collector/tests.rs +++ b/crates/block-producer/src/fee_collector/tests.rs @@ -247,9 +247,9 @@ impl validator_api::SubmitProvenTransaction for Validator { assert_eq!(inputs.account().id(), transaction.account_id()); assert!(transaction.input_notes().is_empty()); assert!(transaction.output_notes().is_empty()); - let outcome = + // Batch proving settles any remaining precompile work. + let _outcome = TransactionVerifier::new(MIN_PROOF_SECURITY_LEVEL).verify(&transaction).unwrap(); - assert!(outcome.is_complete()); self.transactions.lock().unwrap().insert(transaction.id()); Ok(()) } diff --git a/crates/block-producer/src/fee_collector/transaction.rs b/crates/block-producer/src/fee_collector/transaction.rs index 0e5b069f60..fe65f84f7c 100644 --- a/crates/block-producer/src/fee_collector/transaction.rs +++ b/crates/block-producer/src/fee_collector/transaction.rs @@ -184,9 +184,7 @@ impl DataStore for PassThroughDataStore { _foreign_account_id: AccountId, _ref_block: BlockNumber, ) -> impl FutureMaybeSend> { - async { - Err(DataStoreError::other("pass-through transactions do not use foreign accounts")) - } + async { Err(DataStoreError::other("todo in followup: support native faucet callbacks")) } } fn get_vault_asset_witnesses( @@ -213,7 +211,7 @@ impl DataStore for PassThroughDataStore { _map_root: Word, _map_key: StorageMapKey, ) -> impl FutureMaybeSend> { - async { Err(DataStoreError::other("pass-through transactions do not use storage maps")) } + async { Err(DataStoreError::other("todo in followup: support native faucet callbacks")) } } fn get_note_script( @@ -262,9 +260,8 @@ mod tests { ) .await?; let deployment = PassThroughTransactionBuilder::prove(executed)?; - let outcome = TransactionVerifier::new(miden_protocol::MIN_PROOF_SECURITY_LEVEL) + let _outcome = TransactionVerifier::new(miden_protocol::MIN_PROOF_SECURITY_LEVEL) .verify(&deployment)?; - assert!(outcome.is_complete()); assert_eq!(deployment.account_update().initial_state_commitment(), Word::empty()); assert_eq!(deployment.input_notes().num_notes(), 0); assert_eq!(deployment.output_notes().num_notes(), 0); @@ -303,9 +300,8 @@ mod tests { ) .await?; let transaction = PassThroughTransactionBuilder::prove(executed)?; - let outcome = TransactionVerifier::new(miden_protocol::MIN_PROOF_SECURITY_LEVEL) + let _outcome = TransactionVerifier::new(miden_protocol::MIN_PROOF_SECURITY_LEVEL) .verify(&transaction)?; - assert!(outcome.is_complete()); assert_eq!(transaction.account_id(), builder.account.id()); assert_eq!( diff --git a/crates/block-producer/src/test_utils/account.rs b/crates/block-producer/src/test_utils/account.rs index 1871a049eb..930982a676 100644 --- a/crates/block-producer/src/test_utils/account.rs +++ b/crates/block-producer/src/test_utils/account.rs @@ -15,7 +15,7 @@ use miden_standards::account::auth::AuthTxFeeCollector; use miden_standards::account::wallets::BasicWallet; pub fn mock_collection_account() -> AccountFile { - let key = AuthSecretKey::new_falcon512_poseidon2(); + let key = AuthSecretKey::new_ecdsa_k256_keccak(); let account = AccountBuilder::new(rand::random()) .account_type(AccountType::Public) .with_component(AuthTxFeeCollector::from_public_key(key.public_key())) From 9b9b4d1ca38441d722592593c8190f98bceffcde Mon Sep 17 00:00:00 2001 From: Mirko von Leipzig <48352201+Mirko-von-Leipzig@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:46:33 +0200 Subject: [PATCH 3/3] Name the transaction builder for fee collection --- crates/block-producer/src/fee_collector.rs | 6 +-- .../src/fee_collector/transaction.rs | 40 ++++++++++--------- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/crates/block-producer/src/fee_collector.rs b/crates/block-producer/src/fee_collector.rs index b679de46e3..4d83fb720a 100644 --- a/crates/block-producer/src/fee_collector.rs +++ b/crates/block-producer/src/fee_collector.rs @@ -23,7 +23,7 @@ use crate::{COMPONENT, LOG_TARGET}; mod tests; mod transaction; -pub(crate) use transaction::PassThroughTransactionBuilder; +pub(crate) use transaction::FeeCollectorTransactionBuilder; /// Deploys a new collector in one block and proves the transaction, batch, and block locally. /// @@ -54,7 +54,7 @@ pub async fn deploy_fee_collector( return Ok(()); } // Deployment creates no output note, so the recipient is not used. - let builder = PassThroughTransactionBuilder::new(account_file.account.id(), account_file)?; + let builder = FeeCollectorTransactionBuilder::new(account_file.account.id(), account_file)?; let validator = BlockProducerValidatorClient::new(validator_urls, validator_timeout)?; let (header, config, blockchain, genesis) = state @@ -78,7 +78,7 @@ pub async fn deploy_fee_collector( let executed = builder.execute(Vec::new(), header.clone(), config, blockchain.clone()).await?; let inputs = executed.tx_inputs().clone(); let transaction = - spawn_blocking_in_current_span(move || PassThroughTransactionBuilder::prove(executed)) + spawn_blocking_in_current_span(move || FeeCollectorTransactionBuilder::prove(executed)) .await??; miden_span_record!(transaction.id = transaction.id()); validator diff --git a/crates/block-producer/src/fee_collector/transaction.rs b/crates/block-producer/src/fee_collector/transaction.rs index fe65f84f7c..91454de778 100644 --- a/crates/block-producer/src/fee_collector/transaction.rs +++ b/crates/block-producer/src/fee_collector/transaction.rs @@ -39,13 +39,13 @@ use miden_tx::{ /// Builds transactions that deploy the fee collector or convert fee notes into one P2ID note. #[derive(Clone)] -pub(crate) struct PassThroughTransactionBuilder { +pub(crate) struct FeeCollectorTransactionBuilder { account: Account, target: AccountId, authenticator: BasicAuthenticator, } -impl PassThroughTransactionBuilder { +impl FeeCollectorTransactionBuilder { pub(crate) fn new(target: AccountId, account_file: AccountFile) -> anyhow::Result { let AccountFile { account, auth_secret_keys } = account_file; let auth_root = AuthTxFeeCollector::code() @@ -54,11 +54,11 @@ impl PassThroughTransactionBuilder { .expect("the fee collector exports its authentication procedure"); anyhow::ensure!( account.code().procedures().first() == Some(&auth_root), - "pass-through account must use AuthTxFeeCollector", + "fee collector account must use AuthTxFeeCollector", ); anyhow::ensure!( account.vault().is_empty(), - "pass-through account must have an empty vault", + "fee collector account must have an empty vault", ); let public_key = account.storage().get_item(AuthTxFeeCollector::public_key_slot())?; let signature_scheme = @@ -68,7 +68,7 @@ impl PassThroughTransactionBuilder { Word::from(key.public_key().to_commitment()) == public_key && Word::from([key.auth_scheme().as_u8(), 0, 0, 0]) == signature_scheme }), - "pass-through account file must contain its signing key", + "fee collector account file must contain its signing key", ); let authenticator = BasicAuthenticator::new(&auth_secret_keys); @@ -89,7 +89,7 @@ impl PassThroughTransactionBuilder { .collect::>(); anyhow::ensure!( asset_ids.len() <= NoteAssets::MAX_NUM_ASSETS, - "pass-through transaction names {} assets but at most {} fit into one note", + "fee collector transaction names {} assets but at most {} fit into one note", asset_ids.len(), NoteAssets::MAX_NUM_ASSETS, ); @@ -104,7 +104,7 @@ impl PassThroughTransactionBuilder { } let output_note_recipient = P2idNoteStorage::new(self.target).into_recipient(serial_number); tx_args.extend_advice_map(output_note_recipient.to_advice_map_entries()); - let data_store = PassThroughDataStore::new( + let data_store = FeeCollectorDataStore::new( self.account.clone(), reference_block_header, protocol_config, @@ -127,7 +127,7 @@ impl PassThroughTransactionBuilder { } } -struct PassThroughDataStore { +struct FeeCollectorDataStore { account: Account, reference_block_header: BlockHeader, protocol_config: ProtocolConfig, @@ -135,7 +135,7 @@ struct PassThroughDataStore { mast_store: TransactionMastStore, } -impl PassThroughDataStore { +impl FeeCollectorDataStore { fn new( account: Account, reference_block_header: BlockHeader, @@ -155,7 +155,7 @@ impl PassThroughDataStore { } } -impl DataStore for PassThroughDataStore { +impl DataStore for FeeCollectorDataStore { fn get_transaction_inputs( &self, account_id: AccountId, @@ -167,7 +167,7 @@ impl DataStore for PassThroughDataStore { if account_id != self.account.id() || !ref_blocks.contains(&self.reference_block_header.block_num()) { - return Err(DataStoreError::other("invalid pass-through transaction inputs")); + return Err(DataStoreError::other("invalid fee collector transaction inputs")); } Ok(( @@ -195,7 +195,7 @@ impl DataStore for PassThroughDataStore { ) -> impl FutureMaybeSend, DataStoreError>> { async move { if account_id != self.account.id() || vault_root != self.account.vault().root() { - return Err(DataStoreError::other("invalid pass-through account vault")); + return Err(DataStoreError::other("invalid fee collector account vault")); } Ok(asset_ids @@ -222,7 +222,7 @@ impl DataStore for PassThroughDataStore { } } -impl MastForestStore for PassThroughDataStore { +impl MastForestStore for FeeCollectorDataStore { fn get(&self, procedure_hash: &Word) -> Option { self.mast_store.get(procedure_hash) } @@ -248,7 +248,7 @@ mod tests { -> anyhow::Result<()> { let mut chain = MockChain::builder().verification_base_fee(1).build()?; let target = ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE.try_into()?; - let mut builder = PassThroughTransactionBuilder::new(target, mock_collection_account())?; + let mut builder = FeeCollectorTransactionBuilder::new(target, mock_collection_account())?; assert!(builder.account.is_new()); assert!(builder.account.vault().is_empty()); let executed = builder @@ -259,7 +259,7 @@ mod tests { chain.latest_partial_blockchain(), ) .await?; - let deployment = PassThroughTransactionBuilder::prove(executed)?; + let deployment = FeeCollectorTransactionBuilder::prove(executed)?; let _outcome = TransactionVerifier::new(miden_protocol::MIN_PROOF_SECURITY_LEVEL) .verify(&deployment)?; assert_eq!(deployment.account_update().initial_state_commitment(), Word::empty()); @@ -299,7 +299,7 @@ mod tests { chain.latest_partial_blockchain(), ) .await?; - let transaction = PassThroughTransactionBuilder::prove(executed)?; + let transaction = FeeCollectorTransactionBuilder::prove(executed)?; let _outcome = TransactionVerifier::new(miden_protocol::MIN_PROOF_SECURITY_LEVEL) .verify(&transaction)?; @@ -330,8 +330,10 @@ mod tests { let account = mock_collection_account().account; let target = ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE.try_into()?; for keys in [vec![], vec![AuthSecretKey::new_falcon512_poseidon2()]] { - let result = - PassThroughTransactionBuilder::new(target, AccountFile::new(account.clone(), keys)); + let result = FeeCollectorTransactionBuilder::new( + target, + AccountFile::new(account.clone(), keys), + ); let error = result.err().expect("the collector must require its own signing key"); assert!(error.to_string().contains("signing key")); } @@ -343,7 +345,7 @@ mod tests { fn rejects_an_ordinary_wallet_as_the_collector() -> anyhow::Result<()> { let account = MockChain::builder().add_existing_wallet(Auth::basic_ecdsa())?; let target = ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE.try_into()?; - let result = PassThroughTransactionBuilder::new(target, AccountFile::new(account, vec![])); + let result = FeeCollectorTransactionBuilder::new(target, AccountFile::new(account, vec![])); let error = result.err().expect("an ordinary wallet must not collect batch fees"); assert!(error.to_string().contains("AuthTxFeeCollector")); Ok(())