From e072c061b5afb50f0e57dc9ce1523ba725800fdd Mon Sep 17 00:00:00 2001 From: Mirko von Leipzig <48352201+Mirko-von-Leipzig@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:19:37 +0200 Subject: [PATCH 1/4] feat(genesis): add pass-through account --- Cargo.lock | 1 + crates/store/Cargo.toml | 1 + crates/store/src/genesis/config/errors.rs | 2 + crates/store/src/genesis/config/mod.rs | 10 ++ crates/store/src/genesis/config/tests.rs | 37 +++++- crates/store/src/genesis/mod.rs | 1 + .../store/src/genesis/pass_through/auth.masm | 24 ++++ crates/store/src/genesis/pass_through/mod.rs | 121 ++++++++++++++++++ .../store/src/genesis/pass_through/sweep.masm | 35 +++++ 9 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 crates/store/src/genesis/pass_through/auth.masm create mode 100644 crates/store/src/genesis/pass_through/mod.rs create mode 100644 crates/store/src/genesis/pass_through/sweep.masm diff --git a/Cargo.lock b/Cargo.lock index d8f3506598..60c34c0dcc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4296,6 +4296,7 @@ dependencies = [ "miden-node-utils", "miden-protocol", "miden-standards", + "miden-testing", "pretty_assertions", "rand 0.10.2", "rand_chacha 0.10.0", diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index e6453b7bda..ee86785e3c 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -61,6 +61,7 @@ miden-node-tracing = { features = ["tracing-forest"], 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 } rand = { workspace = true } tempfile = { workspace = true } diff --git a/crates/store/src/genesis/config/errors.rs b/crates/store/src/genesis/config/errors.rs index 37a9e4a7b0..8d85e0d112 100644 --- a/crates/store/src/genesis/config/errors.rs +++ b/crates/store/src/genesis/config/errors.rs @@ -25,6 +25,8 @@ pub enum GenesisConfigError { NativeFaucetNotFungible { path: PathBuf }, #[error("account translation from config to state failed")] Account(#[from] AccountError), + #[error("failed to build the pass-through account")] + PassThroughAccountBuild(#[source] anyhow::Error), #[error("asset translation from config to state failed")] Asset(#[from] AssetError), #[error("adding assets to account failed")] diff --git a/crates/store/src/genesis/config/mod.rs b/crates/store/src/genesis/config/mod.rs index 258cd43dbc..dc6e6c633e 100644 --- a/crates/store/src/genesis/config/mod.rs +++ b/crates/store/src/genesis/config/mod.rs @@ -33,6 +33,7 @@ use rand::{RngExt, SeedableRng}; use rand_chacha::ChaCha20Rng; use serde::{Deserialize, Serialize}; +use crate::genesis::pass_through::build_pass_through_account; use crate::{GenesisState, LOG_TARGET}; mod errors; @@ -53,6 +54,9 @@ pub const NATIVE_FAUCET_FILE_NAME: &str = "native_faucet.mac"; /// Name of the account file written for the generated faucet operator. pub const FAUCET_OPERATOR_FILE_NAME: &str = "faucet_operator.mac"; +/// Name of the account file written for the pass-through account. +pub const PASS_THROUGH_ACCOUNT_FILE_NAME: &str = "pass_through.mac"; + // GENESIS CONFIG // ================================================================================================ @@ -224,6 +228,10 @@ impl GenesisConfig { None => None, }; + let pass_through_account = + build_pass_through_account().map_err(GenesisConfigError::PassThroughAccountBuild)?; + secrets.push((PASS_THROUGH_ACCOUNT_FILE_NAME.to_string(), pass_through_account.id(), None)); + faucet_accounts.insert(symbol.clone(), native_faucet_account); // Setup additional fungible faucets from parameters @@ -356,6 +364,8 @@ impl GenesisConfig { // Ensure the faucets always precede the wallets referencing them all_accounts.extend(wallet_accounts); + all_accounts.push(pass_through_account); + // Append file-loaded accounts as-is all_accounts.extend(file_loaded_accounts); diff --git a/crates/store/src/genesis/config/tests.rs b/crates/store/src/genesis/config/tests.rs index 2abe1a4de1..696a0491d9 100644 --- a/crates/store/src/genesis/config/tests.rs +++ b/crates/store/src/genesis/config/tests.rs @@ -107,9 +107,10 @@ async fn genesis_accounts_have_nonce_one() -> TestResult { let gcfg = GenesisConfig::default(); let (state, secrets) = gcfg.into_state(dev_validator_keys()).unwrap(); - // The default configuration generates the native faucet and its operator. + // The default configuration generates the native faucet, its operator, and the pass-through + // account. let account_files = secrets.as_account_files(&state).collect::, _>>()?; - assert_eq!(account_files.len(), 2); + assert_eq!(account_files.len(), 3); for AccountFileWithName { account_file, name } in account_files { assert_eq!(account_file.account.nonce(), ONE, "{name} should be deployed at genesis"); } @@ -118,6 +119,29 @@ async fn genesis_accounts_have_nonce_one() -> TestResult { Ok(()) } +#[test] +fn pass_through_account_is_part_of_genesis() -> TestResult { + let (state, secrets) = GenesisConfig::default().into_state(dev_validator_keys())?; + let expected = build_pass_through_account()?; + + let account = state + .accounts + .iter() + .find(|account| account.id() == expected.id()) + .expect("the pass-through account should be part of the genesis state"); + assert_eq!(account, &expected); + + let (_, account_id, secret) = secrets + .secrets + .iter() + .find(|(name, ..)| name == PASS_THROUGH_ACCOUNT_FILE_NAME) + .expect("the pass-through account file should be generated"); + assert_eq!(*account_id, account.id()); + assert!(secret.is_none()); + + Ok(()) +} + #[test] fn parsing_account_from_file() -> TestResult { use miden_protocol::account::auth::AuthScheme; @@ -193,7 +217,7 @@ fn generated_native_faucet_is_a_network_account_owned_by_an_operator() -> TestRe .find(|(name, ..)| name == file_name) .unwrap_or_else(|| panic!("{file_name} should be generated")) }; - assert_eq!(secrets.secrets.len(), 2); + assert_eq!(secrets.secrets.len(), 3); let (_, faucet_id, faucet_secret) = find(NATIVE_FAUCET_FILE_NAME); let (_, operator_id, operator_secret) = find(FAUCET_OPERATOR_FILE_NAME); assert_eq!(*faucet_id, native_faucet.id()); @@ -321,8 +345,11 @@ verification_base_fee = 0 let (state, secrets) = gcfg.into_state(dev_validator_keys())?; assert!(state.accounts.iter().any(|a| a.id() == faucet_id)); - // No secrets should be generated for file-loaded native faucet - assert!(secrets.secrets.is_empty()); + // The pass-through account has no key. A file-loaded faucet creates no additional secret. + assert_eq!(secrets.secrets.len(), 1); + let (name, _, secret) = &secrets.secrets[0]; + assert_eq!(name, PASS_THROUGH_ACCOUNT_FILE_NAME); + assert!(secret.is_none()); Ok(()) } diff --git a/crates/store/src/genesis/mod.rs b/crates/store/src/genesis/mod.rs index cfa2700bce..e6de32cd2d 100644 --- a/crates/store/src/genesis/mod.rs +++ b/crates/store/src/genesis/mod.rs @@ -19,6 +19,7 @@ use miden_protocol::note::Nullifier; use miden_protocol::transaction::{OrderedTransactionHeaders, TransactionKernel}; pub mod config; +pub mod pass_through; // GENESIS STATE // ================================================================================================ diff --git a/crates/store/src/genesis/pass_through/auth.masm b/crates/store/src/genesis/pass_through/auth.masm new file mode 100644 index 0000000000..296ed70377 --- /dev/null +++ b/crates/store/src/genesis/pass_through/auth.masm @@ -0,0 +1,24 @@ +# The authentication procedure for the genesis pass-through account. + +use miden::protocol::active_account +use miden::protocol::native_account + +const ERR_PASS_THROUGH_ACCOUNT_STATE_CHANGED = "a pass-through account must not change its state" + +#! Verifies that the account commitment did not change during the transaction. +#! This procedure does not increment the nonce or create a fee note. +#! Anyone can execute a transaction against an account that uses this procedure. +#! The input note scripts must make the transferred assets safe for unrestricted consumption. +#! +#! Inputs: [AUTH_ARGS, pad(12)] +#! Outputs: [pad(16)] +#! +#! Panics if the account commitment changed. +@auth_script +pub proc auth_pass_through + dropw + + exec.native_account::get_initial_commitment + exec.active_account::compute_commitment + assert_eqw.err=ERR_PASS_THROUGH_ACCOUNT_STATE_CHANGED +end diff --git a/crates/store/src/genesis/pass_through/mod.rs b/crates/store/src/genesis/pass_through/mod.rs new file mode 100644 index 0000000000..7285646bb6 --- /dev/null +++ b/crates/store/src/genesis/pass_through/mod.rs @@ -0,0 +1,121 @@ +use anyhow::Context; +use miden_protocol::ONE; +use miden_protocol::account::component::{AccountComponentCode, AccountComponentMetadata}; +use miden_protocol::account::{Account, AccountBuilder, AccountComponent, AccountType}; +use miden_standards::account::wallets::BasicWallet; +use miden_standards::code_builder::CodeBuilder; + +const PASS_THROUGH_ACCOUNT_INIT_SEED: [u8; 32] = *b"miden-pass-through-account-seed!"; + +const PASS_THROUGH_AUTH_COMPONENT_PATH: &str = "miden_node::account::auth::pass_through"; +const PASS_THROUGH_AUTH_COMPONENT_SOURCE: &str = include_str!("auth.masm"); + +/// The module path of the pass-through sweep component. +pub const PASS_THROUGH_SWEEP_COMPONENT_PATH: &str = "miden_node::account::pass_through::sweep"; +const PASS_THROUGH_SWEEP_COMPONENT_SOURCE: &str = include_str!("sweep.masm"); + +/// Builds the public pass-through account that each genesis state contains. +/// +/// The account has a stable ID because it uses a fixed seed. Its authentication procedure rejects +/// every state change. The account has no secret key. +pub fn build_pass_through_account() -> anyhow::Result { + let auth_component = compile_component( + PASS_THROUGH_AUTH_COMPONENT_PATH, + PASS_THROUGH_AUTH_COMPONENT_SOURCE, + "Pass-through authentication component", + )?; + let sweep_component = compile_component( + PASS_THROUGH_SWEEP_COMPONENT_PATH, + PASS_THROUGH_SWEEP_COMPONENT_SOURCE, + "Pass-through asset sweep component", + )?; + + let mut account = AccountBuilder::new(PASS_THROUGH_ACCOUNT_INIT_SEED) + .account_type(AccountType::Public) + .with_component(auth_component) + .with_component(BasicWallet) + .with_component(sweep_component) + .build()?; + + // The genesis block deploys the account without a transaction. + account.set_nonce(ONE)?; + + Ok(account) +} + +/// Compiles the component that moves complete asset balances into an output note. +/// +/// Transaction scripts must link this code dynamically before they call the sweep procedure. +pub fn pass_through_sweep_component_code() -> anyhow::Result { + compile_component_code(PASS_THROUGH_SWEEP_COMPONENT_PATH, PASS_THROUGH_SWEEP_COMPONENT_SOURCE) +} + +fn compile_component( + path: &'static str, + source: &'static str, + description: &'static str, +) -> anyhow::Result { + let code = compile_component_code(path, source)?; + let metadata = AccountComponentMetadata::new(path).with_description(description); + Ok(AccountComponent::new(code, vec![], metadata)?) +} + +fn compile_component_code( + path: &'static str, + source: &'static str, +) -> anyhow::Result { + CodeBuilder::default() + .compile_component_code(path, source) + .with_context(|| format!("failed to compile account component {path}")) +} + +#[cfg(test)] +mod tests { + use miden_protocol::asset::FungibleAsset; + use miden_protocol::note::NoteType; + use miden_protocol::testing::account_id::ACCOUNT_ID_SENDER; + use miden_testing::MockChain; + + use super::*; + + #[test] + fn pass_through_account_is_stable_and_empty() -> anyhow::Result<()> { + let account = build_pass_through_account()?; + let rebuilt = build_pass_through_account()?; + + assert_eq!(account.id(), rebuilt.id()); + assert!(account.is_public()); + assert_eq!(account.nonce(), ONE); + assert!(account.vault().is_empty()); + assert_eq!(account.storage().num_slots(), 0); + + Ok(()) + } + + #[tokio::test] + async fn pass_through_account_rejects_state_changes() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let account = build_pass_through_account()?; + builder.add_account(account.clone())?; + + let note = builder.add_p2id_note( + ACCOUNT_ID_SENDER.try_into()?, + account.id(), + &[FungibleAsset::mock(10)], + NoteType::Public, + )?; + let mock_chain = builder.build()?; + + let result = mock_chain + .build_transaction(account.id()) + .authenticated_input_note(note.id()) + .build()? + .execute() + .await; + + let error = result.expect_err("the pass-through account must reject a state change"); + assert!(error.to_string().contains("pass-through account must not change its state")); + + Ok(()) + } +} diff --git a/crates/store/src/genesis/pass_through/sweep.masm b/crates/store/src/genesis/pass_through/sweep.masm new file mode 100644 index 0000000000..6f3b8f9621 --- /dev/null +++ b/crates/store/src/genesis/pass_through/sweep.masm @@ -0,0 +1,35 @@ +# The asset sweep procedure for the genesis pass-through account. + +use miden::core::word +use miden::protocol::active_account +use miden::protocol::native_account +use miden::protocol::output_note +use {AssetId} from miden::protocol::types + +const ERR_PASS_THROUGH_ACCOUNT_ALREADY_HELD_ASSET = "a pass-through account must not hold the swept asset before the transaction" + +#! Moves the complete balance of one asset into an output note. +#! The account must not hold the asset before the transaction starts. +#! The procedure does nothing when the current balance is zero. +#! +#! Inputs: [ASSET_ID, note_idx, pad(11)] +#! Outputs: [pad(16)] +#! +#! Panics if the account held the asset before the transaction. +@account_procedure +pub proc sweep_asset_to_note(asset_id: AssetId, note_idx: u16) + dupw exec.native_account::get_initial_asset exec.word::eqz + assert.err=ERR_PASS_THROUGH_ACCOUNT_ALREADY_HELD_ASSET + + dupw exec.active_account::get_asset + dupw exec.word::eqz + + if.true + dropw dropw drop + else + swapw + dupw.1 dupw.1 + exec.native_account::remove_asset dropw + exec.output_note::add_asset + end +end From de170144db3911dc0acb1bca1ebc8415f93d4fe0 Mon Sep 17 00:00:00 2001 From: Mirko von Leipzig <48352201+Mirko-von-Leipzig@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:34:55 +0200 Subject: [PATCH 2/4] feat(genesis): use the protocol fee collector account --- Cargo.lock | 1 - crates/store/Cargo.toml | 1 - crates/store/src/genesis/config/mod.rs | 8 +- crates/store/src/genesis/config/tests.rs | 41 +++--- .../store/src/genesis/pass_through/auth.masm | 24 ---- crates/store/src/genesis/pass_through/mod.rs | 128 ++---------------- .../store/src/genesis/pass_through/sweep.masm | 35 ----- .../network-operator/bootstrap-and-genesis.md | 3 + 8 files changed, 46 insertions(+), 195 deletions(-) delete mode 100644 crates/store/src/genesis/pass_through/auth.masm delete mode 100644 crates/store/src/genesis/pass_through/sweep.masm diff --git a/Cargo.lock b/Cargo.lock index 29c1864936..de46c1a555 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4297,7 +4297,6 @@ dependencies = [ "miden-node-utils", "miden-protocol", "miden-standards", - "miden-testing", "pretty_assertions", "rand 0.10.2", "rand_chacha 0.10.0", diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index 49d8e4b878..f3bf4eb3f1 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -62,7 +62,6 @@ miden-node-tracing = { features = ["tracing-forest"], 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 } rand = { workspace = true } tempfile = { workspace = true } diff --git a/crates/store/src/genesis/config/mod.rs b/crates/store/src/genesis/config/mod.rs index cf74d970f5..78d678b6e8 100644 --- a/crates/store/src/genesis/config/mod.rs +++ b/crates/store/src/genesis/config/mod.rs @@ -226,9 +226,13 @@ impl GenesisConfig { None => None, }; - let pass_through_account = + let (pass_through_account, pass_through_secret) = build_pass_through_account().map_err(GenesisConfigError::PassThroughAccountBuild)?; - secrets.push((PASS_THROUGH_ACCOUNT_FILE_NAME.to_string(), pass_through_account.id(), None)); + secrets.push(( + PASS_THROUGH_ACCOUNT_FILE_NAME.to_string(), + pass_through_account.id(), + Some(pass_through_secret), + )); faucet_accounts.insert(symbol.clone(), native_faucet_account); diff --git a/crates/store/src/genesis/config/tests.rs b/crates/store/src/genesis/config/tests.rs index f31cd18b53..b2cf4e7f45 100644 --- a/crates/store/src/genesis/config/tests.rs +++ b/crates/store/src/genesis/config/tests.rs @@ -122,23 +122,28 @@ async fn genesis_accounts_have_nonce_one() -> TestResult { #[test] fn pass_through_account_is_part_of_genesis() -> TestResult { - let (state, secrets) = GenesisConfig::default().into_state(dev_validator_keys())?; - let expected = build_pass_through_account()?; - - let account = state - .accounts - .iter() - .find(|account| account.id() == expected.id()) - .expect("the pass-through account should be part of the genesis state"); - assert_eq!(account, &expected); - - let (_, account_id, secret) = secrets - .secrets - .iter() - .find(|(name, ..)| name == PASS_THROUGH_ACCOUNT_FILE_NAME) + let (state, secrets) = GenesisConfig::default().into_state(dev_validator_config())?; + let exported = secrets + .as_account_files(&state) + .collect::, _>>()? + .into_iter() + .find(|file| file.name == PASS_THROUGH_ACCOUNT_FILE_NAME) .expect("the pass-through account file should be generated"); - assert_eq!(*account_id, account.id()); - assert!(secret.is_none()); + let account = &exported.account_file.account; + assert!(state.accounts.contains(account)); + assert!(account.is_public()); + assert_eq!(account.nonce(), ONE); + assert!(account.vault().is_empty()); + assert_eq!(exported.account_file.auth_secret_keys.len(), 1); + + assert_eq!( + account + .storage() + .get_item(miden_standards::account::auth::AuthTxFeeCollector::public_key_slot(),)?, + miden_protocol::Word::from( + exported.account_file.auth_secret_keys[0].public_key().to_commitment(), + ), + ); Ok(()) } @@ -344,11 +349,11 @@ verification_base_fee = 0 let (state, secrets) = gcfg.into_state(dev_validator_config())?; assert!(state.accounts.iter().any(|a| a.id() == faucet_id)); - // The pass-through account has no key. A file-loaded faucet creates no additional secret. + // A file-loaded faucet creates no additional secret. assert_eq!(secrets.secrets.len(), 1); let (name, _, secret) = &secrets.secrets[0]; assert_eq!(name, PASS_THROUGH_ACCOUNT_FILE_NAME); - assert!(secret.is_none()); + assert!(secret.is_some()); Ok(()) } diff --git a/crates/store/src/genesis/pass_through/auth.masm b/crates/store/src/genesis/pass_through/auth.masm deleted file mode 100644 index 296ed70377..0000000000 --- a/crates/store/src/genesis/pass_through/auth.masm +++ /dev/null @@ -1,24 +0,0 @@ -# The authentication procedure for the genesis pass-through account. - -use miden::protocol::active_account -use miden::protocol::native_account - -const ERR_PASS_THROUGH_ACCOUNT_STATE_CHANGED = "a pass-through account must not change its state" - -#! Verifies that the account commitment did not change during the transaction. -#! This procedure does not increment the nonce or create a fee note. -#! Anyone can execute a transaction against an account that uses this procedure. -#! The input note scripts must make the transferred assets safe for unrestricted consumption. -#! -#! Inputs: [AUTH_ARGS, pad(12)] -#! Outputs: [pad(16)] -#! -#! Panics if the account commitment changed. -@auth_script -pub proc auth_pass_through - dropw - - exec.native_account::get_initial_commitment - exec.active_account::compute_commitment - assert_eqw.err=ERR_PASS_THROUGH_ACCOUNT_STATE_CHANGED -end diff --git a/crates/store/src/genesis/pass_through/mod.rs b/crates/store/src/genesis/pass_through/mod.rs index 7285646bb6..aea7905f20 100644 --- a/crates/store/src/genesis/pass_through/mod.rs +++ b/crates/store/src/genesis/pass_through/mod.rs @@ -1,121 +1,21 @@ -use anyhow::Context; -use miden_protocol::ONE; -use miden_protocol::account::component::{AccountComponentCode, AccountComponentMetadata}; -use miden_protocol::account::{Account, AccountBuilder, AccountComponent, AccountType}; +use miden_protocol::account::{Account, AccountBuilder, AccountType}; +use miden_protocol::crypto::dsa::falcon512_poseidon2::SecretKey; +use miden_standards::account::auth::AuthTxFeeCollector; use miden_standards::account::wallets::BasicWallet; -use miden_standards::code_builder::CodeBuilder; +use rand::{RngExt, SeedableRng}; +use rand_chacha::ChaCha20Rng; -const PASS_THROUGH_ACCOUNT_INIT_SEED: [u8; 32] = *b"miden-pass-through-account-seed!"; - -const PASS_THROUGH_AUTH_COMPONENT_PATH: &str = "miden_node::account::auth::pass_through"; -const PASS_THROUGH_AUTH_COMPONENT_SOURCE: &str = include_str!("auth.masm"); - -/// The module path of the pass-through sweep component. -pub const PASS_THROUGH_SWEEP_COMPONENT_PATH: &str = "miden_node::account::pass_through::sweep"; -const PASS_THROUGH_SWEEP_COMPONENT_SOURCE: &str = include_str!("sweep.masm"); - -/// Builds the public pass-through account that each genesis state contains. +/// Builds a public fee collector and its signing key for genesis. /// -/// The account has a stable ID because it uses a fixed seed. Its authentication procedure rejects -/// every state change. The account has no secret key. -pub fn build_pass_through_account() -> anyhow::Result { - let auth_component = compile_component( - PASS_THROUGH_AUTH_COMPONENT_PATH, - PASS_THROUGH_AUTH_COMPONENT_SOURCE, - "Pass-through authentication component", - )?; - let sweep_component = compile_component( - PASS_THROUGH_SWEEP_COMPONENT_PATH, - PASS_THROUGH_SWEEP_COMPONENT_SOURCE, - "Pass-through asset sweep component", - )?; - - let mut account = AccountBuilder::new(PASS_THROUGH_ACCOUNT_INIT_SEED) +/// The account forwards input-note assets to a P2ID note without changing its state. +pub fn build_pass_through_account() -> anyhow::Result<(Account, SecretKey)> { + let mut rng = ChaCha20Rng::from_seed(rand::random()); + let secret_key = SecretKey::with_rng(&mut rng); + let account = AccountBuilder::new(rng.random()) .account_type(AccountType::Public) - .with_component(auth_component) + .with_component(AuthTxFeeCollector::falcon512_poseidon2(secret_key.public_key())) .with_component(BasicWallet) - .with_component(sweep_component) - .build()?; - - // The genesis block deploys the account without a transaction. - account.set_nonce(ONE)?; - - Ok(account) -} - -/// Compiles the component that moves complete asset balances into an output note. -/// -/// Transaction scripts must link this code dynamically before they call the sweep procedure. -pub fn pass_through_sweep_component_code() -> anyhow::Result { - compile_component_code(PASS_THROUGH_SWEEP_COMPONENT_PATH, PASS_THROUGH_SWEEP_COMPONENT_SOURCE) -} - -fn compile_component( - path: &'static str, - source: &'static str, - description: &'static str, -) -> anyhow::Result { - let code = compile_component_code(path, source)?; - let metadata = AccountComponentMetadata::new(path).with_description(description); - Ok(AccountComponent::new(code, vec![], metadata)?) -} - -fn compile_component_code( - path: &'static str, - source: &'static str, -) -> anyhow::Result { - CodeBuilder::default() - .compile_component_code(path, source) - .with_context(|| format!("failed to compile account component {path}")) -} - -#[cfg(test)] -mod tests { - use miden_protocol::asset::FungibleAsset; - use miden_protocol::note::NoteType; - use miden_protocol::testing::account_id::ACCOUNT_ID_SENDER; - use miden_testing::MockChain; - - use super::*; - - #[test] - fn pass_through_account_is_stable_and_empty() -> anyhow::Result<()> { - let account = build_pass_through_account()?; - let rebuilt = build_pass_through_account()?; - - assert_eq!(account.id(), rebuilt.id()); - assert!(account.is_public()); - assert_eq!(account.nonce(), ONE); - assert!(account.vault().is_empty()); - assert_eq!(account.storage().num_slots(), 0); - - Ok(()) - } - - #[tokio::test] - async fn pass_through_account_rejects_state_changes() -> anyhow::Result<()> { - let mut builder = MockChain::builder(); - let account = build_pass_through_account()?; - builder.add_account(account.clone())?; - - let note = builder.add_p2id_note( - ACCOUNT_ID_SENDER.try_into()?, - account.id(), - &[FungibleAsset::mock(10)], - NoteType::Public, - )?; - let mock_chain = builder.build()?; - - let result = mock_chain - .build_transaction(account.id()) - .authenticated_input_note(note.id()) - .build()? - .execute() - .await; - - let error = result.expect_err("the pass-through account must reject a state change"); - assert!(error.to_string().contains("pass-through account must not change its state")); + .build_existing()?; - Ok(()) - } + Ok((account, secret_key)) } diff --git a/crates/store/src/genesis/pass_through/sweep.masm b/crates/store/src/genesis/pass_through/sweep.masm deleted file mode 100644 index 6f3b8f9621..0000000000 --- a/crates/store/src/genesis/pass_through/sweep.masm +++ /dev/null @@ -1,35 +0,0 @@ -# The asset sweep procedure for the genesis pass-through account. - -use miden::core::word -use miden::protocol::active_account -use miden::protocol::native_account -use miden::protocol::output_note -use {AssetId} from miden::protocol::types - -const ERR_PASS_THROUGH_ACCOUNT_ALREADY_HELD_ASSET = "a pass-through account must not hold the swept asset before the transaction" - -#! Moves the complete balance of one asset into an output note. -#! The account must not hold the asset before the transaction starts. -#! The procedure does nothing when the current balance is zero. -#! -#! Inputs: [ASSET_ID, note_idx, pad(11)] -#! Outputs: [pad(16)] -#! -#! Panics if the account held the asset before the transaction. -@account_procedure -pub proc sweep_asset_to_note(asset_id: AssetId, note_idx: u16) - dupw exec.native_account::get_initial_asset exec.word::eqz - assert.err=ERR_PASS_THROUGH_ACCOUNT_ALREADY_HELD_ASSET - - dupw exec.active_account::get_asset - dupw exec.word::eqz - - if.true - dropw dropw drop - else - swapw - dupw.1 dupw.1 - exec.native_account::remove_asset dropw - exec.output_note::add_asset - end -end diff --git a/docs/external/src/network-operator/bootstrap-and-genesis.md b/docs/external/src/network-operator/bootstrap-and-genesis.md index 1a8b12417c..8419aafcc5 100644 --- a/docs/external/src/network-operator/bootstrap-and-genesis.md +++ b/docs/external/src/network-operator/bootstrap-and-genesis.md @@ -62,6 +62,9 @@ printed. The operator file carries the only signing key permitted to mint, so tr To run a faucet against the network, pass `faucet_operator.mac` to the faucet's `init --import`, and the faucet account id to `--faucet-account-id`. +Genesis also creates `pass_through.mac`. This public account uses `AuthTxFeeCollector` to forward fee-note assets into a +P2ID note without changing its state. Keep the file private because it contains the collector signing key. + Upload `genesis-data/genesis.dat` so it is served at: ```text From 310e86d77715bf143b5270d90ff8f499d4f499bf Mon Sep 17 00:00:00 2001 From: Mirko von Leipzig <48352201+Mirko-von-Leipzig@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:55:06 +0200 Subject: [PATCH 3/4] refactor(genesis): name the batch builder collection account --- crates/store/src/genesis/config/mod.rs | 6 +++--- crates/store/src/genesis/config/tests.rs | 4 ++-- docs/external/src/network-operator/bootstrap-and-genesis.md | 5 +++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/store/src/genesis/config/mod.rs b/crates/store/src/genesis/config/mod.rs index 78d678b6e8..eca187e690 100644 --- a/crates/store/src/genesis/config/mod.rs +++ b/crates/store/src/genesis/config/mod.rs @@ -55,8 +55,8 @@ pub const NATIVE_FAUCET_FILE_NAME: &str = "native_faucet.mac"; /// Name of the account file written for the generated faucet operator. pub const FAUCET_OPERATOR_FILE_NAME: &str = "faucet_operator.mac"; -/// Name of the account file written for the pass-through account. -pub const PASS_THROUGH_ACCOUNT_FILE_NAME: &str = "pass_through.mac"; +/// Name of the account file written for the batch builder's collection account. +pub const BATCH_BUILDER_COLLECTION_ACCOUNT_FILE_NAME: &str = "batch_builder_collection_account.mac"; // GENESIS CONFIG // ================================================================================================ @@ -229,7 +229,7 @@ impl GenesisConfig { let (pass_through_account, pass_through_secret) = build_pass_through_account().map_err(GenesisConfigError::PassThroughAccountBuild)?; secrets.push(( - PASS_THROUGH_ACCOUNT_FILE_NAME.to_string(), + BATCH_BUILDER_COLLECTION_ACCOUNT_FILE_NAME.to_string(), pass_through_account.id(), Some(pass_through_secret), )); diff --git a/crates/store/src/genesis/config/tests.rs b/crates/store/src/genesis/config/tests.rs index b2cf4e7f45..2ca4745acc 100644 --- a/crates/store/src/genesis/config/tests.rs +++ b/crates/store/src/genesis/config/tests.rs @@ -127,7 +127,7 @@ fn pass_through_account_is_part_of_genesis() -> TestResult { .as_account_files(&state) .collect::, _>>()? .into_iter() - .find(|file| file.name == PASS_THROUGH_ACCOUNT_FILE_NAME) + .find(|file| file.name == BATCH_BUILDER_COLLECTION_ACCOUNT_FILE_NAME) .expect("the pass-through account file should be generated"); let account = &exported.account_file.account; assert!(state.accounts.contains(account)); @@ -352,7 +352,7 @@ verification_base_fee = 0 // A file-loaded faucet creates no additional secret. assert_eq!(secrets.secrets.len(), 1); let (name, _, secret) = &secrets.secrets[0]; - assert_eq!(name, PASS_THROUGH_ACCOUNT_FILE_NAME); + assert_eq!(name, BATCH_BUILDER_COLLECTION_ACCOUNT_FILE_NAME); assert!(secret.is_some()); Ok(()) diff --git a/docs/external/src/network-operator/bootstrap-and-genesis.md b/docs/external/src/network-operator/bootstrap-and-genesis.md index 8419aafcc5..7430e683e1 100644 --- a/docs/external/src/network-operator/bootstrap-and-genesis.md +++ b/docs/external/src/network-operator/bootstrap-and-genesis.md @@ -62,8 +62,9 @@ printed. The operator file carries the only signing key permitted to mint, so tr To run a faucet against the network, pass `faucet_operator.mac` to the faucet's `init --import`, and the faucet account id to `--faucet-account-id`. -Genesis also creates `pass_through.mac`. This public account uses `AuthTxFeeCollector` to forward fee-note assets into a -P2ID note without changing its state. Keep the file private because it contains the collector signing key. +Genesis also creates `batch_builder_collection_account.mac`. This public account uses `AuthTxFeeCollector` to combine +fee notes into one P2ID payment without changing its state. Keep the file private because it contains the collection +account's signing key. Upload `genesis-data/genesis.dat` so it is served at: From c672ac1f5bbe7eb12c892e478de5818a8422b51c Mon Sep 17 00:00:00 2001 From: Mirko von Leipzig <48352201+Mirko-von-Leipzig@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:46:35 +0200 Subject: [PATCH 4/4] Create the collector account during node bootstrap --- Cargo.lock | 2 + bin/node/Cargo.toml | 2 + bin/node/src/commands/lifecycle.rs | 64 +++++++++++++++++++ crates/store/src/data_directory.rs | 4 ++ crates/store/src/genesis/config/errors.rs | 2 - crates/store/src/genesis/config/mod.rs | 14 ---- crates/store/src/genesis/config/tests.rs | 42 ++---------- crates/store/src/genesis/mod.rs | 1 - crates/store/src/genesis/pass_through/mod.rs | 21 ------ .../network-operator/bootstrap-and-genesis.md | 7 +- 10 files changed, 80 insertions(+), 79 deletions(-) delete mode 100644 crates/store/src/genesis/pass_through/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 1257d18844..a558fb9c81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4121,6 +4121,8 @@ dependencies = [ "miden-node-tracing", "miden-node-utils", "miden-protocol", + "miden-standards", + "rand 0.10.2", "serde", "serde_json", "tempfile", 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/lifecycle.rs b/bin/node/src/commands/lifecycle.rs index 648e12c195..29beab4ff1 100644 --- a/bin/node/src/commands/lifecycle.rs +++ b/bin/node/src/commands/lifecycle.rs @@ -1,3 +1,4 @@ +use std::io::Write; use std::path::{Path, PathBuf}; use anyhow::Context; @@ -8,9 +9,48 @@ use miden_node_store::{DataDirectory, Db, State}; use miden_node_tracing::info; use miden_node_utils::fs::ensure_empty_directory; use miden_node_utils::genesis::{OfficialNetwork, fetch_genesis_block, read_genesis_block}; +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 super::ENV_DATA_DIRECTORY; +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bootstrap_saves_a_new_collector_and_preserves_its_signing_key() -> anyhow::Result<()> { + let directory = tempfile::tempdir()?; + let directory = DataDirectory::load(directory.path().to_path_buf())?; + create_collection_account(&directory)?; + let path = directory.batch_builder_collection_account_path(); + let account_file = AccountFile::read(&path)?; + 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!(create_collection_account(&directory).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(()) + } +} + // BOOTSTRAP // ================================================================================================ @@ -58,6 +98,8 @@ impl BootstrapCommand { read_bootstrap_genesis_block(self.genesis_block_file.as_deref(), self.network).await?; let genesis_commitment = genesis_block.inner().header().commitment(); State::bootstrap(genesis_block, &self.data_directory)?; + create_collection_account(&DataDirectory::load(self.data_directory.clone())?) + .context("failed to create the batch builder collection account")?; info!( target: crate::LOG_TARGET, "Node bootstrap complete", @@ -68,6 +110,28 @@ impl BootstrapCommand { } } +/// Saves the collector and its signing key without registering the account on-chain. +fn create_collection_account(directory: &DataDirectory) -> anyhow::Result<()> { + 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(directory.batch_builder_collection_account_path())?; + file.write_all(&account_file.to_bytes())?; + file.sync_all()?; + Ok(()) +} + /// Reads the genesis block from the configured source and validates it. async fn read_bootstrap_genesis_block( genesis_block_file: Option<&Path>, diff --git a/crates/store/src/data_directory.rs b/crates/store/src/data_directory.rs index 975dbeabb2..420e156bfb 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 batch_builder_collection_account_path(&self) -> PathBuf { + self.0.join("batch_builder_collection_account.mac") + } + pub fn display(&self) -> std::path::Display<'_> { self.0.display() } diff --git a/crates/store/src/genesis/config/errors.rs b/crates/store/src/genesis/config/errors.rs index 7a236abb82..5eadf18839 100644 --- a/crates/store/src/genesis/config/errors.rs +++ b/crates/store/src/genesis/config/errors.rs @@ -26,8 +26,6 @@ pub enum GenesisConfigError { NativeFaucetNotFungible { path: PathBuf }, #[error("account translation from config to state failed")] Account(#[from] AccountError), - #[error("failed to build the pass-through account")] - PassThroughAccountBuild(#[source] anyhow::Error), #[error("asset translation from config to state failed")] Asset(#[from] AssetError), #[error("adding assets to account failed")] diff --git a/crates/store/src/genesis/config/mod.rs b/crates/store/src/genesis/config/mod.rs index eca187e690..9eb8dbf7f9 100644 --- a/crates/store/src/genesis/config/mod.rs +++ b/crates/store/src/genesis/config/mod.rs @@ -34,7 +34,6 @@ use rand::{RngExt, SeedableRng}; use rand_chacha::ChaCha20Rng; use serde::{Deserialize, Serialize}; -use crate::genesis::pass_through::build_pass_through_account; use crate::{GenesisState, LOG_TARGET}; mod errors; @@ -55,9 +54,6 @@ pub const NATIVE_FAUCET_FILE_NAME: &str = "native_faucet.mac"; /// Name of the account file written for the generated faucet operator. pub const FAUCET_OPERATOR_FILE_NAME: &str = "faucet_operator.mac"; -/// Name of the account file written for the batch builder's collection account. -pub const BATCH_BUILDER_COLLECTION_ACCOUNT_FILE_NAME: &str = "batch_builder_collection_account.mac"; - // GENESIS CONFIG // ================================================================================================ @@ -226,14 +222,6 @@ impl GenesisConfig { None => None, }; - let (pass_through_account, pass_through_secret) = - build_pass_through_account().map_err(GenesisConfigError::PassThroughAccountBuild)?; - secrets.push(( - BATCH_BUILDER_COLLECTION_ACCOUNT_FILE_NAME.to_string(), - pass_through_account.id(), - Some(pass_through_secret), - )); - faucet_accounts.insert(symbol.clone(), native_faucet_account); // Setup additional fungible faucets from parameters @@ -367,8 +355,6 @@ impl GenesisConfig { // Ensure the faucets always precede the wallets referencing them all_accounts.extend(wallet_accounts); - all_accounts.push(pass_through_account); - // Append file-loaded accounts as-is all_accounts.extend(file_loaded_accounts); diff --git a/crates/store/src/genesis/config/tests.rs b/crates/store/src/genesis/config/tests.rs index 2ca4745acc..8b988d3b69 100644 --- a/crates/store/src/genesis/config/tests.rs +++ b/crates/store/src/genesis/config/tests.rs @@ -108,10 +108,9 @@ async fn genesis_accounts_have_nonce_one() -> TestResult { let gcfg = GenesisConfig::default(); let (state, secrets) = gcfg.into_state(dev_validator_config()).unwrap(); - // The default configuration generates the native faucet, its operator, and the pass-through - // account. + // The default configuration generates the native faucet and its operator. let account_files = secrets.as_account_files(&state).collect::, _>>()?; - assert_eq!(account_files.len(), 3); + assert_eq!(account_files.len(), 2); for AccountFileWithName { account_file, name } in account_files { assert_eq!(account_file.account.nonce(), ONE, "{name} should be deployed at genesis"); } @@ -120,34 +119,6 @@ async fn genesis_accounts_have_nonce_one() -> TestResult { Ok(()) } -#[test] -fn pass_through_account_is_part_of_genesis() -> TestResult { - let (state, secrets) = GenesisConfig::default().into_state(dev_validator_config())?; - let exported = secrets - .as_account_files(&state) - .collect::, _>>()? - .into_iter() - .find(|file| file.name == BATCH_BUILDER_COLLECTION_ACCOUNT_FILE_NAME) - .expect("the pass-through account file should be generated"); - let account = &exported.account_file.account; - assert!(state.accounts.contains(account)); - assert!(account.is_public()); - assert_eq!(account.nonce(), ONE); - assert!(account.vault().is_empty()); - assert_eq!(exported.account_file.auth_secret_keys.len(), 1); - - assert_eq!( - account - .storage() - .get_item(miden_standards::account::auth::AuthTxFeeCollector::public_key_slot(),)?, - miden_protocol::Word::from( - exported.account_file.auth_secret_keys[0].public_key().to_commitment(), - ), - ); - - Ok(()) -} - #[test] fn parsing_account_from_file() -> TestResult { use miden_protocol::account::auth::AuthScheme; @@ -222,7 +193,7 @@ fn generated_native_faucet_is_a_network_account_owned_by_an_operator() -> TestRe .find(|(name, ..)| name == file_name) .unwrap_or_else(|| panic!("{file_name} should be generated")) }; - assert_eq!(secrets.secrets.len(), 3); + assert_eq!(secrets.secrets.len(), 2); let (_, faucet_id, faucet_secret) = find(NATIVE_FAUCET_FILE_NAME); let (_, operator_id, operator_secret) = find(FAUCET_OPERATOR_FILE_NAME); assert_eq!(*faucet_id, native_faucet.id()); @@ -349,11 +320,8 @@ verification_base_fee = 0 let (state, secrets) = gcfg.into_state(dev_validator_config())?; assert!(state.accounts.iter().any(|a| a.id() == faucet_id)); - // A file-loaded faucet creates no additional secret. - assert_eq!(secrets.secrets.len(), 1); - let (name, _, secret) = &secrets.secrets[0]; - assert_eq!(name, BATCH_BUILDER_COLLECTION_ACCOUNT_FILE_NAME); - assert!(secret.is_some()); + // No secrets should be generated for file-loaded native faucet + assert!(secrets.secrets.is_empty()); Ok(()) } diff --git a/crates/store/src/genesis/mod.rs b/crates/store/src/genesis/mod.rs index c8e8d72e42..3ba945e97b 100644 --- a/crates/store/src/genesis/mod.rs +++ b/crates/store/src/genesis/mod.rs @@ -19,7 +19,6 @@ use miden_protocol::protocol_config::ProtocolConfig; use miden_protocol::transaction::OrderedTransactionHeaders; pub mod config; -pub mod pass_through; pub use miden_node_utils::genesis::GenesisBlock; diff --git a/crates/store/src/genesis/pass_through/mod.rs b/crates/store/src/genesis/pass_through/mod.rs deleted file mode 100644 index aea7905f20..0000000000 --- a/crates/store/src/genesis/pass_through/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -use miden_protocol::account::{Account, AccountBuilder, AccountType}; -use miden_protocol::crypto::dsa::falcon512_poseidon2::SecretKey; -use miden_standards::account::auth::AuthTxFeeCollector; -use miden_standards::account::wallets::BasicWallet; -use rand::{RngExt, SeedableRng}; -use rand_chacha::ChaCha20Rng; - -/// Builds a public fee collector and its signing key for genesis. -/// -/// The account forwards input-note assets to a P2ID note without changing its state. -pub fn build_pass_through_account() -> anyhow::Result<(Account, SecretKey)> { - let mut rng = ChaCha20Rng::from_seed(rand::random()); - let secret_key = SecretKey::with_rng(&mut rng); - let account = AccountBuilder::new(rng.random()) - .account_type(AccountType::Public) - .with_component(AuthTxFeeCollector::falcon512_poseidon2(secret_key.public_key())) - .with_component(BasicWallet) - .build_existing()?; - - Ok((account, secret_key)) -} diff --git a/docs/external/src/network-operator/bootstrap-and-genesis.md b/docs/external/src/network-operator/bootstrap-and-genesis.md index 7430e683e1..9c277a9f2f 100644 --- a/docs/external/src/network-operator/bootstrap-and-genesis.md +++ b/docs/external/src/network-operator/bootstrap-and-genesis.md @@ -25,6 +25,9 @@ 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. +Node bootstrap also creates `batch_builder_collection_account.mac` in the node data directory. Keep this file private +because it contains the collection account's signing key. + ## Bootstrap Flow @@ -62,10 +65,6 @@ printed. The operator file carries the only signing key permitted to mint, so tr To run a faucet against the network, pass `faucet_operator.mac` to the faucet's `init --import`, and the faucet account id to `--faucet-account-id`. -Genesis also creates `batch_builder_collection_account.mac`. This public account uses `AuthTxFeeCollector` to combine -fee notes into one P2ID payment without changing its state. Keep the file private because it contains the collection -account's signing key. - Upload `genesis-data/genesis.dat` so it is served at: ```text