diff --git a/Cargo.lock b/Cargo.lock index e94dcca4fd..88b43f2d26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4027,6 +4027,26 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "miden-large-account-benchmark" +version = "0.16.0-rc.1" +dependencies = [ + "anyhow", + "clap", + "fs-err", + "hex", + "humantime", + "miden-node-proto", + "miden-protocol", + "miden-standards", + "miden-tx", + "rand 0.10.2", + "rand_chacha 0.10.0", + "tokio", + "tonic", + "url", +] + [[package]] name = "miden-lifted-air" version = "0.29.0" diff --git a/Cargo.toml b/Cargo.toml index 84139473d5..40a37f8000 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "bin/benchmark", + "bin/large-account-benchmark", "bin/network-monitor", "bin/node", "bin/ntx-builder", diff --git a/Makefile b/Makefile index 88622fbc13..7ba3f7eb1d 100644 --- a/Makefile +++ b/Makefile @@ -183,6 +183,10 @@ install-network-monitor: ## Installs network monitor binary install-benchmark: ## Installs the benchmark binary cargo install --path bin/benchmark --locked +.PHONY: install-large-account-benchmark +install-large-account-benchmark: ## Installs the large account benchmark binary + cargo install --path bin/large-account-benchmark --locked + # --- docker -------------------------------------------------------------------------------------- .PHONY: local-network-build diff --git a/bin/large-account-benchmark/Cargo.toml b/bin/large-account-benchmark/Cargo.toml new file mode 100644 index 0000000000..36a55bff0a --- /dev/null +++ b/bin/large-account-benchmark/Cargo.toml @@ -0,0 +1,33 @@ +[package] +authors.workspace = true +description = "A binary for benchmarking the ntx-builder against very large network accounts" +edition.workspace = true +exclude.workspace = true +homepage.workspace = true +keywords = ["benchmark", "genesis", "miden"] +license.workspace = true +name = "miden-large-account-benchmark" +publish = false +readme = "README.md" +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +clap = { features = ["derive", "env"], workspace = true } +fs-err = { workspace = true } +hex = { workspace = true } +humantime = { workspace = true } +miden-node-proto = { workspace = true } +miden-protocol = { features = ["std"], workspace = true } +miden-standards = { workspace = true } +miden-tx = { workspace = true } +rand = { workspace = true } +rand_chacha = { workspace = true } +tokio = { features = ["macros", "rt-multi-thread", "time"], workspace = true } +tonic = { workspace = true } +url = { workspace = true } diff --git a/bin/large-account-benchmark/README.md b/bin/large-account-benchmark/README.md new file mode 100644 index 0000000000..1dd821163a --- /dev/null +++ b/bin/large-account-benchmark/README.md @@ -0,0 +1,100 @@ +# Miden large account benchmark + +A self-contained tool for the ntx-builder large-account benchmark. It seeds an oversized network account into a genesis +configuration, then submits an increment against it on a running chain and asserts the ntx-builder consumes it. + +## The short version + +`scripts/large-account-harness.sh` does everything below in one command: seeds the pair, brings up a local network with +it committed at genesis via `scripts/run-node.sh`, submits one increment, and asserts the counter advances. + +```bash +MAP_ENTRIES=1000 ./scripts/large-account-harness.sh +``` + +```text +Seeding the wallet + counter pair (10000 map entries) + +Measuring the account in isolation (no network) + +Starting the local network + +Submitting an increment and waiting for the counter to advance + +PASS — the ntx-builder loaded the account and consumed the network note + +10000 map entries + counter on disk 628.8 KiB (64 B/entry) + wallet on disk 4.2 KiB + account in isolation 83.4 MiB resident, 128.2 MiB peak, 91.7 ms to load + ntx-builder peak RSS 339.4 MiB + sequencer peak RSS 450.8 MiB + timings 6s ready, 1.50s proving, 2 blocks to consume +``` + +## Seeding + +```bash +miden-large-account-benchmark seed --output-dir ./seeded --counter-map-entries 1000000 +``` + +This writes `faucet.mac`, `wallet.mac` (both carrying their signing key) and `counter.mac` into `./seeded`, and prints +all three account ids. Reference them from a genesis configuration: + +```toml +native_faucet = "seeded/faucet.mac" +validators = [""] + +[fee_parameters] +verification_base_fee = 0 + +[[account]] +path = "seeded/wallet.mac" + +[[account]] +path = "seeded/counter.mac" +``` + +Paths are resolved relative to the genesis configuration file's directory. Build the genesis block and bootstrap each +service from it: + +```bash +miden-validator genesis --genesis-block-directory ./genesis --accounts-directory ./accounts \ + --config ./genesis.toml +miden-validator bootstrap --data-directory ./data/validator --genesis ./genesis/genesis.dat +miden-node bootstrap --data-directory ./data/node --genesis ./genesis/genesis.dat +miden-ntx-builder bootstrap --data-directory ./data/ntx-builder --genesis ./genesis/genesis.dat +``` + +## Verifying the setup + +Once the network is up, check the whole path works before measuring anything. `verify` submits a single increment and +asserts the counter advances: + +```bash +miden-large-account-benchmark verify --accounts-dir ./seeded \ + --rpc-url http://localhost:57291 \ + --validator-signing-public-key "$VALIDATOR_1_PUBKEY" \ + --validator-signing-public-key "$VALIDATOR_2_PUBKEY" +``` + +It exits zero only if the counter moved, which requires every part of the chain to be working: the seeded accounts are +on chain, the node accepts a transaction against the wallet, and the ntx-builder can load an account this large and +consume the network note. Anything less exits non-zero with the reason. + +```text +baseline: counter=0 chain_tip=42 +submitted increment at block 43 · proved in 1.38s · tx 0x8f2c… +waiting up to 20 blocks for the ntx-builder to consume the note... + counter=0 blocks_elapsed=1/20 + counter=0 blocks_elapsed=2/20 +counter advanced to 1 after 3 blocks +PASS: the ntx-builder loaded the account and consumed the network note +``` + +The budget is counted in blocks (`--wait-blocks`, 20 by default) rather than seconds, so it does not depend on how fast +the network produces them. + +## License + +This project is [MIT licensed](../../LICENSE). diff --git a/bin/large-account-benchmark/src/accounts.rs b/bin/large-account-benchmark/src/accounts.rs new file mode 100644 index 0000000000..d6b23688d3 --- /dev/null +++ b/bin/large-account-benchmark/src/accounts.rs @@ -0,0 +1,414 @@ +//! Account construction for the seeded faucet + wallet + counter set. +//! +//! Self-contained by design: the assembly under `src/assets/`, the storage slot names, the component +//! paths, and the build order all live here, so this tool depends on nothing outside its own crate. +//! +//! Two internal invariants tie these accounts to the increment driver in [`crate::increment`]: +//! +//! - the counter's note allowlist must contain the root of the increment note script, pinned as +//! `INCREMENT_NOTE_SCRIPT_ROOT` in this module's tests, and +//! - the wallet must expose `increment_and_create_note` at [`WALLET_COUNTER_COMPONENT_PATH`], which +//! the increment transaction script `call`s. +//! +//! Both derive from the assembly here, and both sides of each pair are built from the same constants, +//! so they cannot disagree within this binary. The pinned root exists to catch an accidental edit to +//! the assembly, which would otherwise only surface as rejected notes on a live chain. + +use std::sync::LazyLock; + +use anyhow::{Context, Result}; +use miden_protocol::account::auth::AuthScheme; +use miden_protocol::account::component::AccountComponentMetadata; +use miden_protocol::account::{ + Account, + AccountBuilder, + AccountComponent, + AccountComponentCode, + AccountId, + AccountType, + StorageMap, + StorageMapKey, + StorageSlot, + StorageSlotName, +}; +use miden_protocol::asset::{AssetAmount, TokenSymbol}; +use miden_protocol::crypto::dsa::falcon512_poseidon2::SecretKey; +use miden_protocol::note::NoteScript; +use miden_protocol::{Felt, Word}; +use miden_standards::account::auth::{Approver, AuthNetworkAccount, AuthSingleSig}; +use miden_standards::account::faucets::{FungibleFaucet, TokenName}; +use miden_standards::account::fees::{BasicConstantFeePolicy, FeePolicyManager}; +use miden_standards::account::policies::{BurnPolicy, MintPolicy, TokenPolicyManager}; +use miden_standards::code_builder::CodeBuilder; +use miden_standards::tx_script::ExpirationTransactionScript; +use rand::{RngExt, SeedableRng}; +use rand_chacha::ChaCha20Rng; + +// MASM SOURCES +// ================================================================================================ + +/// The counter account's program (also linked into the increment note script). +const COUNTER_PROGRAM: &str = + include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/assets/counter_program.masm")); + +/// The wallet self-counter component source. +const WALLET_COUNTER_PROGRAM: &str = + include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/assets/wallet_counter_program.masm")); + +/// The increment note script source. +const INCREMENT_COUNTER_NOTE: &str = + include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/assets/increment_counter.masm")); + +// STORAGE SLOT NAMES +// ================================================================================================ + +/// Storage slot on the wallet holding the number of increment transactions it has committed. +static WALLET_COUNTER_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::monitor::wallet_contract::counter") + .expect("storage slot name should be valid") +}); + +/// Storage slot on the counter account holding the owner's account id. +static OWNER_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::monitor::counter_contract::owner") + .expect("storage slot name should be valid") +}); + +/// Name of the storage slot on the counter account holding the counter value. Exposed as a string +/// so the value can be read back from RPC by name. +pub const COUNTER_SLOT: &str = "miden::monitor::counter_contract::counter"; + +/// Storage slot on the counter account holding the counter value. +static COUNTER_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new(COUNTER_SLOT).expect("storage slot name should be valid") +}); + +/// Storage slot holding the large map that makes the account oversized. The counter's own logic +/// never touches this slot, but the ntx-builder must load the full account — map included — to +/// build every increment transaction, which is the whole point of the benchmark. +static BIG_MAP_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::monitor::counter_contract::big_map") + .expect("storage slot name should be valid") +}); + +/// Module path under which the wallet's self-counter component is compiled. The increment +/// transaction script `call`s `increment_and_create_note` under this exact path, so the call +/// resolves to the procedure root the account registered. +pub const WALLET_COUNTER_COMPONENT_PATH: &str = "wallet::program"; + +/// Compiles the wallet's self-counter component code. +/// +/// Both the account builder and the increment transaction script go through here: the script +/// dynamically links this code so its `call` resolves to the same procedure root the account +/// registered. Compilation is deterministic, so both sites get identical code. +pub fn wallet_counter_component_code() -> Result { + CodeBuilder::default() + .compile_component_code(WALLET_COUNTER_COMPONENT_PATH, WALLET_COUNTER_PROGRAM) + .context("failed to compile wallet counter component code") +} + +// FEE FAUCET +// ================================================================================================ + +/// Token symbol of the seeded fee faucet. Matches the genesis default so a run reads the same as a +/// stock local network. +const FEE_FAUCET_SYMBOL: &str = "MIDEN"; +/// Decimals of the seeded fee faucet, matching the genesis default. +const FEE_FAUCET_DECIMALS: u8 = 6; +/// Max supply of the seeded fee faucet, matching the genesis default. +const FEE_FAUCET_MAX_SUPPLY: u64 = 100_000_000_000_000_000; + +/// Creates the fungible faucet that fees are denominated in. Returns the account and its signing +/// key. +/// +/// The account is left at nonce zero, as a freshly generated account. Genesis bumps it to one when +/// it commits it, exactly as it does for a faucet it generated itself. +pub fn create_fee_faucet_account() -> Result<(Account, SecretKey)> { + let mut rng = ChaCha20Rng::from_seed(rand::random()); + let secret_key = SecretKey::with_rng(&mut rng); + let auth = AuthSingleSig::new(Approver::new( + secret_key.public_key().into(), + AuthScheme::Falcon512Poseidon2, + )); + let init_seed: [u8; 32] = rng.random(); + + let symbol = + TokenSymbol::new(FEE_FAUCET_SYMBOL).context("fee faucet symbol should be valid")?; + let faucet = FungibleFaucet::builder() + .name(TokenName::new(FEE_FAUCET_SYMBOL).context("fee faucet name should be valid")?) + .symbol(symbol) + .decimals(FEE_FAUCET_DECIMALS) + .max_supply(AssetAmount::new(FEE_FAUCET_MAX_SUPPLY)?) + .build() + .context("failed to build the fee faucet component")?; + + let account = AccountBuilder::new(init_seed) + .account_type(AccountType::Public) + .with_component(auth) + .with_component(faucet) + .with_components( + TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::allow_all()) + .active_burn_policy(BurnPolicy::allow_all()) + .build(), + ) + .build() + .context("failed to build the fee faucet account")?; + + Ok((account, secret_key)) +} + +// WALLET +// ================================================================================================ + +/// Creates the owner wallet: Falcon512 auth plus the self-counter component the increment +/// transaction script calls into. Returns the account and its signing key. +pub fn create_wallet_account() -> Result<(Account, SecretKey)> { + let mut rng = ChaCha20Rng::from_seed(rand::random()); + let secret_key = SecretKey::with_rng(&mut rng); + let auth_component: AccountComponent = AuthSingleSig::new(Approver::new( + secret_key.public_key().into(), + AuthScheme::Falcon512Poseidon2, + )) + .into(); + let init_seed: [u8; 32] = rng.random(); + + let component_code = wallet_counter_component_code()?; + + let counter_slot = StorageSlot::with_value(WALLET_COUNTER_SLOT_NAME.clone(), Word::empty()); + let metadata = AccountComponentMetadata::new(WALLET_COUNTER_COMPONENT_PATH); + let counter_component = AccountComponent::new(component_code, vec![counter_slot], metadata)?; + + let account = AccountBuilder::new(init_seed) + .account_type(AccountType::Public) + .with_component(auth_component) + .with_component(counter_component) + .build() + .context("failed to build wallet account")?; + + Ok((account, secret_key)) +} + +// COUNTER +// ================================================================================================ + +/// Compiles the increment note script whose root must appear in the counter's note allowlist. +pub fn create_increment_script() -> Result { + CodeBuilder::new() + .with_linked_module("external_contract::counter_contract", COUNTER_PROGRAM) + .context("failed to create script builder with library")? + .compile_note_script(INCREMENT_COUNTER_NOTE) + .context("failed to compile note script") +} + +/// Builds the large storage-map slot, keyed `[i, 0, 0, 0]`. Returns an empty vector when `entries` is +/// zero. +/// +/// The values carry no meaning and nothing ever reads them. The counter's own logic never touches +/// this slot. The one requirement is that they are not all-zero, since a zero value denotes deletion +/// in the underlying SMT and the entry would not be stored at all: hence the trailing `1`. +fn counter_big_map_slots(entries: u32) -> Vec { + if entries == 0 { + return Vec::new(); + } + + let map_entries: Vec<(StorageMapKey, Word)> = (0..entries) + .map(|i| (StorageMapKey::from_index(i), Word::from([i, 0, 0, 1]))) + .collect(); + + let map = StorageMap::with_entries(map_entries).expect("map entries should be valid"); + vec![StorageSlot::with_map(BIG_MAP_SLOT_NAME.clone(), map)] +} + +/// Creates the network counter account owned by `owner_account_id`, with `big_map_entries` entries +/// pre-populated into its benchmark storage map. +/// +/// `fee_faucet_id` must be the faucet the chain's `fee_parameters` name, otherwise the account +/// prices its notes in an asset the network does not settle in. See +/// [`create_fee_faucet_account`] for how the two are kept in agreement. +pub fn create_counter_account( + owner_account_id: AccountId, + fee_faucet_id: AccountId, + big_map_entries: u32, +) -> Result { + let owner_account_id_prefix = owner_account_id.prefix().as_felt(); + let owner_account_id_suffix = owner_account_id.suffix(); + + let owner_id_slot = StorageSlot::with_value( + OWNER_SLOT_NAME.clone(), + Word::from([owner_account_id_suffix, owner_account_id_prefix, Felt::ZERO, Felt::ZERO]), + ); + let counter_slot = StorageSlot::with_value(COUNTER_SLOT_NAME.clone(), Word::empty()); + + let component_code = CodeBuilder::default() + .compile_component_code("counter::program", COUNTER_PROGRAM) + .context("failed to compile counter component code")?; + + // The counter's own two value slots, followed by the large benchmark map slot. + // `AccountComponent::new` only bounds the slot count (<256); the map slot need not be + // referenced by the component MASM. + let mut storage_slots = vec![counter_slot, owner_id_slot]; + storage_slots.extend(counter_big_map_slots(big_map_entries)); + + let metadata = AccountComponentMetadata::new("counter::program"); + let account_code = AccountComponent::new(component_code, storage_slots, metadata)?; + + let increment_script = create_increment_script()?; + let allowed_scripts = [increment_script.root()].into_iter().collect(); + + let fee_policy = BasicConstantFeePolicy::new() + .with_fees([(increment_script.root(), AssetAmount::ZERO)]) + .into(); + let fee_policy_manager = FeePolicyManager::builder() + .fee_faucet_id(fee_faucet_id) + .active_fee_policy(fee_policy) + .build(); + + let allowed_tx_scripts = [ExpirationTransactionScript::script_root()]; + let network_account_auth = AuthNetworkAccount::custom(allowed_scripts, fee_policy_manager) + .expect("list is not empty") + .with_allowed_tx_scripts(allowed_tx_scripts); + + let init_seed: [u8; 32] = rand::random(); + AccountBuilder::new(init_seed) + .account_type(AccountType::Public) + .with_component(account_code) + .with_components(network_account_auth) + .build() + .context("failed to build counter account") +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use miden_protocol::asset::AssetId; + use miden_standards::account::auth::NetworkAccount; + + use super::*; + + /// The increment note script root. + const INCREMENT_NOTE_SCRIPT_ROOT: &str = + "0x84cab4ae9c724836a015a458fc540c850fd2126602290b0d3dcd2dd2085e6aa3"; + + #[test] + fn increment_note_script_root_is_unchanged() { + let root = create_increment_script().expect("increment script should compile").root(); + + assert_eq!( + root.to_string(), + INCREMENT_NOTE_SCRIPT_ROOT, + "the increment note script root changed; accounts seeded from this version will reject \ + increment notes built from a different copy of the assembly", + ); + } + + /// Both accounts must assemble, and the counter must be buildable with a populated map. A small + /// entry count keeps this fast while still exercising the map-slot path. + #[test] + fn accounts_build_with_and_without_a_populated_map() { + let (wallet, _secret_key) = create_wallet_account().expect("wallet should build"); + let (faucet, _faucet_key) = create_fee_faucet_account().expect("faucet should build"); + + let small = create_counter_account(wallet.id(), faucet.id(), 0) + .expect("counter should build empty"); + let big = create_counter_account(wallet.id(), faucet.id(), 64) + .expect("counter should build with a map"); + + // Assert the delta rather than absolute counts, which also include the auth component's + // slots: a populated map must add exactly one slot, and an empty one must add none. + assert_eq!( + big.storage().slots().len(), + small.storage().slots().len() + 1, + "a populated map should add exactly one storage slot", + ); + } + + /// Every note script the counter allowlists must also be priced: a script without a schedule + /// entry aborts fee estimation. + #[test] + fn every_allowlisted_note_script_is_priced_at_zero() { + let (wallet, _secret_key) = create_wallet_account().expect("wallet should build"); + let (faucet, _faucet_key) = create_fee_faucet_account().expect("faucet should build"); + let counter = + create_counter_account(wallet.id(), faucet.id(), 0).expect("counter should build"); + + let allowlisted = NetworkAccount::new(counter.clone()) + .expect("counter should be a valid network account") + .allowed_notes() + .allowed_script_roots() + .clone(); + assert_eq!( + allowlisted.len(), + 1, + "only the increment note may be allowlisted, got {allowlisted:?}" + ); + + // A scheduled entry is `[fee_amount, 0, 0, 1]`: the trailing set-marker is what + // distinguishes an explicit zero fee from an absent key, since storage maps prune zero + // words and return the zero word for anything unset. + let expected_entry = Word::from([Felt::ZERO, Felt::ZERO, Felt::ZERO, Felt::ONE]); + for root in &allowlisted { + let entry = counter + .storage() + .get_map_item( + BasicConstantFeePolicy::fee_schedule_slot_name(), + StorageMapKey::new(root.as_word()), + ) + .expect("the fee schedule slot should be a map"); + assert_eq!( + entry, expected_entry, + "note script root {root} is allowlisted but has no zero-fee schedule entry" + ); + } + } + + /// The counter must price its notes in the seeded faucet's asset, which the genesis + /// configuration adopts as the chain's native asset via `native_faucet`. If the two drift, the + /// account settles in an asset the network does not. + #[test] + fn counter_prices_notes_in_the_seeded_faucet_asset() { + let (wallet, _secret_key) = create_wallet_account().expect("wallet should build"); + let (faucet, _faucet_key) = create_fee_faucet_account().expect("faucet should build"); + let counter = + create_counter_account(wallet.id(), faucet.id(), 0).expect("counter should build"); + + let active = counter + .storage() + .get_item(FeePolicyManager::active_fee_policy_slot()) + .expect("the active fee policy slot should exist"); + assert_eq!( + active, + BasicConstantFeePolicy::root().as_word(), + "the basic constant fee policy must be the active policy" + ); + + let fee_asset = counter + .storage() + .get_item(FeePolicyManager::fee_asset_id_slot()) + .expect("the fee asset slot should exist"); + assert_eq!( + fee_asset, + AssetId::new_fungible(faucet.id()).to_word(), + "fees must be charged in the seeded faucet's asset" + ); + } + + /// Genesis loads `faucet.mac` through `native_faucet`, which validates it is a fungible faucet + /// and then bumps its nonce to one itself. A faucet seeded as already-deployed would be + /// rejected by that path. + #[test] + fn fee_faucet_is_a_fungible_faucet_awaiting_deployment() { + let (faucet, _secret_key) = create_fee_faucet_account().expect("faucet should build"); + + assert_eq!( + faucet.nonce(), + Felt::ZERO, + "genesis bumps the nonce when it commits the faucet" + ); + // The exact check `NativeFaucetConfig::build_account` runs on a file-loaded faucet. + FungibleFaucet::try_from(&faucet) + .expect("the seeded faucet must satisfy the genesis native-faucet check"); + } +} diff --git a/bin/large-account-benchmark/src/assets/counter_program.masm b/bin/large-account-benchmark/src/assets/counter_program.masm new file mode 100644 index 0000000000..4db465e8d1 --- /dev/null +++ b/bin/large-account-benchmark/src/assets/counter_program.masm @@ -0,0 +1,56 @@ +# Counter program for network monitoring with note authentication +# Storage layout: +# - OWNER_SLOT: authorized wallet account id as [suffix, prefix, 0, 0] +# - COUNTER_SLOT: counter value (u64) + +use miden::core::sys +use miden::protocol::active_account +use miden::protocol::native_account +use miden::protocol::active_note +use miden::protocol::account_id +use miden::protocol::tx + + +const COUNTER_SLOT = word("miden::monitor::counter_contract::counter") +const OWNER_SLOT = word("miden::monitor::counter_contract::owner") + +# Increment function with note authentication +# => [] +@account_procedure +pub proc increment + # Ensure the note sender matches the authorized wallet. + push.OWNER_SLOT[0..2] exec.active_account::get_item + # => [owner_suffix, owner_prefix, 0, 0] + + exec.active_note::get_sender + # => [sender_suffix, sender_prefix, owner_suffix, owner_prefix, 0, 0] + + exec.account_id::eq + # => [are_equal, 0, 0] + + assert.err="Note sender not authorized" drop drop + # => [] + + push.COUNTER_SLOT[0..2] exec.active_account::get_item + # => [count, 0, 0, 0] + + push.1 add + # => [count+1] + + push.COUNTER_SLOT[0..2] exec.native_account::set_item + # => [count, 0, 0, 0] + + dropw + # => [] +end + +# Get the counter (no auth required) +# => [count] +@account_procedure +pub proc get_count + push.COUNTER_SLOT[0..2] exec.active_account::get_item + # => [count, 0, 0, 0] + + exec.sys::truncate_stack + # => [count] +end diff --git a/bin/large-account-benchmark/src/assets/increment_counter.masm b/bin/large-account-benchmark/src/assets/increment_counter.masm new file mode 100644 index 0000000000..0a5afee4c4 --- /dev/null +++ b/bin/large-account-benchmark/src/assets/increment_counter.masm @@ -0,0 +1,10 @@ +# Note script to increment the external counter contract. +# This script is executed as a note and calls the +# `counter_contract::increment` entrypoint. + +use external_contract::counter_contract + +@note_script +pub proc main + call.counter_contract::increment +end diff --git a/bin/large-account-benchmark/src/assets/wallet_counter_program.masm b/bin/large-account-benchmark/src/assets/wallet_counter_program.masm new file mode 100644 index 0000000000..220e9fbc00 --- /dev/null +++ b/bin/large-account-benchmark/src/assets/wallet_counter_program.masm @@ -0,0 +1,54 @@ +# Wallet self-counter + note-creation component for the network monitor. +# +# A single account procedure atomically (a) creates the increment network note and (b) bumps the +# wallet's own counter slot. Because both effects happen in one account procedure, a transaction can +# never do one without the other, so the emitted note and the authoritative `expected` counter can +# never diverge. +# +# Storage layout: +# - COUNTER_SLOT: counter value (u64) + +use miden::core::sys +use miden::protocol::active_account +use miden::protocol::native_account +use miden::protocol::output_note + +const COUNTER_SLOT = word("miden::monitor::wallet_contract::counter") + +# Create the increment network note and bump the wallet's own counter slot, atomically. +# +# Inputs: [tag, note_type, RECIPIENT, pad(10)] +# Outputs: [note_idx, pad(15)] +# +# Invocation: call +@account_procedure +pub proc increment_and_create_note + # Create the output note. `output_note::create` is only callable from the account context, so it + # must run here inside an account procedure. + exec.output_note::create + # => [note_idx, pad(15)] + + # Bump the wallet's own counter slot. These ops are self-contained and leave note_idx on top. + push.COUNTER_SLOT[0..2] exec.active_account::get_item + # => [count, 0, 0, 0, note_idx, pad(...)] + + push.1 add + # => [count+1, 0, 0, 0, note_idx, pad(...)] + + push.COUNTER_SLOT[0..2] exec.native_account::set_item + # => [OLD_VALUE, note_idx, pad(...)] + + dropw + # => [note_idx, pad(15)] +end + +# Read the wallet's own counter slot (no auth required). +# => [count] +@account_procedure +pub proc get_count + push.COUNTER_SLOT[0..2] exec.active_account::get_item + # => [count, 0, 0, 0] + + exec.sys::truncate_stack + # => [count] +end diff --git a/bin/large-account-benchmark/src/increment.rs b/bin/large-account-benchmark/src/increment.rs new file mode 100644 index 0000000000..4f54473e65 --- /dev/null +++ b/bin/large-account-benchmark/src/increment.rs @@ -0,0 +1,470 @@ +//! Drives increments against the seeded counter account. + +use std::collections::{BTreeSet, HashMap}; +use std::fmt::Write as _; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use miden_protocol::account::auth::AuthSecretKey; +use miden_protocol::account::{ + Account, + AccountId, + PartialAccount, + StorageMapKey, + StorageMapWitness, + StorageSlotContent, +}; +use miden_protocol::asset::{AssetId, AssetWitness}; +use miden_protocol::block::account_tree::AccountWitness; +use miden_protocol::block::{BlockHeader, BlockNumber}; +use miden_protocol::crypto::dsa::falcon512_poseidon2::SecretKey; +use miden_protocol::note::{ + Note, + NoteAssets, + NoteAttachment, + NoteAttachments, + NoteRecipient, + NoteScript, + NoteScriptRoot, + NoteStorage, + NoteType, + PartialNote, + PartialNoteMetadata, +}; +use miden_protocol::transaction::{ + AccountInputs, + InputNotes, + PartialBlockchain, + TransactionArgs, + TransactionScript, +}; +use miden_protocol::utils::serde::Serializable; +use miden_protocol::{Felt, Word}; +use miden_standards::account::auth::{FeeConversionInfo, commit_fee_conversion_info}; +use miden_standards::code_builder::CodeBuilder; +use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint}; +use miden_tx::auth::BasicAuthenticator; +use miden_tx::{ + DataStore, + DataStoreError, + LoadedMastForest, + LocalTransactionProver, + MastForestStore, + TransactionExecutor, + TransactionMastStore, +}; +use rand::RngExt; +use rand_chacha::ChaCha20Rng; + +use crate::accounts::{ + COUNTER_SLOT, + WALLET_COUNTER_COMPONENT_PATH, + create_increment_script, + wallet_counter_component_code, +}; +use crate::rpc::SubmissionClient; + +/// Everything one increment needs, carried across iterations of the loop. +pub struct Driver { + wallet: Account, + counter: Account, + /// Proves the counter's inclusion in the genesis account tree, which every increment references + /// as its FPI anchor. Genesis commits the counter, so the anchor is valid from the first block + /// and stays valid as later increments change the live state. + counter_witness: AccountWitness, + secret_key: SecretKey, + increment_script: NoteScript, + genesis_header: BlockHeader, + prover: LocalTransactionProver, + rng: ChaCha20Rng, +} + +impl Driver { + pub async fn new( + wallet: Account, + counter: Account, + secret_key: SecretKey, + client: &SubmissionClient, + rng: ChaCha20Rng, + ) -> Result { + let genesis_header = client.genesis_header().clone(); + let counter_witness = client + .account_witness(counter.id(), genesis_header.block_num()) + .await + .context("failed to fetch the counter's genesis account witness")?; + + anyhow::ensure!( + counter_witness.state_commitment() == counter.to_commitment(), + "the chain's genesis state for counter account {} does not match the seeded \ + counter.mac; the accounts directory and the running chain were seeded separately", + counter.id(), + ); + + Ok(Self { + wallet, + counter, + counter_witness, + secret_key, + increment_script: create_increment_script() + .context("failed to compile the increment note script")?, + genesis_header, + prover: LocalTransactionProver::default(), + rng, + }) + } + + /// The wallet in its current local state, for persisting between increments. + pub fn wallet(&self) -> &Account { + &self.wallet + } + + /// Reads the counter account's on-chain value. + /// + /// This only advances once the ntx-builder has loaded the large account and consumed one of the + /// network notes, so it is the signal that the harness is exercising what it means to. + pub async fn observed_counter(&self, client: &SubmissionClient) -> Result> { + client.slot_value(self.counter.id(), COUNTER_SLOT).await + } + + /// Builds, proves, and submits one increment, then advances the local wallet by the resulting + /// patch. Returns the accepted block height and how long proving took. + pub async fn submit_one(&mut self, client: &SubmissionClient) -> Result { + let (network_note, recipient) = create_network_note( + &self.wallet, + &self.counter, + self.increment_script.clone(), + &mut self.rng, + )?; + + let script = create_increment_tx_script(&network_note)?; + let mut tx_args = TransactionArgs::default().with_tx_script(script); + + // The wallet's auth procedure pays the transaction fee in the chain's native asset. It + // reads the conversion rate from the advice map, keyed by a commitment it recomputes from + // the auth args, so both halves have to be supplied. + let (auth_args, conversion_info_preimage) = self.fee_conversion_auth_args(); + tx_args = tx_args.with_auth_args(auth_args); + tx_args.extend_advice_map([(auth_args, conversion_info_preimage)]); + tx_args.add_output_note_recipient(Box::new(recipient)); + + let mut data_store = + DriverDataStore::new(self.genesis_header.clone(), PartialBlockchain::default()); + data_store.add_account(self.wallet.clone()); + // The counter is *foreign* to this transaction: creating a note targeted at it makes the + // wallet's auth procedure price the note through the counter's `estimate_note_fee` via FPI. + data_store.add_foreign_account(self.counter.clone(), self.counter_witness.clone()); + + let authenticator = + BasicAuthenticator::new(&[AuthSecretKey::Falcon512Poseidon2(self.secret_key.clone())]); + let executor = TransactionExecutor::new(&data_store).with_authenticator(&authenticator); + + let executed = executor + .execute_transaction( + self.wallet.id(), + self.genesis_header.block_num(), + InputNotes::default(), + tx_args, + ) + .await + .context("failed to execute the increment transaction")?; + + let tx_inputs = executed.tx_inputs().to_bytes(); + let patch = executed.account_patch().clone(); + + let proving_started = Instant::now(); + let proven = self.prover.prove(executed).context("failed to prove the transaction")?; + let proving_time = proving_started.elapsed(); + + let block_num = client.submit(&proven, &tx_inputs).await?; + + self.wallet + .apply_patch(&patch) + .context("failed to apply the transaction patch to the local wallet")?; + + Ok(Submitted { + tx_id: proven.id().to_hex(), + block_num, + proving_time, + }) + } + + /// Builds the auth args committing to paying the fee in the chain's native asset at rate 1/1, + /// together with the advice-map preimage the auth procedure verifies against them in-VM. + fn fee_conversion_auth_args(&mut self) -> (Word, Vec) { + let fee_faucet_id = self.genesis_header.fee_parameters().fee_faucet_id(); + // The salt keeps the auth args usable as a per-transaction unique value for replay + // protection. + let salt = Word::new([ + Felt::new_unchecked(self.rng.random()), + Felt::new_unchecked(self.rng.random()), + Felt::new_unchecked(self.rng.random()), + Felt::new_unchecked(self.rng.random()), + ]); + + commit_fee_conversion_info(FeeConversionInfo::one_to_one(fee_faucet_id), salt) + } +} + +/// The outcome of one accepted increment. +pub struct Submitted { + pub tx_id: String, + pub block_num: BlockNumber, + pub proving_time: Duration, +} + +// NOTE + SCRIPT CONSTRUCTION +// ================================================================================================ + +/// Builds the network note addressed to the counter account. +/// +/// The `NetworkAccountTarget` attachment is what makes this a *network* note: the ntx-builder watches +/// for notes targeting network accounts and authors the consuming transaction itself. +fn create_network_note( + wallet: &Account, + counter: &Account, + script: NoteScript, + rng: &mut ChaCha20Rng, +) -> Result<(Note, NoteRecipient)> { + let target = NetworkAccountTarget::new(counter.id(), NoteExecutionHint::Always) + .context("counter account should be a valid network target")?; + let attachment: NoteAttachment = target.into(); + let attachments = NoteAttachments::from(attachment); + + let partial_metadata = PartialNoteMetadata::new(wallet.id(), NoteType::Public); + + let serial_num = Word::new([ + Felt::new_unchecked(rng.random()), + Felt::new_unchecked(rng.random()), + Felt::new_unchecked(rng.random()), + Felt::new_unchecked(rng.random()), + ]); + + let recipient = NoteRecipient::new(serial_num, script, NoteStorage::new(vec![])?); + let note = Note::with_attachments( + NoteAssets::new(vec![])?, + partial_metadata, + recipient.clone(), + attachments, + ); + + Ok((note, recipient)) +} + +/// Builds the transaction script for one increment. +/// +/// The whole transaction is a single `call` into the wallet's `increment_and_create_note` procedure, +/// which creates the network note and bumps the wallet's counter slot atomically. +fn create_increment_tx_script(network_note: &Note) -> Result { + let wallet_component = wallet_counter_component_code()?; + + let partial: PartialNote = network_note.clone().into(); + let recipient = partial.recipient_digest(); + let note_type = Felt::from(partial.metadata().note_type()); + let tag = Felt::from(partial.metadata().tag()); + + // `increment_and_create_note` shares `create_note`'s stack contract: it consumes `[tag, + // note_type, RECIPIENT, pad(10)]` and returns `[note_idx, pad(15)]`. The padding is built + // explicitly and the trailing pads reduced back to `[note_idx]`, otherwise they survive on the + // overflow stack and `main` returns at the wrong depth. + let call_target = format!("::{WALLET_COUNTER_COMPONENT_PATH}::increment_and_create_note"); + let mut note_section = format!( + " + padw padw push.0.0 + push.{recipient} + push.{note_type} + push.{tag} + # => [tag, note_type, RECIPIENT, pad(10)] + call.{call_target} + # => [note_idx, pad(15)] + movdn.15 dropw dropw dropw drop drop drop + # => [note_idx] + " + ); + + for attachment in partial.attachments().iter() { + let scheme = attachment.attachment_scheme().as_u16(); + let commitment = attachment.content().to_commitment(); + // `add_attachment` consumes `[attachment_scheme, ATTACHMENT_COMMITMENT, note_idx]`, so dup + // the note index for it to consume and keep our own copy for the next attachment / the + // drop. + write!( + note_section, + " + dup + push.{commitment} + push.{scheme} + # => [attachment_scheme, ATTACHMENT_COMMITMENT, note_idx, note_idx] + exec.::miden::protocol::output_note::add_attachment + # => [note_idx] + " + ) + .expect("writing to a String cannot fail"); + } + note_section.push_str(" drop\n"); + + let script_src = format!( + "@transaction_script + pub proc main +{note_section} + end" + ); + + let mut code_builder = CodeBuilder::new() + .with_dynamically_linked_package(&wallet_component) + .context("failed to dynamically link the wallet counter component")?; + + // Attachments are resolved at runtime from the advice map, keyed by their commitment. + for attachment in partial.attachments().iter() { + code_builder.add_advice_map_entry(attachment.to_commitment(), attachment.to_elements()); + } + + code_builder + .compile_tx_script(script_src) + .context("failed to compile the increment transaction script") +} + +// DATA STORE +// ================================================================================================ + +/// An in-memory [`DataStore`] over the genesis header and the two accounts involved. +/// +/// The transaction consumes no input notes and reads no storage maps, so only the account, +/// blockchain, foreign-account and vault-witness methods need real implementations. +struct DriverDataStore { + accounts: HashMap, + account_witnesses: HashMap, + block_header: BlockHeader, + partial_blockchain: PartialBlockchain, + mast_store: TransactionMastStore, +} + +impl DriverDataStore { + fn new(block_header: BlockHeader, partial_blockchain: PartialBlockchain) -> Self { + Self { + accounts: HashMap::new(), + account_witnesses: HashMap::new(), + block_header, + partial_blockchain, + mast_store: TransactionMastStore::new(), + } + } + + fn add_account(&mut self, account: Account) { + self.mast_store.load_account_code(account.code()); + self.accounts.insert(account.id(), account); + } + + /// Registers an account the transaction reaches through a foreign procedure invocation, + /// together with the account-tree witness proving its state in the reference block. + fn add_foreign_account(&mut self, account: Account, witness: AccountWitness) { + self.add_account(account); + self.account_witnesses.insert(witness.id(), witness); + } + + fn account(&self, account_id: AccountId) -> Result<&Account, DataStoreError> { + self.accounts.get(&account_id).ok_or_else(|| DataStoreError::Other { + error_msg: "unknown account".into(), + source: None, + }) + } +} + +impl DataStore for DriverDataStore { + async fn get_transaction_inputs( + &self, + account_id: AccountId, + _block_refs: BTreeSet, + ) -> Result<(PartialAccount, BlockHeader, PartialBlockchain), DataStoreError> { + let account = self.account(account_id)?; + + Ok(( + PartialAccount::from(account), + self.block_header.clone(), + self.partial_blockchain.clone(), + )) + } + + /// Opens a map slot of the requested account by root. + /// + /// Reached through the counter's fee policy: `estimate_note_fee` looks the note's script root up + /// in the `basic_constant_fee` schedule, which is a storage map. + async fn get_storage_map_witness( + &self, + account_id: AccountId, + map_root: Word, + map_key: StorageMapKey, + ) -> Result { + let account = self.account(account_id)?; + + account + .storage() + .slots() + .iter() + .filter_map(|slot| match slot.content() { + StorageSlotContent::Map(map) => Some(map), + StorageSlotContent::Value(_) => None, + }) + .find(|map| map.root() == map_root) + .map(|map| map.open(&map_key)) + .ok_or_else(|| DataStoreError::Other { + error_msg: format!("no storage map with root {map_root} in account {account_id}") + .into(), + source: None, + }) + } + + async fn get_foreign_account_inputs( + &self, + foreign_account_id: AccountId, + _ref_block: BlockNumber, + ) -> Result { + let account = self.account(foreign_account_id)?; + let witness = + self.account_witnesses.get(&foreign_account_id).cloned().ok_or_else(|| { + DataStoreError::Other { + error_msg: format!( + "no account witness for foreign account {foreign_account_id}" + ) + .into(), + source: None, + } + })?; + + Ok(AccountInputs::new(PartialAccount::from(account), witness)) + } + + async fn get_vault_asset_witnesses( + &self, + account_id: AccountId, + vault_root: Word, + vault_keys: BTreeSet, + ) -> Result, DataStoreError> { + let account = self.account(account_id)?; + + if account.vault().root() != vault_root { + return Err(DataStoreError::other("vault root mismatch")); + } + + Result::, _>::from_iter(vault_keys.into_iter().map(|vault_key| { + AssetWitness::new(account.vault().open(vault_key).into(), [vault_key]).map_err(|err| { + DataStoreError::Other { + error_msg: "failed to open the vault asset tree".into(), + source: Some(Box::new(err)), + } + }) + })) + } + + async fn get_note_script( + &self, + _script_root: NoteScriptRoot, + ) -> Result, DataStoreError> { + Ok(None) + } +} + +impl MastForestStore for DriverDataStore { + fn get(&self, procedure_hash: &Word) -> Option { + self.mast_store.get(procedure_hash) + } +} diff --git a/bin/large-account-benchmark/src/main.rs b/bin/large-account-benchmark/src/main.rs new file mode 100644 index 0000000000..545b5fd108 --- /dev/null +++ b/bin/large-account-benchmark/src/main.rs @@ -0,0 +1,393 @@ +//! Seeds an oversized network account and checks the ntx-builder can work with it. +//! +//! - `seed` writes the counter account, its owner wallet, and the faucet fees are denominated in as +//! `.mac` [`AccountFile`]s. A genesis configuration references the first two via `[[account]]` +//! entries, so the store and the ntx-builder both load the account from `genesis.dat` on disk with +//! no wire transfer involved, and the faucet via `native_faucet`, which makes the asset the counter +//! prices its notes in the asset the chain settles in. +//! - `verify` submits one increment and asserts the counter advances within a block budget. The +//! increment emits a network note, which makes the ntx-builder load the full account — so a passing +//! run is the evidence that an account this large can be worked with at all. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use miden_protocol::ONE; +use miden_protocol::account::auth::AuthSecretKey; +use miden_protocol::account::{Account, AccountFile}; +use miden_protocol::crypto::dsa::falcon512_poseidon2::SecretKey; +use rand::SeedableRng; +use rand_chacha::ChaCha20Rng; +use url::Url; + +mod accounts; +mod increment; +mod rpc; + +use self::accounts::{create_counter_account, create_fee_faucet_account, create_wallet_account}; + +/// File name of the seeded owner wallet (carries its signing key). +const WALLET_FILE: &str = "wallet.mac"; +/// File name of the seeded fee faucet (carries its signing key). Referenced by the genesis +/// configuration's `native_faucet`, not by an `[[account]]` entry. +const FAUCET_FILE: &str = "faucet.mac"; +/// File name of the seeded network counter account (no secret key; the ntx-builder authors its +/// transactions). +const COUNTER_FILE: &str = "counter.mac"; + +#[derive(Parser)] +#[command(name = "miden-large-account-benchmark", version, about, long_about = None)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Write the wallet + counter pair as `.mac` files for a genesis configuration to reference. + Seed(SeedArgs), + /// Submit one increment and assert the counter advances, then exit. + /// + /// This is the end-to-end check that the whole setup works: that the seeded accounts are on + /// chain, that a transaction against the wallet is accepted, and — the part that matters — that + /// the ntx-builder can load an account this large and consume the resulting network note. Exits + /// non-zero if the counter has not advanced within the block budget. + Verify(VerifyArgs), +} + +#[derive(clap::Args)] +struct SeedArgs { + /// Directory into which `wallet.mac` and `counter.mac` are written. + #[arg(long, value_name = "DIR")] + output_dir: PathBuf, + + /// Number of entries to pre-populate into the counter account's storage map. + #[arg(long, default_value_t = 0, value_name = "N")] + counter_map_entries: u32, +} + +#[derive(clap::Args)] +struct VerifyArgs { + /// Directory holding the `wallet.mac` and `counter.mac` written by `seed`. + #[arg(long, value_name = "DIR")] + accounts_dir: PathBuf, + + /// URL of the node's RPC endpoint. + #[arg(long, env = "MIDEN_LARGE_ACCOUNT_BENCH_RPC_URL", value_name = "URL")] + rpc_url: Url, + + /// Hex-encoded public keys of the validators trusted to attest the node's transaction + /// encryption key. + #[arg( + long = "validator-signing-public-key", + env = "MIDEN_LARGE_ACCOUNT_BENCH_VALIDATOR_SIGNING_PUBLIC_KEYS", + value_delimiter = ',', + required = true, + value_name = "HEX" + )] + validator_signing_public_keys: Vec, + + /// How many blocks to allow for the counter to advance before failing. + #[arg(long, default_value_t = 20, value_name = "N")] + wait_blocks: u32, + + /// How often to poll the counter and the chain tip while waiting. + #[arg(long, default_value = "2s", value_parser = humantime_duration, value_name = "DURATION")] + poll_interval: Duration, + + /// Per-request RPC timeout. + #[arg(long, default_value = "30s", value_parser = humantime_duration, value_name = "DURATION")] + request_timeout: Duration, +} + +fn humantime_duration(raw: &str) -> Result { + raw.parse::() + .map(Into::into) + .map_err(|err| err.to_string()) +} + +#[tokio::main] +async fn main() -> Result<()> { + match Cli::parse().command { + Command::Seed(args) => seed(&args), + Command::Verify(args) => verify(&args).await, + } +} + +// SEED +// ================================================================================================ + +fn seed(args: &SeedArgs) -> Result<()> { + fs_err::create_dir_all(&args.output_dir) + .with_context(|| format!("failed to create output dir {}", args.output_dir.display()))?; + + let Seeded { + faucet, + faucet_secret_key, + wallet, + wallet_secret_key, + counter, + } = build_seeded_accounts(args.counter_map_entries)?; + + let faucet_id = faucet.id(); + let wallet_id = wallet.id(); + let counter_id = counter.id(); + + write_wallet(&args.output_dir, &wallet, &wallet_secret_key)?; + AccountFile::new(faucet, vec![AuthSecretKey::Falcon512Poseidon2(faucet_secret_key)]) + .write(args.output_dir.join(FAUCET_FILE)) + .context("failed to write faucet.mac")?; + AccountFile::new(counter, vec![]) + .write(args.output_dir.join(COUNTER_FILE)) + .context("failed to write counter.mac")?; + + println!("fee_faucet_id={}", faucet_id.to_hex()); + println!("wallet_id={}", wallet_id.to_hex()); + println!("counter_id={}", counter_id.to_hex()); + println!("counter_map_entries={}", args.counter_map_entries); + println!("output_dir={}", args.output_dir.display()); + + Ok(()) +} + +/// The accounts a seed run writes. +struct Seeded { + /// The faucet fees are denominated in, at nonce zero for genesis to adopt as its native faucet. + faucet: Account, + faucet_secret_key: SecretKey, + /// The owner wallet, in committed form. + wallet: Account, + wallet_secret_key: SecretKey, + /// The oversized network counter, in committed form. + counter: Account, +} + +/// Builds the faucet + wallet + counter set. +/// +/// The wallet and the counter are committed here by bumping their nonce, since genesis takes them +/// as `[[account]]` entries and writes them into the block as-is. The faucet is left at nonce zero +/// because genesis commits that one itself. +fn build_seeded_accounts(counter_map_entries: u32) -> Result { + let (faucet, faucet_secret_key) = + create_fee_faucet_account().context("failed to create the fee faucet")?; + + let (mut wallet, wallet_secret_key) = + create_wallet_account().context("failed to create wallet")?; + wallet.set_nonce(ONE).context("failed to bump wallet nonce")?; + + let mut counter = create_counter_account(wallet.id(), faucet.id(), counter_map_entries) + .context("failed to create counter account")?; + counter.set_nonce(ONE).context("failed to bump counter nonce")?; + + Ok(Seeded { + faucet, + faucet_secret_key, + wallet, + wallet_secret_key, + counter, + }) +} + +// VERIFY +// ================================================================================================ + +async fn verify(args: &VerifyArgs) -> Result<()> { + let (wallet, secret_key, counter) = load_pair(&args.accounts_dir)?; + + println!("wallet_id={}", wallet.id().to_hex()); + println!("counter_id={}", counter.id().to_hex()); + println!("connecting to {}", args.rpc_url); + + // Connecting is itself part of the check: it performs the genesis handshake and verifies the + // node's encryption key against the trusted validator, so a broken node fails here. + let client = rpc::SubmissionClient::connect( + &args.rpc_url, + args.request_timeout, + &args.validator_signing_public_keys, + ) + .await?; + + let mut driver = increment::Driver::new( + wallet, + counter, + secret_key.clone(), + &client, + ChaCha20Rng::from_rng(&mut rand::rng()), + ) + .await?; + + // Read both baselines before submitting, so the assertion is against a known starting point. + let baseline = driver + .observed_counter(&client) + .await + .context("failed to read the counter before submitting")?; + let start_tip = client.chain_tip().await?; + + println!( + "baseline: counter={} chain_tip={start_tip}", + baseline.map_or_else(|| "not on chain".to_string(), |v| v.to_string()), + ); + + let result = driver + .submit_one(&client) + .await + .context("the increment transaction was rejected")?; + println!( + "submitted increment at block {} · proved in {:.2}s · tx {}", + result.block_num, + result.proving_time.as_secs_f64(), + result.tx_id, + ); + + write_wallet(&args.accounts_dir, driver.wallet(), &secret_key)?; + + println!( + "waiting up to {} blocks for the ntx-builder to consume the note...", + args.wait_blocks, + ); + + let baseline_value = baseline.unwrap_or(0); + loop { + tokio::time::sleep(args.poll_interval).await; + + let observed = driver + .observed_counter(&client) + .await + .context("failed to read the counter while waiting")?; + let tip = client.chain_tip().await?; + let blocks_elapsed = tip.saturating_sub(start_tip); + + match poll_progress(baseline_value, observed, blocks_elapsed, args.wait_blocks) { + Progress::Increased => { + println!( + "counter advanced to {} after {blocks_elapsed} blocks", + observed.expect("an increased counter is on chain"), + ); + println!("PASS: the ntx-builder loaded the account and consumed the network note"); + return Ok(()); + }, + Progress::TimedOut => { + anyhow::bail!( + "counter did not advance past {baseline_value} within {blocks_elapsed} blocks \ + (still {}). The wallet transaction landed, so the note was emitted — the \ + ntx-builder did not consume it. Check its logs; for an account this large, \ + failing or timing out while loading it is the expected cause", + observed.map_or_else(|| "not on chain".to_string(), |v| v.to_string()), + ); + }, + Progress::Waiting => println!( + " counter={} blocks_elapsed={blocks_elapsed}/{}", + observed.map_or_else(|| "not on chain".to_string(), |v| v.to_string()), + args.wait_blocks, + ), + } + } +} + +/// What a single poll of the verification loop concluded. +#[derive(Debug, PartialEq, Eq)] +enum Progress { + /// The counter advanced past the baseline: the ntx-builder consumed the note. + Increased, + /// The block budget elapsed without the counter moving. + TimedOut, + /// Neither yet — keep polling. + Waiting, +} + +/// Decides whether verification has succeeded, failed, or should keep waiting. +/// +/// Progress is judged on the counter strictly exceeding the value read before the increment was +/// submitted, and the deadline is measured in blocks rather than seconds so the check does not depend +/// on how fast the network happens to be producing them. +/// +/// The block budget is checked *after* the counter, so a counter that advances on the very block the +/// budget expires still passes rather than racing the deadline. +fn poll_progress( + baseline: u64, + observed: Option, + blocks_elapsed: u32, + budget: u32, +) -> Progress { + if observed.is_some_and(|value| value > baseline) { + return Progress::Increased; + } + if blocks_elapsed >= budget { + return Progress::TimedOut; + } + Progress::Waiting +} + +// ACCOUNT FILES +// ================================================================================================ + +/// Reads the wallet (with its signing key) and the counter account from a seeded directory. +fn load_pair(dir: &Path) -> Result<(Account, SecretKey, Account)> { + let wallet_file = + AccountFile::read(dir.join(WALLET_FILE)).context("failed to read wallet.mac")?; + let counter_file = + AccountFile::read(dir.join(COUNTER_FILE)).context("failed to read counter.mac")?; + + let secret_key = wallet_file + .auth_secret_keys + .iter() + .find_map(|key| match key { + AuthSecretKey::Falcon512Poseidon2(sk) => Some(sk.clone()), + _ => None, + }) + .context("wallet.mac does not contain a Falcon512Poseidon2 secret key")?; + + Ok((wallet_file.account, secret_key, counter_file.account)) +} + +/// Writes the wallet and its signing key to `wallet.mac`, replacing any existing file. +fn write_wallet(dir: &Path, wallet: &Account, secret_key: &SecretKey) -> Result<()> { + let final_path = dir.join(WALLET_FILE); + let temp_path = dir.join(format!("{WALLET_FILE}.tmp")); + + AccountFile::new(wallet.clone(), vec![AuthSecretKey::Falcon512Poseidon2(secret_key.clone())]) + .write(&temp_path) + .context("failed to write the wallet account file")?; + + fs_err::rename(&temp_path, &final_path).context("failed to replace wallet.mac")?; + + Ok(()) +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use super::{Progress, poll_progress}; + + /// The verification loop must not declare success on a counter that has not moved, must not + /// give up while the block budget remains, and must not fail a counter that advances on the + /// very block the budget runs out. + #[test] + fn poll_progress_decides_on_counter_movement_then_block_budget() { + // Advanced past the baseline: success, regardless of how many blocks it took. + assert_eq!(poll_progress(5, Some(6), 1, 20), Progress::Increased); + assert_eq!(poll_progress(0, Some(1), 19, 20), Progress::Increased); + + // Unmoved, budget remaining: keep waiting. + assert_eq!(poll_progress(5, Some(5), 0, 20), Progress::Waiting); + assert_eq!(poll_progress(5, Some(5), 19, 20), Progress::Waiting); + assert_eq!(poll_progress(0, None, 3, 20), Progress::Waiting); + + // Budget exhausted with no movement: fail. + assert_eq!(poll_progress(5, Some(5), 20, 20), Progress::TimedOut); + assert_eq!(poll_progress(0, None, 25, 20), Progress::TimedOut); + + // Counter movement wins on the boundary block, so a late consume is not a spurious failure. + assert_eq!(poll_progress(5, Some(6), 20, 20), Progress::Increased); + + // A counter *below* the baseline is not progress (it should not happen, but must not pass). + assert_eq!(poll_progress(5, Some(4), 1, 20), Progress::Waiting); + + // A zero budget fails immediately rather than looping forever. + assert_eq!(poll_progress(5, Some(5), 0, 0), Progress::TimedOut); + } +} diff --git a/bin/large-account-benchmark/src/rpc.rs b/bin/large-account-benchmark/src/rpc.rs new file mode 100644 index 0000000000..4c666e3070 --- /dev/null +++ b/bin/large-account-benchmark/src/rpc.rs @@ -0,0 +1,335 @@ +//! RPC plumbing for submitting increment transactions. + +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result}; +use miden_node_proto::clients::{Builder, RpcClient}; +use miden_node_proto::domain::account::AccountResponse; +use miden_node_proto::domain::encryption::{ + TransactionInputsSealer, + TrustedTransactionEncryptionState, + verify_transaction_encryption_key, +}; +use miden_node_proto::generated::account::AccountId as ProtoAccountId; +use miden_node_proto::generated::rpc::account_request::AccountDetailRequest; +use miden_node_proto::generated::rpc::{AccountRequest, BlockHeaderByNumberRequest}; +use miden_node_proto::generated::transaction::ProvenTransaction as ProtoProvenTransaction; +use miden_protocol::Word; +use miden_protocol::account::AccountId; +use miden_protocol::block::account_tree::AccountWitness; +use miden_protocol::block::{BlockHeader, BlockNumber}; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey as ValidatorPublicKey; +use miden_protocol::transaction::ProvenTransaction; +use miden_protocol::utils::serde::{Deserializable, Serializable}; +use tokio::sync::Mutex; +use url::Url; + +/// An RPC client that can submit transactions, plus the genesis header everything is anchored to. +pub struct SubmissionClient { + rpc: RpcClient, + genesis_header: BlockHeader, + genesis_commitment: Word, + trusted_validator_keys: Arc<[ValidatorPublicKey]>, + sealer: Mutex>, +} + +impl SubmissionClient { + /// Connects to `rpc_url`, discovers the genesis block, and pins the validator key that + /// attestations must be signed by. + pub async fn connect( + rpc_url: &Url, + timeout: Duration, + validator_signing_public_keys: &[String], + ) -> Result { + anyhow::ensure!( + !validator_signing_public_keys.is_empty(), + "at least one validator signing public key is required", + ); + + let trusted_keys = validator_signing_public_keys + .iter() + .map(|encoded| { + let bytes = hex::decode(encoded).with_context(|| { + format!("validator signing public key {encoded} is not hex") + })?; + ValidatorPublicKey::read_from_bytes(&bytes).with_context(|| { + format!("validator signing public key {encoded} is not a valid K256 key") + }) + }) + .collect::>>()?; + + // Step one: a client with no genesis metadata, used only to learn the genesis header. TLS + // follows the URL scheme, so a local `http://` stack needs no extra flags. The builder is a + // typestate, so the scheme branch is inlined rather than factored into a helper. + let discovery = Builder::new(rpc_url.clone()); + let discovery = if rpc_url.scheme() == "https" { + discovery.with_tls().context("failed to configure TLS for the RPC client")? + } else { + discovery.without_tls() + }; + let mut discovery: RpcClient = discovery + .with_timeout(timeout) + .without_metadata_version() + .without_metadata_genesis() + .without_otel_context_injection() + .connect() + .await + .context("failed to connect to RPC for genesis discovery")?; + + let genesis_header = genesis_block_header(&mut discovery).await?; + let genesis_commitment = genesis_header.commitment(); + + // Step two: the real client, carrying the genesis commitment so writes are accepted. + let genesis_aware = Builder::new(rpc_url.clone()); + let genesis_aware = if rpc_url.scheme() == "https" { + genesis_aware.with_tls().context("failed to configure TLS for the RPC client")? + } else { + genesis_aware.without_tls() + }; + let rpc: RpcClient = genesis_aware + .with_timeout(timeout) + .without_metadata_version() + .with_metadata_genesis(genesis_commitment) + .without_otel_context_injection() + .connect() + .await + .context("failed to connect to RPC")?; + + let client = Self { + rpc, + genesis_header, + genesis_commitment, + trusted_validator_keys: Arc::from(trusted_keys), + sealer: Mutex::new(None), + }; + + // Fetch and verify the encryption key up front, so a bad validator key fails at startup + // rather than on the first submission. + client.sealer().await?; + + Ok(client) + } + + /// The genesis block header, used as the reference block for every increment transaction. + pub fn genesis_header(&self) -> &BlockHeader { + &self.genesis_header + } + + /// Reads the current chain tip height. + /// + /// Prefers the block producer's view when the node exposes one, since that is the height blocks + /// are actually being produced at; otherwise falls back to the node's own tip. + pub async fn chain_tip(&self) -> Result { + let status = self + .rpc + .clone() + .status(()) + .await + .context("failed to read node status")? + .into_inner(); + + Ok(status.block_producer.map_or(status.chain_tip, |producer| producer.chain_tip)) + } + + /// Reads the `u64` held in the named value slot of an account, or `None` if the account is not + /// on-chain yet. + /// + /// This is how the harness observes progress on the *other* side: the wallet's slot counts what + /// this tool submitted, while the counter account's slot only advances when the ntx-builder has + /// actually loaded the large account and consumed a network note. + pub async fn slot_value(&self, account_id: AccountId, slot_name: &str) -> Result> { + let id_bytes: [u8; 15] = account_id.into(); + let request = AccountRequest { + account_id: Some(ProtoAccountId { id: id_bytes.to_vec() }), + block_num: None, + details: Some(AccountDetailRequest { + code_commitment: None, + asset_vault_commitment: None, + storage_request: None, + }), + }; + + let response = self + .rpc + .clone() + .get_account(request) + .await + .context("failed to read the account from RPC")? + .into_inner(); + + let Some(details) = response.details else { + return Ok(None); + }; + let storage_header = details + .storage_details + .context("RPC returned no storage details")? + .header + .context("RPC returned no storage header")?; + + let slot = storage_header + .slots + .iter() + .find(|slot| slot.slot_name == slot_name) + .with_context(|| format!("account has no storage slot named '{slot_name}'"))?; + + let value: Word = slot + .commitment + .as_ref() + .context("storage slot carries no value")? + .try_into() + .context("failed to decode the storage slot value")?; + + // A value slot holds the number in the word's first element. + Ok(Some( + value + .as_elements() + .first() + .expect("a word has four elements") + .as_canonical_u64(), + )) + } + + /// Fetches the account-tree witness proving `account_id`'s state in `block_num`. + /// + /// Every increment emits a note targeted at the counter, which makes the wallet's auth procedure + /// invoke the counter's `estimate_note_fee` through FPI. The kernel authenticates that foreign + /// account against the reference block's account root, so the executor needs this witness. + pub async fn account_witness( + &self, + account_id: AccountId, + block_num: BlockNumber, + ) -> Result { + let id_bytes: [u8; 15] = account_id.into(); + let request = AccountRequest { + account_id: Some(ProtoAccountId { id: id_bytes.to_vec() }), + block_num: Some(block_num.into()), + details: None, + }; + + let response = self + .rpc + .clone() + .get_account(request) + .await + .context("failed to fetch the account witness from RPC")? + .into_inner(); + + let response = + AccountResponse::try_from(response).context("failed to decode the account response")?; + + // An account-ID prefix collision makes the tree return a witness for the *other* account, + // and the data store keys witnesses by the account they prove. + anyhow::ensure!( + response.witness.id() == account_id, + "account tree returned a witness for {} when {account_id} was requested", + response.witness.id(), + ); + + Ok(response.witness) + } + + /// Returns the cached sealer, fetching and verifying the attested encryption key on first use. + async fn sealer(&self) -> Result { + let mut cached = self.sealer.lock().await; + if let Some(sealer) = cached.clone() { + return Ok(sealer); + } + + let key = self + .rpc + .clone() + .get_transaction_encryption_key(()) + .await + .context("failed to fetch the transaction encryption key")? + .into_inner(); + + let verified = verify_transaction_encryption_key( + key, + TrustedTransactionEncryptionState::new( + self.genesis_commitment, + &self.trusted_validator_keys, + ), + ) + .context( + "the node's transaction encryption key is not attested by the trusted validator", + )?; + + let sealer = TransactionInputsSealer::new(verified); + *cached = Some(sealer.clone()); + Ok(sealer) + } + + /// Seals the transaction inputs and submits the proven transaction, returning the block height + /// the node accepted it at. + /// + /// A rejection with `FailedPrecondition` means the encryption key rotated under us, so the cached + /// sealer is dropped and the submission retried once against a freshly fetched key. + pub async fn submit( + &self, + proven_tx: &ProvenTransaction, + tx_inputs: &[u8], + ) -> Result { + match self.try_submit(proven_tx, tx_inputs).await { + Err(err) if is_stale_key(&err) => { + *self.sealer.lock().await = None; + self.try_submit(proven_tx, tx_inputs) + .await + .context("submission failed again after refreshing the encryption key") + }, + other => other, + } + } + + async fn try_submit( + &self, + proven_tx: &ProvenTransaction, + tx_inputs: &[u8], + ) -> Result { + let sealed = self + .sealer() + .await? + .seal(proven_tx.id(), tx_inputs) + .context("failed to seal the transaction inputs")?; + + let response = self + .rpc + .clone() + .submit_proven_tx(ProtoProvenTransaction { + transaction: proven_tx.to_bytes(), + sealed_transaction_inputs: Some(sealed), + }) + .await + .context("failed to submit the proven transaction")?; + + Ok(response.into_inner().block_num.into()) + } +} + +/// Reads the genesis block header, which anchors both the client metadata and transaction +/// execution. +async fn genesis_block_header(rpc: &mut RpcClient) -> Result { + let response = rpc + .get_block_header_by_number(BlockHeaderByNumberRequest { + block_num: Some(BlockNumber::GENESIS.as_u32()), + include_mmr_proof: None, + }) + .await + .context("failed to read the genesis block header")? + .into_inner(); + + response + .block_header + .context("RPC returned no genesis block header")? + .try_into() + .context("failed to decode the genesis block header") +} + +/// True when the node rejected a submission because our sealed inputs used a stale encryption key. +fn is_stale_key(err: &anyhow::Error) -> bool { + err.chain().any(|cause| { + cause + .downcast_ref::() + .is_some_and(|status| status.code() == tonic::Code::FailedPrecondition) + }) +} diff --git a/bin/ntx-builder/Cargo.toml b/bin/ntx-builder/Cargo.toml index 5ffa356240..36574d865e 100644 --- a/bin/ntx-builder/Cargo.toml +++ b/bin/ntx-builder/Cargo.toml @@ -51,3 +51,8 @@ miden-standards = { features = ["testing"], workspace = true } rand_chacha = { workspace = true } rstest = { workspace = true } tempfile = { workspace = true } + +# Benchmark for building network transactions against very large accounts. +[[bench]] +harness = false +name = "large_account" diff --git a/bin/ntx-builder/README.md b/bin/ntx-builder/README.md index 93960ea3d5..53db0f2bcc 100644 --- a/bin/ntx-builder/README.md +++ b/bin/ntx-builder/README.md @@ -20,6 +20,22 @@ The builder has its own persistent database and must be initialized from the sam the network before it starts. In a complete node deployment, `node` connects to this service so network-note status can be exposed through the public RPC API. +## Benchmarks + +`benches/large_account.rs` measures the cost of a large network account, which each actor keeps fully resident for its +lifetime and reloads from the database on start and after every expired submission. It synthesizes a network account +with a populated storage map and reports resident/peak heap, serialized size, and per-operation timings. Per-candidate +cost is not a factor: the account is shared via `Arc` and advanced with `Arc::make_mut`, and `PartialAccount::from` is +constant-time in the map size for existing accounts. + +```bash +# Default sizes (1k, 10k, 100k entries). +cargo bench -p miden-ntx-builder --bench large_account + +# Custom sizes (entries per storage map). 1M needs several GiB of RAM, so it is opt-in: +cargo bench -p miden-ntx-builder --bench large_account -- 1000 100000 1000000 +``` + ## License This project is [MIT licensed](../../LICENSE). diff --git a/bin/ntx-builder/benches/large_account.rs b/bin/ntx-builder/benches/large_account.rs new file mode 100644 index 0000000000..fc6c679002 --- /dev/null +++ b/bin/ntx-builder/benches/large_account.rs @@ -0,0 +1,332 @@ +//! Benchmark: building a network transaction against a very large account. +//! +//! The `ntx-builder` keeps the native network `Account` fully resident in memory for the lifetime of +//! an actor. For an account with a large storage map (e.g. a million entries) that could become +//! untenable. This benchmark measures the costs so we can decide whether lazy/partial native-account +//! loading is worth implementing. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::collections::BTreeSet; +use std::hint::black_box; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use miden_protocol::Word; +use miden_protocol::account::{ + Account, + AccountBuilder, + AccountType, + PartialAccount, + StorageMap, + StorageMapKey, + StorageSlot, + StorageSlotName, +}; +use miden_protocol::asset::FungibleAsset; +use miden_protocol::testing::account_id::AccountIdBuilder; +use miden_protocol::utils::serde::{Deserializable, Serializable}; +use miden_standards::account::auth::AuthNetworkAccount; +use miden_standards::account::fees::FeePolicyManager; +use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint}; +use miden_standards::testing::account_component::MockAccountComponent; +use miden_standards::testing::note::NoteBuilder; +use rand_chacha::ChaCha20Rng; +use rand_chacha::rand_core::SeedableRng; + +// COUNTING GLOBAL ALLOCATOR +// ================================================================================================ + +/// A thin wrapper around the [`System`] allocator that tracks the live and peak number of bytes +/// allocated. It lets us measure the real resident heap footprint of an [`Account`] without relying +/// on platform-specific RSS probing. +/// +/// `realloc`/`alloc_zeroed` are intentionally not overridden so the trait's default +/// implementations route through `alloc`/`dealloc`, keeping the byte accounting consistent. +struct CountingAllocator; + +/// Currently live allocated bytes. +static LIVE: AtomicUsize = AtomicUsize::new(0); +/// High-water mark of live allocated bytes since the last [`reset_peak`]. +static PEAK: AtomicUsize = AtomicUsize::new(0); + +unsafe impl GlobalAlloc for CountingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let ptr = unsafe { System.alloc(layout) }; + if !ptr.is_null() { + let live = LIVE.fetch_add(layout.size(), Ordering::Relaxed) + layout.size(); + PEAK.fetch_max(live, Ordering::Relaxed); + } + ptr + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) }; + LIVE.fetch_sub(layout.size(), Ordering::Relaxed); + } +} + +#[global_allocator] +static ALLOCATOR: CountingAllocator = CountingAllocator; + +/// Returns the currently live allocated bytes. +fn live_bytes() -> usize { + LIVE.load(Ordering::Relaxed) +} + +/// Returns the peak live bytes recorded since the last [`reset_peak`]. +fn peak_bytes() -> usize { + PEAK.load(Ordering::Relaxed) +} + +/// Resets the peak counter to the current live value, so the next measurement window starts fresh. +fn reset_peak() { + PEAK.store(LIVE.load(Ordering::Relaxed), Ordering::Relaxed); +} + +// ACCOUNT GENERATION +// ================================================================================================ + +/// Builds an [`AuthNetworkAccount`] auth component with a single-entry note allowlist. +fn network_auth_component() -> AuthNetworkAccount { + let mut rng = ChaCha20Rng::from_seed([7u8; 32]); + let sender = AccountIdBuilder::new() + .account_type(AccountType::Private) + .build_with_rng(&mut rng); + let target_id = AccountIdBuilder::new() + .account_type(AccountType::Public) + .build_with_seed([9u8; 32]); + let target = NetworkAccountTarget::new(target_id, NoteExecutionHint::Always) + .expect("network account should be a valid target"); + let note = NoteBuilder::new(sender, rng) + .attachment(target) + .build() + .expect("note should build"); + let root = note.script().root(); + + // Nothing here executes a transaction, so the mock manager's empty fee schedule is enough: it + // only has to make the component constructible and install the three fee-policy slots. + AuthNetworkAccount::new( + BTreeSet::from_iter([root]), + FeePolicyManager::mock(FungibleAsset::mock_issuer()), + ) + .expect("non-empty allowlist should construct") +} + +/// Builds a single storage slot holding a map with `num_entries` entries, keyed `[i, 0, 0, 0]`. +fn large_map_slot(slot_idx: u32, num_entries: u32) -> StorageSlot { + let entries: Vec<(StorageMapKey, Word)> = (0..num_entries) + .map(|i| (StorageMapKey::from_index(i), Word::from([i, 0, 0, 1]))) + .collect(); + + let name = StorageSlotName::new(format!("miden::bench::map_slot_{slot_idx}")) + .expect("slot name should be valid"); + StorageSlot::with_map(name, StorageMap::with_entries(entries).expect("valid map")) +} + +/// Synthesizes a network [`Account`] with `num_maps` storage maps, each holding `entries_per_map` +/// entries. Mirrors the `ntx-builder`'s network-account recipe (`MockAccountComponent` + +/// `AuthNetworkAccount`), extended with populated map slots. +fn build_large_network_account(num_maps: u32, entries_per_map: u32) -> Account { + let slots: Vec = + (0..num_maps).map(|m| large_map_slot(m, entries_per_map)).collect(); + + AccountBuilder::new([0u8; 32]) + .account_type(AccountType::Public) + .with_component(MockAccountComponent::with_slots(slots)) + .with_components(network_auth_component()) + .build_existing() + .expect("account should build") +} + +// TIMING +// ================================================================================================ + +/// Runs `op` `iters` times and returns the median wall-clock duration. The result of each call is +/// passed through [`black_box`] so the work is not optimized away. +fn median_time(iters: usize, mut op: impl FnMut() -> T) -> Duration { + let mut samples: Vec = Vec::with_capacity(iters); + for _ in 0..iters { + let start = Instant::now(); + let out = op(); + let elapsed = start.elapsed(); + black_box(out); + samples.push(elapsed); + } + samples.sort_unstable(); + samples[samples.len() / 2] +} + +// REPORTING +// ================================================================================================ + +/// One row of the results table. +struct Row { + entries: u32, + resident_bytes: usize, + build_peak_bytes: usize, + serialized_bytes: usize, + deserialize: Duration, + clone: Duration, + partial_from: Duration, + serialize: Duration, +} + +/// Measures all metrics for a single map size. `num_maps` maps each hold `entries_per_map` entries. +fn measure(num_maps: u32, entries_per_map: u32) -> Row { + let iters = if entries_per_map >= 500_000 { 3 } else { 11 }; + + // Resident + peak footprint of the built account. The transient entry vectors are freed inside + // `build_large_network_account`, so `live_after - live_before` approximates the account's own + // resident heap, while the peak captures the construction high-water mark. + let live_before = live_bytes(); + reset_peak(); + let account = build_large_network_account(num_maps, entries_per_map); + let resident_bytes = live_bytes().saturating_sub(live_before); + let build_peak_bytes = peak_bytes().saturating_sub(live_before); + + // Serialize once for the size figure and to feed the deserialize benchmark. + let bytes = account.to_bytes(); + let serialized_bytes = bytes.len(); + + let deserialize = median_time(iters, || { + Account::read_from_bytes(&bytes).expect("account should deserialize") + }); + let clone = median_time(iters, || account.clone()); + let partial_from = median_time(iters, || PartialAccount::from(&account)); + let serialize = median_time(iters, || account.to_bytes()); + + Row { + entries: entries_per_map, + resident_bytes, + build_peak_bytes, + serialized_bytes, + deserialize, + clone, + partial_from, + serialize, + } +} + +/// Formats a byte count in the largest unit that keeps it readable, with one decimal. Integer math +/// throughout to avoid lossy casts. A fixed `MiB` unit would print the smaller cases as `0.0 MiB`. +fn bytes(bytes: usize) -> String { + const UNITS: [(usize, &str); 4] = + [(1 << 30, "GiB"), (1 << 20, "MiB"), (1 << 10, "KiB"), (1, "B")]; + + for (scale, unit) in UNITS { + if bytes >= scale { + if scale == 1 { + return format!("{bytes} {unit}"); + } + return format!("{}.{} {unit}", bytes / scale, (bytes % scale) * 10 / scale); + } + } + "0 B".to_string() +} + +/// Formats a duration as milliseconds with three decimals. +fn ms(d: Duration) -> String { + format!("{:.3} ms", d.as_secs_f64() * 1_000.0) +} + +fn print_table(rows: &[Row]) { + println!( + "\n{:>10} {:>12} {:>12} {:>12} {:>12} {:>12} {:>14} {:>12}", + "entries", + "resident", + "build_peak", + "serialized", + "deserialize", + "clone", + "partial_from", + "serialize", + ); + println!("{}", "-".repeat(112)); + for r in rows { + println!( + "{:>10} {:>12} {:>12} {:>12} {:>12} {:>12} {:>14} {:>12}", + r.entries, + bytes(r.resident_bytes), + bytes(r.build_peak_bytes), + bytes(r.serialized_bytes), + ms(r.deserialize), + ms(r.clone), + ms(r.partial_from), + ms(r.serialize), + ); + } +} + +/// Summarises the largest case measured, and where each figure is paid. +/// +/// Deliberately states no threshold and passes no judgement: what counts as too much depends on the +/// deployment — the memory available to the process, how many actors it runs concurrently, and how +/// long a reload may take before the submission it is reloading for expires. None of that is known +/// here, so this reports sizes and leaves the conclusion to the reader. +fn print_summary(rows: &[Row]) { + let Some(largest) = rows.iter().max_by_key(|r| r.resident_bytes) else { + return; + }; + let entries = usize::try_from(largest.entries.max(1)).unwrap_or(1); + + println!("\nSummary"); + println!("{}", "-".repeat(112)); + println!( + "Largest case measured: {} entries -> {} resident, {} peak while building, {} on disk, {} \ + to load.", + largest.entries, + bytes(largest.resident_bytes), + bytes(largest.build_peak_bytes), + bytes(largest.serialized_bytes), + ms(largest.deserialize), + ); + println!( + "That is {}x the on-disk size held in memory, or {} of heap per {} on disk, per entry.", + largest.resident_bytes / largest.serialized_bytes.max(1), + bytes(largest.resident_bytes / entries), + bytes(largest.serialized_bytes / entries), + ); + println!( + "Where each is paid: `resident` for the actor's whole lifetime, `build_peak` transiently \ + while loading, `deserialize` on actor start and on every reload after a submission expires." + ); + println!( + "Flat in map size: {} to derive a PartialAccount (minimal partial storage), which also \ + bounds the submitted TransactionInputs.", + ms(largest.partial_from), + ); +} + +// ENTRY POINT +// ================================================================================================ + +fn main() { + // Sizes are the number of entries per storage map. One storage map is used per account. 1M is + // deliberately not a default: it needs several GiB of RAM. Pass it explicitly. + let default_sizes: Vec = vec![1_000, 10_000, 100_000]; + // `cargo bench` passes `--bench` to a `harness = false` binary; non-numeric args are ignored. + let sizes: Vec = std::env::args() + .skip(1) + .filter_map(|a| a.parse::().ok()) + .collect::>(); + let sizes = if sizes.is_empty() { default_sizes } else { sizes }; + + // Keep a stable account type digest in the output so the reader knows what was measured. + println!("ntx-builder large-account benchmark (issue #2363)"); + println!("account: 1 storage map, MockAccountComponent + AuthNetworkAccount, type = Public"); + + // Warm up before measuring anything. The first account build lazily allocates the assembler's + // MAST forests and other one-time statics, which the allocator counts against whichever row + // triggers them — enough to dominate the smallest row's `resident` figure outright. + println!("warming up..."); + drop(black_box(build_large_network_account(1, 1))); + + let mut rows = Vec::with_capacity(sizes.len()); + for &n in &sizes { + println!("building account with {n} map entries..."); + rows.push(measure(1, n)); + } + + print_table(&rows); + print_summary(&rows); +} diff --git a/scripts/large-account-harness.sh b/scripts/large-account-harness.sh new file mode 100755 index 0000000000..39dabe8f38 --- /dev/null +++ b/scripts/large-account-harness.sh @@ -0,0 +1,302 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Runs the ntx-builder large-account harness end to end: +# +# 1. seeds a counter account with a large storage map, plus the wallet that owns it +# 2. measures that account in isolation, with no network running +# 3. brings up a local network with the pair committed at genesis, via run-node.sh +# 4. submits one increment and asserts the counter advances +# +# Environment: +# MAP_ENTRIES entries in the counter's storage map (default 1000) +# RUN_OFFLINE set to 0 to skip the isolated measurements +# WORK_DIR where seeded accounts, the genesis config, and logs go (default a temp dir) +# WAIT_BLOCKS blocks to allow for the counter to advance (default 20) +# SKIP_BUILD set to 1 to reuse existing release binaries +# KEEP set to 1 to leave WORK_DIR and the running stack in place on success +# VERBOSE set to 1 to also stream every step's output +# +# Note: run-node.sh removes and recreates /tmp/{node,validator-*,ntx-builder,genesis,accounts} and +# kills whatever holds its ports, so do not run this alongside another local stack. + +MAP_ENTRIES="${MAP_ENTRIES:-1000}" +WAIT_BLOCKS="${WAIT_BLOCKS:-20}" +SKIP_BUILD="${SKIP_BUILD:-0}" +KEEP="${KEEP:-0}" +RUN_OFFLINE="${RUN_OFFLINE:-1}" +VERBOSE="${VERBOSE:-0}" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +WORK_DIR="${WORK_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/large-account-harness.XXXXXX")}" +mkdir -p "$WORK_DIR" +SEEDED_DIR="$WORK_DIR/seeded" +GENESIS_CONFIG_FILE="$WORK_DIR/genesis.toml" +BUILD_LOG="$WORK_DIR/build.log" +SEED_LOG="$WORK_DIR/seed.log" +OFFLINE_OUT="$WORK_DIR/offline.out" +STACK_LOG="$WORK_DIR/stack.log" +VERIFY_OUT="$WORK_DIR/verify.out" + +RPC_PORT=57291 +NTX_BUILDER_PORT=50301 +# Must match run-node.sh's validator keys. +VALIDATOR_1_KEY_HEX="0101010101010101010101010101010101010101010101010101010101010101" +VALIDATOR_2_KEY_HEX="0202020202020202020202020202020202020202020202020202020202020202" + +BIN_DIR="$REPO_ROOT/target/release" +BENCH_BIN="$BIN_DIR/miden-large-account-benchmark" +VALIDATOR_BIN="$BIN_DIR/miden-validator" + +STACK_PID="" +SAMPLER_PID="" +STOP_SAMPLING="$WORK_DIR/.stop-sampling" +NTX_RSS_FILE="$WORK_DIR/ntx-builder.rss" +NODE_RSS_FILE="$WORK_DIR/node.rss" + +# The log to show if the run fails, set as each phase begins. +FAILURE_LOG="" + +phase() { + FAILURE_LOG="${2:-}" + printf '\n%s\n' "$1" +} + +# Runs a command with its output captured to $1, streaming it too when VERBOSE=1. +quietly() { + local log="$1" + shift + + if [[ "$VERBOSE" == "1" ]]; then + "$@" 2>&1 | tee "$log" + else + "$@" > "$log" 2>&1 + fi +} + +# Formats a byte count in the largest unit that stays readable. +human_bytes() { + awk -v b="$1" 'BEGIN { + if (b >= 1073741824) printf "%.1f GiB", b / 1073741824; + else if (b >= 1048576) printf "%.1f MiB", b / 1048576; + else if (b >= 1024) printf "%.1f KiB", b / 1024; + else printf "%d B", b; + }' +} + +# Tracks the peak resident set size of the processes matching $1, writing the running maximum (in +# KiB, summed across matches) to $2 until STOP_SAMPLING appears. +# +# Sampling from outside is deliberate: it measures what the real service holds, without needing any +# instrumentation inside the node or the ntx-builder. +sample_peak_rss() { + local pattern="$1" out="$2" max=0 total rss pids + + echo 0 > "$out" + while [[ ! -f "$STOP_SAMPLING" ]]; do + total=0 + pids=$(pgrep -f "$pattern" 2>/dev/null || true) + for pid in $pids; do + rss=$(ps -o rss= -p "$pid" 2>/dev/null | tr -d ' ' || true) + if [[ -n "$rss" ]]; then + total=$((total + rss)) + fi + done + if [[ "$total" -gt "$max" ]]; then + max=$total + echo "$max" > "$out" + fi + sleep 0.5 + done +} + +cleanup() { + local status=$? + + touch "$STOP_SAMPLING" 2>/dev/null || true + if [[ -n "$SAMPLER_PID" ]]; then + wait "$SAMPLER_PID" 2>/dev/null || true + fi + + if [[ -n "$STACK_PID" ]] && kill -0 "$STACK_PID" 2>/dev/null; then + if [[ "$KEEP" == "1" && $status -eq 0 ]]; then + printf '\nStack left running (KEEP=1), pid %s. Stop it with: kill %s\n' \ + "$STACK_PID" "$STACK_PID" + echo "Seeded accounts: $SEEDED_DIR" + return + fi + kill -TERM "$STACK_PID" 2>/dev/null || true + wait "$STACK_PID" 2>/dev/null || true + fi + + if [[ $status -ne 0 ]]; then + printf '\nFAILED\n' + if [[ -n "$FAILURE_LOG" && -s "$FAILURE_LOG" ]]; then + printf '\n--- %s (last 30 lines) ---\n' "$FAILURE_LOG" + tail -n 30 "$FAILURE_LOG" + fi + # The stack log usually holds the real cause even when another step reported the failure. + if [[ -s "$STACK_LOG" && "$FAILURE_LOG" != "$STACK_LOG" ]]; then + printf '\n--- %s (errors) ---\n' "$STACK_LOG" + grep -iE "error|panic" "$STACK_LOG" | tail -n 10 || echo "(none)" + fi + printf '\nLogs: %s\n' "$WORK_DIR" + elif [[ "$KEEP" != "1" ]]; then + rm -rf "$WORK_DIR" + fi +} +trap cleanup EXIT + +# --- build --------------------------------------------------------------------------------------- + +if [[ "$SKIP_BUILD" != "1" ]]; then + phase "Building release binaries" "$BUILD_LOG" + quietly "$BUILD_LOG" cargo build --release \ + -p miden-large-account-benchmark \ + -p miden-node \ + -p miden-validator \ + -p miden-ntx-builder \ + -p miden-remote-prover +fi + +for bin in "$BENCH_BIN" "$VALIDATOR_BIN" "$BIN_DIR/miden-node" \ + "$BIN_DIR/miden-ntx-builder" "$BIN_DIR/miden-remote-prover"; do + [[ -x "$bin" ]] || { echo "error: missing binary $bin (run without SKIP_BUILD=1)" >&2; exit 1; } +done + +# --- seed ---------------------------------------------------------------------------------------- + +phase "Seeding the faucet + wallet + counter set ($MAP_ENTRIES map entries)" "$SEED_LOG" +quietly "$SEED_LOG" "$BENCH_BIN" seed \ + --output-dir "$SEEDED_DIR" --counter-map-entries "$MAP_ENTRIES" + +COUNTER_SIZE=$(wc -c < "$SEEDED_DIR/counter.mac" | tr -d ' ') +WALLET_SIZE=$(wc -c < "$SEEDED_DIR/wallet.mac" | tr -d ' ') + +# --- isolated measurements ----------------------------------------------------------------------- + +if [[ "$RUN_OFFLINE" == "1" ]]; then + phase "Measuring the account in isolation (no network)" "$OFFLINE_OUT" + quietly "$OFFLINE_OUT" cargo bench --quiet \ + -p miden-ntx-builder --bench large_account -- "$MAP_ENTRIES" +fi + +# --- network ------------------------------------------------------------------------------------- + +# Absolute account paths: run-node.sh prepends the validators list into a copy under /tmp, so +# relative paths would resolve against /tmp rather than this work dir. +cat > "$GENESIS_CONFIG_FILE" < "$STACK_LOG" 2>&1 & +STACK_PID=$! + +for _ in $(seq 1 180); do + if ! kill -0 "$STACK_PID" 2>/dev/null; then + echo "error: the stack exited during startup" >&2 + exit 1 + fi + # Both must be up: the ntx-builder connects to the sequencer while starting and exits if + # refused, so a dead builder would otherwise surface later as the counter never advancing. + if nc -z 127.0.0.1 "$RPC_PORT" 2>/dev/null && nc -z 127.0.0.1 "$NTX_BUILDER_PORT" 2>/dev/null + then + break + fi + sleep 1 +done + +if ! nc -z 127.0.0.1 "$RPC_PORT" 2>/dev/null; then + echo "error: the sequencer's RPC never came up on :$RPC_PORT" >&2 + exit 1 +fi +if ! nc -z 127.0.0.1 "$NTX_BUILDER_PORT" 2>/dev/null; then + echo "error: the ntx-builder never came up on :$NTX_BUILDER_PORT" >&2 + exit 1 +fi + +STACK_READY_SECS=$(( $(date +%s) - STACK_START_EPOCH )) + +# Sample from here on, so the peak covers the ntx-builder loading the account and building the +# network transaction. +sample_peak_rss "miden-ntx-builder start" "$NTX_RSS_FILE" & +SAMPLER_PID=$! +sample_peak_rss "miden-node sequencer" "$NODE_RSS_FILE" & + +# The ports bind before the first block is produced; give the sequencer a moment so the genesis +# handshake and encryption-key fetch have something to talk to. +sleep 5 + +# --- verify -------------------------------------------------------------------------------------- + +VALIDATOR_1_PUBKEY=$("$VALIDATOR_BIN" pubkey --signing-key.hex "$VALIDATOR_1_KEY_HEX") +VALIDATOR_2_PUBKEY=$("$VALIDATOR_BIN" pubkey --signing-key.hex "$VALIDATOR_2_KEY_HEX") + +phase "Submitting an increment and waiting for the counter to advance" "$VERIFY_OUT" +quietly "$VERIFY_OUT" "$BENCH_BIN" verify \ + --accounts-dir "$SEEDED_DIR" \ + --rpc-url "http://127.0.0.1:$RPC_PORT" \ + --validator-signing-public-key "$VALIDATOR_1_PUBKEY" \ + --validator-signing-public-key "$VALIDATOR_2_PUBKEY" \ + --wait-blocks "$WAIT_BLOCKS" + +# --- results ------------------------------------------------------------------------------------- + +touch "$STOP_SAMPLING" +sleep 1 + +NTX_PEAK_KIB=$(cat "$NTX_RSS_FILE" 2>/dev/null || echo 0) +NODE_PEAK_KIB=$(cat "$NODE_RSS_FILE" 2>/dev/null || echo 0) +PROVING=$(sed -n 's/.*proved in \([0-9.]*\)s.*/\1/p' "$VERIFY_OUT" | head -1) +BLOCKS=$(sed -n 's/.*after \([0-9]*\) blocks.*/\1/p' "$VERIFY_OUT" | head -1) +PER_ENTRY=$(( MAP_ENTRIES > 0 ? COUNTER_SIZE / MAP_ENTRIES : 0 )) + +# Pull the isolated figures out of the benchmark's fixed-width table row for this size. +ISOLATED_RESIDENT="" +if [[ -s "$OFFLINE_OUT" ]]; then + # Each value is "number unit", so the fields are separated with a character that cannot appear + # inside them — splitting on whitespace would break them apart. + IFS='|' read -r ISOLATED_RESIDENT ISOLATED_PEAK ISOLATED_LOAD < <( + awk -v n="$MAP_ENTRIES" -v OFS='|' \ + '$1 == n { print $2" "$3, $4" "$5, $8" "$9; exit }' "$OFFLINE_OUT" + ) || true +fi + +printf '\nPASS — the ntx-builder loaded the account and consumed the network note\n' +printf '\n%s map entries\n' "$MAP_ENTRIES" +printf ' %-24s %s (%s/entry)\n' "counter on disk" \ + "$(human_bytes "$COUNTER_SIZE")" "$(human_bytes "$PER_ENTRY")" +printf ' %-24s %s\n' "wallet on disk" "$(human_bytes "$WALLET_SIZE")" + +if [[ -n "$ISOLATED_RESIDENT" ]]; then + printf ' %-24s %s resident, %s peak, %s to load\n' "account in isolation" \ + "$ISOLATED_RESIDENT" "$ISOLATED_PEAK" "$ISOLATED_LOAD" +fi + +printf ' %-24s %s\n' "ntx-builder peak RSS" "$(human_bytes $((NTX_PEAK_KIB * 1024)))" +printf ' %-24s %s\n' "sequencer peak RSS" "$(human_bytes $((NODE_PEAK_KIB * 1024)))" +printf ' %-24s %ss ready, %ss proving, %s blocks to consume\n' "timings" \ + "$STACK_READY_SECS" "${PROVING:-?}" "${BLOCKS:-?}" +printf '\nIsolated figures are exact; RSS is whole-process, sampled at 0.5s, so a floor.\n' diff --git a/scripts/run-node.sh b/scripts/run-node.sh index fdb99d27e5..508b2d62c1 100755 --- a/scripts/run-node.sh +++ b/scripts/run-node.sh @@ -38,7 +38,7 @@ VALIDATOR_INSECURE_STORAGE_KEY_PUBLIC_KEY_SET="${VALIDATOR_INSECURE_STORAGE_KEY_ VALIDATOR_1_INSECURE_STORAGE_KEY_SECRET_SHARE="${VALIDATOR_INSECURE_STORAGE_KEY_DIRECTORY}/validator-1/secret-share.wire" VALIDATOR_2_INSECURE_STORAGE_KEY_SECRET_SHARE="${VALIDATOR_INSECURE_STORAGE_KEY_DIRECTORY}/validator-2/secret-share.wire" -GENESIS_CONFIG="crates/store/src/genesis/config/samples/01-simple.toml" +GENESIS_CONFIG="${GENESIS_CONFIG:-crates/store/src/genesis/config/samples/01-simple.toml}" NODE_DIR="/tmp/node" FULL_NODE_1_DIR="/tmp/full-node-1" FULL_NODE_2_DIR="/tmp/full-node-2" @@ -109,6 +109,26 @@ bootstrap_ntx_builder() { --genesis "$GENESIS_DIR/genesis.dat" } +# Blocks until something is listening on a port, or gives up after roughly $2 seconds. +# +# The ntx-builder connects to the sequencer's RPC during startup and exits if that connection is +# refused, so it must not be started against a fixed sleep: a large genesis state (e.g. an account +# with a big storage map) delays the sequencer's bind well past a couple of seconds. +wait_for_port() { + local port="$1" + local timeout="${2:-120}" + + for _ in $(seq 1 "$timeout"); do + if nc -z 127.0.0.1 "$port" 2>/dev/null; then + return 0 + fi + sleep 1 + done + + echo "error: nothing listening on port $port after ${timeout}s" >&2 + return 1 +} + node_resource_attributes() { local instance_id="$1" @@ -233,8 +253,10 @@ echo "Starting remote prover..." --port="$REMOTE_PROVER_PORT" & PIDS+=($!) -# Give the sequencer a moment to bind before starting the NTX builder -sleep 2 +# The NTX builder connects to the sequencer's RPC while starting up and exits if it is refused, so +# wait for the port rather than sleeping a fixed amount. +echo "Waiting for the sequencer's RPC on :$RPC_PORT before starting the NTX builder..." +wait_for_port "$RPC_PORT" echo "Starting network transaction builder..." "$NTX_BUILDER_BINARY" start \