From 2c632b54f2f8cbe9cb23f9c7ce556f5db1f2a190 Mon Sep 17 00:00:00 2001 From: Bernhard Schuster Date: Mon, 16 Feb 2026 14:31:42 +0100 Subject: [PATCH 01/17] feat: do not load the full account from DB --- CHANGELOG.md | 1 + crates/store/src/db/mod.rs | 7 + .../store/src/db/models/queries/accounts.rs | 237 ++++-- .../src/db/models/queries/accounts/delta.rs | 253 +++++++ .../db/models/queries/accounts/delta/tests.rs | 693 ++++++++++++++++++ .../src/db/models/queries/accounts/tests.rs | 41 +- crates/store/src/db/models/queries/mod.rs | 13 +- crates/store/src/db/tests.rs | 158 +++- crates/store/src/inner_forest/mod.rs | 331 ++++----- crates/store/src/state/apply_block.rs | 17 +- crates/store/src/state/mod.rs | 3 + 11 files changed, 1477 insertions(+), 277 deletions(-) create mode 100644 crates/store/src/db/models/queries/accounts/delta.rs create mode 100644 crates/store/src/db/models/queries/accounts/delta/tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e14f06844..9f31e530d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,7 @@ - Pin tool versions in CI ([#1523](https://github.com/0xMiden/miden-node/pull/1523)). - Add `GetVaultAssetWitnesses` and `GetStorageMapWitness` RPC endpoints to store ([#1529](https://github.com/0xMiden/miden-node/pull/1529)). - Add check to ensure tree store state is in sync with database storage ([#1532](https://github.com/0xMiden/miden-node/issues/1534)). +- Improve speed account updates ([#1567](https://github.com/0xMiden/miden-node/pull/1567)). - Ensure store terminates on nullifier tree or account tree root vs header mismatch (#[#1569](https://github.com/0xMiden/miden-node/pull/1569)). - Added support for foreign accounts to `NtxDataStore` and add `GetAccount` endpoint to NTX Builder gRPC store client ([#1521](https://github.com/0xMiden/miden-node/pull/1521)). - Use paged queries for tree rebuilding to reduce memory usage during startup ([#1536](https://github.com/0xMiden/miden-node/pull/1536)). diff --git a/crates/store/src/db/mod.rs b/crates/store/src/db/mod.rs index 0b8f0fd42..f82fb6b84 100644 --- a/crates/store/src/db/mod.rs +++ b/crates/store/src/db/mod.rs @@ -38,6 +38,7 @@ pub use crate::db::models::queries::{ use crate::db::models::{Page, queries}; use crate::errors::{DatabaseError, DatabaseSetupError, NoteSyncError}; use crate::genesis::GenesisBlock; +use crate::inner_forest::BlockAccountRoots; pub(crate) mod manager; @@ -244,6 +245,7 @@ impl Db { &[], genesis.body().updated_accounts(), genesis.body().transactions(), + &BlockAccountRoots::new(), ) }) .context("failed to insert genesis block")?; @@ -556,6 +558,9 @@ impl Db { /// /// `allow_acquire` and `acquire_done` are used to synchronize writes to the DB with writes to /// the in-memory trees. Further details available on [`super::state::State::apply_block`]. + /// + /// `precomputed_roots` contains vault and storage map roots computed by `InnerForest`. These + /// are used directly instead of reloading all entries from disk to recompute roots. // TODO: This span is logged in a root span, we should connect it to the parent one. #[instrument(target = COMPONENT, skip_all, err)] pub async fn apply_block( @@ -564,6 +569,7 @@ impl Db { acquire_done: oneshot::Receiver<()>, signed_block: SignedBlock, notes: Vec<(NoteRecord, Option)>, + precomputed_roots: BlockAccountRoots, ) -> Result<()> { self.transact("apply block", move |conn| -> Result<()> { models::queries::apply_block( @@ -574,6 +580,7 @@ impl Db { signed_block.body().created_nullifiers(), signed_block.body().updated_accounts(), signed_block.body().transactions(), + &precomputed_roots, )?; // XXX FIXME TODO free floating mutex MUST NOT exist diff --git a/crates/store/src/db/models/queries/accounts.rs b/crates/store/src/db/models/queries/accounts.rs index da0d875a9..4ddeb20bb 100644 --- a/crates/store/src/db/models/queries/accounts.rs +++ b/crates/store/src/db/models/queries/accounts.rs @@ -19,12 +19,10 @@ use diesel::{ }; use miden_node_proto::domain::account::{AccountInfo, AccountSummary}; use miden_node_utils::limiter::MAX_RESPONSE_PAYLOAD_BYTES; -use miden_protocol::Word; use miden_protocol::account::delta::AccountUpdateDetails; use miden_protocol::account::{ Account, AccountCode, - AccountDelta, AccountId, AccountStorage, AccountStorageHeader, @@ -38,6 +36,7 @@ use miden_protocol::account::{ use miden_protocol::asset::{Asset, AssetVault, AssetVaultKey, FungibleAsset}; use miden_protocol::block::{BlockAccountUpdate, BlockNumber}; use miden_protocol::utils::{Deserializable, Serializable}; +use miden_protocol::{Felt, Word}; use crate::COMPONENT; use crate::db::models::conv::{SqlTypeConvert, nonce_to_raw_sql, raw_sql_to_nonce}; @@ -52,6 +51,15 @@ pub(crate) use at_block::{ select_account_vault_at_block, }; +mod delta; +use delta::{ + AccountStateForInsert, + PartialAccountState, + apply_storage_delta_with_precomputed_roots, + select_account_state_for_delta, + select_vault_balances_by_faucet_ids, +}; + #[cfg(test)] mod tests; @@ -162,7 +170,7 @@ pub(crate) fn select_account( /// `State` which contains an `SmtForest` to serve the latest and most recent /// historical data. // TODO: remove eventually once refactoring is complete -fn select_full_account( +pub(crate) fn select_full_account( conn: &mut SqliteConnection, account_id: AccountId, ) -> Result { @@ -951,6 +959,7 @@ pub(crate) fn upsert_accounts( conn: &mut SqliteConnection, accounts: &[BlockAccountUpdate], block_num: BlockNumber, + precomputed_roots: &crate::inner_forest::BlockAccountRoots, ) -> Result { let mut count = 0; for update in accounts { @@ -965,7 +974,7 @@ pub(crate) fn upsert_accounts( }; // Preserve the original creation block when updating existing accounts. - let created_at_block = QueryDsl::select( + let created_at_block_raw = QueryDsl::select( schema::accounts::table.filter( schema::accounts::account_id .eq(&account_id_bytes) @@ -977,15 +986,17 @@ pub(crate) fn upsert_accounts( .optional() .map_err(DatabaseError::Diesel)? .unwrap_or(block_num_raw); + let created_at_block = BlockNumber::from_raw_sql(created_at_block_raw)?; // NOTE: we collect storage / asset inserts to apply them only after the account row is // written. The storage and vault tables have FKs pointing to `accounts (account_id, // block_num)`, so inserting them earlier would violate those constraints when inserting a // brand-new account. - let (full_account, pending_storage_inserts, pending_asset_inserts) = match update.details() + let (account_state, pending_storage_inserts, pending_asset_inserts) = match update.details() { - AccountUpdateDetails::Private => (None, vec![], vec![]), + AccountUpdateDetails::Private => (AccountStateForInsert::Private, vec![], vec![]), + // New account is always a full account, but also comes as an update AccountUpdateDetails::Delta(delta) if delta.is_full_state() => { let account = Account::try_from(delta)?; debug_assert_eq!(account_id, account.id()); @@ -1020,12 +1031,16 @@ pub(crate) fn upsert_accounts( } } - (Some(account), storage, assets) + (AccountStateForInsert::FullAccount(account), storage, assets) }, + // Update of an existing account AccountUpdateDetails::Delta(delta) => { - // Reconstruct the full account from database tables - let account = select_full_account(conn, account_id)?; + // Load only the minimal data needed. Only load those storage map entries and vault + // entries that will receive updates. + // The next line fetches the header, which will always change with the exception of + // an empty delta. + let state = select_account_state_for_delta(conn, account_id)?; // --- collect storage map updates ---------------------------- @@ -1036,23 +1051,31 @@ pub(crate) fn upsert_accounts( } } - // apply delta to the account; we need to do this before we process asset updates - // because we currently need to get the current value of fungible assets from the - // account - let account_after = apply_delta(account, delta, &update.final_state_commitment())?; - // --- process asset updates ---------------------------------- + // Only query balances for faucet_ids that are being updated + let faucet_ids = Vec::from_iter(delta.vault().fungible().iter().map(|(id, _)| *id)); + let prev_balances = + select_vault_balances_by_faucet_ids(conn, account_id, &faucet_ids)?; let mut assets = Vec::new(); - for (faucet_id, _) in delta.vault().fungible().iter() { - let current_amount = account_after.vault().get_balance(*faucet_id).unwrap(); - let asset: Asset = FungibleAsset::new(*faucet_id, current_amount)?.into(); - let update_or_remove = if current_amount == 0 { None } else { Some(asset) }; - + // update fungible assets + for (faucet_id, amount_delta) in delta.vault().fungible().iter() { + let prev_balance = prev_balances.get(faucet_id).copied().unwrap_or(0); + let new_balance = + u64::try_from(i128::from(prev_balance) + i128::from(*amount_delta)) + .map_err(|_| { + DatabaseError::DataCorrupted(format!( + "Balance underflow for account {account_id}, faucet {faucet_id}" + )) + })?; + + let asset: Asset = FungibleAsset::new(*faucet_id, new_balance)?.into(); + let update_or_remove = if new_balance == 0 { None } else { Some(asset) }; assets.push((account_id, asset.vault_key(), update_or_remove)); } + // update non-fungible assets for (asset, delta_action) in delta.vault().non_fungible().iter() { let asset_update = match delta_action { NonFungibleDeltaAction::Add => Some(Asset::NonFungible(*asset)), @@ -1061,11 +1084,37 @@ pub(crate) fn upsert_accounts( assets.push((account_id, asset.vault_key(), asset_update)); } - (Some(account_after), storage, assets) + // --- compute updated account state for the accounts row --- + // Apply nonce delta + let new_nonce = Felt::new(state.nonce.as_int() + delta.nonce_delta().as_int()); + + let account_roots = precomputed_roots.get(&account_id); + + // Apply storage map value updates to header + let new_storage_header = apply_storage_delta_with_precomputed_roots( + &state.storage_header, + delta.storage(), + account_roots.map(|roots| &roots.storage_map_roots), + )?; + + let new_vault_root = + account_roots.and_then(|roots| roots.vault_root).unwrap_or(state.vault_root); + + // Create minimal account state data for the row insert + let account_state = PartialAccountState { + nonce: new_nonce, + code_commitment: state.code_commitment, + storage_header: new_storage_header, + vault_root: new_vault_root, + }; + + (AccountStateForInsert::PartialState(account_state), storage, assets) }, }; - if let Some(code) = full_account.as_ref().map(Account::code) { + // Insert account code for full accounts (new account creation) + if let AccountStateForInsert::FullAccount(ref account) = account_state { + let code = account.code(); let code_value = AccountCodeRowInsert { code_commitment: code.commitment().to_bytes(), code: code.to_bytes(), @@ -1087,24 +1136,49 @@ pub(crate) fn upsert_accounts( .set(schema::accounts::is_latest.eq(false)) .execute(conn)?; - let account_value = AccountRowInsert { - account_id: account_id_bytes, - network_account_type: network_account_type.to_raw_sql(), - account_commitment: update.final_state_commitment().to_bytes(), - block_num: block_num_raw, - nonce: full_account.as_ref().map(|account| nonce_to_raw_sql(account.nonce())), - code_commitment: full_account - .as_ref() - .map(|account| account.code().commitment().to_bytes()), - // Store only the header (slot metadata + map roots), not full storage with map contents - storage_header: full_account - .as_ref() - .map(|account| account.storage().to_header().to_bytes()), - vault_root: full_account.as_ref().map(|account| account.vault().root().to_bytes()), - is_latest: true, - created_at_block, + let account_value = match &account_state { + AccountStateForInsert::Private => AccountRowInsert::new_private( + account_id, + network_account_type, + update.final_state_commitment(), + block_num, + created_at_block, + ), + AccountStateForInsert::FullAccount(account) => AccountRowInsert::new_from_account( + account_id, + network_account_type, + update.final_state_commitment(), + block_num, + created_at_block, + account, + ), + AccountStateForInsert::PartialState(state) => AccountRowInsert::new_from_partial( + account_id, + network_account_type, + update.final_state_commitment(), + block_num, + created_at_block, + state, + ), }; + if let AccountStateForInsert::PartialState(state) = &account_state { + let account_header = miden_protocol::account::AccountHeader::new( + account_id, + state.nonce, + state.vault_root, + state.storage_header.to_commitment(), + state.code_commitment, + ); + + if account_header.commitment() != update.final_state_commitment() { + return Err(DatabaseError::AccountCommitmentsMismatch { + calculated: account_header.commitment(), + expected: update.final_state_commitment(), + }); + } + } + diesel::insert_into(schema::accounts::table) .values(&account_value) .execute(conn)?; @@ -1124,25 +1198,6 @@ pub(crate) fn upsert_accounts( Ok(count) } -/// Deserializes account and applies account delta. -pub(crate) fn apply_delta( - mut account: Account, - delta: &AccountDelta, - final_state_commitment: &Word, -) -> crate::db::Result { - account.apply_delta(delta)?; - - let actual_commitment = account.commitment(); - if &actual_commitment != final_state_commitment { - return Err(DatabaseError::AccountCommitmentsMismatch { - calculated: actual_commitment, - expected: *final_state_commitment, - }); - } - - Ok(account) -} - #[derive(Insertable, Debug, Clone)] #[diesel(table_name = schema::account_codes)] pub(crate) struct AccountCodeRowInsert { @@ -1165,6 +1220,76 @@ pub(crate) struct AccountRowInsert { pub(crate) created_at_block: i64, } +impl AccountRowInsert { + /// Creates an insert row for a private account (no public state). + fn new_private( + account_id: AccountId, + network_account_type: NetworkAccountType, + account_commitment: Word, + block_num: BlockNumber, + created_at_block: BlockNumber, + ) -> Self { + Self { + account_id: account_id.to_bytes(), + network_account_type: network_account_type.to_raw_sql(), + account_commitment: account_commitment.to_bytes(), + block_num: block_num.to_raw_sql(), + nonce: None, + code_commitment: None, + storage_header: None, + vault_root: None, + is_latest: true, + created_at_block: created_at_block.to_raw_sql(), + } + } + + /// Creates an insert row from a full account (new account creation). + fn new_from_account( + account_id: AccountId, + network_account_type: NetworkAccountType, + account_commitment: Word, + block_num: BlockNumber, + created_at_block: BlockNumber, + account: &Account, + ) -> Self { + Self { + account_id: account_id.to_bytes(), + network_account_type: network_account_type.to_raw_sql(), + account_commitment: account_commitment.to_bytes(), + block_num: block_num.to_raw_sql(), + nonce: Some(nonce_to_raw_sql(account.nonce())), + code_commitment: Some(account.code().commitment().to_bytes()), + storage_header: Some(account.storage().to_header().to_bytes()), + vault_root: Some(account.vault().root().to_bytes()), + is_latest: true, + created_at_block: created_at_block.to_raw_sql(), + } + } + + /// Creates an insert row from a partial account state (delta update). + fn new_from_partial( + account_id: AccountId, + network_account_type: NetworkAccountType, + account_commitment: Word, + block_num: BlockNumber, + created_at_block: BlockNumber, + state: &PartialAccountState, + ) -> Self { + Self { + account_id: account_id.to_bytes(), + network_account_type: network_account_type.to_raw_sql(), + account_commitment: account_commitment.to_bytes(), + block_num: block_num.to_raw_sql(), + nonce: Some(nonce_to_raw_sql(state.nonce)), + code_commitment: Some(state.code_commitment.to_bytes()), + storage_header: Some(state.storage_header.to_bytes()), + vault_root: Some(state.vault_root.to_bytes()), + is_latest: true, + created_at_block: created_at_block.to_raw_sql(), + } + } +} + #[derive(Insertable, AsChangeset, Debug, Clone)] #[diesel(table_name = schema::account_vault_assets)] pub(crate) struct AccountAssetRowInsert { diff --git a/crates/store/src/db/models/queries/accounts/delta.rs b/crates/store/src/db/models/queries/accounts/delta.rs new file mode 100644 index 000000000..bae443d61 --- /dev/null +++ b/crates/store/src/db/models/queries/accounts/delta.rs @@ -0,0 +1,253 @@ +//! Optimized delta update support for account updates. +//! +//! Provides functions and types for applying partial delta updates to accounts +//! without loading the full account state. Avoids loading: +//! - Full account code bytes +//! - All storage map entries +//! - All vault assets +//! +//! Instead, only the minimal data needed for the update is fetched. + +use std::collections::BTreeMap; + +use diesel::query_dsl::methods::SelectDsl; +use diesel::{ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl, SqliteConnection}; +use miden_protocol::account::delta::AccountStorageDelta; +use miden_protocol::account::{ + Account, + AccountId, + AccountStorageHeader, + StorageSlotHeader, + StorageSlotName, +}; +use miden_protocol::asset::{Asset, FungibleAsset}; +use miden_protocol::utils::{Deserializable, Serializable}; +use miden_protocol::{Felt, Word}; + +use crate::db::models::conv::raw_sql_to_nonce; +use crate::db::schema; +use crate::errors::DatabaseError; + +#[cfg(test)] +mod tests; + +// TYPES +// ================================================================================================ + +/// Raw row type for account state delta queries. +/// +/// Fields: (`nonce`, `code_commitment`, `storage_header`, `vault_root`) +#[derive(diesel::prelude::Queryable)] +struct AccountStateDeltaRow { + nonce: Option, + code_commitment: Option>, + storage_header: Option>, + vault_root: Option>, +} + +/// Data needed for applying a delta update to an existing account. +/// Fetches only the minimal data required, avoiding loading full code and storage. +#[derive(Debug, Clone)] +pub(super) struct AccountStateForDelta { + pub nonce: Felt, + pub code_commitment: Word, + pub storage_header: AccountStorageHeader, + pub vault_root: Word, +} + +/// Minimal account state computed from a partial delta update. +/// Contains only the fields needed for the accounts table row insert. +#[derive(Debug, Clone)] +pub(super) struct PartialAccountState { + pub nonce: Felt, + pub code_commitment: Word, + pub storage_header: AccountStorageHeader, + pub vault_root: Word, +} + +/// Represents the account state to be inserted, either from a full account +/// or from a partial delta update. +pub(super) enum AccountStateForInsert { + /// Private account - no public state stored + Private, + /// Full account state (from full-state delta, i.e., new account) + FullAccount(Account), + /// Partial account state (from partial delta, i.e., existing account update) + PartialState(PartialAccountState), +} + +// QUERIES +// ================================================================================================ + +/// Selects the minimal account state needed for applying a delta update. +/// +/// Optimized query that only fetches: +/// - `nonce` (to add `nonce_delta`) +/// - `code_commitment` (unchanged in partial deltas) +/// - `storage_header` (to apply storage delta) +/// - `vault_root` (to apply vault delta) +/// +/// # Raw SQL +/// +/// ```sql +/// SELECT nonce, code_commitment, storage_header, vault_root +/// FROM accounts +/// WHERE account_id = ?1 AND is_latest = 1 +/// ``` +pub(super) fn select_account_state_for_delta( + conn: &mut SqliteConnection, + account_id: AccountId, +) -> Result { + let row: AccountStateDeltaRow = SelectDsl::select( + schema::accounts::table, + ( + schema::accounts::nonce, + schema::accounts::code_commitment, + schema::accounts::storage_header, + schema::accounts::vault_root, + ), + ) + .filter(schema::accounts::account_id.eq(account_id.to_bytes())) + .filter(schema::accounts::is_latest.eq(true)) + .get_result(conn) + .optional()? + .ok_or(DatabaseError::AccountNotFoundInDb(account_id))?; + + let nonce = raw_sql_to_nonce(row.nonce.ok_or_else(|| { + DatabaseError::DataCorrupted(format!("No nonce found for account {account_id}")) + })?); + + let code_commitment = row + .code_commitment + .map(|bytes| Word::read_from_bytes(&bytes)) + .transpose()? + .ok_or_else(|| { + DatabaseError::DataCorrupted(format!( + "No code_commitment found for account {account_id}" + )) + })?; + + let storage_header = match row.storage_header { + Some(bytes) => AccountStorageHeader::read_from_bytes(&bytes)?, + None => AccountStorageHeader::new(Vec::new())?, + }; + + let vault_root = row + .vault_root + .map(|bytes| Word::read_from_bytes(&bytes)) + .transpose()? + .unwrap_or(Word::default()); + + Ok(AccountStateForDelta { + nonce, + code_commitment, + storage_header, + vault_root, + }) +} + +/// Selects vault balances for specific faucet IDs. +/// +/// Optimized query that only fetches balances for the faucet IDs +/// that are being updated by a delta, rather than loading all vault assets. +/// +/// Returns a map from `faucet_id` to the current balance (0 if not found). +/// +/// # Raw SQL +/// +/// ```sql +/// SELECT vault_key, asset +/// FROM account_vault_assets +/// WHERE account_id = ?1 AND is_latest = 1 AND vault_key IN (?2, ?3, ...) +/// ``` +pub(super) fn select_vault_balances_by_faucet_ids( + conn: &mut SqliteConnection, + account_id: AccountId, + faucet_ids: &[AccountId], +) -> Result, DatabaseError> { + use schema::account_vault_assets as vault; + + if faucet_ids.is_empty() { + return Ok(BTreeMap::new()); + } + + let account_id_bytes = account_id.to_bytes(); + + // Compute vault keys for each faucet ID + let vault_keys: Vec> = Result::from_iter(faucet_ids.iter().map(|faucet_id| { + let asset = FungibleAsset::new(*faucet_id, 0) + .map_err(|_| DatabaseError::DataCorrupted(format!("Invalid faucet id {faucet_id}")))?; + let key: Word = asset.vault_key().into(); + Ok::<_, DatabaseError>(key.to_bytes()) + }))?; + + let entries: Vec<(Vec, Option>)> = + SelectDsl::select(vault::table, (vault::vault_key, vault::asset)) + .filter(vault::account_id.eq(&account_id_bytes)) + .filter(vault::is_latest.eq(true)) + .filter(vault::vault_key.eq_any(&vault_keys)) + .load(conn)?; + + let mut balances = BTreeMap::new(); + + for (_vault_key_bytes, maybe_asset_bytes) in entries { + if let Some(asset_bytes) = maybe_asset_bytes { + let asset = Asset::read_from_bytes(&asset_bytes)?; + if let Asset::Fungible(fungible) = asset { + balances.insert(fungible.faucet_id(), fungible.amount()); + } + } + } + + Ok(balances) +} + +// HELPER FUNCTIONS +// ================================================================================================ + +/// Applies storage delta to an existing storage header using precomputed map roots. +/// +/// For value slots, updates the slot value directly. +/// For map slots, uses the precomputed roots for updated maps. +pub(super) fn apply_storage_delta_with_precomputed_roots( + header: &AccountStorageHeader, + delta: &AccountStorageDelta, + storage_map_roots: Option<&BTreeMap>, +) -> Result { + let mut value_updates: BTreeMap<&StorageSlotName, Word> = BTreeMap::new(); + let mut map_updates: BTreeMap<&StorageSlotName, Word> = BTreeMap::new(); + + for (slot_name, new_value) in delta.values() { + value_updates.insert(slot_name, *new_value); + } + + for (slot_name, map_delta) in delta.maps() { + if map_delta.is_empty() { + continue; + } + + let new_root = storage_map_roots + .and_then(|roots| roots.get(slot_name).copied()) + .ok_or_else(|| { + DatabaseError::DataCorrupted(format!( + "Missing precomputed storage map root for slot {slot_name}" + )) + })?; + map_updates.insert(slot_name, new_root); + } + + let new_slots = Vec::from_iter(header.slots().map(|slot| { + let slot_name = slot.name(); + if let Some(&new_value) = value_updates.get(slot_name) { + StorageSlotHeader::new(slot_name.clone(), slot.slot_type(), new_value) + } else if let Some(&new_root) = map_updates.get(slot_name) { + StorageSlotHeader::new(slot_name.clone(), slot.slot_type(), new_root) + } else { + slot.clone() + } + })); + + AccountStorageHeader::new(new_slots).map_err(|e| { + DatabaseError::DataCorrupted(format!("Failed to create storage header: {e:?}")) + }) +} diff --git a/crates/store/src/db/models/queries/accounts/delta/tests.rs b/crates/store/src/db/models/queries/accounts/delta/tests.rs new file mode 100644 index 000000000..88542bdfd --- /dev/null +++ b/crates/store/src/db/models/queries/accounts/delta/tests.rs @@ -0,0 +1,693 @@ +//! +//! Tests for delta update functionality. + +use std::collections::BTreeMap; + +use assert_matches::assert_matches; +use diesel::{Connection, ExpressionMethods, QueryDsl, RunQueryDsl, SqliteConnection}; +use diesel_migrations::MigrationHarness; +use miden_node_utils::fee::test_fee_params; +use miden_protocol::account::auth::PublicKeyCommitment; +use miden_protocol::account::delta::{ + AccountStorageDelta, + AccountUpdateDetails, + AccountVaultDelta, + StorageMapDelta, + StorageSlotDelta, +}; +use miden_protocol::account::{ + AccountBuilder, + AccountComponent, + AccountDelta, + AccountId, + AccountStorageMode, + AccountType, + StorageMap, + StorageSlot, + StorageSlotName, +}; +use miden_protocol::asset::{Asset, FungibleAsset}; +use miden_protocol::block::{BlockAccountUpdate, BlockHeader, BlockNumber}; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SecretKey; +use miden_protocol::testing::account_id::{ + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1, +}; +use miden_protocol::utils::Serializable; +use miden_protocol::{EMPTY_WORD, Felt, Word}; +use miden_standards::account::auth::AuthFalcon512Rpo; +use miden_standards::code_builder::CodeBuilder; + +use crate::db::migrations::MIGRATIONS; +use crate::db::models::queries::accounts::{ + select_account_header_with_storage_header_at_block, + select_account_vault_at_block, + select_full_account, + upsert_accounts, +}; +use crate::db::schema::accounts; +use crate::inner_forest::{BlockAccountRoots, PrecomputedAccountRoots}; + +/// Builds `BlockAccountRoots` from an expected final account state. +/// +/// Simulates what `InnerForest::apply_block_updates` computes for storage map and vault roots. +fn precomputed_roots_from_account(account: &miden_protocol::account::Account) -> BlockAccountRoots { + use miden_protocol::account::StorageSlotContent; + + let mut storage_map_roots = BTreeMap::new(); + for slot in account.storage().slots() { + if let StorageSlotContent::Map(map) = slot.content() { + storage_map_roots.insert(slot.name().clone(), map.root()); + } + } + + BTreeMap::from_iter([( + account.id(), + PrecomputedAccountRoots { + vault_root: Some(account.vault().root()), + storage_map_roots, + }, + )]) +} + +fn setup_test_db() -> SqliteConnection { + let mut conn = + SqliteConnection::establish(":memory:").expect("Failed to create in-memory database"); + + conn.run_pending_migrations(MIGRATIONS).expect("Failed to run migrations"); + + conn +} + +fn insert_block_header(conn: &mut SqliteConnection, block_num: BlockNumber) { + use crate::db::schema::block_headers; + + let secret_key = SecretKey::new(); + let block_header = BlockHeader::new( + 1_u8.into(), + Word::default(), + block_num, + Word::default(), + Word::default(), + Word::default(), + Word::default(), + Word::default(), + Word::default(), + secret_key.public_key(), + test_fee_params(), + 0_u8.into(), + ); + let signature = secret_key.sign(block_header.commitment()); + + diesel::insert_into(block_headers::table) + .values(( + block_headers::block_num.eq(i64::from(block_num.as_u32())), + block_headers::block_header.eq(block_header.to_bytes()), + block_headers::signature.eq(signature.to_bytes()), + )) + .execute(conn) + .expect("Failed to insert block header"); +} + +/// Tests that the optimized delta update path produces the same results as the old +/// method that loads the full account. +/// +/// Covers partial deltas that update: +/// - Nonce (via `nonce_delta`) +/// - Value storage slots +/// - Vault assets (fungible) starting from empty vault +/// +/// The test ensures the optimized code path in `upsert_accounts` produces correct results +/// by comparing the final account state against a manually constructed expected state. +#[test] +#[expect( + clippy::too_many_lines, + reason = "test exercises multiple storage and vault paths" +)] +fn optimized_delta_matches_full_account_method() { + // Use deterministic account seed to keep account IDs stable. + const ACCOUNT_SEED: [u8; 32] = [10u8; 32]; + // Use fixed block numbers to ensure deterministic ordering. + const BLOCK_NUM_1: u32 = 1; + const BLOCK_NUM_2: u32 = 2; + // Use explicit slot indices to avoid magic numbers. + const SLOT_INDEX_PRIMARY: usize = 0; + const SLOT_INDEX_SECONDARY: usize = 1; + // Use fixed values to verify storage delta updates. + const INITIAL_SLOT_VALUES: [u64; 4] = [100, 200, 300, 400]; + const UPDATED_SLOT_VALUES: [u64; 4] = [111, 222, 333, 444]; + // Use fixed delta values to validate nonce and vault changes. + const NONCE_DELTA: u64 = 5; + const VAULT_AMOUNT: u64 = 500; + + let mut conn = setup_test_db(); + + // Create an account with value slots only (no map slots to avoid SmtForest complexity) + let slot_value_initial = Word::from([ + Felt::new(INITIAL_SLOT_VALUES[0]), + Felt::new(INITIAL_SLOT_VALUES[1]), + Felt::new(INITIAL_SLOT_VALUES[2]), + Felt::new(INITIAL_SLOT_VALUES[3]), + ]); + + let component_storage = vec![ + StorageSlot::with_value(StorageSlotName::mock(SLOT_INDEX_PRIMARY), slot_value_initial), + StorageSlot::with_value(StorageSlotName::mock(SLOT_INDEX_SECONDARY), EMPTY_WORD), + ]; + + let account_component_code = CodeBuilder::default() + .compile_component_code("test::interface", "pub proc foo push.1 end") + .unwrap(); + + let component = AccountComponent::new(account_component_code, component_storage) + .unwrap() + .with_supported_type(AccountType::RegularAccountImmutableCode); + + let account = AccountBuilder::new(ACCOUNT_SEED) + .account_type(AccountType::RegularAccountImmutableCode) + .storage_mode(AccountStorageMode::Public) + .with_component(component) + .with_auth_component(AuthFalcon512Rpo::new(PublicKeyCommitment::from(EMPTY_WORD))) + .build_existing() + .unwrap(); + + let block_1 = BlockNumber::from(BLOCK_NUM_1); + let block_2 = BlockNumber::from(BLOCK_NUM_2); + insert_block_header(&mut conn, block_1); + insert_block_header(&mut conn, block_2); + + // Insert the initial account at block 1 (full state) - no vault assets + let delta_initial = AccountDelta::try_from(account.clone()).unwrap(); + let account_update_initial = BlockAccountUpdate::new( + account.id(), + account.commitment(), + AccountUpdateDetails::Delta(delta_initial), + ); + upsert_accounts(&mut conn, &[account_update_initial], block_1, &BlockAccountRoots::new()) + .expect("Initial upsert failed"); + + // Verify initial state + let full_account_before = + select_full_account(&mut conn, account.id()).expect("Failed to load full account"); + assert_eq!(full_account_before.nonce(), account.nonce()); + assert!( + full_account_before.vault().assets().next().is_none(), + "Vault should be empty initially" + ); + + // Create a partial delta to apply: + // - Increment nonce by 5 + // - Update the first value slot + // - Add 500 tokens to the vault (starting from empty) + + let new_slot_value = Word::from([ + Felt::new(UPDATED_SLOT_VALUES[0]), + Felt::new(UPDATED_SLOT_VALUES[1]), + Felt::new(UPDATED_SLOT_VALUES[2]), + Felt::new(UPDATED_SLOT_VALUES[3]), + ]); + let faucet_id = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).unwrap(); + + // Find the slot name from the account's storage + let value_slot_name = + full_account_before.storage().slots().iter().next().unwrap().name().clone(); + + // Build the storage delta (value slot update only) + let storage_delta = { + let deltas = BTreeMap::from_iter([( + value_slot_name.clone(), + StorageSlotDelta::Value(new_slot_value), + )]); + AccountStorageDelta::from_raw(deltas) + }; + + // Build the vault delta (add 500 tokens to empty vault) + let vault_delta = { + let mut delta = AccountVaultDelta::default(); + let asset = Asset::Fungible(FungibleAsset::new(faucet_id, VAULT_AMOUNT).unwrap()); + delta.add_asset(asset).unwrap(); + delta + }; + + // Create a partial delta + let nonce_delta = Felt::new(NONCE_DELTA); + let partial_delta = AccountDelta::new( + full_account_before.id(), + storage_delta.clone(), + vault_delta.clone(), + nonce_delta, + ) + .unwrap(); + assert!(!partial_delta.is_full_state(), "Delta should be partial, not full state"); + + // Construct the expected final account by applying the delta + let expected_nonce = Felt::new(full_account_before.nonce().as_int() + nonce_delta.as_int()); + let expected_code_commitment = full_account_before.code().commitment(); + + let mut expected_account = full_account_before.clone(); + expected_account.apply_delta(&partial_delta).unwrap(); + let final_account_for_commitment = expected_account; + + let final_commitment = final_account_for_commitment.commitment(); + let expected_storage_commitment = final_account_for_commitment.storage().to_commitment(); + let expected_vault_root = final_account_for_commitment.vault().root(); + + // ----- Apply the partial delta via upsert_accounts (optimized path) ----- + let account_update = BlockAccountUpdate::new( + account.id(), + final_commitment, + AccountUpdateDetails::Delta(partial_delta), + ); + let roots = precomputed_roots_from_account(&final_account_for_commitment); + upsert_accounts(&mut conn, &[account_update], block_2, &roots) + .expect("Partial delta upsert failed"); + + // ----- VERIFY: Query the DB and check that optimized path produced correct results ----- + + let (header_after, storage_header_after) = + select_account_header_with_storage_header_at_block(&mut conn, account.id(), block_2) + .expect("Query should succeed") + .expect("Account should exist"); + + // Verify nonce + assert_eq!( + header_after.nonce(), + expected_nonce, + "Nonce mismatch: optimized={:?}, expected={:?}", + header_after.nonce(), + expected_nonce + ); + + // Verify code commitment (should be unchanged) + assert_eq!( + header_after.code_commitment(), + expected_code_commitment, + "Code commitment mismatch" + ); + + // Verify storage header commitment + assert_eq!( + storage_header_after.to_commitment(), + expected_storage_commitment, + "Storage header commitment mismatch" + ); + + // Verify vault assets + let vault_assets_after = select_account_vault_at_block(&mut conn, account.id(), block_2) + .expect("Query vault should succeed"); + + assert_eq!(vault_assets_after.len(), 1, "Should have 1 vault asset"); + assert_matches!(&vault_assets_after[0], Asset::Fungible(f) => { + assert_eq!(f.faucet_id(), faucet_id, "Faucet ID should match"); + assert_eq!(f.amount(), VAULT_AMOUNT, "Amount should be 500"); + }); + + // Verify the account commitment matches + assert_eq!( + header_after.commitment(), + final_commitment, + "Account commitment should match the expected final state" + ); + + // Also verify we can load the full account and it has correct state + let full_account_after = select_full_account(&mut conn, account.id()) + .expect("Failed to load full account after update"); + + assert_eq!(full_account_after.nonce(), expected_nonce, "Full account nonce mismatch"); + assert_eq!( + full_account_after.storage().to_commitment(), + expected_storage_commitment, + "Full account storage commitment mismatch" + ); + assert_eq!( + full_account_after.vault().root(), + expected_vault_root, + "Full account vault root mismatch" + ); +} + +#[test] +fn optimized_delta_updates_non_empty_vault() { + const ACCOUNT_SEED: [u8; 32] = [40u8; 32]; + const BLOCK_NUM_1: u32 = 1; + const BLOCK_NUM_2: u32 = 2; + const NONCE_DELTA: u64 = 1; + const INITIAL_AMOUNT: u64 = 700; + const ADDED_AMOUNT: u64 = 250; + const SLOT_INDEX: usize = 0; + + let mut conn = setup_test_db(); + + let faucet_id = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).unwrap(); + let faucet_id_1 = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1).unwrap(); + let initial_asset = Asset::Fungible(FungibleAsset::new(faucet_id, INITIAL_AMOUNT).unwrap()); + + let component_storage = + vec![StorageSlot::with_value(StorageSlotName::mock(SLOT_INDEX), EMPTY_WORD)]; + + let account_component_code = CodeBuilder::default() + .compile_component_code("test::interface", "pub proc vault push.1 end") + .unwrap(); + + let component = AccountComponent::new(account_component_code, component_storage) + .unwrap() + .with_supported_type(AccountType::RegularAccountImmutableCode); + + let account = AccountBuilder::new(ACCOUNT_SEED) + .account_type(AccountType::RegularAccountImmutableCode) + .storage_mode(AccountStorageMode::Public) + .with_component(component) + .with_auth_component(AuthFalcon512Rpo::new(PublicKeyCommitment::from(EMPTY_WORD))) + .with_assets([initial_asset]) + .build_existing() + .unwrap(); + + let block_1 = BlockNumber::from(BLOCK_NUM_1); + let block_2 = BlockNumber::from(BLOCK_NUM_2); + insert_block_header(&mut conn, block_1); + insert_block_header(&mut conn, block_2); + + let delta_initial = AccountDelta::try_from(account.clone()).unwrap(); + let account_update_initial = BlockAccountUpdate::new( + account.id(), + account.commitment(), + AccountUpdateDetails::Delta(delta_initial), + ); + upsert_accounts(&mut conn, &[account_update_initial], block_1, &BlockAccountRoots::new()) + .expect("Initial upsert failed"); + + let full_account_before = + select_full_account(&mut conn, account.id()).expect("Failed to load full account"); + + let mut vault_delta = AccountVaultDelta::default(); + vault_delta + .add_asset(Asset::Fungible(FungibleAsset::new(faucet_id_1, ADDED_AMOUNT).unwrap())) + .unwrap(); + vault_delta + .remove_asset(Asset::Fungible(FungibleAsset::new(faucet_id, INITIAL_AMOUNT).unwrap())) + .unwrap(); + + let partial_delta = AccountDelta::new( + account.id(), + AccountStorageDelta::new(), + vault_delta, + Felt::new(NONCE_DELTA), + ) + .unwrap(); + + let mut expected_account = full_account_before.clone(); + expected_account.apply_delta(&partial_delta).unwrap(); + let expected_commitment = expected_account.commitment(); + let expected_vault_root = expected_account.vault().root(); + + let account_update = BlockAccountUpdate::new( + account.id(), + expected_commitment, + AccountUpdateDetails::Delta(partial_delta), + ); + let roots = precomputed_roots_from_account(&expected_account); + upsert_accounts(&mut conn, &[account_update], block_2, &roots) + .expect("Partial delta upsert failed"); + + let vault_assets_after = select_account_vault_at_block(&mut conn, account.id(), block_2) + .expect("Query vault should succeed"); + + assert_eq!(vault_assets_after.len(), 1, "Should have 1 vault asset"); + assert_matches!(&vault_assets_after[0], Asset::Fungible(f) => { + assert_eq!(f.faucet_id(), faucet_id_1, "Faucet ID should match"); + assert_eq!(f.amount(), ADDED_AMOUNT, "Amount should match"); + }); + + let full_account_after = select_full_account(&mut conn, account.id()) + .expect("Failed to load full account after update"); + + assert_eq!(full_account_after.vault().root(), expected_vault_root); + assert_eq!(full_account_after.commitment(), expected_commitment); +} + +#[test] +fn optimized_delta_updates_storage_map_header() { + // Use deterministic account seed to keep account IDs stable. + const ACCOUNT_SEED: [u8; 32] = [30u8; 32]; + // Use fixed block numbers to ensure deterministic ordering. + const BLOCK_NUM_1: u32 = 1; + const BLOCK_NUM_2: u32 = 2; + // Use explicit slot index to avoid magic numbers. + const SLOT_INDEX_MAP: usize = 3; + // Use fixed map values to validate root updates. + const MAP_KEY_VALUES: [u64; 4] = [7, 0, 0, 0]; + const MAP_VALUE_INITIAL: [u64; 4] = [10, 20, 30, 40]; + const MAP_VALUE_UPDATED: [u64; 4] = [50, 60, 70, 80]; + // Use nonzero nonce delta (required when storage/vault changes). + const NONCE_DELTA: u64 = 1; + + let mut conn = setup_test_db(); + + let map_key = Word::from([ + Felt::new(MAP_KEY_VALUES[0]), + Felt::new(MAP_KEY_VALUES[1]), + Felt::new(MAP_KEY_VALUES[2]), + Felt::new(MAP_KEY_VALUES[3]), + ]); + let map_value_initial = Word::from([ + Felt::new(MAP_VALUE_INITIAL[0]), + Felt::new(MAP_VALUE_INITIAL[1]), + Felt::new(MAP_VALUE_INITIAL[2]), + Felt::new(MAP_VALUE_INITIAL[3]), + ]); + let map_value_updated = Word::from([ + Felt::new(MAP_VALUE_UPDATED[0]), + Felt::new(MAP_VALUE_UPDATED[1]), + Felt::new(MAP_VALUE_UPDATED[2]), + Felt::new(MAP_VALUE_UPDATED[3]), + ]); + + let storage_map = StorageMap::with_entries(vec![(map_key, map_value_initial)]).unwrap(); + let component_storage = + vec![StorageSlot::with_map(StorageSlotName::mock(SLOT_INDEX_MAP), storage_map)]; + + let account_component_code = CodeBuilder::default() + .compile_component_code("test::interface", "pub proc map push.1 end") + .unwrap(); + + let component = AccountComponent::new(account_component_code, component_storage) + .unwrap() + .with_supported_type(AccountType::RegularAccountImmutableCode); + + let account = AccountBuilder::new(ACCOUNT_SEED) + .account_type(AccountType::RegularAccountImmutableCode) + .storage_mode(AccountStorageMode::Public) + .with_component(component) + .with_auth_component(AuthFalcon512Rpo::new(PublicKeyCommitment::from(EMPTY_WORD))) + .build_existing() + .unwrap(); + + let block_1 = BlockNumber::from(BLOCK_NUM_1); + let block_2 = BlockNumber::from(BLOCK_NUM_2); + insert_block_header(&mut conn, block_1); + insert_block_header(&mut conn, block_2); + + let delta_initial = AccountDelta::try_from(account.clone()).unwrap(); + let account_update_initial = BlockAccountUpdate::new( + account.id(), + account.commitment(), + AccountUpdateDetails::Delta(delta_initial), + ); + upsert_accounts(&mut conn, &[account_update_initial], block_1, &BlockAccountRoots::new()) + .expect("Initial upsert failed"); + + let full_account_before = + select_full_account(&mut conn, account.id()).expect("Failed to load full account"); + + let mut map_delta = StorageMapDelta::default(); + map_delta.insert(map_key, map_value_updated); + let storage_delta = AccountStorageDelta::from_raw(BTreeMap::from_iter([( + StorageSlotName::mock(SLOT_INDEX_MAP), + StorageSlotDelta::Map(map_delta), + )])); + + let partial_delta = AccountDelta::new( + account.id(), + storage_delta, + AccountVaultDelta::default(), + Felt::new(NONCE_DELTA), + ) + .unwrap(); + + let mut expected_account = full_account_before.clone(); + expected_account.apply_delta(&partial_delta).unwrap(); + let expected_commitment = expected_account.commitment(); + let expected_storage_commitment = expected_account.storage().to_commitment(); + + let account_update = BlockAccountUpdate::new( + account.id(), + expected_commitment, + AccountUpdateDetails::Delta(partial_delta), + ); + let roots = precomputed_roots_from_account(&expected_account); + upsert_accounts(&mut conn, &[account_update], block_2, &roots) + .expect("Partial delta upsert failed"); + + let (header_after, storage_header_after) = + select_account_header_with_storage_header_at_block(&mut conn, account.id(), block_2) + .expect("Query should succeed") + .expect("Account should exist"); + + assert_eq!( + storage_header_after.to_commitment(), + expected_storage_commitment, + "Storage commitment should match after map delta" + ); + assert_eq!( + header_after.commitment(), + expected_commitment, + "Account commitment should match after map delta" + ); +} + +/// Tests that a private account update (no public state) is handled correctly. +/// +/// Private accounts store only the account commitment, not the full state. +#[test] +fn upsert_private_account() { + use miden_protocol::account::{AccountIdVersion, AccountStorageMode, AccountType}; + + // Use deterministic account seed to keep account IDs stable. + const ACCOUNT_ID_SEED: [u8; 15] = [20u8; 15]; + // Use fixed block number to keep test ordering deterministic. + const BLOCK_NUM: u32 = 1; + // Use fixed commitment values to validate storage behavior. + const COMMITMENT_WORDS: [u64; 4] = [1, 2, 3, 4]; + + let mut conn = setup_test_db(); + + let block_num = BlockNumber::from(BLOCK_NUM); + insert_block_header(&mut conn, block_num); + + // Create a private account ID + let account_id = AccountId::dummy( + ACCOUNT_ID_SEED, + AccountIdVersion::Version0, + AccountType::RegularAccountImmutableCode, + AccountStorageMode::Private, + ); + + let account_commitment = Word::from([ + Felt::new(COMMITMENT_WORDS[0]), + Felt::new(COMMITMENT_WORDS[1]), + Felt::new(COMMITMENT_WORDS[2]), + Felt::new(COMMITMENT_WORDS[3]), + ]); + + // Insert as private account + let account_update = + BlockAccountUpdate::new(account_id, account_commitment, AccountUpdateDetails::Private); + + upsert_accounts(&mut conn, &[account_update], block_num, &BlockAccountRoots::new()) + .expect("Private account upsert failed"); + + // Verify the account exists and commitment matches + + let (stored_commitment, stored_nonce, stored_code): (Vec, Option, Option>) = + accounts::table + .filter(accounts::account_id.eq(account_id.to_bytes())) + .filter(accounts::is_latest.eq(true)) + .select((accounts::account_commitment, accounts::nonce, accounts::code_commitment)) + .first(&mut conn) + .expect("Account should exist in DB"); + + assert_eq!( + stored_commitment, + account_commitment.to_bytes(), + "Stored commitment should match" + ); + + // Private accounts have NULL for nonce, code_commitment, storage_header, vault_root + assert!(stored_nonce.is_none(), "Private account should have NULL nonce"); + assert!(stored_code.is_none(), "Private account should have NULL code_commitment"); +} + +/// Tests that a full-state delta (new account creation) is handled correctly. +/// +/// Full-state deltas contain the complete account state including code. +#[test] +fn upsert_full_state_delta() { + // Use deterministic account seed to keep account IDs stable. + const ACCOUNT_SEED: [u8; 32] = [20u8; 32]; + // Use fixed block number to keep test ordering deterministic. + const BLOCK_NUM: u32 = 1; + // Use fixed slot values to validate storage behavior. + const SLOT_VALUES: [u64; 4] = [10, 20, 30, 40]; + // Use explicit slot index to avoid magic numbers. + const SLOT_INDEX: usize = 0; + + let mut conn = setup_test_db(); + + let block_num = BlockNumber::from(BLOCK_NUM); + insert_block_header(&mut conn, block_num); + + // Create an account with storage + let slot_value = Word::from([ + Felt::new(SLOT_VALUES[0]), + Felt::new(SLOT_VALUES[1]), + Felt::new(SLOT_VALUES[2]), + Felt::new(SLOT_VALUES[3]), + ]); + let component_storage = + vec![StorageSlot::with_value(StorageSlotName::mock(SLOT_INDEX), slot_value)]; + + let account_component_code = CodeBuilder::default() + .compile_component_code("test::interface", "pub proc bar push.2 end") + .unwrap(); + + let component = AccountComponent::new(account_component_code, component_storage) + .unwrap() + .with_supported_type(AccountType::RegularAccountImmutableCode); + + let account = AccountBuilder::new(ACCOUNT_SEED) + .account_type(AccountType::RegularAccountImmutableCode) + .storage_mode(AccountStorageMode::Public) + .with_component(component) + .with_auth_component(AuthFalcon512Rpo::new(PublicKeyCommitment::from(EMPTY_WORD))) + .build_existing() + .unwrap(); + + // Create a full-state delta from the account + let delta = AccountDelta::try_from(account.clone()).unwrap(); + assert!(delta.is_full_state(), "Delta should be full state"); + + let account_update = BlockAccountUpdate::new( + account.id(), + account.commitment(), + AccountUpdateDetails::Delta(delta), + ); + + upsert_accounts(&mut conn, &[account_update], block_num, &BlockAccountRoots::new()) + .expect("Full-state delta upsert failed"); + + // Verify the account state was stored correctly + let (header, storage_header) = + select_account_header_with_storage_header_at_block(&mut conn, account.id(), block_num) + .expect("Query should succeed") + .expect("Account should exist"); + + assert_eq!(header.nonce(), account.nonce(), "Nonce should match"); + assert_eq!( + header.code_commitment(), + account.code().commitment(), + "Code commitment should match" + ); + assert_eq!( + storage_header.to_commitment(), + account.storage().to_commitment(), + "Storage commitment should match" + ); + + // Verify we can load the full account back + let loaded_account = + select_full_account(&mut conn, account.id()).expect("Should load full account"); + + assert_eq!(loaded_account.nonce(), account.nonce()); + assert_eq!(loaded_account.code().commitment(), account.code().commitment()); + assert_eq!(loaded_account.storage().to_commitment(), account.storage().to_commitment()); +} diff --git a/crates/store/src/db/models/queries/accounts/tests.rs b/crates/store/src/db/models/queries/accounts/tests.rs index fa1e77e85..60d40819f 100644 --- a/crates/store/src/db/models/queries/accounts/tests.rs +++ b/crates/store/src/db/models/queries/accounts/tests.rs @@ -43,6 +43,7 @@ use crate::db::migrations::MIGRATIONS; use crate::db::models::conv::SqlTypeConvert; use crate::db::schema; use crate::errors::DatabaseError; +use crate::inner_forest::BlockAccountRoots; fn setup_test_db() -> SqliteConnection { let mut conn = @@ -229,7 +230,8 @@ fn test_select_account_header_at_block_returns_correct_header() { AccountUpdateDetails::Delta(delta), ); - upsert_accounts(&mut conn, &[account_update], block_num).expect("upsert_accounts failed"); + upsert_accounts(&mut conn, &[account_update], block_num, &BlockAccountRoots::new()) + .expect("upsert_accounts failed"); // Query the account header let (header, _storage_header) = @@ -266,7 +268,8 @@ fn test_select_account_header_at_block_historical_query() { AccountUpdateDetails::Delta(delta_1), ); - upsert_accounts(&mut conn, &[account_update_1], block_num_1).expect("First upsert failed"); + upsert_accounts(&mut conn, &[account_update_1], block_num_1, &BlockAccountRoots::new()) + .expect("First upsert failed"); // Query at block 1 - should return the account let (header_1, _) = @@ -305,7 +308,8 @@ fn test_select_account_vault_at_block_empty() { AccountUpdateDetails::Delta(delta), ); - upsert_accounts(&mut conn, &[account_update], block_num).expect("upsert_accounts failed"); + upsert_accounts(&mut conn, &[account_update], block_num, &BlockAccountRoots::new()) + .expect("upsert_accounts failed"); // Query vault - should return empty (the test account has no assets) let assets = select_account_vault_at_block(&mut conn, account_id, block_num) @@ -338,7 +342,8 @@ fn test_upsert_accounts_inserts_storage_header() { BlockAccountUpdate::new(account_id, account_commitment, AccountUpdateDetails::Delta(delta)); // Upsert account - let result = upsert_accounts(&mut conn, &[account_update], block_num); + let result = + upsert_accounts(&mut conn, &[account_update], block_num, &BlockAccountRoots::new()); assert!(result.is_ok(), "upsert_accounts failed: {:?}", result.err()); assert_eq!(result.unwrap(), 1, "Expected 1 account to be inserted"); @@ -393,7 +398,8 @@ fn test_upsert_accounts_updates_is_latest_flag() { AccountUpdateDetails::Delta(delta_1), ); - upsert_accounts(&mut conn, &[account_update_1], block_num_1).expect("First upsert failed"); + upsert_accounts(&mut conn, &[account_update_1], block_num_1, &BlockAccountRoots::new()) + .expect("First upsert failed"); // Create modified account with different storage value let storage_value_modified = @@ -429,7 +435,8 @@ fn test_upsert_accounts_updates_is_latest_flag() { AccountUpdateDetails::Delta(delta_2), ); - upsert_accounts(&mut conn, &[account_update_2], block_num_2).expect("Second upsert failed"); + upsert_accounts(&mut conn, &[account_update_2], block_num_2, &BlockAccountRoots::new()) + .expect("Second upsert failed"); // Verify 2 total account rows exist (both historical records) let total_accounts: i64 = schema::accounts::table @@ -520,7 +527,7 @@ fn test_upsert_accounts_with_multiple_storage_slots() { let account_update = BlockAccountUpdate::new(account_id, account_commitment, AccountUpdateDetails::Delta(delta)); - upsert_accounts(&mut conn, &[account_update], block_num) + upsert_accounts(&mut conn, &[account_update], block_num, &BlockAccountRoots::new()) .expect("Upsert with multiple storage slots failed"); // Query back and verify @@ -582,7 +589,7 @@ fn test_upsert_accounts_with_empty_storage() { let account_update = BlockAccountUpdate::new(account_id, account_commitment, AccountUpdateDetails::Delta(delta)); - upsert_accounts(&mut conn, &[account_update], block_num) + upsert_accounts(&mut conn, &[account_update], block_num, &BlockAccountRoots::new()) .expect("Upsert with empty storage failed"); // Query back and verify @@ -654,8 +661,13 @@ fn test_select_account_vault_at_block_historical_with_updates() { ); for block in [block_1, block_2, block_3] { - upsert_accounts(&mut conn, std::slice::from_ref(&account_update), block) - .expect("upsert_accounts failed"); + upsert_accounts( + &mut conn, + std::slice::from_ref(&account_update), + block, + &BlockAccountRoots::new(), + ) + .expect("upsert_accounts failed"); } // Insert vault asset at block 1: vault_key_1 = 1000 tokens @@ -760,8 +772,13 @@ fn test_select_account_vault_at_block_with_deletion() { ); for block in [block_1, block_2, block_3] { - upsert_accounts(&mut conn, std::slice::from_ref(&account_update), block) - .expect("upsert_accounts failed"); + upsert_accounts( + &mut conn, + std::slice::from_ref(&account_update), + block, + &BlockAccountRoots::new(), + ) + .expect("upsert_accounts failed"); } // Insert vault asset at block 1 diff --git a/crates/store/src/db/models/queries/mod.rs b/crates/store/src/db/models/queries/mod.rs index 35c38c5ad..69b6b48e9 100644 --- a/crates/store/src/db/models/queries/mod.rs +++ b/crates/store/src/db/models/queries/mod.rs @@ -33,6 +33,7 @@ use miden_protocol::transaction::OrderedTransactionHeaders; use super::DatabaseError; use crate::db::NoteRecord; +use crate::inner_forest::BlockAccountRoots; mod transactions; pub use transactions::*; @@ -48,9 +49,18 @@ pub(crate) use notes::*; /// Apply a new block to the state /// +/// # Arguments +/// +/// * `precomputed_roots` - Vault and storage map roots computed by `InnerForest`. Used directly +/// instead of reloading all entries from disk to recompute roots during delta updates. +/// /// # Returns /// /// Number of records inserted and/or updated. +#[expect( + clippy::too_many_arguments, + reason = "apply_block wires block inserts to multiple tables" +)] pub(crate) fn apply_block( conn: &mut SqliteConnection, block_header: &BlockHeader, @@ -59,11 +69,12 @@ pub(crate) fn apply_block( nullifiers: &[Nullifier], accounts: &[BlockAccountUpdate], transactions: &OrderedTransactionHeaders, + precomputed_roots: &BlockAccountRoots, ) -> Result { let mut count = 0; // Note: ordering here is important as the relevant tables have FK dependencies. count += insert_block_header(conn, block_header, signature)?; - count += upsert_accounts(conn, accounts, block_header.block_num())?; + count += upsert_accounts(conn, accounts, block_header.block_num(), precomputed_roots)?; count += insert_scripts(conn, notes.iter().map(|(note, _)| note))?; count += insert_notes(conn, notes)?; count += insert_transactions(conn, block_header.block_num(), transactions)?; diff --git a/crates/store/src/db/tests.rs b/crates/store/src/db/tests.rs index 2c132c5d8..7a321f5e4 100644 --- a/crates/store/src/db/tests.rs +++ b/crates/store/src/db/tests.rs @@ -77,6 +77,7 @@ use crate::db::models::queries::{ }; use crate::db::models::{Page, queries, utils}; use crate::errors::DatabaseError; +use crate::inner_forest::BlockAccountRoots; fn create_db() -> SqliteConnection { let mut conn = SqliteConnection::establish(":memory:").expect("In memory sqlite always works"); @@ -219,7 +220,13 @@ fn sql_select_notes() { let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); - queries::upsert_accounts(conn, &[mock_block_account_update(account_id, 0)], block_num).unwrap(); + queries::upsert_accounts( + conn, + &[mock_block_account_update(account_id, 0)], + block_num, + &BlockAccountRoots::new(), + ) + .unwrap(); let new_note = create_note(account_id); @@ -264,7 +271,13 @@ fn sql_select_note_script_by_root() { let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); - queries::upsert_accounts(conn, &[mock_block_account_update(account_id, 0)], block_num).unwrap(); + queries::upsert_accounts( + conn, + &[mock_block_account_update(account_id, 0)], + block_num, + &BlockAccountRoots::new(), + ) + .unwrap(); let new_note = create_note(account_id); @@ -316,6 +329,7 @@ fn make_account_and_note( AccountUpdateDetails::Delta(AccountDelta::try_from(account).unwrap()), )], block_num, + &BlockAccountRoots::new(), ) .unwrap(); @@ -448,6 +462,7 @@ fn sql_select_accounts() { AccountUpdateDetails::Private, )], block_num, + &BlockAccountRoots::new(), ); assert_eq!(res.unwrap(), 1, "One element must have been inserted"); @@ -475,8 +490,13 @@ fn sync_account_vault_basic_validation() { create_block(conn, block_to); for block in [block_from, block_mid, block_to] { - queries::upsert_accounts(conn, &[mock_block_account_update(public_account_id, 0)], block) - .unwrap(); + queries::upsert_accounts( + conn, + &[mock_block_account_update(public_account_id, 0)], + block, + &BlockAccountRoots::new(), + ) + .unwrap(); } // Create some test vault assets @@ -815,7 +835,13 @@ fn notes() { // test insertion - queries::upsert_accounts(conn, &[mock_block_account_update(sender, 0)], block_num_1).unwrap(); + queries::upsert_accounts( + conn, + &[mock_block_account_update(sender, 0)], + block_num_1, + &BlockAccountRoots::new(), + ) + .unwrap(); let new_note = create_note(sender); let note_index = BlockNoteIndex::new(0, 2).unwrap(); @@ -950,8 +976,20 @@ fn sql_account_storage_map_values_insertion() { let account_id = AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2).unwrap(); - queries::upsert_accounts(conn, &[mock_block_account_update(account_id, 0)], block1).unwrap(); - queries::upsert_accounts(conn, &[mock_block_account_update(account_id, 0)], block2).unwrap(); + queries::upsert_accounts( + conn, + &[mock_block_account_update(account_id, 0)], + block1, + &BlockAccountRoots::new(), + ) + .unwrap(); + queries::upsert_accounts( + conn, + &[mock_block_account_update(account_id, 0)], + block2, + &BlockAccountRoots::new(), + ) + .unwrap(); let slot_name = StorageSlotName::mock(3); let key1 = Word::from([1u32, 2, 3, 4]); @@ -1025,8 +1063,13 @@ fn select_storage_map_sync_values() { let block3 = BlockNumber::from(3); for block in [block1, block2, block3] { - queries::upsert_accounts(&mut conn, &[mock_block_account_update(account_id, 0)], block) - .unwrap(); + queries::upsert_accounts( + &mut conn, + &[mock_block_account_update(account_id, 0)], + block, + &BlockAccountRoots::new(), + ) + .unwrap(); } // Insert data across multiple blocks using individual inserts @@ -1201,7 +1244,8 @@ fn insert_transactions(conn: &mut SqliteConnection) -> usize { mock_block_transaction(AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(), 2); let ordered_tx_headers = OrderedTransactionHeaders::new_unchecked(vec![mock_tx1, mock_tx2]); - queries::upsert_accounts(conn, &account_updates, block_num).unwrap(); + queries::upsert_accounts(conn, &account_updates, block_num, &BlockAccountRoots::new()) + .unwrap(); let count = queries::insert_transactions(conn, block_num, &ordered_tx_headers).unwrap(); Ok::<_, DatabaseError>(count) @@ -1281,6 +1325,7 @@ fn test_select_account_code_by_commitment() { AccountUpdateDetails::Delta(AccountDelta::try_from(account).unwrap()), )], block_num_1, + &BlockAccountRoots::new(), ) .unwrap(); @@ -1329,6 +1374,7 @@ fn test_select_account_code_by_commitment_multiple_codes() { AccountUpdateDetails::Delta(AccountDelta::try_from(account_v1).unwrap()), )], block_num_1, + &BlockAccountRoots::new(), ) .unwrap(); @@ -1362,6 +1408,7 @@ fn test_select_account_code_by_commitment_multiple_codes() { AccountUpdateDetails::Delta(AccountDelta::try_from(account_v2).unwrap()), )], block_num_2, + &BlockAccountRoots::new(), ) .unwrap(); @@ -1617,7 +1664,8 @@ fn regression_1461_full_state_delta_inserts_vault_assets() { AccountUpdateDetails::Delta(account_delta), ); - queries::upsert_accounts(&mut conn, &[block_update], block_num).unwrap(); + queries::upsert_accounts(&mut conn, &[block_update], block_num, &BlockAccountRoots::new()) + .unwrap(); let (_, vault_assets) = queries::select_account_vault_assets( &mut conn, @@ -1839,7 +1887,8 @@ fn db_roundtrip_account() { account_commitment, AccountUpdateDetails::Delta(account_delta), ); - queries::upsert_accounts(&mut conn, &[block_update], block_num).unwrap(); + queries::upsert_accounts(&mut conn, &[block_update], block_num, &BlockAccountRoots::new()) + .unwrap(); // Retrieve let retrieved = queries::select_all_accounts(&mut conn).unwrap(); @@ -1865,8 +1914,13 @@ fn db_roundtrip_notes() { create_block(&mut conn, block_num); let sender = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); - queries::upsert_accounts(&mut conn, &[mock_block_account_update(sender, 0)], block_num) - .unwrap(); + queries::upsert_accounts( + &mut conn, + &[mock_block_account_update(sender, 0)], + block_num, + &BlockAccountRoots::new(), + ) + .unwrap(); let new_note = create_note(sender); let note_index = BlockNoteIndex::new(0, 0).unwrap(); @@ -1911,6 +1965,52 @@ fn db_roundtrip_notes() { ); } +#[test] +#[miden_node_test_macro::enable_logging] +fn db_roundtrip_transactions() { + let mut conn = create_db(); + let block_num = BlockNumber::from(1); + create_block(&mut conn, block_num); + + let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); + queries::upsert_accounts( + &mut conn, + &[mock_block_account_update(account_id, 1)], + block_num, + &BlockAccountRoots::new(), + ) + .unwrap(); + + let tx = mock_block_transaction(account_id, 1); + let ordered_tx = OrderedTransactionHeaders::new_unchecked(vec![tx.clone()]); + + // Insert + queries::insert_transactions(&mut conn, block_num, &ordered_tx).unwrap(); + + // Retrieve + let (_, retrieved) = queries::select_transactions_records( + &mut conn, + &[account_id], + BlockNumber::from(0)..=BlockNumber::from(2), + ) + .unwrap(); + + assert_eq!(retrieved.len(), 1, "Should have one transaction"); + let retrieved_tx = &retrieved[0]; + + assert_eq!( + tx.account_id(), + retrieved_tx.account_id, + "AccountId DB roundtrip must be symmetric" + ); + assert_eq!( + tx.id(), + retrieved_tx.transaction_id, + "TransactionId DB roundtrip must be symmetric" + ); + assert_eq!(block_num, retrieved_tx.block_num, "Block number must match"); +} + #[test] #[miden_node_test_macro::enable_logging] fn db_roundtrip_vault_assets() { @@ -1922,8 +2022,13 @@ fn db_roundtrip_vault_assets() { let account_id = AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap(); // Create account first - queries::upsert_accounts(&mut conn, &[mock_block_account_update(account_id, 0)], block_num) - .unwrap(); + queries::upsert_accounts( + &mut conn, + &[mock_block_account_update(account_id, 0)], + block_num, + &BlockAccountRoots::new(), + ) + .unwrap(); let fungible_asset = FungibleAsset::new(faucet_id, 5000).unwrap(); let asset: Asset = fungible_asset.into(); @@ -1957,8 +2062,13 @@ fn db_roundtrip_storage_map_values() { create_block(&mut conn, block_num); let account_id = AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap(); - queries::upsert_accounts(&mut conn, &[mock_block_account_update(account_id, 0)], block_num) - .unwrap(); + queries::upsert_accounts( + &mut conn, + &[mock_block_account_update(account_id, 0)], + block_num, + &BlockAccountRoots::new(), + ) + .unwrap(); let slot_name = StorageSlotName::mock(5); let key = num_to_word(12345); let value = num_to_word(67890); @@ -2046,7 +2156,8 @@ fn db_roundtrip_account_storage_with_maps() { account.commitment(), AccountUpdateDetails::Delta(account_delta), ); - queries::upsert_accounts(&mut conn, &[block_update], block_num).unwrap(); + queries::upsert_accounts(&mut conn, &[block_update], block_num, &BlockAccountRoots::new()) + .unwrap(); // Retrieve the storage using select_latest_account_storage (reconstructs from header + map // values) @@ -2180,8 +2291,13 @@ fn test_prune_history() { // Create account for block in [block_0, block_old, block_cutoff, block_update, block_tip] { - queries::upsert_accounts(conn, &[mock_block_account_update(public_account_id, 0)], block) - .unwrap(); + queries::upsert_accounts( + conn, + &[mock_block_account_update(public_account_id, 0)], + block, + &BlockAccountRoots::new(), + ) + .unwrap(); } // Insert vault assets at different blocks diff --git a/crates/store/src/inner_forest/mod.rs b/crates/store/src/inner_forest/mod.rs index c2b5b495b..b90f0530f 100644 --- a/crates/store/src/inner_forest/mod.rs +++ b/crates/store/src/inner_forest/mod.rs @@ -20,6 +20,24 @@ use thiserror::Error; #[cfg(test)] mod tests; +// TYPES +// ================================================================================================ + +/// Precomputed account roots from in-memory SMT updates. +/// +/// Contains the vault root and storage map roots computed by applying deltas to the in-memory +/// `SmtForest`. Used to avoid reloading all entries from the database when updating accounts. +#[derive(Debug, Clone, Default)] +pub struct PrecomputedAccountRoots { + /// New vault root after applying delta (None if vault unchanged). + pub vault_root: Option, + /// New storage map roots by slot name after applying delta. + pub storage_map_roots: BTreeMap, +} + +/// Collection of precomputed roots for all accounts updated in a block. +pub type BlockAccountRoots = BTreeMap; + // ERRORS // ================================================================================================ @@ -216,9 +234,10 @@ impl InnerForest { // PUBLIC INTERFACE // -------------------------------------------------------------------------------------------- - /// Applies account updates from a block to the forest. + /// Applies account updates from a block to the forest and returns precomputed roots. /// - /// Iterates through account updates and applies each delta to the forest. + /// Iterates through account updates and applies each delta to the forest. Returns the + /// computed vault and storage map roots for each account, to be used by DB writes. /// Private accounts should be filtered out before calling this method. /// /// # Arguments @@ -226,6 +245,10 @@ impl InnerForest { /// * `block_num` - Block number for which these updates apply /// * `account_updates` - Iterator of `AccountDelta` for public accounts /// + /// # Returns + /// + /// A map from account id to precomputed roots (vault root and storage map roots). + /// /// # Errors /// /// Returns an error if applying a vault delta results in a negative balance. @@ -233,9 +256,12 @@ impl InnerForest { &mut self, block_num: BlockNumber, account_updates: impl IntoIterator, - ) -> Result<(), InnerForestError> { + ) -> Result { + let mut account_roots = BlockAccountRoots::new(); + for delta in account_updates { - self.update_account(block_num, &delta)?; + let roots = self.update_account(block_num, &delta)?; + account_roots.insert(delta.id(), roots); tracing::debug!( target: crate::COMPONENT, @@ -245,7 +271,7 @@ impl InnerForest { "Updated forest with account delta" ); } - Ok(()) + Ok(account_roots) } /// Updates the forest with account vault and storage changes from a delta. @@ -257,6 +283,10 @@ impl InnerForest { /// Full-state deltas (`delta.is_full_state() == true`) populate the forest from scratch using /// an empty SMT root. Partial deltas apply changes on top of the previous block's state. /// + /// # Returns + /// + /// Precomputed roots for the account (vault root and storage map roots). + /// /// # Errors /// /// Returns an error if applying a vault delta results in a negative balance. @@ -264,98 +294,55 @@ impl InnerForest { &mut self, block_num: BlockNumber, delta: &AccountDelta, - ) -> Result<(), InnerForestError> { + ) -> Result { let account_id = delta.id(); let is_full_state = delta.is_full_state(); - if is_full_state { - self.insert_account_vault(block_num, account_id, delta.vault()); - } else if !delta.vault().is_empty() { - self.update_account_vault(block_num, account_id, delta.vault())?; - } + let vault_root = if is_full_state || !delta.vault().is_empty() { + Some(self.update_account_vault(block_num, account_id, delta.vault(), is_full_state)?) + } else { + None + }; - if is_full_state { - self.insert_account_storage(block_num, account_id, delta.storage()); - } else if !delta.storage().is_empty() { - self.update_account_storage(block_num, account_id, delta.storage()); - } + let storage_map_roots = if delta.storage().is_empty() { + BTreeMap::new() + } else { + self.update_account_storage(block_num, account_id, delta.storage(), is_full_state) + }; - Ok(()) + Ok(PrecomputedAccountRoots { vault_root, storage_map_roots }) } // ASSET VAULT DELTA PROCESSING // -------------------------------------------------------------------------------------------- - /// Retrieves the most recent vault SMT root for an account. If no vault root is found for the - /// account, returns an empty SMT root. - fn get_latest_vault_root(&self, account_id: AccountId) -> Word { + /// Retrieves the most recent vault SMT root for an account. + /// If `is_full_state` is true, returns an empty SMT root. + fn get_latest_vault_root(&self, account_id: AccountId, is_full_state: bool) -> Word { + if is_full_state { + return Self::empty_smt_root(); + } + self.vault_roots .range((account_id, BlockNumber::GENESIS)..=(account_id, BlockNumber::MAX)) .next_back() .map_or_else(Self::empty_smt_root, |(_, root)| *root) } - /// Inserts asset vault data into the forest for the specified account. Assumes that asset - /// vault for this account does not yet exist in the forest. - fn insert_account_vault( - &mut self, - block_num: BlockNumber, - account_id: AccountId, - delta: &AccountVaultDelta, - ) { - // get the current vault root for the account, and make sure it is empty - let prev_root = self.get_latest_vault_root(account_id); - assert_eq!(prev_root, Self::empty_smt_root(), "account should not be in the forest"); - - // if there are no assets in the vault, add a root of an empty SMT to the vault roots map - // so that the map has entries for all accounts, and then return (i.e., no need to insert - // anything into the forest) - if delta.is_empty() { - self.vault_roots.insert((account_id, block_num), prev_root); - return; - } - - let mut entries: Vec<(Word, Word)> = Vec::new(); - - // process fungible assets - for (faucet_id, amount_delta) in delta.fungible().iter() { - let amount = - (*amount_delta).try_into().expect("full-state amount should be non-negative"); - let asset = FungibleAsset::new(*faucet_id, amount).expect("valid faucet id"); - entries.push((asset.vault_key().into(), asset.into())); - } - - // process non-fungible assets - for (&asset, _action) in delta.non_fungible().iter() { - // TODO: assert that action is addition - entries.push((asset.vault_key().into(), asset.into())); - } - - assert!(!entries.is_empty(), "non-empty delta should contain entries"); - let num_entries = entries.len(); - - let new_root = self - .forest - .batch_insert(prev_root, entries) - .expect("forest insertion should succeed"); - - self.vault_roots.insert((account_id, block_num), new_root); - - tracing::debug!( - target: crate::COMPONENT, - %account_id, - %block_num, - vault_entries = num_entries, - "Inserted vault into forest" - ); - } - - /// Updates the forest with vault changes from a delta. The vault delta is assumed to be - /// non-empty. + /// Updates the forest with vault changes from a delta and returns the new root. /// /// Processes both fungible and non-fungible asset changes, building entries for the vault SMT /// and tracking the new root. /// + /// # Arguments + /// + /// * `is_full_state` - If `true`, delta values are absolute (new account or DB reconstruction). + /// If `false`, delta values are relative changes applied to previous state. + /// + /// # Returns + /// + /// The new vault root after applying the delta. + /// /// # Errors /// /// Returns an error if applying a delta results in a negative balance. @@ -363,17 +350,15 @@ impl InnerForest { &mut self, block_num: BlockNumber, account_id: AccountId, - delta: &AccountVaultDelta, - ) -> Result<(), InnerForestError> { - assert!(!delta.is_empty(), "expected the delta not to be empty"); - - // get the previous vault root; the root could be for an empty or non-empty SMT - let prev_root = self.get_latest_vault_root(account_id); + vault_delta: &AccountVaultDelta, + is_full_state: bool, + ) -> Result { + let prev_root = self.get_latest_vault_root(account_id, is_full_state); let mut entries: Vec<(Word, Word)> = Vec::new(); // Process fungible assets - for (faucet_id, amount_delta) in delta.fungible().iter() { + for (faucet_id, amount_delta) in vault_delta.fungible().iter() { let key: Word = FungibleAsset::new(*faucet_id, 0).expect("valid faucet id").vault_key().into(); @@ -408,7 +393,7 @@ impl InnerForest { } // Process non-fungible assets - for (asset, action) in delta.non_fungible().iter() { + for (asset, action) in vault_delta.non_fungible().iter() { let value = match action { NonFungibleDeltaAction::Add => Word::from(Asset::NonFungible(*asset)), NonFungibleDeltaAction::Remove => EMPTY_WORD, @@ -416,7 +401,13 @@ impl InnerForest { entries.push((asset.vault_key().into(), value)); } - assert!(!entries.is_empty(), "non-empty delta should contain entries"); + if entries.is_empty() { + if is_full_state { + self.vault_roots.insert((account_id, block_num), prev_root); + } + return Ok(prev_root); + } + let num_entries = entries.len(); let new_root = self @@ -433,19 +424,24 @@ impl InnerForest { vault_entries = num_entries, "Updated vault in forest" ); - Ok(()) + Ok(new_root) } // STORAGE MAP DELTA PROCESSING // -------------------------------------------------------------------------------------------- - /// Retrieves the most recent storage map SMT root for an account slot. If no storage root is - /// found for the slot, returns an empty SMT root. + /// Retrieves the most recent storage map SMT root for an account slot. + /// If `is_full_state` is true, returns an empty SMT root. fn get_latest_storage_map_root( &self, account_id: AccountId, slot_name: &StorageSlotName, + is_full_state: bool, ) -> Word { + if is_full_state { + return Self::empty_smt_root(); + } + self.storage_map_roots .range( (account_id, slot_name.clone(), BlockNumber::GENESIS) @@ -472,120 +468,87 @@ impl InnerForest { .unwrap_or_default() } - /// Inserts all storage maps from the provided storage delta into the forest. + /// Updates the forest with storage map changes from a delta and returns updated roots. + /// + /// Processes storage map slot deltas, building SMTs for each modified slot + /// and tracking the new roots and accumulated entries. /// - /// Assumes that storage maps for the provided account are not in the forest already. - fn insert_account_storage( + /// # Arguments + /// + /// * `is_full_state` - If `true`, delta values are absolute (new account or DB reconstruction). + /// If `false`, delta values are relative changes applied to previous state. + /// + /// # Returns + /// + /// A map from slot name to the new storage map root for that slot. + fn update_account_storage( &mut self, block_num: BlockNumber, account_id: AccountId, - delta: &AccountStorageDelta, - ) { - for (slot_name, map_delta) in delta.maps() { - // get the latest root for this map, and make sure the root is for an empty tree - let prev_root = self.get_latest_storage_map_root(account_id, slot_name); - assert_eq!(prev_root, Self::empty_smt_root(), "account should not be in the forest"); - - // build a vector of entries and filter out any empty values; such values shouldn't - // be present in full-state deltas, but it is good to exclude them explicitly - let map_entries: Vec<(Word, Word)> = map_delta - .entries() - .iter() - .filter_map(|(&key, &value)| { + storage_delta: &AccountStorageDelta, + is_full_state: bool, + ) -> BTreeMap { + let mut updated_roots = BTreeMap::new(); + + for (slot_name, map_delta) in storage_delta.maps() { + let prev_root = self.get_latest_storage_map_root(account_id, slot_name, is_full_state); + if is_full_state { + assert_eq!( + prev_root, + Self::empty_smt_root(), + "account should not be in the forest" + ); + } + + let delta_entries = if is_full_state { + Vec::from_iter(map_delta.entries().iter().filter_map(|(&key, &value)| { if value == EMPTY_WORD { None } else { Some((Word::from(key), value)) } - }) - .collect(); - - // if the delta is empty, make sure we create an entry in the storage map roots map - // and storage entries map (so storage_map_entries() queries work) - if map_entries.is_empty() { - self.storage_map_roots - .insert((account_id, slot_name.clone(), block_num), prev_root); - self.storage_entries - .insert((account_id, slot_name.clone(), block_num), BTreeMap::new()); - - continue; - } - - // insert the updates into the forest and update storage map roots map - let new_root = self - .forest - .batch_insert(prev_root, map_entries.iter().copied()) - .expect("forest insertion should succeed"); - - self.storage_map_roots - .insert((account_id, slot_name.clone(), block_num), new_root); - - assert!(!map_entries.is_empty(), "a non-empty delta should have entries"); - let num_entries = map_entries.len(); - - // keep track of the state of storage map entries - // TODO: this is a temporary solution until the LargeSmtForest is implemented as - // tracking multiple versions of all storage maps will be prohibitively expensive - let map_entries = BTreeMap::from_iter(map_entries); - self.storage_entries - .insert((account_id, slot_name.clone(), block_num), map_entries); - - tracing::debug!( - target: crate::COMPONENT, - %account_id, - %block_num, - ?slot_name, - delta_entries = num_entries, - "Inserted storage map into forest" - ); - } - } - - /// Updates the forest with storage map changes from a delta. - /// - /// Processes storage map slot deltas, building SMTs for each modified slot and tracking the - /// new roots and accumulated entries. - fn update_account_storage( - &mut self, - block_num: BlockNumber, - account_id: AccountId, - delta: &AccountStorageDelta, - ) { - assert!(!delta.is_empty(), "expected the delta not to be empty"); + })) + } else { + Vec::from_iter( + map_delta.entries().iter().map(|(key, value)| ((*key).into(), *value)), + ) + }; - for (slot_name, map_delta) in delta.maps() { - // map delta shouldn't be empty, but if it is for some reason, there is nothing to do - if map_delta.is_empty() { + if delta_entries.is_empty() { + if is_full_state { + self.storage_map_roots + .insert((account_id, slot_name.clone(), block_num), prev_root); + self.storage_entries + .insert((account_id, slot_name.clone(), block_num), BTreeMap::new()); + updated_roots.insert(slot_name.clone(), prev_root); + } continue; } - // update the storage map tree in the forest and add an entry to the storage map roots - let prev_root = self.get_latest_storage_map_root(account_id, slot_name); - let delta_entries: Vec<(Word, Word)> = - map_delta.entries().iter().map(|(key, value)| ((*key).into(), *value)).collect(); - - let new_root = self + let updated_root = self .forest .batch_insert(prev_root, delta_entries.iter().copied()) .expect("forest insertion should succeed"); self.storage_map_roots - .insert((account_id, slot_name.clone(), block_num), new_root); - - // merge the delta with the latest entries in the map - // TODO: this is a temporary solution until the LargeSmtForest is implemented as - // tracking multiple versions of all storage maps will be prohibitively expensive - let mut latest_entries = self.get_latest_storage_map_entries(account_id, slot_name); - for (key, value) in &delta_entries { - if *value == EMPTY_WORD { - latest_entries.remove(key); - } else { - latest_entries.insert(*key, *value); + .insert((account_id, slot_name.clone(), block_num), updated_root); + updated_roots.insert(slot_name.clone(), updated_root); + + let entries = if is_full_state { + BTreeMap::from_iter(delta_entries.iter().copied()) + } else { + let mut latest_entries = self.get_latest_storage_map_entries(account_id, slot_name); + for (key, value) in &delta_entries { + if *value == EMPTY_WORD { + latest_entries.remove(key); + } else { + latest_entries.insert(*key, *value); + } } - } + latest_entries + }; - self.storage_entries - .insert((account_id, slot_name.clone(), block_num), latest_entries); + self.storage_entries.insert((account_id, slot_name.clone(), block_num), entries); tracing::debug!( target: crate::COMPONENT, @@ -596,6 +559,8 @@ impl InnerForest { "Updated storage map in forest" ); } + + updated_roots } // TODO: tie in-memory forest retention to DB pruning policy once forest queries rely on it. diff --git a/crates/store/src/state/apply_block.rs b/crates/store/src/state/apply_block.rs index 145432c97..e4d9a2501 100644 --- a/crates/store/src/state/apply_block.rs +++ b/crates/store/src/state/apply_block.rs @@ -221,14 +221,25 @@ impl State { }, )); + // Compute roots in InnerForest BEFORE spawning DB task, so DB can use precomputed roots. + // This avoids loading all entries from disk to recompute roots during DB writes. + let precomputed_roots = self + .forest + .write() + .await + .apply_block_updates(block_num, account_deltas.clone())?; + // The DB and in-memory state updates need to be synchronized and are partially // overlapping. Namely, the DB transaction only proceeds after this task acquires the // in-memory write lock. This requires the DB update to run concurrently, so a new task is // spawned. let db = Arc::clone(&self.db); let db_update_task = tokio::spawn( - async move { db.apply_block(allow_acquire, acquire_done, signed_block, notes).await } - .in_current_span(), + async move { + db.apply_block(allow_acquire, acquire_done, signed_block, notes, precomputed_roots) + .await + } + .in_current_span(), ); // Wait for the message from the DB update task, that we ready to commit the DB transaction. @@ -284,8 +295,6 @@ impl State { .in_current_span() .await?; - self.forest.write().await.apply_block_updates(block_num, account_deltas)?; - info!(%block_commitment, block_num = block_num.as_u32(), COMPONENT, "apply_block successful"); Ok(()) diff --git a/crates/store/src/state/mod.rs b/crates/store/src/state/mod.rs index 40f6f29e6..ad56c28bd 100644 --- a/crates/store/src/state/mod.rs +++ b/crates/store/src/state/mod.rs @@ -182,6 +182,9 @@ impl State { }) } + // STATE MUTATOR + // -------------------------------------------------------------------------------------------- + // STATE ACCESSORS // -------------------------------------------------------------------------------------------- From a3246645a03a1bcf24951b34042116f1bee6b596 Mon Sep 17 00:00:00 2001 From: Bernhard Schuster Date: Mon, 16 Feb 2026 17:44:54 +0100 Subject: [PATCH 02/17] fixup --- .../store/src/db/models/queries/accounts.rs | 39 ++++++++++--------- .../src/db/models/queries/accounts/delta.rs | 12 +++--- 2 files changed, 26 insertions(+), 25 deletions(-) diff --git a/crates/store/src/db/models/queries/accounts.rs b/crates/store/src/db/models/queries/accounts.rs index 4ddeb20bb..2972cd268 100644 --- a/crates/store/src/db/models/queries/accounts.rs +++ b/crates/store/src/db/models/queries/accounts.rs @@ -56,7 +56,7 @@ use delta::{ AccountStateForInsert, PartialAccountState, apply_storage_delta_with_precomputed_roots, - select_account_state_for_delta, + select_minimal_account_state_headers, select_vault_balances_by_faucet_ids, }; @@ -1040,16 +1040,7 @@ pub(crate) fn upsert_accounts( // entries that will receive updates. // The next line fetches the header, which will always change with the exception of // an empty delta. - let state = select_account_state_for_delta(conn, account_id)?; - - // --- collect storage map updates ---------------------------- - - let mut storage = Vec::new(); - for (slot_name, map_delta) in delta.storage().maps() { - for (key, value) in map_delta.entries() { - storage.push((account_id, slot_name.clone(), (*key).into(), *value)); - } - } + let state_headers = select_minimal_account_state_headers(conn, account_id)?; // --- process asset updates ---------------------------------- // Only query balances for faucet_ids that are being updated @@ -1084,26 +1075,36 @@ pub(crate) fn upsert_accounts( assets.push((account_id, asset.vault_key(), asset_update)); } - // --- compute updated account state for the accounts row --- - // Apply nonce delta - let new_nonce = Felt::new(state.nonce.as_int() + delta.nonce_delta().as_int()); + // --- collect storage map updates ---------------------------- - let account_roots = precomputed_roots.get(&account_id); + let mut storage = Vec::new(); + for (slot_name, map_delta) in delta.storage().maps() { + for (key, value) in map_delta.entries() { + storage.push((account_id, slot_name.clone(), (*key).into(), *value)); + } + } + + let roots = precomputed_roots.get(&account_id); // Apply storage map value updates to header let new_storage_header = apply_storage_delta_with_precomputed_roots( - &state.storage_header, + &state_headers.storage_header, delta.storage(), - account_roots.map(|roots| &roots.storage_map_roots), + roots.map(|roots| &roots.storage_map_roots), )?; let new_vault_root = - account_roots.and_then(|roots| roots.vault_root).unwrap_or(state.vault_root); + roots.and_then(|roots| roots.vault_root).unwrap_or(state_headers.vault_root); + + // --- compute updated account state for the accounts row --- + // Apply nonce delta + let new_nonce = + Felt::new(state_headers.nonce.as_int() + delta.nonce_delta().as_int()); // Create minimal account state data for the row insert let account_state = PartialAccountState { nonce: new_nonce, - code_commitment: state.code_commitment, + code_commitment: state_headers.code_commitment, storage_header: new_storage_header, vault_root: new_vault_root, }; diff --git a/crates/store/src/db/models/queries/accounts/delta.rs b/crates/store/src/db/models/queries/accounts/delta.rs index bae443d61..b34523ff6 100644 --- a/crates/store/src/db/models/queries/accounts/delta.rs +++ b/crates/store/src/db/models/queries/accounts/delta.rs @@ -48,7 +48,7 @@ struct AccountStateDeltaRow { /// Data needed for applying a delta update to an existing account. /// Fetches only the minimal data required, avoiding loading full code and storage. #[derive(Debug, Clone)] -pub(super) struct AccountStateForDelta { +pub(super) struct AccountStateHeadersForDelta { pub nonce: Felt, pub code_commitment: Word, pub storage_header: AccountStorageHeader, @@ -94,10 +94,10 @@ pub(super) enum AccountStateForInsert { /// FROM accounts /// WHERE account_id = ?1 AND is_latest = 1 /// ``` -pub(super) fn select_account_state_for_delta( +pub(super) fn select_minimal_account_state_headers( conn: &mut SqliteConnection, account_id: AccountId, -) -> Result { +) -> Result { let row: AccountStateDeltaRow = SelectDsl::select( schema::accounts::table, ( @@ -138,7 +138,7 @@ pub(super) fn select_account_state_for_delta( .transpose()? .unwrap_or(Word::default()); - Ok(AccountStateForDelta { + Ok(AccountStateHeadersForDelta { nonce, code_commitment, storage_header, @@ -236,7 +236,7 @@ pub(super) fn apply_storage_delta_with_precomputed_roots( map_updates.insert(slot_name, new_root); } - let new_slots = Vec::from_iter(header.slots().map(|slot| { + let slots = Vec::from_iter(header.slots().map(|slot| { let slot_name = slot.name(); if let Some(&new_value) = value_updates.get(slot_name) { StorageSlotHeader::new(slot_name.clone(), slot.slot_type(), new_value) @@ -247,7 +247,7 @@ pub(super) fn apply_storage_delta_with_precomputed_roots( } })); - AccountStorageHeader::new(new_slots).map_err(|e| { + AccountStorageHeader::new(slots).map_err(|e| { DatabaseError::DataCorrupted(format!("Failed to create storage header: {e:?}")) }) } From 544a668970a62193d6218161f45fef40fb191846 Mon Sep 17 00:00:00 2001 From: Bernhard Schuster Date: Tue, 17 Feb 2026 10:03:54 +0100 Subject: [PATCH 03/17] maybe --- crates/store/src/db/mod.rs | 7 - .../store/src/db/models/queries/accounts.rs | 132 +++++++++++++----- .../src/db/models/queries/accounts/delta.rs | 27 ++-- .../db/models/queries/accounts/delta/tests.rs | 48 ++----- .../src/db/models/queries/accounts/tests.rs | 41 ++---- crates/store/src/db/models/queries/mod.rs | 7 +- crates/store/src/db/tests.rs | 121 +++------------- crates/store/src/state/apply_block.rs | 26 +--- 8 files changed, 158 insertions(+), 251 deletions(-) diff --git a/crates/store/src/db/mod.rs b/crates/store/src/db/mod.rs index f82fb6b84..0b8f0fd42 100644 --- a/crates/store/src/db/mod.rs +++ b/crates/store/src/db/mod.rs @@ -38,7 +38,6 @@ pub use crate::db::models::queries::{ use crate::db::models::{Page, queries}; use crate::errors::{DatabaseError, DatabaseSetupError, NoteSyncError}; use crate::genesis::GenesisBlock; -use crate::inner_forest::BlockAccountRoots; pub(crate) mod manager; @@ -245,7 +244,6 @@ impl Db { &[], genesis.body().updated_accounts(), genesis.body().transactions(), - &BlockAccountRoots::new(), ) }) .context("failed to insert genesis block")?; @@ -558,9 +556,6 @@ impl Db { /// /// `allow_acquire` and `acquire_done` are used to synchronize writes to the DB with writes to /// the in-memory trees. Further details available on [`super::state::State::apply_block`]. - /// - /// `precomputed_roots` contains vault and storage map roots computed by `InnerForest`. These - /// are used directly instead of reloading all entries from disk to recompute roots. // TODO: This span is logged in a root span, we should connect it to the parent one. #[instrument(target = COMPONENT, skip_all, err)] pub async fn apply_block( @@ -569,7 +564,6 @@ impl Db { acquire_done: oneshot::Receiver<()>, signed_block: SignedBlock, notes: Vec<(NoteRecord, Option)>, - precomputed_roots: BlockAccountRoots, ) -> Result<()> { self.transact("apply block", move |conn| -> Result<()> { models::queries::apply_block( @@ -580,7 +574,6 @@ impl Db { signed_block.body().created_nullifiers(), signed_block.body().updated_accounts(), signed_block.body().transactions(), - &precomputed_roots, )?; // XXX FIXME TODO free floating mutex MUST NOT exist diff --git a/crates/store/src/db/models/queries/accounts.rs b/crates/store/src/db/models/queries/accounts.rs index 2972cd268..d05d3bade 100644 --- a/crates/store/src/db/models/queries/accounts.rs +++ b/crates/store/src/db/models/queries/accounts.rs @@ -55,7 +55,7 @@ mod delta; use delta::{ AccountStateForInsert, PartialAccountState, - apply_storage_delta_with_precomputed_roots, + apply_storage_delta, select_minimal_account_state_headers, select_vault_balances_by_faucet_ids, }; @@ -750,6 +750,57 @@ pub(crate) fn select_latest_account_storage( conn: &mut SqliteConnection, account_id: AccountId, ) -> Result { + let (storage_header, map_entries_by_slot) = + select_latest_account_storage_components(conn, account_id)?; + + // Reconstruct StorageSlots from header slots + map entries + let mut slots = Vec::new(); + for slot_header in storage_header.slots() { + let slot = match slot_header.slot_type() { + StorageSlotType::Value => { + // For value slots, the header value IS the slot value + StorageSlot::with_value(slot_header.name().clone(), slot_header.value()) + }, + StorageSlotType::Map => { + // For map slots, reconstruct from map entries + let entries = + map_entries_by_slot.get(slot_header.name()).cloned().unwrap_or_default(); + let storage_map = StorageMap::with_entries(entries)?; + StorageSlot::with_map(slot_header.name().clone(), storage_map) + }, + }; + slots.push(slot); + } + + Ok(AccountStorage::new(slots)?) +} + +pub(crate) fn select_latest_storage_map_entries( + conn: &mut SqliteConnection, + account_id: AccountId, + slot_name: StorageSlotName, +) -> Result, DatabaseError> { + use schema::account_storage_map_values as t; + + let account_id_bytes = account_id.to_bytes(); + let map_values: Vec<(String, Vec, Vec)> = + SelectDsl::select(t::table, (t::slot_name, t::key, t::value)) + .filter(t::account_id.eq(&account_id_bytes)) + .filter(t::slot_name.eq(slot_name.clone().to_raw_sql())) + .filter(t::is_latest.eq(true)) + .load(conn)?; + + let grouped = group_storage_map_entries(map_values)?; + Ok(grouped + .get(&slot_name) + .map(|entries| BTreeMap::from_iter(entries.iter().copied())) + .unwrap_or_default()) +} + +pub(crate) fn select_latest_account_storage_components( + conn: &mut SqliteConnection, + account_id: AccountId, +) -> Result<(AccountStorageHeader, BTreeMap>), DatabaseError> { use schema::account_storage_map_values as t; let account_id_bytes = account_id.to_bytes(); @@ -763,14 +814,11 @@ pub(crate) fn select_latest_account_storage( .optional()? .flatten(); - let Some(blob) = storage_blob else { - // No storage means empty storage - return Ok(AccountStorage::new(Vec::new())?); + let header = match storage_blob { + Some(blob) => AccountStorageHeader::read_from_bytes(&blob)?, + None => AccountStorageHeader::new(Vec::new())?, }; - // Deserialize the AccountStorageHeader from the blob - let header = AccountStorageHeader::read_from_bytes(&blob)?; - // Query all latest map values for this account let map_values: Vec<(String, Vec, Vec)> = SelectDsl::select(t::table, (t::slot_name, t::key, t::value)) @@ -778,7 +826,12 @@ pub(crate) fn select_latest_account_storage( .filter(t::is_latest.eq(true)) .load(conn)?; - // Group map values by slot name + Ok((header, group_storage_map_entries(map_values)?)) +} + +fn group_storage_map_entries( + map_values: Vec<(String, Vec, Vec)>, +) -> Result>, DatabaseError> { let mut map_entries_by_slot: BTreeMap> = BTreeMap::new(); for (slot_name_str, key_bytes, value_bytes) in map_values { let slot_name: StorageSlotName = slot_name_str.parse().map_err(|_| { @@ -789,25 +842,7 @@ pub(crate) fn select_latest_account_storage( map_entries_by_slot.entry(slot_name).or_default().push((key, value)); } - // Reconstruct StorageSlots from header slots + map entries - let mut slots = Vec::new(); - for slot_header in header.slots() { - let slot = match slot_header.slot_type() { - StorageSlotType::Value => { - // For value slots, the header value IS the slot value - StorageSlot::with_value(slot_header.name().clone(), slot_header.value()) - }, - StorageSlotType::Map => { - // For map slots, reconstruct from map entries - let entries = map_entries_by_slot.remove(slot_header.name()).unwrap_or_default(); - let storage_map = StorageMap::with_entries(entries)?; - StorageSlot::with_map(slot_header.name().clone(), storage_map) - }, - }; - slots.push(slot); - } - - Ok(AccountStorage::new(slots)?) + Ok(map_entries_by_slot) } // ACCOUNT MUTATION @@ -959,7 +994,6 @@ pub(crate) fn upsert_accounts( conn: &mut SqliteConnection, accounts: &[BlockAccountUpdate], block_num: BlockNumber, - precomputed_roots: &crate::inner_forest::BlockAccountRoots, ) -> Result { let mut count = 0; for update in accounts { @@ -1084,22 +1118,48 @@ pub(crate) fn upsert_accounts( } } - let roots = precomputed_roots.get(&account_id); + let mut map_entries = BTreeMap::new(); + for (slot_name, map_delta) in delta.storage().maps() { + if map_delta.is_empty() { + continue; + } - // Apply storage map value updates to header - let new_storage_header = apply_storage_delta_with_precomputed_roots( + let slot_entries = + select_latest_storage_map_entries(conn, account_id, slot_name.clone())?; + map_entries.insert(slot_name.clone(), slot_entries); + } + + let new_storage_header = apply_storage_delta( &state_headers.storage_header, delta.storage(), - roots.map(|roots| &roots.storage_map_roots), + &map_entries, )?; - let new_vault_root = - roots.and_then(|roots| roots.vault_root).unwrap_or(state_headers.vault_root); + let new_vault_root = { + let (_last_block, assets) = select_account_vault_assets( + conn, + account_id, + BlockNumber::GENESIS..=block_num, + )?; + let assets: Vec = + assets.into_iter().filter_map(|entry| entry.asset).collect(); + let mut vault = AssetVault::new(&assets)?; + vault.apply_delta(delta.vault())?; + vault.root() + }; // --- compute updated account state for the accounts row --- // Apply nonce delta - let new_nonce = - Felt::new(state_headers.nonce.as_int() + delta.nonce_delta().as_int()); + let new_nonce_value = state_headers + .nonce + .as_int() + .checked_add(delta.nonce_delta().as_int()) + .ok_or_else(|| { + DatabaseError::DataCorrupted(format!( + "Nonce overflow for account {account_id}" + )) + })?; + let new_nonce = Felt::new(new_nonce_value); // Create minimal account state data for the row insert let account_state = PartialAccountState { diff --git a/crates/store/src/db/models/queries/accounts/delta.rs b/crates/store/src/db/models/queries/accounts/delta.rs index b34523ff6..10c005aa4 100644 --- a/crates/store/src/db/models/queries/accounts/delta.rs +++ b/crates/store/src/db/models/queries/accounts/delta.rs @@ -17,12 +17,13 @@ use miden_protocol::account::{ Account, AccountId, AccountStorageHeader, + StorageMap, StorageSlotHeader, StorageSlotName, }; use miden_protocol::asset::{Asset, FungibleAsset}; use miden_protocol::utils::{Deserializable, Serializable}; -use miden_protocol::{Felt, Word}; +use miden_protocol::{EMPTY_WORD, Felt, Word}; use crate::db::models::conv::raw_sql_to_nonce; use crate::db::schema; @@ -209,10 +210,10 @@ pub(super) fn select_vault_balances_by_faucet_ids( /// /// For value slots, updates the slot value directly. /// For map slots, uses the precomputed roots for updated maps. -pub(super) fn apply_storage_delta_with_precomputed_roots( +pub(super) fn apply_storage_delta( header: &AccountStorageHeader, delta: &AccountStorageDelta, - storage_map_roots: Option<&BTreeMap>, + map_entries: &BTreeMap>, ) -> Result { let mut value_updates: BTreeMap<&StorageSlotName, Word> = BTreeMap::new(); let mut map_updates: BTreeMap<&StorageSlotName, Word> = BTreeMap::new(); @@ -226,14 +227,18 @@ pub(super) fn apply_storage_delta_with_precomputed_roots( continue; } - let new_root = storage_map_roots - .and_then(|roots| roots.get(slot_name).copied()) - .ok_or_else(|| { - DatabaseError::DataCorrupted(format!( - "Missing precomputed storage map root for slot {slot_name}" - )) - })?; - map_updates.insert(slot_name, new_root); + let mut entries = map_entries.get(slot_name).cloned().unwrap_or_default(); + for (key, value) in map_delta.entries() { + if *value == EMPTY_WORD { + entries.remove(&(*key).into()); + } else { + entries.insert((*key).into(), *value); + } + } + + let storage_map = StorageMap::with_entries(entries.into_iter()) + .map_err(DatabaseError::StorageMapError)?; + map_updates.insert(slot_name, storage_map.root()); } let slots = Vec::from_iter(header.slots().map(|slot| { diff --git a/crates/store/src/db/models/queries/accounts/delta/tests.rs b/crates/store/src/db/models/queries/accounts/delta/tests.rs index 88542bdfd..6e601a74d 100644 --- a/crates/store/src/db/models/queries/accounts/delta/tests.rs +++ b/crates/store/src/db/models/queries/accounts/delta/tests.rs @@ -46,29 +46,6 @@ use crate::db::models::queries::accounts::{ upsert_accounts, }; use crate::db::schema::accounts; -use crate::inner_forest::{BlockAccountRoots, PrecomputedAccountRoots}; - -/// Builds `BlockAccountRoots` from an expected final account state. -/// -/// Simulates what `InnerForest::apply_block_updates` computes for storage map and vault roots. -fn precomputed_roots_from_account(account: &miden_protocol::account::Account) -> BlockAccountRoots { - use miden_protocol::account::StorageSlotContent; - - let mut storage_map_roots = BTreeMap::new(); - for slot in account.storage().slots() { - if let StorageSlotContent::Map(map) = slot.content() { - storage_map_roots.insert(slot.name().clone(), map.root()); - } - } - - BTreeMap::from_iter([( - account.id(), - PrecomputedAccountRoots { - vault_root: Some(account.vault().root()), - storage_map_roots, - }, - )]) -} fn setup_test_db() -> SqliteConnection { let mut conn = @@ -183,8 +160,7 @@ fn optimized_delta_matches_full_account_method() { account.commitment(), AccountUpdateDetails::Delta(delta_initial), ); - upsert_accounts(&mut conn, &[account_update_initial], block_1, &BlockAccountRoots::new()) - .expect("Initial upsert failed"); + upsert_accounts(&mut conn, &[account_update_initial], block_1).expect("Initial upsert failed"); // Verify initial state let full_account_before = @@ -258,9 +234,7 @@ fn optimized_delta_matches_full_account_method() { final_commitment, AccountUpdateDetails::Delta(partial_delta), ); - let roots = precomputed_roots_from_account(&final_account_for_commitment); - upsert_accounts(&mut conn, &[account_update], block_2, &roots) - .expect("Partial delta upsert failed"); + upsert_accounts(&mut conn, &[account_update], block_2).expect("Partial delta upsert failed"); // ----- VERIFY: Query the DB and check that optimized path produced correct results ----- @@ -373,8 +347,7 @@ fn optimized_delta_updates_non_empty_vault() { account.commitment(), AccountUpdateDetails::Delta(delta_initial), ); - upsert_accounts(&mut conn, &[account_update_initial], block_1, &BlockAccountRoots::new()) - .expect("Initial upsert failed"); + upsert_accounts(&mut conn, &[account_update_initial], block_1).expect("Initial upsert failed"); let full_account_before = select_full_account(&mut conn, account.id()).expect("Failed to load full account"); @@ -405,9 +378,7 @@ fn optimized_delta_updates_non_empty_vault() { expected_commitment, AccountUpdateDetails::Delta(partial_delta), ); - let roots = precomputed_roots_from_account(&expected_account); - upsert_accounts(&mut conn, &[account_update], block_2, &roots) - .expect("Partial delta upsert failed"); + upsert_accounts(&mut conn, &[account_update], block_2).expect("Partial delta upsert failed"); let vault_assets_after = select_account_vault_at_block(&mut conn, account.id(), block_2) .expect("Query vault should succeed"); @@ -493,8 +464,7 @@ fn optimized_delta_updates_storage_map_header() { account.commitment(), AccountUpdateDetails::Delta(delta_initial), ); - upsert_accounts(&mut conn, &[account_update_initial], block_1, &BlockAccountRoots::new()) - .expect("Initial upsert failed"); + upsert_accounts(&mut conn, &[account_update_initial], block_1).expect("Initial upsert failed"); let full_account_before = select_full_account(&mut conn, account.id()).expect("Failed to load full account"); @@ -524,9 +494,7 @@ fn optimized_delta_updates_storage_map_header() { expected_commitment, AccountUpdateDetails::Delta(partial_delta), ); - let roots = precomputed_roots_from_account(&expected_account); - upsert_accounts(&mut conn, &[account_update], block_2, &roots) - .expect("Partial delta upsert failed"); + upsert_accounts(&mut conn, &[account_update], block_2).expect("Partial delta upsert failed"); let (header_after, storage_header_after) = select_account_header_with_storage_header_at_block(&mut conn, account.id(), block_2) @@ -583,7 +551,7 @@ fn upsert_private_account() { let account_update = BlockAccountUpdate::new(account_id, account_commitment, AccountUpdateDetails::Private); - upsert_accounts(&mut conn, &[account_update], block_num, &BlockAccountRoots::new()) + upsert_accounts(&mut conn, &[account_update], block_num) .expect("Private account upsert failed"); // Verify the account exists and commitment matches @@ -662,7 +630,7 @@ fn upsert_full_state_delta() { AccountUpdateDetails::Delta(delta), ); - upsert_accounts(&mut conn, &[account_update], block_num, &BlockAccountRoots::new()) + upsert_accounts(&mut conn, &[account_update], block_num) .expect("Full-state delta upsert failed"); // Verify the account state was stored correctly diff --git a/crates/store/src/db/models/queries/accounts/tests.rs b/crates/store/src/db/models/queries/accounts/tests.rs index 60d40819f..fa1e77e85 100644 --- a/crates/store/src/db/models/queries/accounts/tests.rs +++ b/crates/store/src/db/models/queries/accounts/tests.rs @@ -43,7 +43,6 @@ use crate::db::migrations::MIGRATIONS; use crate::db::models::conv::SqlTypeConvert; use crate::db::schema; use crate::errors::DatabaseError; -use crate::inner_forest::BlockAccountRoots; fn setup_test_db() -> SqliteConnection { let mut conn = @@ -230,8 +229,7 @@ fn test_select_account_header_at_block_returns_correct_header() { AccountUpdateDetails::Delta(delta), ); - upsert_accounts(&mut conn, &[account_update], block_num, &BlockAccountRoots::new()) - .expect("upsert_accounts failed"); + upsert_accounts(&mut conn, &[account_update], block_num).expect("upsert_accounts failed"); // Query the account header let (header, _storage_header) = @@ -268,8 +266,7 @@ fn test_select_account_header_at_block_historical_query() { AccountUpdateDetails::Delta(delta_1), ); - upsert_accounts(&mut conn, &[account_update_1], block_num_1, &BlockAccountRoots::new()) - .expect("First upsert failed"); + upsert_accounts(&mut conn, &[account_update_1], block_num_1).expect("First upsert failed"); // Query at block 1 - should return the account let (header_1, _) = @@ -308,8 +305,7 @@ fn test_select_account_vault_at_block_empty() { AccountUpdateDetails::Delta(delta), ); - upsert_accounts(&mut conn, &[account_update], block_num, &BlockAccountRoots::new()) - .expect("upsert_accounts failed"); + upsert_accounts(&mut conn, &[account_update], block_num).expect("upsert_accounts failed"); // Query vault - should return empty (the test account has no assets) let assets = select_account_vault_at_block(&mut conn, account_id, block_num) @@ -342,8 +338,7 @@ fn test_upsert_accounts_inserts_storage_header() { BlockAccountUpdate::new(account_id, account_commitment, AccountUpdateDetails::Delta(delta)); // Upsert account - let result = - upsert_accounts(&mut conn, &[account_update], block_num, &BlockAccountRoots::new()); + let result = upsert_accounts(&mut conn, &[account_update], block_num); assert!(result.is_ok(), "upsert_accounts failed: {:?}", result.err()); assert_eq!(result.unwrap(), 1, "Expected 1 account to be inserted"); @@ -398,8 +393,7 @@ fn test_upsert_accounts_updates_is_latest_flag() { AccountUpdateDetails::Delta(delta_1), ); - upsert_accounts(&mut conn, &[account_update_1], block_num_1, &BlockAccountRoots::new()) - .expect("First upsert failed"); + upsert_accounts(&mut conn, &[account_update_1], block_num_1).expect("First upsert failed"); // Create modified account with different storage value let storage_value_modified = @@ -435,8 +429,7 @@ fn test_upsert_accounts_updates_is_latest_flag() { AccountUpdateDetails::Delta(delta_2), ); - upsert_accounts(&mut conn, &[account_update_2], block_num_2, &BlockAccountRoots::new()) - .expect("Second upsert failed"); + upsert_accounts(&mut conn, &[account_update_2], block_num_2).expect("Second upsert failed"); // Verify 2 total account rows exist (both historical records) let total_accounts: i64 = schema::accounts::table @@ -527,7 +520,7 @@ fn test_upsert_accounts_with_multiple_storage_slots() { let account_update = BlockAccountUpdate::new(account_id, account_commitment, AccountUpdateDetails::Delta(delta)); - upsert_accounts(&mut conn, &[account_update], block_num, &BlockAccountRoots::new()) + upsert_accounts(&mut conn, &[account_update], block_num) .expect("Upsert with multiple storage slots failed"); // Query back and verify @@ -589,7 +582,7 @@ fn test_upsert_accounts_with_empty_storage() { let account_update = BlockAccountUpdate::new(account_id, account_commitment, AccountUpdateDetails::Delta(delta)); - upsert_accounts(&mut conn, &[account_update], block_num, &BlockAccountRoots::new()) + upsert_accounts(&mut conn, &[account_update], block_num) .expect("Upsert with empty storage failed"); // Query back and verify @@ -661,13 +654,8 @@ fn test_select_account_vault_at_block_historical_with_updates() { ); for block in [block_1, block_2, block_3] { - upsert_accounts( - &mut conn, - std::slice::from_ref(&account_update), - block, - &BlockAccountRoots::new(), - ) - .expect("upsert_accounts failed"); + upsert_accounts(&mut conn, std::slice::from_ref(&account_update), block) + .expect("upsert_accounts failed"); } // Insert vault asset at block 1: vault_key_1 = 1000 tokens @@ -772,13 +760,8 @@ fn test_select_account_vault_at_block_with_deletion() { ); for block in [block_1, block_2, block_3] { - upsert_accounts( - &mut conn, - std::slice::from_ref(&account_update), - block, - &BlockAccountRoots::new(), - ) - .expect("upsert_accounts failed"); + upsert_accounts(&mut conn, std::slice::from_ref(&account_update), block) + .expect("upsert_accounts failed"); } // Insert vault asset at block 1 diff --git a/crates/store/src/db/models/queries/mod.rs b/crates/store/src/db/models/queries/mod.rs index 69b6b48e9..31f5f16ba 100644 --- a/crates/store/src/db/models/queries/mod.rs +++ b/crates/store/src/db/models/queries/mod.rs @@ -33,7 +33,6 @@ use miden_protocol::transaction::OrderedTransactionHeaders; use super::DatabaseError; use crate::db::NoteRecord; -use crate::inner_forest::BlockAccountRoots; mod transactions; pub use transactions::*; @@ -51,9 +50,6 @@ pub(crate) use notes::*; /// /// # Arguments /// -/// * `precomputed_roots` - Vault and storage map roots computed by `InnerForest`. Used directly -/// instead of reloading all entries from disk to recompute roots during delta updates. -/// /// # Returns /// /// Number of records inserted and/or updated. @@ -69,12 +65,11 @@ pub(crate) fn apply_block( nullifiers: &[Nullifier], accounts: &[BlockAccountUpdate], transactions: &OrderedTransactionHeaders, - precomputed_roots: &BlockAccountRoots, ) -> Result { let mut count = 0; // Note: ordering here is important as the relevant tables have FK dependencies. count += insert_block_header(conn, block_header, signature)?; - count += upsert_accounts(conn, accounts, block_header.block_num(), precomputed_roots)?; + count += upsert_accounts(conn, accounts, block_header.block_num())?; count += insert_scripts(conn, notes.iter().map(|(note, _)| note))?; count += insert_notes(conn, notes)?; count += insert_transactions(conn, block_header.block_num(), transactions)?; diff --git a/crates/store/src/db/tests.rs b/crates/store/src/db/tests.rs index 7a321f5e4..ea67eee8a 100644 --- a/crates/store/src/db/tests.rs +++ b/crates/store/src/db/tests.rs @@ -77,7 +77,6 @@ use crate::db::models::queries::{ }; use crate::db::models::{Page, queries, utils}; use crate::errors::DatabaseError; -use crate::inner_forest::BlockAccountRoots; fn create_db() -> SqliteConnection { let mut conn = SqliteConnection::establish(":memory:").expect("In memory sqlite always works"); @@ -220,13 +219,7 @@ fn sql_select_notes() { let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); - queries::upsert_accounts( - conn, - &[mock_block_account_update(account_id, 0)], - block_num, - &BlockAccountRoots::new(), - ) - .unwrap(); + queries::upsert_accounts(conn, &[mock_block_account_update(account_id, 0)], block_num).unwrap(); let new_note = create_note(account_id); @@ -271,13 +264,7 @@ fn sql_select_note_script_by_root() { let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); - queries::upsert_accounts( - conn, - &[mock_block_account_update(account_id, 0)], - block_num, - &BlockAccountRoots::new(), - ) - .unwrap(); + queries::upsert_accounts(conn, &[mock_block_account_update(account_id, 0)], block_num).unwrap(); let new_note = create_note(account_id); @@ -329,7 +316,6 @@ fn make_account_and_note( AccountUpdateDetails::Delta(AccountDelta::try_from(account).unwrap()), )], block_num, - &BlockAccountRoots::new(), ) .unwrap(); @@ -462,7 +448,6 @@ fn sql_select_accounts() { AccountUpdateDetails::Private, )], block_num, - &BlockAccountRoots::new(), ); assert_eq!(res.unwrap(), 1, "One element must have been inserted"); @@ -490,13 +475,8 @@ fn sync_account_vault_basic_validation() { create_block(conn, block_to); for block in [block_from, block_mid, block_to] { - queries::upsert_accounts( - conn, - &[mock_block_account_update(public_account_id, 0)], - block, - &BlockAccountRoots::new(), - ) - .unwrap(); + queries::upsert_accounts(conn, &[mock_block_account_update(public_account_id, 0)], block) + .unwrap(); } // Create some test vault assets @@ -835,13 +815,7 @@ fn notes() { // test insertion - queries::upsert_accounts( - conn, - &[mock_block_account_update(sender, 0)], - block_num_1, - &BlockAccountRoots::new(), - ) - .unwrap(); + queries::upsert_accounts(conn, &[mock_block_account_update(sender, 0)], block_num_1).unwrap(); let new_note = create_note(sender); let note_index = BlockNoteIndex::new(0, 2).unwrap(); @@ -976,20 +950,8 @@ fn sql_account_storage_map_values_insertion() { let account_id = AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2).unwrap(); - queries::upsert_accounts( - conn, - &[mock_block_account_update(account_id, 0)], - block1, - &BlockAccountRoots::new(), - ) - .unwrap(); - queries::upsert_accounts( - conn, - &[mock_block_account_update(account_id, 0)], - block2, - &BlockAccountRoots::new(), - ) - .unwrap(); + queries::upsert_accounts(conn, &[mock_block_account_update(account_id, 0)], block1).unwrap(); + queries::upsert_accounts(conn, &[mock_block_account_update(account_id, 0)], block2).unwrap(); let slot_name = StorageSlotName::mock(3); let key1 = Word::from([1u32, 2, 3, 4]); @@ -1063,13 +1025,8 @@ fn select_storage_map_sync_values() { let block3 = BlockNumber::from(3); for block in [block1, block2, block3] { - queries::upsert_accounts( - &mut conn, - &[mock_block_account_update(account_id, 0)], - block, - &BlockAccountRoots::new(), - ) - .unwrap(); + queries::upsert_accounts(&mut conn, &[mock_block_account_update(account_id, 0)], block) + .unwrap(); } // Insert data across multiple blocks using individual inserts @@ -1244,8 +1201,7 @@ fn insert_transactions(conn: &mut SqliteConnection) -> usize { mock_block_transaction(AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(), 2); let ordered_tx_headers = OrderedTransactionHeaders::new_unchecked(vec![mock_tx1, mock_tx2]); - queries::upsert_accounts(conn, &account_updates, block_num, &BlockAccountRoots::new()) - .unwrap(); + queries::upsert_accounts(conn, &account_updates, block_num).unwrap(); let count = queries::insert_transactions(conn, block_num, &ordered_tx_headers).unwrap(); Ok::<_, DatabaseError>(count) @@ -1325,7 +1281,6 @@ fn test_select_account_code_by_commitment() { AccountUpdateDetails::Delta(AccountDelta::try_from(account).unwrap()), )], block_num_1, - &BlockAccountRoots::new(), ) .unwrap(); @@ -1374,7 +1329,6 @@ fn test_select_account_code_by_commitment_multiple_codes() { AccountUpdateDetails::Delta(AccountDelta::try_from(account_v1).unwrap()), )], block_num_1, - &BlockAccountRoots::new(), ) .unwrap(); @@ -1408,7 +1362,6 @@ fn test_select_account_code_by_commitment_multiple_codes() { AccountUpdateDetails::Delta(AccountDelta::try_from(account_v2).unwrap()), )], block_num_2, - &BlockAccountRoots::new(), ) .unwrap(); @@ -1664,8 +1617,7 @@ fn regression_1461_full_state_delta_inserts_vault_assets() { AccountUpdateDetails::Delta(account_delta), ); - queries::upsert_accounts(&mut conn, &[block_update], block_num, &BlockAccountRoots::new()) - .unwrap(); + queries::upsert_accounts(&mut conn, &[block_update], block_num).unwrap(); let (_, vault_assets) = queries::select_account_vault_assets( &mut conn, @@ -1887,8 +1839,7 @@ fn db_roundtrip_account() { account_commitment, AccountUpdateDetails::Delta(account_delta), ); - queries::upsert_accounts(&mut conn, &[block_update], block_num, &BlockAccountRoots::new()) - .unwrap(); + queries::upsert_accounts(&mut conn, &[block_update], block_num).unwrap(); // Retrieve let retrieved = queries::select_all_accounts(&mut conn).unwrap(); @@ -1914,13 +1865,8 @@ fn db_roundtrip_notes() { create_block(&mut conn, block_num); let sender = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); - queries::upsert_accounts( - &mut conn, - &[mock_block_account_update(sender, 0)], - block_num, - &BlockAccountRoots::new(), - ) - .unwrap(); + queries::upsert_accounts(&mut conn, &[mock_block_account_update(sender, 0)], block_num) + .unwrap(); let new_note = create_note(sender); let note_index = BlockNoteIndex::new(0, 0).unwrap(); @@ -1973,13 +1919,8 @@ fn db_roundtrip_transactions() { create_block(&mut conn, block_num); let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); - queries::upsert_accounts( - &mut conn, - &[mock_block_account_update(account_id, 1)], - block_num, - &BlockAccountRoots::new(), - ) - .unwrap(); + queries::upsert_accounts(&mut conn, &[mock_block_account_update(account_id, 1)], block_num) + .unwrap(); let tx = mock_block_transaction(account_id, 1); let ordered_tx = OrderedTransactionHeaders::new_unchecked(vec![tx.clone()]); @@ -2022,13 +1963,8 @@ fn db_roundtrip_vault_assets() { let account_id = AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap(); // Create account first - queries::upsert_accounts( - &mut conn, - &[mock_block_account_update(account_id, 0)], - block_num, - &BlockAccountRoots::new(), - ) - .unwrap(); + queries::upsert_accounts(&mut conn, &[mock_block_account_update(account_id, 0)], block_num) + .unwrap(); let fungible_asset = FungibleAsset::new(faucet_id, 5000).unwrap(); let asset: Asset = fungible_asset.into(); @@ -2062,13 +1998,8 @@ fn db_roundtrip_storage_map_values() { create_block(&mut conn, block_num); let account_id = AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap(); - queries::upsert_accounts( - &mut conn, - &[mock_block_account_update(account_id, 0)], - block_num, - &BlockAccountRoots::new(), - ) - .unwrap(); + queries::upsert_accounts(&mut conn, &[mock_block_account_update(account_id, 0)], block_num) + .unwrap(); let slot_name = StorageSlotName::mock(5); let key = num_to_word(12345); let value = num_to_word(67890); @@ -2156,8 +2087,7 @@ fn db_roundtrip_account_storage_with_maps() { account.commitment(), AccountUpdateDetails::Delta(account_delta), ); - queries::upsert_accounts(&mut conn, &[block_update], block_num, &BlockAccountRoots::new()) - .unwrap(); + queries::upsert_accounts(&mut conn, &[block_update], block_num).unwrap(); // Retrieve the storage using select_latest_account_storage (reconstructs from header + map // values) @@ -2291,13 +2221,8 @@ fn test_prune_history() { // Create account for block in [block_0, block_old, block_cutoff, block_update, block_tip] { - queries::upsert_accounts( - conn, - &[mock_block_account_update(public_account_id, 0)], - block, - &BlockAccountRoots::new(), - ) - .unwrap(); + queries::upsert_accounts(conn, &[mock_block_account_update(public_account_id, 0)], block) + .unwrap(); } // Insert vault assets at different blocks diff --git a/crates/store/src/state/apply_block.rs b/crates/store/src/state/apply_block.rs index e4d9a2501..cfd5ecc5e 100644 --- a/crates/store/src/state/apply_block.rs +++ b/crates/store/src/state/apply_block.rs @@ -1,7 +1,6 @@ use std::sync::Arc; use miden_node_utils::ErrorReport; -use miden_protocol::account::delta::AccountUpdateDetails; use miden_protocol::block::SignedBlock; use miden_protocol::note::NoteDetails; use miden_protocol::transaction::OutputNote; @@ -211,35 +210,14 @@ impl State { // Signals the write lock has been acquired, and the transaction can be committed. let (inform_acquire_done, acquire_done) = oneshot::channel::<()>(); - // Extract public account updates with deltas before block is moved into async task. - // Private accounts are filtered out since they don't expose their state changes. - let account_deltas = - Vec::from_iter(body.updated_accounts().iter().filter_map( - |update| match update.details() { - AccountUpdateDetails::Delta(delta) => Some(delta.clone()), - AccountUpdateDetails::Private => None, - }, - )); - - // Compute roots in InnerForest BEFORE spawning DB task, so DB can use precomputed roots. - // This avoids loading all entries from disk to recompute roots during DB writes. - let precomputed_roots = self - .forest - .write() - .await - .apply_block_updates(block_num, account_deltas.clone())?; - // The DB and in-memory state updates need to be synchronized and are partially // overlapping. Namely, the DB transaction only proceeds after this task acquires the // in-memory write lock. This requires the DB update to run concurrently, so a new task is // spawned. let db = Arc::clone(&self.db); let db_update_task = tokio::spawn( - async move { - db.apply_block(allow_acquire, acquire_done, signed_block, notes, precomputed_roots) - .await - } - .in_current_span(), + async move { db.apply_block(allow_acquire, acquire_done, signed_block, notes).await } + .in_current_span(), ); // Wait for the message from the DB update task, that we ready to commit the DB transaction. From e44b4468eb7637ef662b22293fe3847987a6afdb Mon Sep 17 00:00:00 2001 From: Bernhard Schuster Date: Wed, 18 Feb 2026 15:35:36 +0100 Subject: [PATCH 04/17] Update CHANGELOG.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f31e530d..f405f7ad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,7 +83,7 @@ - Pin tool versions in CI ([#1523](https://github.com/0xMiden/miden-node/pull/1523)). - Add `GetVaultAssetWitnesses` and `GetStorageMapWitness` RPC endpoints to store ([#1529](https://github.com/0xMiden/miden-node/pull/1529)). - Add check to ensure tree store state is in sync with database storage ([#1532](https://github.com/0xMiden/miden-node/issues/1534)). -- Improve speed account updates ([#1567](https://github.com/0xMiden/miden-node/pull/1567)). +- Improve speed of account updates ([#1567](https://github.com/0xMiden/miden-node/pull/1567)). - Ensure store terminates on nullifier tree or account tree root vs header mismatch (#[#1569](https://github.com/0xMiden/miden-node/pull/1569)). - Added support for foreign accounts to `NtxDataStore` and add `GetAccount` endpoint to NTX Builder gRPC store client ([#1521](https://github.com/0xMiden/miden-node/pull/1521)). - Use paged queries for tree rebuilding to reduce memory usage during startup ([#1536](https://github.com/0xMiden/miden-node/pull/1536)). From c755ff306cca8a2544226ad271a37cca3ad59029 Mon Sep 17 00:00:00 2001 From: Bernhard Schuster Date: Wed, 18 Feb 2026 16:54:10 +0100 Subject: [PATCH 05/17] error handling --- .../store/src/db/models/queries/accounts.rs | 10 ++++--- .../src/db/models/queries/accounts/delta.rs | 2 +- crates/store/src/inner_forest/mod.rs | 26 +++++++++---------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/crates/store/src/db/models/queries/accounts.rs b/crates/store/src/db/models/queries/accounts.rs index d05d3bade..9e53ffe46 100644 --- a/crates/store/src/db/models/queries/accounts.rs +++ b/crates/store/src/db/models/queries/accounts.rs @@ -64,6 +64,8 @@ use delta::{ mod tests; type StorageMapValueRow = (i64, String, Vec, Vec); +type StorageHeaderWithEntries = + (AccountStorageHeader, BTreeMap>); // NETWORK ACCOUNT TYPE // ================================================================================================ @@ -778,7 +780,7 @@ pub(crate) fn select_latest_account_storage( pub(crate) fn select_latest_storage_map_entries( conn: &mut SqliteConnection, account_id: AccountId, - slot_name: StorageSlotName, + slot_name: &StorageSlotName, ) -> Result, DatabaseError> { use schema::account_storage_map_values as t; @@ -792,7 +794,7 @@ pub(crate) fn select_latest_storage_map_entries( let grouped = group_storage_map_entries(map_values)?; Ok(grouped - .get(&slot_name) + .get(slot_name) .map(|entries| BTreeMap::from_iter(entries.iter().copied())) .unwrap_or_default()) } @@ -800,7 +802,7 @@ pub(crate) fn select_latest_storage_map_entries( pub(crate) fn select_latest_account_storage_components( conn: &mut SqliteConnection, account_id: AccountId, -) -> Result<(AccountStorageHeader, BTreeMap>), DatabaseError> { +) -> Result { use schema::account_storage_map_values as t; let account_id_bytes = account_id.to_bytes(); @@ -1125,7 +1127,7 @@ pub(crate) fn upsert_accounts( } let slot_entries = - select_latest_storage_map_entries(conn, account_id, slot_name.clone())?; + select_latest_storage_map_entries(conn, account_id, slot_name)?; map_entries.insert(slot_name.clone(), slot_entries); } diff --git a/crates/store/src/db/models/queries/accounts/delta.rs b/crates/store/src/db/models/queries/accounts/delta.rs index 10c005aa4..01616b5d0 100644 --- a/crates/store/src/db/models/queries/accounts/delta.rs +++ b/crates/store/src/db/models/queries/accounts/delta.rs @@ -189,7 +189,7 @@ pub(super) fn select_vault_balances_by_faucet_ids( .filter(vault::vault_key.eq_any(&vault_keys)) .load(conn)?; - let mut balances = BTreeMap::new(); + let mut balances = BTreeMap::from_iter(faucet_ids.iter().map(|faucet_id| (*faucet_id, 0))); for (_vault_key_bytes, maybe_asset_bytes) in entries { if let Some(asset_bytes) = maybe_asset_bytes { diff --git a/crates/store/src/inner_forest/mod.rs b/crates/store/src/inner_forest/mod.rs index b90f0530f..fe388aab0 100644 --- a/crates/store/src/inner_forest/mod.rs +++ b/crates/store/src/inner_forest/mod.rs @@ -53,6 +53,10 @@ pub enum InnerForestError { prev_balance: u64, delta: i64, }, + #[error(transparent)] + AssetError(#[cause] AssetError), + #[error(transparent)] + MerkleError(#[cause] MerkleError), } #[derive(Debug, Error)] @@ -307,7 +311,7 @@ impl InnerForest { let storage_map_roots = if delta.storage().is_empty() { BTreeMap::new() } else { - self.update_account_storage(block_num, account_id, delta.storage(), is_full_state) + self.update_account_storage(block_num, account_id, delta.storage(), is_full_state)? }; Ok(PrecomputedAccountRoots { vault_root, storage_map_roots }) @@ -359,8 +363,7 @@ impl InnerForest { // Process fungible assets for (faucet_id, amount_delta) in vault_delta.fungible().iter() { - let key: Word = - FungibleAsset::new(*faucet_id, 0).expect("valid faucet id").vault_key().into(); + let key: Word = FungibleAsset::new(*faucet_id, 0)?.vault_key().into(); let new_amount = { // amount delta is a change that must be applied to previous balance. @@ -387,7 +390,7 @@ impl InnerForest { let value = if new_amount == 0 { EMPTY_WORD } else { - FungibleAsset::new(*faucet_id, new_amount).expect("valid fungible asset").into() + FungibleAsset::new(*faucet_id, new_amount)?.into() }; entries.push((key, value)); } @@ -410,10 +413,7 @@ impl InnerForest { let num_entries = entries.len(); - let new_root = self - .forest - .batch_insert(prev_root, entries) - .expect("forest insertion should succeed"); + let new_root = self.forest.batch_insert(prev_root, entries)?; self.vault_roots.insert((account_id, block_num), new_root); @@ -487,7 +487,7 @@ impl InnerForest { account_id: AccountId, storage_delta: &AccountStorageDelta, is_full_state: bool, - ) -> BTreeMap { + ) -> Result, InnerForestError> { let mut updated_roots = BTreeMap::new(); for (slot_name, map_delta) in storage_delta.maps() { @@ -525,10 +525,8 @@ impl InnerForest { continue; } - let updated_root = self - .forest - .batch_insert(prev_root, delta_entries.iter().copied()) - .expect("forest insertion should succeed"); + let updated_root = + self.forest.batch_insert(prev_root, delta_entries.iter().copied())?; self.storage_map_roots .insert((account_id, slot_name.clone(), block_num), updated_root); @@ -560,7 +558,7 @@ impl InnerForest { ); } - updated_roots + Ok(updated_roots) } // TODO: tie in-memory forest retention to DB pruning policy once forest queries rely on it. From 230654aada3e85a03daf623ada6b6f12969094ea Mon Sep 17 00:00:00 2001 From: Bernhard Schuster Date: Mon, 23 Feb 2026 08:57:55 +0100 Subject: [PATCH 06/17] safe --- crates/store/src/inner_forest/mod.rs | 90 ++++------------------------ crates/store/src/state/loader.rs | 1 - 2 files changed, 12 insertions(+), 79 deletions(-) diff --git a/crates/store/src/inner_forest/mod.rs b/crates/store/src/inner_forest/mod.rs index fe388aab0..cd857097f 100644 --- a/crates/store/src/inner_forest/mod.rs +++ b/crates/store/src/inner_forest/mod.rs @@ -20,24 +20,6 @@ use thiserror::Error; #[cfg(test)] mod tests; -// TYPES -// ================================================================================================ - -/// Precomputed account roots from in-memory SMT updates. -/// -/// Contains the vault root and storage map roots computed by applying deltas to the in-memory -/// `SmtForest`. Used to avoid reloading all entries from the database when updating accounts. -#[derive(Debug, Clone, Default)] -pub struct PrecomputedAccountRoots { - /// New vault root after applying delta (None if vault unchanged). - pub vault_root: Option, - /// New storage map roots by slot name after applying delta. - pub storage_map_roots: BTreeMap, -} - -/// Collection of precomputed roots for all accounts updated in a block. -pub type BlockAccountRoots = BTreeMap; - // ERRORS // ================================================================================================ @@ -53,10 +35,10 @@ pub enum InnerForestError { prev_balance: u64, delta: i64, }, - #[error(transparent)] - AssetError(#[cause] AssetError), - #[error(transparent)] - MerkleError(#[cause] MerkleError), + #[error("asset error")] + AssetError(#[from] AssetError), + #[error("merkle error")] + MerkleError(#[from] MerkleError), } #[derive(Debug, Error)] @@ -238,46 +220,6 @@ impl InnerForest { // PUBLIC INTERFACE // -------------------------------------------------------------------------------------------- - /// Applies account updates from a block to the forest and returns precomputed roots. - /// - /// Iterates through account updates and applies each delta to the forest. Returns the - /// computed vault and storage map roots for each account, to be used by DB writes. - /// Private accounts should be filtered out before calling this method. - /// - /// # Arguments - /// - /// * `block_num` - Block number for which these updates apply - /// * `account_updates` - Iterator of `AccountDelta` for public accounts - /// - /// # Returns - /// - /// A map from account id to precomputed roots (vault root and storage map roots). - /// - /// # Errors - /// - /// Returns an error if applying a vault delta results in a negative balance. - pub(crate) fn apply_block_updates( - &mut self, - block_num: BlockNumber, - account_updates: impl IntoIterator, - ) -> Result { - let mut account_roots = BlockAccountRoots::new(); - - for delta in account_updates { - let roots = self.update_account(block_num, &delta)?; - account_roots.insert(delta.id(), roots); - - tracing::debug!( - target: crate::COMPONENT, - account_id = %delta.id(), - %block_num, - is_full_state = delta.is_full_state(), - "Updated forest with account delta" - ); - } - Ok(account_roots) - } - /// Updates the forest with account vault and storage changes from a delta. /// /// Unified interface for updating all account state in the forest, handling both full-state @@ -287,10 +229,6 @@ impl InnerForest { /// Full-state deltas (`delta.is_full_state() == true`) populate the forest from scratch using /// an empty SMT root. Partial deltas apply changes on top of the previous block's state. /// - /// # Returns - /// - /// Precomputed roots for the account (vault root and storage map roots). - /// /// # Errors /// /// Returns an error if applying a vault delta results in a negative balance. @@ -298,23 +236,19 @@ impl InnerForest { &mut self, block_num: BlockNumber, delta: &AccountDelta, - ) -> Result { + ) -> Result<(), InnerForestError> { let account_id = delta.id(); let is_full_state = delta.is_full_state(); - let vault_root = if is_full_state || !delta.vault().is_empty() { - Some(self.update_account_vault(block_num, account_id, delta.vault(), is_full_state)?) - } else { - None - }; + if is_full_state || !delta.vault().is_empty() { + self.update_account_vault(block_num, account_id, delta.vault(), is_full_state)?; + } - let storage_map_roots = if delta.storage().is_empty() { - BTreeMap::new() - } else { - self.update_account_storage(block_num, account_id, delta.storage(), is_full_state)? - }; + if !delta.storage().is_empty() { + self.update_account_storage(block_num, account_id, delta.storage(), is_full_state)?; + } - Ok(PrecomputedAccountRoots { vault_root, storage_map_roots }) + Ok(()) } // ASSET VAULT DELTA PROCESSING diff --git a/crates/store/src/state/loader.rs b/crates/store/src/state/loader.rs index d237716f3..88a9e876d 100644 --- a/crates/store/src/state/loader.rs +++ b/crates/store/src/state/loader.rs @@ -377,7 +377,6 @@ pub async fn load_smt_forest( StateInitializationError::AccountToDeltaConversionFailed(e.to_string()) })?; - // Use the unified update method (will recognize it's a full-state delta) forest.update_account(block_num, &delta)?; } From 3c284261de13d2a267a8770df15f32154ac7f8b6 Mon Sep 17 00:00:00 2001 From: Bernhard Schuster Date: Mon, 23 Feb 2026 09:29:14 +0100 Subject: [PATCH 07/17] review comments --- .../down.sql | 1 + .../20260206163855_add_account_indices/up.sql | 1 + .../store/src/db/models/queries/accounts.rs | 32 +++++----- .../src/db/models/queries/accounts/tests.rs | 59 +++++++++++++++++++ crates/store/src/state/apply_block.rs | 10 ++++ crates/store/src/state/mod.rs | 3 - 6 files changed, 86 insertions(+), 20 deletions(-) diff --git a/crates/store/src/db/migrations/20260206163855_add_account_indices/down.sql b/crates/store/src/db/migrations/20260206163855_add_account_indices/down.sql index 1a15b55c4..0029d2abd 100644 --- a/crates/store/src/db/migrations/20260206163855_add_account_indices/down.sql +++ b/crates/store/src/db/migrations/20260206163855_add_account_indices/down.sql @@ -1,2 +1,3 @@ DROP INDEX IF EXISTS idx_account_storage_map_latest_by_account_slot_key; +DROP INDEX IF EXISTS idx_account_vault_assets_by_account_key_block; DROP INDEX IF EXISTS idx_account_vault_assets_latest_by_account_key; diff --git a/crates/store/src/db/migrations/20260206163855_add_account_indices/up.sql b/crates/store/src/db/migrations/20260206163855_add_account_indices/up.sql index 83233e157..6687f71e9 100644 --- a/crates/store/src/db/migrations/20260206163855_add_account_indices/up.sql +++ b/crates/store/src/db/migrations/20260206163855_add_account_indices/up.sql @@ -1,2 +1,3 @@ CREATE INDEX idx_account_storage_map_latest_by_account_slot_key ON account_storage_map_values(account_id, slot_name, key, is_latest) WHERE is_latest = 1; CREATE INDEX idx_account_vault_assets_latest_by_account_key ON account_vault_assets(account_id, vault_key, is_latest) WHERE is_latest = 1; +CREATE INDEX idx_account_vault_assets_by_account_key_block ON account_vault_assets(account_id, vault_key, block_num DESC); diff --git a/crates/store/src/db/models/queries/accounts.rs b/crates/store/src/db/models/queries/accounts.rs index 9e53ffe46..c43cf476a 100644 --- a/crates/store/src/db/models/queries/accounts.rs +++ b/crates/store/src/db/models/queries/accounts.rs @@ -1171,6 +1171,21 @@ pub(crate) fn upsert_accounts( vault_root: new_vault_root, }; + let account_header = miden_protocol::account::AccountHeader::new( + account_id, + account_state.nonce, + account_state.vault_root, + account_state.storage_header.to_commitment(), + account_state.code_commitment, + ); + + if account_header.commitment() != update.final_state_commitment() { + return Err(DatabaseError::AccountCommitmentsMismatch { + calculated: account_header.commitment(), + expected: update.final_state_commitment(), + }); + } + (AccountStateForInsert::PartialState(account_state), storage, assets) }, }; @@ -1225,23 +1240,6 @@ pub(crate) fn upsert_accounts( ), }; - if let AccountStateForInsert::PartialState(state) = &account_state { - let account_header = miden_protocol::account::AccountHeader::new( - account_id, - state.nonce, - state.vault_root, - state.storage_header.to_commitment(), - state.code_commitment, - ); - - if account_header.commitment() != update.final_state_commitment() { - return Err(DatabaseError::AccountCommitmentsMismatch { - calculated: account_header.commitment(), - expected: update.final_state_commitment(), - }); - } - } - diesel::insert_into(schema::accounts::table) .values(&account_value) .execute(conn)?; diff --git a/crates/store/src/db/models/queries/accounts/tests.rs b/crates/store/src/db/models/queries/accounts/tests.rs index fa1e77e85..d87dbca7b 100644 --- a/crates/store/src/db/models/queries/accounts/tests.rs +++ b/crates/store/src/db/models/queries/accounts/tests.rs @@ -728,6 +728,65 @@ fn test_select_account_vault_at_block_historical_with_updates() { assert!(amounts.contains(&500), "Block 3 should have vault_key_2 with 500 tokens"); } +/// Tests that a 5-block history returns the correct asset per block. +#[test] +fn test_select_account_vault_at_block_exponential_updates() { + use assert_matches::assert_matches; + use miden_protocol::asset::{AssetVaultKey, FungibleAsset}; + use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET; + + let mut conn = setup_test_db(); + let (account, _) = create_test_account_with_storage(); + let account_id = account.id(); + + let faucet_id = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).unwrap(); + + const BLOCK_COUNT: u32 = 5; + let blocks: Vec = (0..BLOCK_COUNT).map(BlockNumber::from).collect(); + + for block in &blocks { + insert_block_header(&mut conn, *block); + } + + let delta = AccountDelta::try_from(account.clone()).unwrap(); + let account_update = BlockAccountUpdate::new( + account_id, + account.commitment(), + AccountUpdateDetails::Delta(delta), + ); + + for block in &blocks { + upsert_accounts(&mut conn, std::slice::from_ref(&account_update), *block) + .expect("upsert_accounts failed"); + } + + let vault_key = AssetVaultKey::new_unchecked(Word::from([ + Felt::new(3), + Felt::new(0), + Felt::new(0), + Felt::new(0), + ])); + + for (index, block) in blocks.iter().enumerate() { + let amount = 1u64 << index; + let asset = Asset::Fungible(FungibleAsset::new(faucet_id, amount).unwrap()); + insert_account_vault_asset(&mut conn, account_id, *block, vault_key, Some(asset)) + .expect("insert vault asset failed"); + } + + for (index, block) in blocks.iter().enumerate() { + let assets_at_block = select_account_vault_at_block(&mut conn, account_id, *block) + .expect("Query at block should succeed"); + + assert_eq!(assets_at_block.len(), 1, "Should have 1 asset at block"); + let expected_amount = 1u64 << index; + assert_matches!( + &assets_at_block[0], + Asset::Fungible(f) if f.amount() == expected_amount + ); + } +} + /// Tests that deleted vault assets (asset = None) are correctly excluded from results, /// and that the deduplication handles deletion entries properly. #[test] diff --git a/crates/store/src/state/apply_block.rs b/crates/store/src/state/apply_block.rs index cfd5ecc5e..0e9aea785 100644 --- a/crates/store/src/state/apply_block.rs +++ b/crates/store/src/state/apply_block.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use miden_node_utils::ErrorReport; +use miden_protocol::account::delta::AccountUpdateDetails; use miden_protocol::block::SignedBlock; use miden_protocol::note::NoteDetails; use miden_protocol::transaction::OutputNote; @@ -45,6 +46,7 @@ impl State { let header = signed_block.header(); let body = signed_block.body(); + let account_updates = body.updated_accounts().to_vec(); // Validate that header and body match. let tx_commitment = body.transactions().commitment(); @@ -266,6 +268,14 @@ impl State { .account_tree .apply_mutations(account_tree_update) .expect("Unreachable: old account tree root must be checked before this step"); + + let mut forest = self.forest.write().await; + for update in &account_updates { + if let AccountUpdateDetails::Delta(delta) = update.details() { + forest.update_account(block_num, delta)?; + } + } + inner.blockchain.push(block_commitment); Ok(()) diff --git a/crates/store/src/state/mod.rs b/crates/store/src/state/mod.rs index ad56c28bd..40f6f29e6 100644 --- a/crates/store/src/state/mod.rs +++ b/crates/store/src/state/mod.rs @@ -182,9 +182,6 @@ impl State { }) } - // STATE MUTATOR - // -------------------------------------------------------------------------------------------- - // STATE ACCESSORS // -------------------------------------------------------------------------------------------- From 38cb1faa95be881b7d451b60521ccf1bc18a9fea Mon Sep 17 00:00:00 2001 From: Bernhard Schuster Date: Mon, 23 Feb 2026 09:30:59 +0100 Subject: [PATCH 08/17] remvoe dead code --- .../store/src/db/models/queries/accounts/delta.rs | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/crates/store/src/db/models/queries/accounts/delta.rs b/crates/store/src/db/models/queries/accounts/delta.rs index 01616b5d0..c138fa18f 100644 --- a/crates/store/src/db/models/queries/accounts/delta.rs +++ b/crates/store/src/db/models/queries/accounts/delta.rs @@ -37,13 +37,12 @@ mod tests; /// Raw row type for account state delta queries. /// -/// Fields: (`nonce`, `code_commitment`, `storage_header`, `vault_root`) +/// Fields: (`nonce`, `code_commitment`, `storage_header`) #[derive(diesel::prelude::Queryable)] struct AccountStateDeltaRow { nonce: Option, code_commitment: Option>, storage_header: Option>, - vault_root: Option>, } /// Data needed for applying a delta update to an existing account. @@ -53,7 +52,6 @@ pub(super) struct AccountStateHeadersForDelta { pub nonce: Felt, pub code_commitment: Word, pub storage_header: AccountStorageHeader, - pub vault_root: Word, } /// Minimal account state computed from a partial delta update. @@ -86,12 +84,11 @@ pub(super) enum AccountStateForInsert { /// - `nonce` (to add `nonce_delta`) /// - `code_commitment` (unchanged in partial deltas) /// - `storage_header` (to apply storage delta) -/// - `vault_root` (to apply vault delta) /// /// # Raw SQL /// /// ```sql -/// SELECT nonce, code_commitment, storage_header, vault_root +/// SELECT nonce, code_commitment, storage_header /// FROM accounts /// WHERE account_id = ?1 AND is_latest = 1 /// ``` @@ -105,7 +102,6 @@ pub(super) fn select_minimal_account_state_headers( schema::accounts::nonce, schema::accounts::code_commitment, schema::accounts::storage_header, - schema::accounts::vault_root, ), ) .filter(schema::accounts::account_id.eq(account_id.to_bytes())) @@ -133,17 +129,10 @@ pub(super) fn select_minimal_account_state_headers( None => AccountStorageHeader::new(Vec::new())?, }; - let vault_root = row - .vault_root - .map(|bytes| Word::read_from_bytes(&bytes)) - .transpose()? - .unwrap_or(Word::default()); - Ok(AccountStateHeadersForDelta { nonce, code_commitment, storage_header, - vault_root, }) } From c56ba857aae0e1479647c70e43aa2a67bd121549 Mon Sep 17 00:00:00 2001 From: Bernhard Schuster Date: Mon, 23 Feb 2026 11:03:40 +0100 Subject: [PATCH 09/17] clippy --- crates/store/src/db/models/queries/accounts/delta.rs | 6 +----- crates/store/src/db/models/queries/accounts/tests.rs | 3 ++- crates/store/src/db/models/queries/mod.rs | 4 ---- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/crates/store/src/db/models/queries/accounts/delta.rs b/crates/store/src/db/models/queries/accounts/delta.rs index c138fa18f..7a554130c 100644 --- a/crates/store/src/db/models/queries/accounts/delta.rs +++ b/crates/store/src/db/models/queries/accounts/delta.rs @@ -129,11 +129,7 @@ pub(super) fn select_minimal_account_state_headers( None => AccountStorageHeader::new(Vec::new())?, }; - Ok(AccountStateHeadersForDelta { - nonce, - code_commitment, - storage_header, - }) + Ok(AccountStateHeadersForDelta { nonce, code_commitment, storage_header }) } /// Selects vault balances for specific faucet IDs. diff --git a/crates/store/src/db/models/queries/accounts/tests.rs b/crates/store/src/db/models/queries/accounts/tests.rs index d87dbca7b..1164cb0f6 100644 --- a/crates/store/src/db/models/queries/accounts/tests.rs +++ b/crates/store/src/db/models/queries/accounts/tests.rs @@ -731,6 +731,8 @@ fn test_select_account_vault_at_block_historical_with_updates() { /// Tests that a 5-block history returns the correct asset per block. #[test] fn test_select_account_vault_at_block_exponential_updates() { + const BLOCK_COUNT: u32 = 5; + use assert_matches::assert_matches; use miden_protocol::asset::{AssetVaultKey, FungibleAsset}; use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET; @@ -741,7 +743,6 @@ fn test_select_account_vault_at_block_exponential_updates() { let faucet_id = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).unwrap(); - const BLOCK_COUNT: u32 = 5; let blocks: Vec = (0..BLOCK_COUNT).map(BlockNumber::from).collect(); for block in &blocks { diff --git a/crates/store/src/db/models/queries/mod.rs b/crates/store/src/db/models/queries/mod.rs index 31f5f16ba..ad2010f84 100644 --- a/crates/store/src/db/models/queries/mod.rs +++ b/crates/store/src/db/models/queries/mod.rs @@ -53,10 +53,6 @@ pub(crate) use notes::*; /// # Returns /// /// Number of records inserted and/or updated. -#[expect( - clippy::too_many_arguments, - reason = "apply_block wires block inserts to multiple tables" -)] pub(crate) fn apply_block( conn: &mut SqliteConnection, block_header: &BlockHeader, From faaad63aa4999263074574ce1de68c51d5515433 Mon Sep 17 00:00:00 2001 From: Bernhard Schuster Date: Mon, 23 Feb 2026 13:52:18 +0100 Subject: [PATCH 10/17] better --- .../store/src/db/models/queries/accounts.rs | 175 +++++++++++------- 1 file changed, 110 insertions(+), 65 deletions(-) diff --git a/crates/store/src/db/models/queries/accounts.rs b/crates/store/src/db/models/queries/accounts.rs index c43cf476a..b538bc6ed 100644 --- a/crates/store/src/db/models/queries/accounts.rs +++ b/crates/store/src/db/models/queries/accounts.rs @@ -65,7 +65,7 @@ mod tests; type StorageMapValueRow = (i64, String, Vec, Vec); type StorageHeaderWithEntries = - (AccountStorageHeader, BTreeMap>); + (AccountStorageHeader, BTreeMap>); // NETWORK ACCOUNT TYPE // ================================================================================================ @@ -754,57 +754,32 @@ pub(crate) fn select_latest_account_storage( ) -> Result { let (storage_header, map_entries_by_slot) = select_latest_account_storage_components(conn, account_id)?; - // Reconstruct StorageSlots from header slots + map entries - let mut slots = Vec::new(); - for slot_header in storage_header.slots() { - let slot = match slot_header.slot_type() { - StorageSlotType::Value => { - // For value slots, the header value IS the slot value - StorageSlot::with_value(slot_header.name().clone(), slot_header.value()) - }, - StorageSlotType::Map => { - // For map slots, reconstruct from map entries - let entries = - map_entries_by_slot.get(slot_header.name()).cloned().unwrap_or_default(); - let storage_map = StorageMap::with_entries(entries)?; - StorageSlot::with_map(slot_header.name().clone(), storage_map) - }, - }; - slots.push(slot); - } + let slots = + Result::, DatabaseError>::from_iter(storage_header.slots().map(|slot_header| { + let slot = match slot_header.slot_type() { + StorageSlotType::Value => { + // For value slots, the header value IS the slot value + StorageSlot::with_value(slot_header.name().clone(), slot_header.value()) + }, + StorageSlotType::Map => { + // For map slots, reconstruct from map entries + let entries = + map_entries_by_slot.get(slot_header.name()).cloned().unwrap_or_default(); + let storage_map = StorageMap::with_entries(entries.into_iter())?; + StorageSlot::with_map(slot_header.name().clone(), storage_map) + }, + }; + Ok(slot) + }))?; Ok(AccountStorage::new(slots)?) } -pub(crate) fn select_latest_storage_map_entries( - conn: &mut SqliteConnection, - account_id: AccountId, - slot_name: &StorageSlotName, -) -> Result, DatabaseError> { - use schema::account_storage_map_values as t; - - let account_id_bytes = account_id.to_bytes(); - let map_values: Vec<(String, Vec, Vec)> = - SelectDsl::select(t::table, (t::slot_name, t::key, t::value)) - .filter(t::account_id.eq(&account_id_bytes)) - .filter(t::slot_name.eq(slot_name.clone().to_raw_sql())) - .filter(t::is_latest.eq(true)) - .load(conn)?; - - let grouped = group_storage_map_entries(map_values)?; - Ok(grouped - .get(slot_name) - .map(|entries| BTreeMap::from_iter(entries.iter().copied())) - .unwrap_or_default()) -} - pub(crate) fn select_latest_account_storage_components( conn: &mut SqliteConnection, account_id: AccountId, ) -> Result { - use schema::account_storage_map_values as t; - let account_id_bytes = account_id.to_bytes(); // Query storage header blob for this account where is_latest = true @@ -821,27 +796,87 @@ pub(crate) fn select_latest_account_storage_components( None => AccountStorageHeader::new(Vec::new())?, }; - // Query all latest map values for this account + let entries = select_latest_storage_map_entries_all(conn, &account_id)?; + Ok((header, entries)) +} + +// TODO this is expensive and should only be called from tests +fn select_latest_storage_map_entries_all( + conn: &mut SqliteConnection, + account_id: &AccountId, +) -> Result>, DatabaseError> { + use schema::account_storage_map_values as t; + let map_values: Vec<(String, Vec, Vec)> = SelectDsl::select(t::table, (t::slot_name, t::key, t::value)) - .filter(t::account_id.eq(&account_id_bytes)) + .filter(t::account_id.eq(&account_id.to_bytes())) .filter(t::is_latest.eq(true)) .load(conn)?; - Ok((header, group_storage_map_entries(map_values)?)) + group_storage_map_entries(map_values) +} + +fn select_latest_storage_map_entries_for_slots( + conn: &mut SqliteConnection, + account_id: &AccountId, + slot_names: &[StorageSlotName], +) -> Result>, DatabaseError> { + use schema::account_storage_map_values as t; + + if slot_names.is_empty() { + return Ok(BTreeMap::new()); + } + + if let [slot_name] = slot_names { + let entries = select_latest_storage_map_entries_for_slot(conn, account_id, slot_name)?; + if entries.is_empty() { + return Ok(BTreeMap::new()); + } + + let mut map_entries = BTreeMap::new(); + map_entries.insert(slot_name.clone(), entries); + return Ok(map_entries); + } + + let slot_names = Vec::from_iter(slot_names.iter().cloned().map(StorageSlotName::to_raw_sql)); + let map_values: Vec<(String, Vec, Vec)> = + SelectDsl::select(t::table, (t::slot_name, t::key, t::value)) + .filter(t::account_id.eq(&account_id.to_bytes())) + .filter(t::is_latest.eq(true)) + .filter(t::slot_name.eq_any(slot_names)) + .load(conn)?; + + group_storage_map_entries(map_values) +} + +fn select_latest_storage_map_entries_for_slot( + conn: &mut SqliteConnection, + account_id: &AccountId, + slot_name: &StorageSlotName, +) -> Result, DatabaseError> { + use schema::account_storage_map_values as t; + + let map_values: Vec<(String, Vec, Vec)> = + SelectDsl::select(t::table, (t::slot_name, t::key, t::value)) + .filter(t::account_id.eq(&account_id.to_bytes())) + .filter(t::is_latest.eq(true)) + .filter(t::slot_name.eq(slot_name.clone().to_raw_sql())) + .load(conn)?; + + Ok(group_storage_map_entries(map_values)?.remove(slot_name).unwrap_or_default()) } fn group_storage_map_entries( map_values: Vec<(String, Vec, Vec)>, -) -> Result>, DatabaseError> { - let mut map_entries_by_slot: BTreeMap> = BTreeMap::new(); +) -> Result>, DatabaseError> { + let mut map_entries_by_slot: BTreeMap> = BTreeMap::new(); for (slot_name_str, key_bytes, value_bytes) in map_values { let slot_name: StorageSlotName = slot_name_str.parse().map_err(|_| { DatabaseError::DataCorrupted(format!("Invalid slot name: {slot_name_str}")) })?; let key = Word::read_from_bytes(&key_bytes)?; let value = Word::read_from_bytes(&value_bytes)?; - map_entries_by_slot.entry(slot_name).or_default().push((key, value)); + map_entries_by_slot.entry(slot_name).or_default().insert(key, value); } Ok(map_entries_by_slot) @@ -1089,13 +1124,19 @@ pub(crate) fn upsert_accounts( // update fungible assets for (faucet_id, amount_delta) in delta.vault().fungible().iter() { let prev_balance = prev_balances.get(faucet_id).copied().unwrap_or(0); - let new_balance = - u64::try_from(i128::from(prev_balance) + i128::from(*amount_delta)) - .map_err(|_| { - DatabaseError::DataCorrupted(format!( - "Balance underflow for account {account_id}, faucet {faucet_id}" - )) - })?; + let new_balance = amount_delta + .checked_abs() + .and_then(|abs_delta| u64::try_from(abs_delta).ok()) + .and_then(|abs_delta| match amount_delta.cmp(&0) { + std::cmp::Ordering::Greater => prev_balance.checked_add(abs_delta), + std::cmp::Ordering::Less => prev_balance.checked_sub(abs_delta), + std::cmp::Ordering::Equal => Some(prev_balance), + }) + .ok_or_else(|| { + DatabaseError::DataCorrupted(format!( + "Balance underflow for account {account_id}, faucet {faucet_id}" + )) + })?; let asset: Asset = FungibleAsset::new(*faucet_id, new_balance)?.into(); let update_or_remove = if new_balance == 0 { None } else { Some(asset) }; @@ -1120,16 +1161,20 @@ pub(crate) fn upsert_accounts( } } - let mut map_entries = BTreeMap::new(); - for (slot_name, map_delta) in delta.storage().maps() { - if map_delta.is_empty() { - continue; - } + let slot_names: Vec = delta + .storage() + .maps() + .filter_map(|(slot_name, map_delta)| { + if map_delta.is_empty() { + None + } else { + Some(slot_name.clone()) + } + }) + .collect(); - let slot_entries = - select_latest_storage_map_entries(conn, account_id, slot_name)?; - map_entries.insert(slot_name.clone(), slot_entries); - } + let map_entries = + select_latest_storage_map_entries_for_slots(conn, &account_id, &slot_names)?; let new_storage_header = apply_storage_delta( &state_headers.storage_header, From 1c8ccd7dfb0a95706a0ef3e08f55b94e10b89f91 Mon Sep 17 00:00:00 2001 From: Bernhard Schuster Date: Mon, 23 Feb 2026 14:46:24 +0100 Subject: [PATCH 11/17] undo inlining --- crates/store/src/inner_forest/mod.rs | 32 +++++++++++++++++++++++++++ crates/store/src/state/apply_block.rs | 20 ++++++++++------- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/crates/store/src/inner_forest/mod.rs b/crates/store/src/inner_forest/mod.rs index cd857097f..0371e15ac 100644 --- a/crates/store/src/inner_forest/mod.rs +++ b/crates/store/src/inner_forest/mod.rs @@ -220,6 +220,38 @@ impl InnerForest { // PUBLIC INTERFACE // -------------------------------------------------------------------------------------------- + /// Updates the forest with account vault and storage changes from a delta. + /// + /// Iterates through account updates and applies each delta to the forest. + /// Private accounts should be filtered out before calling this method. + /// + /// # Arguments + /// + /// * `block_num` - Block number for which these updates apply + /// * `account_updates` - Iterator of `AccountDelta` for public accounts + /// + /// # Errors + /// + /// Returns an error if applying a vault delta results in a negative balance. + pub(crate) fn apply_block_updates( + &mut self, + block_num: BlockNumber, + account_updates: impl IntoIterator, + ) -> Result<(), InnerForestError> { + for delta in account_updates { + self.update_account(block_num, &delta)?; + + tracing::debug!( + target: crate::COMPONENT, + account_id = %delta.id(), + %block_num, + is_full_state = delta.is_full_state(), + "Updated forest with account delta" + ); + } + Ok(()) + } + /// Updates the forest with account vault and storage changes from a delta. /// /// Unified interface for updating all account state in the forest, handling both full-state diff --git a/crates/store/src/state/apply_block.rs b/crates/store/src/state/apply_block.rs index 0e9aea785..7949fcbeb 100644 --- a/crates/store/src/state/apply_block.rs +++ b/crates/store/src/state/apply_block.rs @@ -46,7 +46,6 @@ impl State { let header = signed_block.header(); let body = signed_block.body(); - let account_updates = body.updated_accounts().to_vec(); // Validate that header and body match. let tx_commitment = body.transactions().commitment(); @@ -212,6 +211,16 @@ impl State { // Signals the write lock has been acquired, and the transaction can be committed. let (inform_acquire_done, acquire_done) = oneshot::channel::<()>(); + // Extract public account updates with deltas before block is moved into async task. + // Private accounts are filtered out since they don't expose their state changes. + let account_deltas = + Vec::from_iter(body.updated_accounts().iter().filter_map( + |update| match update.details() { + AccountUpdateDetails::Delta(delta) => Some(delta.clone()), + AccountUpdateDetails::Private => None, + }, + )); + // The DB and in-memory state updates need to be synchronized and are partially // overlapping. Namely, the DB transaction only proceeds after this task acquires the // in-memory write lock. This requires the DB update to run concurrently, so a new task is @@ -269,13 +278,6 @@ impl State { .apply_mutations(account_tree_update) .expect("Unreachable: old account tree root must be checked before this step"); - let mut forest = self.forest.write().await; - for update in &account_updates { - if let AccountUpdateDetails::Delta(delta) = update.details() { - forest.update_account(block_num, delta)?; - } - } - inner.blockchain.push(block_commitment); Ok(()) @@ -283,6 +285,8 @@ impl State { .in_current_span() .await?; + self.forest.write().await.apply_block_updates(block_num, account_deltas)?; + info!(%block_commitment, block_num = block_num.as_u32(), COMPONENT, "apply_block successful"); Ok(()) From 689fa874a83e5105fc2b9482ff67b6bfc41256b8 Mon Sep 17 00:00:00 2001 From: Bernhard Schuster Date: Wed, 25 Feb 2026 15:50:32 +0100 Subject: [PATCH 12/17] encode invariant explicitly as debug assertion --- crates/store/src/inner_forest/mod.rs | 34 ++++++++++++---------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/crates/store/src/inner_forest/mod.rs b/crates/store/src/inner_forest/mod.rs index 658c1b776..681b43dcf 100644 --- a/crates/store/src/inner_forest/mod.rs +++ b/crates/store/src/inner_forest/mod.rs @@ -272,6 +272,18 @@ impl InnerForest { let account_id = delta.id(); let is_full_state = delta.is_full_state(); + #[cfg(debug_assertions)] + if is_full_state { + let has_vault_root = self.vault_roots.keys().any(|(id, _)| *id == account_id); + let has_storage_root = self.storage_map_roots.keys().any(|(id, ..)| *id == account_id); + let has_storage_entries = self.storage_entries.keys().any(|(id, ..)| *id == account_id); + + assert!( + !has_vault_root && !has_storage_root && !has_storage_entries, + "full-state delta should not be applied to existing account" + ); + } + if is_full_state || !delta.vault().is_empty() { self.update_account_vault(block_num, account_id, delta.vault(), is_full_state)?; } @@ -288,11 +300,7 @@ impl InnerForest { /// Retrieves the most recent vault SMT root for an account. /// If `is_full_state` is true, returns an empty SMT root. - fn get_latest_vault_root(&self, account_id: AccountId, is_full_state: bool) -> Word { - if is_full_state { - return Self::empty_smt_root(); - } - + fn get_latest_vault_root(&self, account_id: AccountId) -> Word { self.vault_roots .range((account_id, BlockNumber::GENESIS)..=(account_id, BlockNumber::MAX)) .next_back() @@ -323,7 +331,7 @@ impl InnerForest { vault_delta: &AccountVaultDelta, is_full_state: bool, ) -> Result { - let prev_root = self.get_latest_vault_root(account_id, is_full_state); + let prev_root = self.get_latest_vault_root(account_id); let mut entries: Vec<(Word, Word)> = Vec::new(); @@ -402,12 +410,7 @@ impl InnerForest { &self, account_id: AccountId, slot_name: &StorageSlotName, - is_full_state: bool, ) -> Word { - if is_full_state { - return Self::empty_smt_root(); - } - self.storage_map_roots .range( (account_id, slot_name.clone(), BlockNumber::GENESIS) @@ -457,14 +460,7 @@ impl InnerForest { let mut updated_roots = BTreeMap::new(); for (slot_name, map_delta) in storage_delta.maps() { - let prev_root = self.get_latest_storage_map_root(account_id, slot_name, is_full_state); - if is_full_state { - assert_eq!( - prev_root, - Self::empty_smt_root(), - "account should not be in the forest" - ); - } + let prev_root = self.get_latest_storage_map_root(account_id, slot_name); let delta_entries = if is_full_state { Vec::from_iter(map_delta.entries().iter().filter_map(|(&key, &value)| { From 2480d2d665a6a0600afbdbe736414f1cfbc00dc5 Mon Sep 17 00:00:00 2001 From: Bernhard Schuster Date: Wed, 25 Feb 2026 16:53:04 +0100 Subject: [PATCH 13/17] inner forest changes --- crates/store/src/inner_forest/mod.rs | 46 +++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/crates/store/src/inner_forest/mod.rs b/crates/store/src/inner_forest/mod.rs index 681b43dcf..001ae4e82 100644 --- a/crates/store/src/inner_forest/mod.rs +++ b/crates/store/src/inner_forest/mod.rs @@ -284,22 +284,45 @@ impl InnerForest { ); } - if is_full_state || !delta.vault().is_empty() { - self.update_account_vault(block_num, account_id, delta.vault(), is_full_state)?; + if is_full_state { + self.insert_account_vault(block_num, account_id, delta.vault())?; + } else if !delta.vault().is_empty() { + self.update_account_vault(block_num, account_id, delta.vault(), false)?; } - if !delta.storage().is_empty() { - self.update_account_storage(block_num, account_id, delta.storage(), is_full_state)?; + if is_full_state { + self.insert_account_storage(block_num, account_id, delta.storage())?; + } else if !delta.storage().is_empty() { + self.update_account_storage(block_num, account_id, delta.storage(), false)?; } Ok(()) } + fn insert_account_vault( + &mut self, + block_num: BlockNumber, + account_id: AccountId, + vault_delta: &AccountVaultDelta, + ) -> Result<(), InnerForestError> { + self.update_account_vault(block_num, account_id, vault_delta, true)?; + Ok(()) + } + + fn insert_account_storage( + &mut self, + block_num: BlockNumber, + account_id: AccountId, + storage_delta: &AccountStorageDelta, + ) -> Result<(), InnerForestError> { + self.update_account_storage(block_num, account_id, storage_delta, true)?; + Ok(()) + } + // ASSET VAULT DELTA PROCESSING // -------------------------------------------------------------------------------------------- /// Retrieves the most recent vault SMT root for an account. - /// If `is_full_state` is true, returns an empty SMT root. fn get_latest_vault_root(&self, account_id: AccountId) -> Word { self.vault_roots .range((account_id, BlockNumber::GENESIS)..=(account_id, BlockNumber::MAX)) @@ -331,7 +354,11 @@ impl InnerForest { vault_delta: &AccountVaultDelta, is_full_state: bool, ) -> Result { - let prev_root = self.get_latest_vault_root(account_id); + let prev_root = if is_full_state { + Self::empty_smt_root() + } else { + self.get_latest_vault_root(account_id) + }; let mut entries: Vec<(Word, Word)> = Vec::new(); @@ -405,7 +432,6 @@ impl InnerForest { // -------------------------------------------------------------------------------------------- /// Retrieves the most recent storage map SMT root for an account slot. - /// If `is_full_state` is true, returns an empty SMT root. fn get_latest_storage_map_root( &self, account_id: AccountId, @@ -460,7 +486,11 @@ impl InnerForest { let mut updated_roots = BTreeMap::new(); for (slot_name, map_delta) in storage_delta.maps() { - let prev_root = self.get_latest_storage_map_root(account_id, slot_name); + let prev_root = if is_full_state { + Self::empty_smt_root() + } else { + self.get_latest_storage_map_root(account_id, slot_name) + }; let delta_entries = if is_full_state { Vec::from_iter(map_delta.entries().iter().filter_map(|(&key, &value)| { From 349d8cbc6b930e081a48bbc9d40f3ec1023b5bf1 Mon Sep 17 00:00:00 2001 From: Bernhard Schuster Date: Wed, 25 Feb 2026 17:43:38 +0100 Subject: [PATCH 14/17] retain split of insert and update --- crates/store/src/inner_forest/mod.rs | 85 +++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 2 deletions(-) diff --git a/crates/store/src/inner_forest/mod.rs b/crates/store/src/inner_forest/mod.rs index 001ae4e82..6ee090f79 100644 --- a/crates/store/src/inner_forest/mod.rs +++ b/crates/store/src/inner_forest/mod.rs @@ -305,7 +305,40 @@ impl InnerForest { account_id: AccountId, vault_delta: &AccountVaultDelta, ) -> Result<(), InnerForestError> { - self.update_account_vault(block_num, account_id, vault_delta, true)?; + let prev_root = self.get_latest_vault_root(account_id); + assert_eq!(prev_root, Self::empty_smt_root(), "account should not be in the forest"); + + if vault_delta.is_empty() { + self.vault_roots.insert((account_id, block_num), prev_root); + return Ok(()); + } + + let mut entries: Vec<(Word, Word)> = Vec::new(); + + for (faucet_id, amount_delta) in vault_delta.fungible().iter() { + let amount = + (*amount_delta).try_into().expect("full-state amount should be non-negative"); + let asset = FungibleAsset::new(*faucet_id, amount)?; + entries.push((asset.vault_key().into(), asset.into())); + } + + for (&asset, _action) in vault_delta.non_fungible().iter() { + entries.push((asset.vault_key().into(), asset.into())); + } + + let num_entries = entries.len(); + + let new_root = self.forest.batch_insert(prev_root, entries)?; + + self.vault_roots.insert((account_id, block_num), new_root); + + tracing::debug!( + target: crate::COMPONENT, + %account_id, + %block_num, + vault_entries = num_entries, + "Inserted vault into forest" + ); Ok(()) } @@ -315,7 +348,55 @@ impl InnerForest { account_id: AccountId, storage_delta: &AccountStorageDelta, ) -> Result<(), InnerForestError> { - self.update_account_storage(block_num, account_id, storage_delta, true)?; + for (slot_name, map_delta) in storage_delta.maps() { + let prev_root = self.get_latest_storage_map_root(account_id, slot_name); + assert_eq!(prev_root, Self::empty_smt_root(), "account should not be in the forest"); + + let raw_map_entries: Vec<(Word, Word)> = Vec::from_iter( + map_delta.entries().iter().filter_map(|(&key, &value)| { + if value == EMPTY_WORD { + None + } else { + Some((Word::from(key), value)) + } + }), + ); + + if raw_map_entries.is_empty() { + self.storage_map_roots + .insert((account_id, slot_name.clone(), block_num), prev_root); + self.storage_entries + .insert((account_id, slot_name.clone(), block_num), BTreeMap::new()); + + continue; + } + + let hashed_entries: Vec<(Word, Word)> = Vec::from_iter( + raw_map_entries + .iter() + .map(|(key, value)| (StorageMap::hash_key(*key), *value)), + ); + + let new_root = self.forest.batch_insert(prev_root, hashed_entries.iter().copied())?; + + self.storage_map_roots + .insert((account_id, slot_name.clone(), block_num), new_root); + + let num_entries = raw_map_entries.len(); + + let map_entries = BTreeMap::from_iter(raw_map_entries); + self.storage_entries + .insert((account_id, slot_name.clone(), block_num), map_entries); + + tracing::debug!( + target: crate::COMPONENT, + %account_id, + %block_num, + ?slot_name, + delta_entries = num_entries, + "Inserted storage map into forest" + ); + } Ok(()) } From a8e88992070374b5a10745208485392d8a172f1d Mon Sep 17 00:00:00 2001 From: Bernhard Schuster Date: Wed, 25 Feb 2026 18:19:21 +0100 Subject: [PATCH 15/17] clarity --- .../store/src/db/models/queries/accounts.rs | 54 ++++++++++--------- crates/store/src/inner_forest/mod.rs | 12 ++--- crates/store/src/inner_forest/tests.rs | 50 +++++++++++++++++ 3 files changed, 83 insertions(+), 33 deletions(-) diff --git a/crates/store/src/db/models/queries/accounts.rs b/crates/store/src/db/models/queries/accounts.rs index a69202553..b48393286 100644 --- a/crates/store/src/db/models/queries/accounts.rs +++ b/crates/store/src/db/models/queries/accounts.rs @@ -1069,9 +1069,11 @@ pub(crate) fn upsert_accounts( // New account is always a full account, but also comes as an update AccountUpdateDetails::Delta(delta) if delta.is_full_state() => { - let account = Account::try_from(delta)?; + let account = Account::try_from(delta) + .expect("Delta to full account always works for full state deltas"); debug_assert_eq!(account_id, account.id()); + // sanity check the commitment of account matches the final state commitment if account.commitment() != update.final_state_commitment() { return Err(DatabaseError::AccountCommitmentsMismatch { calculated: account.commitment(), @@ -1115,32 +1117,32 @@ pub(crate) fn upsert_accounts( // --- process asset updates ---------------------------------- // Only query balances for faucet_ids that are being updated - let faucet_ids = Vec::from_iter(delta.vault().fungible().iter().map(|(id, _)| *id)); + let faucet_ids = Vec::from_iter( + delta.vault().fungible().iter().map(|(faucet_id, _)| *faucet_id), + ); let prev_balances = select_vault_balances_by_faucet_ids(conn, account_id, &faucet_ids)?; + // encode `Some` as update, `None` as removal let mut assets = Vec::new(); // update fungible assets for (faucet_id, amount_delta) in delta.vault().fungible().iter() { - let prev_balance = prev_balances.get(faucet_id).copied().unwrap_or(0); - let new_balance = amount_delta - .checked_abs() - .and_then(|abs_delta| u64::try_from(abs_delta).ok()) - .and_then(|abs_delta| match amount_delta.cmp(&0) { - std::cmp::Ordering::Greater => prev_balance.checked_add(abs_delta), - std::cmp::Ordering::Less => prev_balance.checked_sub(abs_delta), - std::cmp::Ordering::Equal => Some(prev_balance), - }) - .ok_or_else(|| { - DatabaseError::DataCorrupted(format!( - "Balance underflow for account {account_id}, faucet {faucet_id}" - )) - })?; - - let asset: Asset = FungibleAsset::new(*faucet_id, new_balance)?.into(); - let update_or_remove = if new_balance == 0 { None } else { Some(asset) }; - assets.push((account_id, asset.vault_key(), update_or_remove)); + let prev_amount = prev_balances.get(faucet_id).copied().unwrap_or(0); + let prev_asset = FungibleAsset::new(*faucet_id, prev_amount)?; + let amount_abs = amount_delta.unsigned_abs(); + let delta = FungibleAsset::new(*faucet_id, amount_abs)?; + let new_balance = if *amount_delta < 0 { + prev_asset.sub(delta)? + } else { + prev_asset.add(delta)? + }; + let update_or_remove = if new_balance.amount() == 0 { + None + } else { + Some(Asset::from(new_balance)) + }; + assets.push((account_id, new_balance.vault_key(), update_or_remove)); } // update non-fungible assets @@ -1161,27 +1163,27 @@ pub(crate) fn upsert_accounts( } } - let slot_names: Vec = delta - .storage() - .maps() - .filter_map(|(slot_name, map_delta)| { + // first collect entries that have associated changes + let slot_names = + Vec::from_iter(delta.storage().maps().filter_map(|(slot_name, map_delta)| { if map_delta.is_empty() { None } else { Some(slot_name.clone()) } - }) - .collect(); + })); let map_entries = select_latest_storage_map_entries_for_slots(conn, &account_id, &slot_names)?; + // apply the delta storage to the given storage header let new_storage_header = apply_storage_delta( &state_headers.storage_header, delta.storage(), &map_entries, )?; + // --- update the vault root by constructing the asset vault from DB let new_vault_root = { let (_last_block, assets) = select_account_vault_assets( conn, diff --git a/crates/store/src/inner_forest/mod.rs b/crates/store/src/inner_forest/mod.rs index 6ee090f79..38c506b16 100644 --- a/crates/store/src/inner_forest/mod.rs +++ b/crates/store/src/inner_forest/mod.rs @@ -323,6 +323,7 @@ impl InnerForest { } for (&asset, _action) in vault_delta.non_fungible().iter() { + debug_assert_eq!(_action, &NonFungibleDeltaAction::Add); entries.push((asset.vault_key().into(), asset.into())); } @@ -352,15 +353,14 @@ impl InnerForest { let prev_root = self.get_latest_storage_map_root(account_id, slot_name); assert_eq!(prev_root, Self::empty_smt_root(), "account should not be in the forest"); - let raw_map_entries: Vec<(Word, Word)> = Vec::from_iter( - map_delta.entries().iter().filter_map(|(&key, &value)| { + let raw_map_entries: Vec<(Word, Word)> = + Vec::from_iter(map_delta.entries().iter().filter_map(|(&key, &value)| { if value == EMPTY_WORD { None } else { Some((Word::from(key), value)) } - }), - ); + })); if raw_map_entries.is_empty() { self.storage_map_roots @@ -372,9 +372,7 @@ impl InnerForest { } let hashed_entries: Vec<(Word, Word)> = Vec::from_iter( - raw_map_entries - .iter() - .map(|(key, value)| (StorageMap::hash_key(*key), *value)), + raw_map_entries.iter().map(|(key, value)| (StorageMap::hash_key(*key), *value)), ); let new_root = self.forest.batch_insert(prev_root, hashed_entries.iter().copied())?; diff --git a/crates/store/src/inner_forest/tests.rs b/crates/store/src/inner_forest/tests.rs index 5fc0cc6c0..f28718ee9 100644 --- a/crates/store/src/inner_forest/tests.rs +++ b/crates/store/src/inner_forest/tests.rs @@ -445,6 +445,56 @@ fn test_storage_map_incremental_updates() { assert_ne!(root_1, root_3); } +#[test] +fn test_storage_map_removals() { + use std::collections::BTreeMap; + + use miden_protocol::account::delta::{StorageMapDelta, StorageSlotDelta}; + + const SLOT_INDEX: usize = 3; + const KEY_1: [u32; 4] = [1, 0, 0, 0]; + const KEY_2: [u32; 4] = [2, 0, 0, 0]; + const VALUE_1: [u32; 4] = [10, 0, 0, 0]; + const VALUE_2: [u32; 4] = [20, 0, 0, 0]; + + let mut forest = InnerForest::new(); + let account_id = dummy_account(); + let slot_name = StorageSlotName::mock(SLOT_INDEX); + let key_1 = Word::from(KEY_1); + let key_2 = Word::from(KEY_2); + let value_1 = Word::from(VALUE_1); + let value_2 = Word::from(VALUE_2); + + let block_1 = BlockNumber::GENESIS.child(); + let mut map_delta_1 = StorageMapDelta::default(); + map_delta_1.insert(key_1, value_1); + map_delta_1.insert(key_2, value_2); + let raw_1 = BTreeMap::from_iter([(slot_name.clone(), StorageSlotDelta::Map(map_delta_1))]); + let storage_delta_1 = AccountStorageDelta::from_raw(raw_1); + let delta_1 = dummy_partial_delta(account_id, AccountVaultDelta::default(), storage_delta_1); + forest.update_account(block_1, &delta_1).unwrap(); + + let block_2 = block_1.child(); + let map_delta_2 = StorageMapDelta::from_iters([key_1], []); + let raw_2 = BTreeMap::from_iter([(slot_name.clone(), StorageSlotDelta::Map(map_delta_2))]); + let storage_delta_2 = AccountStorageDelta::from_raw(raw_2); + let delta_2 = dummy_partial_delta(account_id, AccountVaultDelta::default(), storage_delta_2); + forest.update_account(block_2, &delta_2).unwrap(); + + let entries = forest + .storage_map_entries(account_id, slot_name, block_2) + .expect("storage entries should be available"); + + let StorageMapEntries::AllEntries(entries) = entries.entries else { + panic!("expected entries without proofs"); + }; + + let entries_by_key = BTreeMap::from_iter(entries); + assert_eq!(entries_by_key.len(), 1); + assert_eq!(entries_by_key.get(&key_2), Some(&value_2)); + assert!(!entries_by_key.contains_key(&key_1)); +} + #[test] fn test_empty_storage_map_entries_query() { use miden_protocol::account::auth::PublicKeyCommitment; From 9b90313e7a4202c5e6af5183411fb59f56ce760c Mon Sep 17 00:00:00 2001 From: Bernhard Schuster Date: Wed, 25 Feb 2026 21:47:34 +0100 Subject: [PATCH 16/17] review refactor --- .../store/src/db/models/queries/accounts.rs | 8 +- .../src/db/models/queries/accounts/tests.rs | 239 +++++++++++++++++- crates/store/src/inner_forest/mod.rs | 4 +- 3 files changed, 233 insertions(+), 18 deletions(-) diff --git a/crates/store/src/db/models/queries/accounts.rs b/crates/store/src/db/models/queries/accounts.rs index b48393286..eadf0c81a 100644 --- a/crates/store/src/db/models/queries/accounts.rs +++ b/crates/store/src/db/models/queries/accounts.rs @@ -748,6 +748,8 @@ pub(crate) fn select_account_storage_map_values( /// Select latest account storage by querying `accounts.storage_header` where `is_latest=true` /// and reconstructing full storage from the header plus map values from /// `account_storage_map_values`. +/// +/// Attention: For large accounts it is prohibitively expensive! pub(crate) fn select_latest_account_storage( conn: &mut SqliteConnection, account_id: AccountId, @@ -776,6 +778,7 @@ pub(crate) fn select_latest_account_storage( Ok(AccountStorage::new(slots)?) } +/// Fetch account storage header and all storage maps pub(crate) fn select_latest_account_storage_components( conn: &mut SqliteConnection, account_id: AccountId, @@ -1059,8 +1062,8 @@ pub(crate) fn upsert_accounts( .unwrap_or(block_num_raw); let created_at_block = BlockNumber::from_raw_sql(created_at_block_raw)?; - // NOTE: we collect storage / asset inserts to apply them only after the account row is - // written. The storage and vault tables have FKs pointing to `accounts (account_id, + // NOTE: we collect storage / asset inserts to apply them only after the account row is + // written. The storage and vault tables have FKs pointing to accounts `(account_id, // block_num)`, so inserting them earlier would violate those constraints when inserting a // brand-new account. let (account_state, pending_storage_inserts, pending_asset_inserts) = match update.details() @@ -1295,6 +1298,7 @@ pub(crate) fn upsert_accounts( .execute(conn)?; // insert pending storage map entries + // TODO consider batching for (acc_id, slot_name, key, value) in pending_storage_inserts { insert_account_storage_map_value(conn, acc_id, block_num, slot_name, key, value)?; } diff --git a/crates/store/src/db/models/queries/accounts/tests.rs b/crates/store/src/db/models/queries/accounts/tests.rs index d7dc78e85..1d3369120 100644 --- a/crates/store/src/db/models/queries/accounts/tests.rs +++ b/crates/store/src/db/models/queries/accounts/tests.rs @@ -14,7 +14,13 @@ use diesel::{ use diesel_migrations::MigrationHarness; use miden_node_utils::fee::test_fee_params; use miden_protocol::account::auth::PublicKeyCommitment; -use miden_protocol::account::delta::AccountUpdateDetails; +use miden_protocol::account::delta::{ + AccountStorageDelta, + AccountUpdateDetails, + AccountVaultDelta, + StorageMapDelta, + StorageSlotDelta, +}; use miden_protocol::account::{ Account, AccountBuilder, @@ -28,13 +34,14 @@ use miden_protocol::account::{ AccountType, StorageMap, StorageSlot, + StorageSlotContent, StorageSlotName, StorageSlotType, }; use miden_protocol::block::{BlockAccountUpdate, BlockHeader, BlockNumber}; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SecretKey; use miden_protocol::utils::{Deserializable, Serializable}; -use miden_protocol::{EMPTY_WORD, Felt, Word}; +use miden_protocol::{EMPTY_WORD, Felt, FieldElement, Word}; use miden_standards::account::auth::AuthFalcon512Rpo; use miden_standards::code_builder::CodeBuilder; @@ -189,6 +196,49 @@ fn insert_block_header(conn: &mut SqliteConnection, block_num: BlockNumber) { .expect("Failed to insert block header"); } +fn create_account_with_map_storage( + slot_name: StorageSlotName, + entries: Vec<(Word, Word)>, +) -> Account { + let storage_map = StorageMap::with_entries(entries).unwrap(); + let component_storage = vec![StorageSlot::with_map(slot_name, storage_map)]; + + let account_component_code = CodeBuilder::default() + .compile_component_code("test::interface", "pub proc map push.1 end") + .unwrap(); + + let component = AccountComponent::new(account_component_code, component_storage) + .unwrap() + .with_supported_type(AccountType::RegularAccountImmutableCode); + + AccountBuilder::new([9u8; 32]) + .account_type(AccountType::RegularAccountImmutableCode) + .storage_mode(AccountStorageMode::Public) + .with_component(component) + .with_auth_component(AuthFalcon512Rpo::new(PublicKeyCommitment::from(EMPTY_WORD))) + .build_existing() + .unwrap() +} + +fn assert_storage_map_slot_entries( + storage: &AccountStorage, + slot_name: &StorageSlotName, + expected: &BTreeMap, +) { + let slot = storage + .slots() + .iter() + .find(|slot| slot.name() == slot_name) + .expect("expected storage slot"); + + let StorageSlotContent::Map(storage_map) = slot.content() else { + panic!("expected map slot"); + }; + + let entries = BTreeMap::from_iter(storage_map.entries().map(|(key, value)| (*key, *value))); + assert_eq!(&entries, expected, "map entries mismatch"); +} + // ACCOUNT HEADER AT BLOCK TESTS // ================================================================================================ @@ -617,6 +667,167 @@ fn test_upsert_accounts_with_empty_storage() { ); } +// STORAGE MAP LATEST ACCOUNT QUERY TESTS +// ================================================================================================ + +#[test] +fn test_select_latest_account_storage_ordering_semantics() { + let mut conn = setup_test_db(); + let block_num = BlockNumber::from_epoch(0); + insert_block_header(&mut conn, block_num); + + let slot_name = StorageSlotName::mock(0); + let key_1 = Word::from([Felt::new(1), Felt::ZERO, Felt::ZERO, Felt::ZERO]); + let key_2 = Word::from([Felt::new(2), Felt::ZERO, Felt::ZERO, Felt::ZERO]); + let key_3 = Word::from([Felt::new(3), Felt::ZERO, Felt::ZERO, Felt::ZERO]); + + let value_1 = Word::from([Felt::new(10), Felt::ZERO, Felt::ZERO, Felt::ZERO]); + let value_2 = Word::from([Felt::new(20), Felt::ZERO, Felt::ZERO, Felt::ZERO]); + let value_3 = Word::from([Felt::new(30), Felt::ZERO, Felt::ZERO, Felt::ZERO]); + + let mut entries = vec![(key_2, value_2), (key_1, value_1), (key_3, value_3)]; + entries.reverse(); + + let account = create_account_with_map_storage(slot_name.clone(), entries.clone()); + let account_id = account.id(); + let account_commitment = account.commitment(); + + let mut reversed_entries = entries.clone(); + reversed_entries.reverse(); + let reordered_account = create_account_with_map_storage(slot_name.clone(), reversed_entries); + assert_eq!( + account.storage().to_commitment(), + reordered_account.storage().to_commitment(), + "storage commitments should be order-independent" + ); + + let delta = AccountDelta::try_from(account).unwrap(); + let account_update = + BlockAccountUpdate::new(account_id, account_commitment, AccountUpdateDetails::Delta(delta)); + + upsert_accounts(&mut conn, &[account_update], block_num).expect("upsert_accounts failed"); + + let storage = + select_latest_account_storage(&mut conn, account_id).expect("Failed to query storage"); + + let expected = BTreeMap::from_iter(entries); + assert_storage_map_slot_entries(&storage, &slot_name, &expected); +} + +#[test] +fn test_select_latest_account_storage_multiple_slots() { + let mut conn = setup_test_db(); + let block_num = BlockNumber::from_epoch(0); + insert_block_header(&mut conn, block_num); + + let slot_name_1 = StorageSlotName::mock(0); + let slot_name_2 = StorageSlotName::mock(1); + + let key_a = Word::from([Felt::new(1), Felt::ZERO, Felt::ZERO, Felt::ZERO]); + let key_b = Word::from([Felt::new(2), Felt::ZERO, Felt::ZERO, Felt::ZERO]); + + let value_a = Word::from([Felt::new(11), Felt::ZERO, Felt::ZERO, Felt::ZERO]); + let value_b = Word::from([Felt::new(22), Felt::ZERO, Felt::ZERO, Felt::ZERO]); + + let map_a = StorageMap::with_entries(vec![(key_a, value_a)]).unwrap(); + let map_b = StorageMap::with_entries(vec![(key_b, value_b)]).unwrap(); + + let component_storage = vec![ + StorageSlot::with_map(slot_name_2.clone(), map_b), + StorageSlot::with_map(slot_name_1.clone(), map_a), + ]; + + let account_component_code = CodeBuilder::default() + .compile_component_code("test::interface", "pub proc map push.1 end") + .unwrap(); + + let component = AccountComponent::new(account_component_code, component_storage) + .unwrap() + .with_supported_type(AccountType::RegularAccountImmutableCode); + + let account = AccountBuilder::new([9u8; 32]) + .account_type(AccountType::RegularAccountImmutableCode) + .storage_mode(AccountStorageMode::Public) + .with_component(component) + .with_auth_component(AuthFalcon512Rpo::new(PublicKeyCommitment::from(EMPTY_WORD))) + .build_existing() + .unwrap(); + + let account_id = account.id(); + let account_commitment = account.commitment(); + let delta = AccountDelta::try_from(account).unwrap(); + let account_update = + BlockAccountUpdate::new(account_id, account_commitment, AccountUpdateDetails::Delta(delta)); + + upsert_accounts(&mut conn, &[account_update], block_num).expect("upsert_accounts failed"); + + let storage = + select_latest_account_storage(&mut conn, account_id).expect("Failed to query storage"); + + let expected_slot_1 = BTreeMap::from_iter([(key_a, value_a)]); + let expected_slot_2 = BTreeMap::from_iter([(key_b, value_b)]); + + assert_storage_map_slot_entries(&storage, &slot_name_1, &expected_slot_1); + assert_storage_map_slot_entries(&storage, &slot_name_2, &expected_slot_2); +} + +#[test] +fn test_select_latest_account_storage_slot_updates() { + let mut conn = setup_test_db(); + let block_1 = BlockNumber::from_epoch(0); + let block_2 = BlockNumber::from_epoch(1); + insert_block_header(&mut conn, block_1); + insert_block_header(&mut conn, block_2); + + let slot_name = StorageSlotName::mock(0); + let key_1 = Word::from([Felt::new(1), Felt::ZERO, Felt::ZERO, Felt::ZERO]); + let key_2 = Word::from([Felt::new(2), Felt::ZERO, Felt::ZERO, Felt::ZERO]); + + let value_1 = Word::from([Felt::new(10), Felt::ZERO, Felt::ZERO, Felt::ZERO]); + let value_2 = Word::from([Felt::new(20), Felt::ZERO, Felt::ZERO, Felt::ZERO]); + let value_3 = Word::from([Felt::new(30), Felt::ZERO, Felt::ZERO, Felt::ZERO]); + + let account = create_account_with_map_storage(slot_name.clone(), vec![(key_1, value_1)]); + let account_id = account.id(); + let account_commitment = account.commitment(); + + let delta = AccountDelta::try_from(account.clone()).unwrap(); + let account_update = + BlockAccountUpdate::new(account_id, account_commitment, AccountUpdateDetails::Delta(delta)); + + upsert_accounts(&mut conn, &[account_update], block_1).expect("upsert_accounts failed"); + + let mut map_delta = StorageMapDelta::default(); + map_delta.insert(key_1, value_2); + map_delta.insert(key_2, value_3); + let storage_delta = AccountStorageDelta::from_raw(BTreeMap::from_iter([( + slot_name.clone(), + StorageSlotDelta::Map(map_delta), + )])); + + let partial_delta = + AccountDelta::new(account_id, storage_delta, AccountVaultDelta::default(), Felt::new(1)) + .unwrap(); + + let mut expected_account = account.clone(); + expected_account.apply_delta(&partial_delta).unwrap(); + let expected_commitment = expected_account.commitment(); + + let account_update = BlockAccountUpdate::new( + account_id, + expected_commitment, + AccountUpdateDetails::Delta(partial_delta), + ); + + upsert_accounts(&mut conn, &[account_update], block_2).expect("upsert_accounts failed"); + + let storage = + select_latest_account_storage(&mut conn, account_id).expect("Failed to query storage"); + + let expected = BTreeMap::from_iter([(key_1, value_2), (key_2, value_3)]); + assert_storage_map_slot_entries(&storage, &slot_name, &expected); +} + // VAULT AT BLOCK HISTORICAL QUERY TESTS // ================================================================================================ @@ -662,9 +873,9 @@ fn test_select_account_vault_at_block_historical_with_updates() { // Insert vault asset at block 1: vault_key_1 = 1000 tokens let vault_key_1 = AssetVaultKey::new_unchecked(Word::from([ Felt::new(1), - Felt::new(0), - Felt::new(0), - Felt::new(0), + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, ])); let asset_v1 = Asset::Fungible(FungibleAsset::new(faucet_id, 1000).unwrap()); @@ -679,9 +890,9 @@ fn test_select_account_vault_at_block_historical_with_updates() { // Add a second vault_key at block 2 let vault_key_2 = AssetVaultKey::new_unchecked(Word::from([ Felt::new(2), - Felt::new(0), - Felt::new(0), - Felt::new(0), + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, ])); let asset_key2 = Asset::Fungible(FungibleAsset::new(faucet_id, 500).unwrap()); insert_account_vault_asset(&mut conn, account_id, block_2, vault_key_2, Some(asset_key2)) @@ -764,9 +975,9 @@ fn test_select_account_vault_at_block_exponential_updates() { let vault_key = AssetVaultKey::new_unchecked(Word::from([ Felt::new(3), - Felt::new(0), - Felt::new(0), - Felt::new(0), + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, ])); for (index, block) in blocks.iter().enumerate() { @@ -828,9 +1039,9 @@ fn test_select_account_vault_at_block_with_deletion() { // Insert vault asset at block 1 let vault_key = AssetVaultKey::new_unchecked(Word::from([ Felt::new(1), - Felt::new(0), - Felt::new(0), - Felt::new(0), + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, ])); let asset = Asset::Fungible(FungibleAsset::new(faucet_id, 1000).unwrap()); diff --git a/crates/store/src/inner_forest/mod.rs b/crates/store/src/inner_forest/mod.rs index 38c506b16..565120e99 100644 --- a/crates/store/src/inner_forest/mod.rs +++ b/crates/store/src/inner_forest/mod.rs @@ -322,8 +322,8 @@ impl InnerForest { entries.push((asset.vault_key().into(), asset.into())); } - for (&asset, _action) in vault_delta.non_fungible().iter() { - debug_assert_eq!(_action, &NonFungibleDeltaAction::Add); + for (&asset, action) in vault_delta.non_fungible().iter() { + debug_assert_eq!(action, &NonFungibleDeltaAction::Add); entries.push((asset.vault_key().into(), asset.into())); } From 0b72a94f9f7aacff6d90b488e7aa08e859478ea9 Mon Sep 17 00:00:00 2001 From: Bernhard Schuster Date: Wed, 25 Feb 2026 22:04:53 +0100 Subject: [PATCH 17/17] extract two fns --- .../store/src/db/models/queries/accounts.rs | 325 +++++++++--------- 1 file changed, 167 insertions(+), 158 deletions(-) diff --git a/crates/store/src/db/models/queries/accounts.rs b/crates/store/src/db/models/queries/accounts.rs index eadf0c81a..daf5974ba 100644 --- a/crates/store/src/db/models/queries/accounts.rs +++ b/crates/store/src/db/models/queries/accounts.rs @@ -1023,6 +1023,170 @@ pub(crate) fn insert_account_storage_map_value( Ok(update_count + insert_count) } +type PendingStorageInserts = Vec<(AccountId, StorageSlotName, Word, Word)>; +type PendingAssetInserts = Vec<(AccountId, AssetVaultKey, Option)>; + +fn prepare_full_account_update( + update: &BlockAccountUpdate, + account: Account, +) -> Result<(AccountStateForInsert, PendingStorageInserts, PendingAssetInserts), DatabaseError> { + let account_id = account.id(); + + // sanity check the commitment of account matches the final state commitment + if account.commitment() != update.final_state_commitment() { + return Err(DatabaseError::AccountCommitmentsMismatch { + calculated: account.commitment(), + expected: update.final_state_commitment(), + }); + } + + // collect storage-map inserts to apply after account upsert + let mut storage = Vec::new(); + for slot in account.storage().slots() { + if let StorageSlotContent::Map(storage_map) = slot.content() { + for (key, value) in storage_map.entries() { + storage.push((account_id, slot.name().clone(), *key, *value)); + } + } + } + + // collect vault-asset inserts to apply after account upsert + let mut assets = Vec::new(); + for asset in account.vault().assets() { + // Only insert assets with non-zero values for fungible assets + let should_insert = match asset { + Asset::Fungible(fungible) => fungible.amount() > 0, + Asset::NonFungible(_) => true, + }; + if should_insert { + assets.push((account_id, asset.vault_key(), Some(asset))); + } + } + + Ok((AccountStateForInsert::FullAccount(account), storage, assets)) +} + +fn prepare_partial_account_update( + conn: &mut SqliteConnection, + update: &BlockAccountUpdate, + account_id: AccountId, + delta: &miden_protocol::account::delta::AccountDelta, + block_num: BlockNumber, +) -> Result<(AccountStateForInsert, PendingStorageInserts, PendingAssetInserts), DatabaseError> { + // Load only the minimal data needed. Only load those storage map entries and vault + // entries that will receive updates. + // The next line fetches the header, which will always change with the exception of + // an empty delta. + let state_headers = select_minimal_account_state_headers(conn, account_id)?; + + // --- process asset updates ---------------------------------- + // Only query balances for faucet_ids that are being updated + let faucet_ids = + Vec::from_iter(delta.vault().fungible().iter().map(|(faucet_id, _)| *faucet_id)); + let prev_balances = select_vault_balances_by_faucet_ids(conn, account_id, &faucet_ids)?; + + // encode `Some` as update, `None` as removal + let mut assets = Vec::new(); + + // update fungible assets + for (faucet_id, amount_delta) in delta.vault().fungible().iter() { + let prev_amount = prev_balances.get(faucet_id).copied().unwrap_or(0); + let prev_asset = FungibleAsset::new(*faucet_id, prev_amount)?; + let amount_abs = amount_delta.unsigned_abs(); + let delta = FungibleAsset::new(*faucet_id, amount_abs)?; + let new_balance = if *amount_delta < 0 { + prev_asset.sub(delta)? + } else { + prev_asset.add(delta)? + }; + let update_or_remove = if new_balance.amount() == 0 { + None + } else { + Some(Asset::from(new_balance)) + }; + assets.push((account_id, new_balance.vault_key(), update_or_remove)); + } + + // update non-fungible assets + for (asset, delta_action) in delta.vault().non_fungible().iter() { + let asset_update = match delta_action { + NonFungibleDeltaAction::Add => Some(Asset::NonFungible(*asset)), + NonFungibleDeltaAction::Remove => None, + }; + assets.push((account_id, asset.vault_key(), asset_update)); + } + + // --- collect storage map updates ---------------------------- + + let mut storage = Vec::new(); + for (slot_name, map_delta) in delta.storage().maps() { + for (key, value) in map_delta.entries() { + storage.push((account_id, slot_name.clone(), (*key).into(), *value)); + } + } + + // first collect entries that have associated changes + let slot_names = Vec::from_iter(delta.storage().maps().filter_map(|(slot_name, map_delta)| { + if map_delta.is_empty() { + None + } else { + Some(slot_name.clone()) + } + })); + + let map_entries = select_latest_storage_map_entries_for_slots(conn, &account_id, &slot_names)?; + + // apply the delta storage to the given storage header + let new_storage_header = + apply_storage_delta(&state_headers.storage_header, delta.storage(), &map_entries)?; + + // --- update the vault root by constructing the asset vault from DB + let new_vault_root = { + let (_last_block, assets) = + select_account_vault_assets(conn, account_id, BlockNumber::GENESIS..=block_num)?; + let assets: Vec = assets.into_iter().filter_map(|entry| entry.asset).collect(); + let mut vault = AssetVault::new(&assets)?; + vault.apply_delta(delta.vault())?; + vault.root() + }; + + // --- compute updated account state for the accounts row --- + // Apply nonce delta + let new_nonce_value = state_headers + .nonce + .as_int() + .checked_add(delta.nonce_delta().as_int()) + .ok_or_else(|| { + DatabaseError::DataCorrupted(format!("Nonce overflow for account {account_id}")) + })?; + let new_nonce = Felt::new(new_nonce_value); + + // Create minimal account state data for the row insert + let account_state = PartialAccountState { + nonce: new_nonce, + code_commitment: state_headers.code_commitment, + storage_header: new_storage_header, + vault_root: new_vault_root, + }; + + let account_header = miden_protocol::account::AccountHeader::new( + account_id, + account_state.nonce, + account_state.vault_root, + account_state.storage_header.to_commitment(), + account_state.code_commitment, + ); + + if account_header.commitment() != update.final_state_commitment() { + return Err(DatabaseError::AccountCommitmentsMismatch { + calculated: account_header.commitment(), + expected: update.final_state_commitment(), + }); + } + + Ok((AccountStateForInsert::PartialState(account_state), storage, assets)) +} + /// Attention: Assumes the account details are NOT null! The schema explicitly allows this though! #[expect(clippy::too_many_lines)] #[tracing::instrument( @@ -1076,171 +1240,16 @@ pub(crate) fn upsert_accounts( .expect("Delta to full account always works for full state deltas"); debug_assert_eq!(account_id, account.id()); - // sanity check the commitment of account matches the final state commitment - if account.commitment() != update.final_state_commitment() { - return Err(DatabaseError::AccountCommitmentsMismatch { - calculated: account.commitment(), - expected: update.final_state_commitment(), - }); - } - - // collect storage-map inserts to apply after account upsert - let mut storage = Vec::new(); - for slot in account.storage().slots() { - if let StorageSlotContent::Map(storage_map) = slot.content() { - for (key, value) in storage_map.entries() { - storage.push((account_id, slot.name().clone(), *key, *value)); - } - } - } - - // collect vault-asset inserts to apply after account upsert - let mut assets = Vec::new(); - for asset in account.vault().assets() { - // Only insert assets with non-zero values for fungible assets - let should_insert = match asset { - Asset::Fungible(fungible) => fungible.amount() > 0, - Asset::NonFungible(_) => true, - }; - if should_insert { - assets.push((account_id, asset.vault_key(), Some(asset))); - } - } - - (AccountStateForInsert::FullAccount(account), storage, assets) + prepare_full_account_update(update, account)? }, // Update of an existing account AccountUpdateDetails::Delta(delta) => { - // Load only the minimal data needed. Only load those storage map entries and vault - // entries that will receive updates. - // The next line fetches the header, which will always change with the exception of - // an empty delta. - let state_headers = select_minimal_account_state_headers(conn, account_id)?; - - // --- process asset updates ---------------------------------- - // Only query balances for faucet_ids that are being updated - let faucet_ids = Vec::from_iter( - delta.vault().fungible().iter().map(|(faucet_id, _)| *faucet_id), - ); - let prev_balances = - select_vault_balances_by_faucet_ids(conn, account_id, &faucet_ids)?; - - // encode `Some` as update, `None` as removal - let mut assets = Vec::new(); - - // update fungible assets - for (faucet_id, amount_delta) in delta.vault().fungible().iter() { - let prev_amount = prev_balances.get(faucet_id).copied().unwrap_or(0); - let prev_asset = FungibleAsset::new(*faucet_id, prev_amount)?; - let amount_abs = amount_delta.unsigned_abs(); - let delta = FungibleAsset::new(*faucet_id, amount_abs)?; - let new_balance = if *amount_delta < 0 { - prev_asset.sub(delta)? - } else { - prev_asset.add(delta)? - }; - let update_or_remove = if new_balance.amount() == 0 { - None - } else { - Some(Asset::from(new_balance)) - }; - assets.push((account_id, new_balance.vault_key(), update_or_remove)); - } - - // update non-fungible assets - for (asset, delta_action) in delta.vault().non_fungible().iter() { - let asset_update = match delta_action { - NonFungibleDeltaAction::Add => Some(Asset::NonFungible(*asset)), - NonFungibleDeltaAction::Remove => None, - }; - assets.push((account_id, asset.vault_key(), asset_update)); - } - - // --- collect storage map updates ---------------------------- - - let mut storage = Vec::new(); - for (slot_name, map_delta) in delta.storage().maps() { - for (key, value) in map_delta.entries() { - storage.push((account_id, slot_name.clone(), (*key).into(), *value)); - } - } - - // first collect entries that have associated changes - let slot_names = - Vec::from_iter(delta.storage().maps().filter_map(|(slot_name, map_delta)| { - if map_delta.is_empty() { - None - } else { - Some(slot_name.clone()) - } - })); - - let map_entries = - select_latest_storage_map_entries_for_slots(conn, &account_id, &slot_names)?; - - // apply the delta storage to the given storage header - let new_storage_header = apply_storage_delta( - &state_headers.storage_header, - delta.storage(), - &map_entries, - )?; - - // --- update the vault root by constructing the asset vault from DB - let new_vault_root = { - let (_last_block, assets) = select_account_vault_assets( - conn, - account_id, - BlockNumber::GENESIS..=block_num, - )?; - let assets: Vec = - assets.into_iter().filter_map(|entry| entry.asset).collect(); - let mut vault = AssetVault::new(&assets)?; - vault.apply_delta(delta.vault())?; - vault.root() - }; - - // --- compute updated account state for the accounts row --- - // Apply nonce delta - let new_nonce_value = state_headers - .nonce - .as_int() - .checked_add(delta.nonce_delta().as_int()) - .ok_or_else(|| { - DatabaseError::DataCorrupted(format!( - "Nonce overflow for account {account_id}" - )) - })?; - let new_nonce = Felt::new(new_nonce_value); - - // Create minimal account state data for the row insert - let account_state = PartialAccountState { - nonce: new_nonce, - code_commitment: state_headers.code_commitment, - storage_header: new_storage_header, - vault_root: new_vault_root, - }; - - let account_header = miden_protocol::account::AccountHeader::new( - account_id, - account_state.nonce, - account_state.vault_root, - account_state.storage_header.to_commitment(), - account_state.code_commitment, - ); - - if account_header.commitment() != update.final_state_commitment() { - return Err(DatabaseError::AccountCommitmentsMismatch { - calculated: account_header.commitment(), - expected: update.final_state_commitment(), - }); - } - - (AccountStateForInsert::PartialState(account_state), storage, assets) + prepare_partial_account_update(conn, update, account_id, delta, block_num)? }, }; - // Insert account code for full accounts (new account creation) + // Insert account _code_ for full accounts (new account creation) if let AccountStateForInsert::FullAccount(ref account) = account_state { let code = account.code(); let code_value = AccountCodeRowInsert {