diff --git a/CHANGELOG.md b/CHANGELOG.md index f8ca9d8e82..a7d4922f7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Breaking Changes +* [BREAKING][removal][rust] Removed `Client::try_get_account`. Use `Client::get_account` and handle the `None` case, or `Client::account_reader` for existence checks and single-field reads that don't need the full materialized account ([#2362](https://github.com/0xMiden/rust-sdk/pull/2362)). * [BREAKING][behavior][rust] Foreign `AccountInputs` keep a fetched asset list only when it hashes to the account header's vault root; otherwise (omitted because unchanged, capped as oversize, or malformed) they carry a root-only partial vault, and any assets the foreign code reads are resolved during execution as per-asset witnesses — everything local first (the store's per-asset reads, then the full local vault), falling back to fetching the vault via RPC at the transaction reference block and verifying it against the required root ([#2417](https://github.com/0xMiden/rust-sdk/pull/2417)). * [BREAKING][behavior][rust] Foreign `AccountInputs` likewise keep a fetched storage-map entry list only when it hashes to the slot's root in the storage header; otherwise (capped as oversize, or malformed) the map is carried root-only and any keys the foreign code reads are resolved during execution as lazy per-key witnesses, instead of syncing an oversized map's full history from genesis before executing ([#2417](https://github.com/0xMiden/rust-sdk/pull/2417)). * [BREAKING][arch][store] The account SMT forest now persists in SQLite (new `forest_trees`, `forest_entries`, `forest_subtrees` and `forest_revision` tables) through a `LargeSmtForest` backend scoped to the store's own transaction, so forest mutations commit or roll back atomically with the account tables and opening the store no longer rebuilds the forest from account data. Tree inner nodes are persisted as packed subtree blobs, so witness reads load a single leaf plus eight blobs instead of rebuilding the account's tree, making their cost independent of the account's map size at the price of a larger store file. Tree updates are computed path-locally from the persisted leaves and subtree blobs, so committed update cost scales with the size of the change set rather than with the map size. Existing stores are not compatible and must be recreated ([#2333](https://github.com/0xMiden/rust-sdk/pull/2333)). @@ -41,6 +42,8 @@ * [FEATURE][rust,store] Added `NoteFilter::ScriptRoots` to query input notes by their note script root directly at the store level, without loading and screening unrelated notes. The filter doesn't apply to output notes: querying output notes with it returns an empty list ([#2335](https://github.com/0xMiden/rust-sdk/pull/2335)). * [rust] Added `PartialBlockchainUpdates::block_headers_to_store`, which narrows the staged headers to the ones a sync must persist: those marked as relevant, genesis, and the block at the sync height. `block_headers` still yields all staged headers ([#2297](https://github.com/0xMiden/rust-sdk/pull/2297)). * [rust] State sync now authenticates every relevant note block but only persists block headers and MMR authentication nodes for blocks containing notes that remain unspent or that a `NoteObserver` explicitly marks as relevant ([#2297](https://github.com/0xMiden/rust-sdk/pull/2297)). +* [rust,store] Single-transaction execution no longer reconstructs the full `Account`: the client works from the minimal partial account, and request validation checks balances against the vault asset list fetched via the new `Store::get_account_assets` (exposed as `AccountReader::assets`). Executor vault witnesses — including emptiness proofs for assets being added — are served by the new `Store::get_vault_asset_witnesses`, which `SqliteStore` answers directly from its Merkle forest instead of rebuilding the vault ([#2362](https://github.com/0xMiden/rust-sdk/pull/2362)). +* [cli] `account --list`, `account show` and `call` no longer load full accounts from the store: faucet token symbols and decimals are read from the faucet's token config storage slot, and `call` checks the account header, importing untracked accounts from the node on demand ([#2362](https://github.com/0xMiden/rust-sdk/pull/2362)). * [rust] `BatchBuilder` now stacks in-batch account state as a `PartialAccount` updated with each transaction's `AccountPatch` instead of reconstructing the full `Account` after every push. Witnesses at the in-batch state are built by replaying the batch's writes onto committed-state proofs, so any `Store` backend supports batches through the witness methods it already implements ([#2277](https://github.com/0xMiden/rust-sdk/pull/2277)). * [FEATURE][cli] Added `account --inspect [:]` to list the procedures an account exposes, grouped into resolved procedures (with their names and signatures) and unresolved ones (listed by MAST root). Names and signatures are resolved from the `.masp` packages in the configured packages directory plus any passed via `--package` (`-p`). `--verbose` prints each procedure's MASM disassembly. ([#2312](https://github.com/0xMiden/rust-sdk/issues/2312)). * Improved the output of the `miden-client init` command when a configuration already exists ([#2357](https://github.com/0xMiden/rust-sdk/pull/2357)). diff --git a/bin/integration-tests/src/tests/fpi.rs b/bin/integration-tests/src/tests/fpi.rs index 8a4962436d..c176a61fea 100644 --- a/bin/integration-tests/src/tests/fpi.rs +++ b/bin/integration-tests/src/tests/fpi.rs @@ -854,7 +854,10 @@ pub(crate) async fn deploy_foreign_account( // NOTE: We get the new account state here since the first transaction updates the nonce from // to 1 - let foreign_account: Account = client.try_get_account(foreign_account_id).await?; + let foreign_account: Account = client + .get_account(foreign_account_id) + .await? + .with_context(|| format!("account {foreign_account_id} should be tracked"))?; Ok((foreign_account, proc_root)) } diff --git a/bin/miden-cli/src/commands/account.rs b/bin/miden-cli/src/commands/account.rs index 61f0ebe361..40e98cec3e 100644 --- a/bin/miden-cli/src/commands/account.rs +++ b/bin/miden-cli/src/commands/account.rs @@ -13,7 +13,7 @@ use miden_client::account::{ StorageSlotContent, }; use miden_client::address::{Address, AddressInterface, NetworkId, RoutingParameters}; -use miden_client::asset::Asset; +use miden_client::asset::{Asset, TokenSymbol}; use miden_client::rpc::domain::account::GetAccountRequest; use miden_client::rpc::{GrpcClient, NodeRpcClient, VerifyingRpcClient}; use miden_client::transaction::{AccountComponentInterface, AccountInterface}; @@ -159,10 +159,10 @@ async fn list_accounts(client: Client) -> Result<(), CliError> { for (acc, _acc_seed) in &accounts { let reader = client.account_reader(acc.id()); let status = reader.status().await?.to_string(); - let token_symbol = get_faucet_component(&client, acc.id()) + let token_symbol = get_faucet_token_info(&client, acc.id()) .await .ok() - .map(|faucet| faucet.symbol().to_string()); + .map(|(symbol, _)| symbol.to_string()); table.add_row(vec![ acc.id().to_hex(), @@ -204,11 +204,10 @@ async fn show_account( Asset::Fungible(fungible_asset) => { let faucet_id = fungible_asset.faucet_id(); let asset_amount = fungible_asset.amount(); - let (faucet, amount) = match get_faucet_component(client, faucet_id).await { - Ok(faucet_component) => ( - faucet_component.symbol().to_string(), - base_units_to_tokens(asset_amount, faucet_component.decimals()), - ), + let (faucet, amount) = match get_faucet_token_info(client, faucet_id).await { + Ok((symbol, decimals)) => { + (symbol.to_string(), base_units_to_tokens(asset_amount, decimals)) + }, Err(_) => (faucet_id.prefix().to_hex(), asset_amount.as_u64().to_string()), }; ("Fungible Asset", faucet, amount) @@ -569,20 +568,31 @@ fn print_summary_table(account: &Account, network_id: NetworkId, token_symbol: O println!("{table}\n"); } -/// Loads the tracked account for `account_id` and reconstructs its [`FungibleFaucet`] component. +/// Reads the faucet's token symbol and decimals from its token config storage slot. /// /// # Errors -/// Returns an error if the account is not tracked by the client or its faucet metadata can't be -/// read. -async fn get_faucet_component( +/// Returns an error if the account is not tracked by the client, has no token config slot (i.e. +/// is not a fungible faucet), or the token config can't be decoded. +async fn get_faucet_token_info( client: &Client, account_id: AccountId, -) -> Result { - let account = client.get_account(account_id).await?.ok_or_else(|| { - CliError::Input(format!("account {account_id} not tracked by the client")) +) -> Result<(TokenSymbol, u8), CliError> { + let token_config = client + .account_reader(account_id) + .get_storage_item(FungibleFaucet::token_config_slot().clone()) + .await?; + + // Token config word layout: `[token_supply, max_supply, decimals, symbol]` (see + // `FungibleFaucet::token_config_slot_value`). + let [_token_supply, _max_supply, decimals, symbol] = *token_config; + let symbol = TokenSymbol::try_from(symbol).map_err(|err| { + CliError::Input(format!("failed to decode token symbol of faucet {account_id}: {err}")) + })?; + let decimals = u8::try_from(decimals.as_canonical_u64()).map_err(|err| { + CliError::Input(format!("failed to decode token decimals of faucet {account_id}: {err}")) })?; - faucet_component_from_account(&account) + Ok((symbol, decimals)) } /// Reconstructs the [`FungibleFaucet`] component from a materialized [`Account`]. diff --git a/bin/miden-cli/src/commands/call.rs b/bin/miden-cli/src/commands/call.rs index 7fc478ce32..ecde012aa9 100644 --- a/bin/miden-cli/src/commands/call.rs +++ b/bin/miden-cli/src/commands/call.rs @@ -67,7 +67,16 @@ impl CallCmd { })?; let account_id = parse_account_id(&client, account_str).await?; - client.try_get_account(account_id).await?; + // Untracked accounts are imported from the node before executing against them; private + // accounts have no public state to import and fail here. + if client.account_reader(account_id).header().await.is_err() { + client.import_account_by_id(account_id).await.map_err(|err| { + CliError::InvalidArgument(format!( + "Account {account_id} is not tracked and could not be imported from the \ + node: {err}" + )) + })?; + } let package = load_package(&self.package)?; diff --git a/crates/rust-client/src/account/account_reader.rs b/crates/rust-client/src/account/account_reader.rs index da8cf5fe01..e9a6092432 100644 --- a/crates/rust-client/src/account/account_reader.rs +++ b/crates/rust-client/src/account/account_reader.rs @@ -121,6 +121,18 @@ impl AccountReader { // VAULT ACCESS // -------------------------------------------------------------------------------------------- + /// Retrieves all assets in the account's vault as a plain list, without building the vault's + /// Merkle tree. + /// + /// To load the entire vault, use + /// [`Client::get_account_vault`](crate::Client::get_account_vault). + pub async fn assets(&self) -> Result, ClientError> { + self.store + .get_account_assets(self.account_id) + .await + .map_err(ClientError::StoreError) + } + /// Retrieves the balance of a fungible asset in the account's vault. /// /// Returns [`AssetAmount::ZERO`] if the asset is not present in the vault or if the asset is diff --git a/crates/rust-client/src/account/mod.rs b/crates/rust-client/src/account/mod.rs index 12983bee7d..e1bc6dee6a 100644 --- a/crates/rust-client/src/account/mod.rs +++ b/crates/rust-client/src/account/mod.rs @@ -559,12 +559,10 @@ impl Client { /// Retrieves the full [`Account`] object from the store, returning `None` if not found. /// - /// This method loads the complete account state including vault, storage, and code. - /// - /// For lazy access that fetches only the data you need, use + /// This method loads the complete account state including vault, storage, and code — + /// including building the vault's Merkle tree. For lazy access that fetches only the data + /// you need (existence checks, single fields, storage items), use /// [`Client::account_reader`] instead. - /// - /// Use [`Client::try_get_account`] if you want to error when the account is not found. pub async fn get_account(&self, account_id: AccountId) -> Result, ClientError> { match self.store.get_account(account_id).await? { Some(record) => Ok(Some(record.try_into()?)), @@ -572,17 +570,6 @@ impl Client { } } - /// Retrieves the full [`Account`] object from the store, erroring if not found. - /// - /// This method loads the complete account state including vault, storage, and code. - /// - /// Use [`Client::get_account`] if you want to handle missing accounts gracefully. - pub async fn try_get_account(&self, account_id: AccountId) -> Result { - self.get_account(account_id) - .await? - .ok_or(ClientError::AccountDataNotFound(account_id)) - } - /// Creates an [`AccountReader`] for lazy access to account data. /// /// The `AccountReader` provides lazy access to account state - each method call diff --git a/crates/rust-client/src/errors.rs b/crates/rust-client/src/errors.rs index ce7bb86e2c..44f2df4a2a 100644 --- a/crates/rust-client/src/errors.rs +++ b/crates/rust-client/src/errors.rs @@ -6,7 +6,13 @@ use core::fmt; use miden_protocol::Word; use miden_protocol::account::AccountId; use miden_protocol::crypto::merkle::MerkleError; -pub use miden_protocol::errors::{AccountError, AccountIdError, AssetError, NetworkIdError}; +pub use miden_protocol::errors::{ + AccountError, + AccountIdError, + AccountPatchError, + AssetError, + NetworkIdError, +}; use miden_protocol::errors::{ NoteError, PartialBlockchainError, @@ -79,6 +85,8 @@ pub enum ClientError { AccountAlreadyTracked(AccountId), #[error("account error")] AccountError(#[from] AccountError), + #[error("account patch error")] + AccountPatchError(#[from] AccountPatchError), #[error("account {0} is locked because the local state may be out of date with the network")] AccountLocked(AccountId), #[error( diff --git a/crates/rust-client/src/store/data_store/mod.rs b/crates/rust-client/src/store/data_store/mod.rs index dcb7666ec4..572728692f 100644 --- a/crates/rust-client/src/store/data_store/mod.rs +++ b/crates/rust-client/src/store/data_store/mod.rs @@ -404,27 +404,13 @@ impl DataStore for ClientDataStore { return Ok(witnesses); } - let mut asset_witnesses = Vec::with_capacity(asset_ids.len()); - for asset_id in asset_ids.iter().copied() { - match self.store.get_account_asset(account_id, asset_id).await { - Ok(Some((_, witness))) if witness.proof().compute_root() == vault_root => { - asset_witnesses.push(witness); - }, - Ok(_) => { - asset_witnesses.clear(); - break; - }, - Err(err) => { - tracing::debug!( - %account_id, - %err, - "asset witness not available locally, will try the full vault" - ); - asset_witnesses.clear(); - break; - }, - } - } + let mut asset_witnesses = self + .store + .get_vault_asset_witnesses(account_id, vault_root, asset_ids.clone()) + .await + .map_err(|err| { + DataStoreError::other_with_source("failed to get vault asset witnesses", err) + })?; // Fall back to the full local vault — an absent asset still needs a non-membership // witness, which only the vault itself can produce — and lastly to an RPC vault fetch, diff --git a/crates/rust-client/src/store/errors.rs b/crates/rust-client/src/store/errors.rs index a19912191b..18ae79b684 100644 --- a/crates/rust-client/src/store/errors.rs +++ b/crates/rust-client/src/store/errors.rs @@ -98,6 +98,8 @@ pub enum StoreError { VaultKeyNotTracked(AssetId, Word), #[error("failed to parse word")] WordError(#[from] WordError), + #[error("operation `{0}` is not supported by this store backend")] + UnsupportedOperation(&'static str), } impl From for DataStoreError { diff --git a/crates/rust-client/src/store/mod.rs b/crates/rust-client/src/store/mod.rs index 897fd82774..89fb17930a 100644 --- a/crates/rust-client/src/store/mod.rs +++ b/crates/rust-client/src/store/mod.rs @@ -41,6 +41,7 @@ use miden_protocol::account::{ use miden_protocol::address::Address; use miden_protocol::asset::{Asset, AssetId, AssetVault, AssetWitness}; use miden_protocol::block::{BlockHeader, BlockNumber}; +use miden_protocol::crypto::merkle::MerkleError; use miden_protocol::crypto::merkle::mmr::{Forest, InOrderIndex, MmrPeaks, PartialMmr}; use miden_protocol::errors::AccountError; use miden_protocol::note::{ @@ -632,6 +633,41 @@ pub trait Store: Send + Sync { /// Retrieves the asset vault for a specific account. async fn get_account_vault(&self, account_id: AccountId) -> Result; + /// Retrieves all assets in the account's vault as a plain list, without building the vault's + /// Merkle tree. + /// + /// Prefer this over [`Store::get_account_vault`] when only asset values are needed (e.g. + /// balance checks): it avoids hashing every asset into an SMT. + /// + /// The default implementation of this method uses [`Store::get_account_vault`]. + async fn get_account_assets(&self, account_id: AccountId) -> Result, StoreError> { + Ok(self.get_account_vault(account_id).await?.assets().collect()) + } + + /// Returns vault asset witnesses for `asset_ids` against the account's vault with root + /// `vault_root`. An asset absent from the vault yields an emptiness proof rather than an + /// error, which the executor needs when an asset is being added to the vault. + /// + /// The default implementation reconstructs the vault via [`Store::get_account_vault`] and + /// opens each witness from it; backends that keep an in-memory Merkle forest (e.g. + /// `SqliteStore`) override it to open the witnesses directly, without materializing the + /// vault. + async fn get_vault_asset_witnesses( + &self, + account_id: AccountId, + vault_root: Word, + asset_ids: BTreeSet, + ) -> Result, StoreError> { + let vault = self.get_account_vault(account_id).await?; + if vault.root() != vault_root { + return Err(StoreError::MerkleStoreError(MerkleError::ConflictingRoots { + expected_root: vault_root, + actual_root: vault.root(), + })); + } + Ok(asset_ids.into_iter().map(|asset_id| vault.open(asset_id)).collect()) + } + /// Retrieves a specific asset (by vault id) from the account's vault along with its Merkle /// witness. /// @@ -708,6 +744,9 @@ pub trait Store: Send + Sync { } } + // IN-BATCH (STAGED) WITNESSES + // -------------------------------------------------------------------------------------------- + // PARTIAL ACCOUNTS // -------------------------------------------------------------------------------------------- diff --git a/crates/rust-client/src/store/smt_forest.rs b/crates/rust-client/src/store/smt_forest.rs index aeef0cac34..7cd5e79e41 100644 --- a/crates/rust-client/src/store/smt_forest.rs +++ b/crates/rust-client/src/store/smt_forest.rs @@ -235,6 +235,32 @@ impl AccountSmtForest { Ok((asset, witness)) } + /// Retrieves vault asset witnesses for the given vault keys. + /// + /// Unlike [`Self::get_asset_and_witness`], keys absent from the vault are served too: their + /// witness is an emptiness proof, which the executor needs when an asset is being added to + /// the vault. + /// + /// The proofs are opened against the latest tree of the account's vault lineage, after + /// verifying that its root matches `expected_vault_root`. + pub fn open_vault_asset_witnesses( + &self, + account_id: AccountId, + expected_vault_root: Word, + asset_ids: impl IntoIterator, + ) -> Result, StoreError> { + let lineage = vault_lineage_id(account_id); + let tree = self.verified_latest_tree(lineage, expected_vault_root)?; + + asset_ids + .into_iter() + .map(|asset_id| { + let proof = self.forest.open(tree, asset_id.hash().into()).map_err(forest_error)?; + Ok(AssetWitness::new(proof, [asset_id])?) + }) + .collect() + } + /// Retrieves the storage map witness for a specific map item. /// /// The proof is opened against the latest tree of the map's lineage, after verifying that diff --git a/crates/rust-client/src/transaction/batch/mod.rs b/crates/rust-client/src/transaction/batch/mod.rs index 611aa9b82b..104eb7a71e 100644 --- a/crates/rust-client/src/transaction/batch/mod.rs +++ b/crates/rust-client/src/transaction/batch/mod.rs @@ -306,9 +306,7 @@ where None => account_reader.partial_account().await?, }; - let prep = client - .prepare_transaction(account.code_interface(), transaction_request) - .await?; + let prep = client.prepare_transaction_for_batch(&account, transaction_request).await?; data_store.register_note_scripts(prep.output_note_scripts()); for fpi_account in &prep.foreign_account_inputs { diff --git a/crates/rust-client/src/transaction/mod.rs b/crates/rust-client/src/transaction/mod.rs index 3afcab320c..ca33d16ad0 100644 --- a/crates/rust-client/src/transaction/mod.rs +++ b/crates/rust-client/src/transaction/mod.rs @@ -67,7 +67,7 @@ use alloc::collections::{BTreeMap, BTreeSet}; use alloc::sync::Arc; use alloc::vec::Vec; -use miden_protocol::account::{Account, AccountCode, AccountCodeInterface, AccountId}; +use miden_protocol::account::{AccountCode, AccountCodeInterface, AccountId, PartialAccount}; use miden_protocol::asset::{Asset, NonFungibleAsset}; use miden_protocol::block::{BlockHeader, BlockNumber}; use miden_protocol::errors::AssetError; @@ -84,7 +84,7 @@ use miden_protocol::transaction::AccountInputs; use miden_protocol::vm::MIN_STACK_DEPTH; use miden_protocol::{Felt, Word}; use miden_standards::account::faucets::FungibleFaucet; -use miden_standards::account::interface::AccountInterfaceExt; +use miden_standards::account::interface::AccountComponentInterfaceExt; use miden_tx::{DataStore, NoteConsumptionChecker, TransactionExecutor}; use tracing::info; @@ -363,10 +363,10 @@ where transaction_request: TransactionRequest, execution_mode: TransactionExecutionMode, ) -> Result { - let account: Account = self.get_native_account_record(account_id).await?.try_into()?; + let account: PartialAccount = + self.get_native_account_record(account_id).await?.try_into()?; - validate_account_request(&transaction_request, &account)?; - let prep = self.prepare_transaction(account.code_interface(), transaction_request).await?; + let prep = self.prepare_transaction(&account, transaction_request).await?; let data_store = ClientDataStore::new(self.store.clone(), self.rpc_api.clone()); data_store.register_note_scripts(prep.output_note_scripts()); @@ -403,22 +403,53 @@ where } /// Performs the data-store-independent setup shared by `execute_transaction` and - /// `execute_transaction_for_batch`: loads/filters input notes, builds the transaction script - /// and args, retrieves foreign-account inputs, and computes the reference block number. + /// `execute_transaction_for_batch`: validates the request against the account's committed + /// store state, loads/filters input notes, builds the transaction script and args, retrieves + /// foreign-account inputs, and computes the reference block number. /// /// This method does not write to the store: any state produced by the transaction is /// persisted only after the transaction executes successfully. /// - /// Checking the request against the account's balances is the caller's job, since it needs a - /// full [`Account`] (see [`validate_account_request`]). Batch execution only has the in-batch - /// [`miden_protocol::account::PartialAccount`] and so skips it; the executor still rejects an - /// unsatisfiable request. + /// In batch execution, request validation is skipped: the committed store state does not + /// reflect balances stacked by prior in-batch pushes, so validating against it would wrongly + /// reject transactions the executor accepts. pub(crate) async fn prepare_transaction( + &self, + account: &PartialAccount, + transaction_request: TransactionRequest, + ) -> Result { + self.prepare_transaction_inner( + account.code_interface(), + transaction_request, + Some(account.id()), + ) + .await + } + + pub(crate) async fn prepare_transaction_for_batch( + &self, + account: &PartialAccount, + transaction_request: TransactionRequest, + ) -> Result { + self.prepare_transaction_inner(account.code_interface(), transaction_request, None) + .await + } + + async fn prepare_transaction_inner( &self, account_code_interface: AccountCodeInterface, transaction_request: TransactionRequest, + account_to_validate: Option, ) -> Result { self.validate_recency().await?; + if let Some(account_id) = account_to_validate { + self.validate_account_request( + &transaction_request, + account_id, + &account_code_interface, + ) + .await?; + } // Retrieve all input notes from the store. let mut stored_note_records = self @@ -752,8 +783,35 @@ where ) -> Result<(), ClientError> { self.validate_recency().await?; validate_output_note_senders(transaction_request, account_id)?; - let account = self.try_get_account(account_id).await?; - validate_account_request(transaction_request, &account) + let account: PartialAccount = self + .store + .get_minimal_partial_account(account_id) + .await? + .ok_or(ClientError::AccountDataNotFound(account_id))? + .try_into()?; + self.validate_account_request(transaction_request, account_id, &account.code_interface()) + .await + } + + /// Validates the request against the account's committed store state: faucet accounts are + /// accepted as-is, other accounts get their vault asset list checked against the request's + /// outgoing assets. Only the asset list is loaded from the store; the account itself is not + /// reconstructed. + async fn validate_account_request( + &self, + transaction_request: &TransactionRequest, + account_id: AccountId, + account_code_interface: &AccountCodeInterface, + ) -> Result<(), ClientError> { + validate_fee_conversion_info_support(transaction_request, account_code_interface)?; + + if account_code_interface.contains([FungibleFaucet::mint_and_send_root()]) { + // TODO(SantiagoPittella): Add faucet validations. + Ok(()) + } else { + let assets = self.account_reader(account_id).assets().await?; + validate_basic_account_request(transaction_request, &assets) + } } async fn validate_recency(&self) -> Result<(), ClientError> { @@ -831,9 +889,9 @@ where /// Filters the provided input notes down to the subset that can be consumed by the account. /// - /// `output_recipients` are the request's expected output recipients; their scripts are - /// registered on the consumption-check data store so output note creation can resolve them - /// without them being present in the store. + /// The provided data store must already have the account's code loaded and the request's + /// output note scripts registered, so output note creation can resolve them without them + /// being present in the store. pub(crate) async fn get_valid_input_notes( &self, data_store: &STORE, @@ -972,15 +1030,17 @@ where Ok(executor) } - /// Loads an [`AccountRecord`] for an account that must be usable as a transaction's native - /// account. Errors out if the account is not tracked or if it is watched. + /// Loads a minimal partial [`AccountRecord`] for an account that must be usable as a + /// transaction's native account. Errors out if the account is not tracked or if it is + /// watched. The full account state is never loaded: the executor reads it lazily through the + /// [`DataStore`]. async fn get_native_account_record( &self, account_id: AccountId, ) -> Result { let account_record = self .store - .get_account(account_id) + .get_minimal_partial_account(account_id) .await? .ok_or(ClientError::AccountDataNotFound(account_id))?; if account_record.is_watched() { @@ -1186,23 +1246,6 @@ fn get_outgoing_assets( request::collect_assets(outgoing_assets) } -/// Validates a transaction request against the supplied `account`. Faucets are currently -/// skipped; for non-faucets, defers to [`validate_basic_account_request`] for asset-balance -/// checks. -pub(super) fn validate_account_request( - transaction_request: &TransactionRequest, - account: &Account, -) -> Result<(), ClientError> { - validate_fee_conversion_info_support(transaction_request, account)?; - - if account.code_interface().contains([FungibleFaucet::mint_and_send_root()]) { - // TODO(SantiagoPittella): Add faucet validations. - Ok(()) - } else { - validate_basic_account_request(transaction_request, account) - } -} - /// Verifies that the account can consume fee conversion info passed through the auth args. /// /// Only the signature-based auth components read the auth args as conversion info (through @@ -1211,13 +1254,17 @@ pub(super) fn validate_account_request( /// instead. fn validate_fee_conversion_info_support( transaction_request: &TransactionRequest, - account: &Account, + account_code_interface: &AccountCodeInterface, ) -> Result<(), ClientError> { if !transaction_request.declares_fee_conversion_info() { return Ok(()); } - let interface = AccountInterface::from_account(account); + let procedures: Vec<_> = account_code_interface.procedures().iter().copied().collect(); + let interface = AccountInterface::new( + account_code_interface.id(), + AccountComponentInterface::from_procedures(&procedures), + ); let auth_component = interface.auth_component(); if matches!( auth_component, @@ -1230,7 +1277,6 @@ fn validate_fee_conversion_info_support( TransactionRequestError::FeeConversionInfoUnsupported(auth_component.name()), )) } - /// Verifies that every output note emitted directly by the transaction declares `account_id` as /// its sender. /// @@ -1257,11 +1303,11 @@ fn validate_output_note_senders( Ok(()) } -/// Ensures a transaction request is compatible with the current account state, +/// Ensures a transaction request is compatible with the account's committed vault assets, /// primarily by checking asset balances against the requested transfers. fn validate_basic_account_request( transaction_request: &TransactionRequest, - account: &Account, + vault_assets: &[Asset], ) -> Result<(), ClientError> { // Get outgoing assets let (fungible_balance_map, non_fungible_set) = get_outgoing_assets(transaction_request); @@ -1273,7 +1319,7 @@ fn validate_basic_account_request( // Aggregate the account's fungible balance per faucet in one pass. A faucet's fungible asset // may occupy more than one callback-flag vault key, so all matching entries are summed. let mut available_fungible: BTreeMap = BTreeMap::new(); - for asset in account.vault().assets() { + for asset in vault_assets { if let Asset::Fungible(fungible) = asset { let balance = available_fungible.entry(fungible.faucet_id()).or_default(); *balance = balance.saturating_add(fungible.amount().as_u64()); @@ -1296,21 +1342,13 @@ fn validate_basic_account_request( // Check if the account balance plus incoming assets is greater than or equal to the // outgoing non fungible assets for non_fungible in &non_fungible_set { - match account.vault().has_non_fungible_asset(*non_fungible) { - Ok(true) => (), - Ok(false) => { - // Check if the non fungible asset is in the incoming assets - if !incoming_non_fungible_balance_set.contains(non_fungible) { - return Err(ClientError::TransactionRequestError( - TransactionRequestError::MissingNonFungibleAsset(non_fungible.faucet_id()), - )); - } - }, - _ => { - return Err(ClientError::TransactionRequestError( - TransactionRequestError::MissingNonFungibleAsset(non_fungible.faucet_id()), - )); - }, + let held = vault_assets + .iter() + .any(|asset| matches!(asset, Asset::NonFungible(nf) if nf == non_fungible)); + if !held && !incoming_non_fungible_balance_set.contains(non_fungible) { + return Err(ClientError::TransactionRequestError( + TransactionRequestError::MissingNonFungibleAsset(non_fungible.faucet_id()), + )); } } @@ -1419,7 +1457,13 @@ mod tests { use miden_protocol::Word; use miden_protocol::account::auth::AuthSecretKey; - use miden_protocol::account::{AccountBuilder, AccountComponent, AccountId, AccountType}; + use miden_protocol::account::{ + Account, + AccountBuilder, + AccountComponent, + AccountId, + AccountType, + }; use miden_protocol::asset::FungibleAsset; use miden_protocol::crypto::rand::RandomCoin; use miden_protocol::note::{Note, NoteType}; @@ -1434,7 +1478,6 @@ mod tests { use miden_standards::note::P2idNote; use super::{ - Account, AccountComponentInterface, TransactionRequest, TransactionRequestBuilder, @@ -1541,16 +1584,22 @@ mod tests { AuthSchemeId::Falcon512Poseidon2, )); - validate_fee_conversion_info_support(&fee_conversion_request(), &account_with_auth(auth)) - .unwrap(); + validate_fee_conversion_info_support( + &fee_conversion_request(), + &account_with_auth(auth).code_interface(), + ) + .unwrap(); } #[test] fn fee_conversion_info_is_rejected_by_an_account_that_cannot_read_it() { let account = account_with_auth(NoAuth); - let err = validate_fee_conversion_info_support(&fee_conversion_request(), &account) - .expect_err("NoAuth does not read the auth args"); + let err = validate_fee_conversion_info_support( + &fee_conversion_request(), + &account.code_interface(), + ) + .expect_err("NoAuth does not read the auth args"); match err { ClientError::TransactionRequestError( TransactionRequestError::FeeConversionInfoUnsupported(auth_component), @@ -1564,7 +1613,7 @@ mod tests { // `NoAuth` cannot read conversion info, but a request that declares none is unaffected. validate_fee_conversion_info_support( &TransactionRequestBuilder::new().build().unwrap(), - &account_with_auth(NoAuth), + &account_with_auth(NoAuth).code_interface(), ) .unwrap(); } diff --git a/crates/sqlite-store/src/account/accounts.rs b/crates/sqlite-store/src/account/accounts.rs index fe1c841b43..c265047105 100644 --- a/crates/sqlite-store/src/account/accounts.rs +++ b/crates/sqlite-store/src/account/accounts.rs @@ -1,6 +1,6 @@ //! Account-related database operations. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::rc::Rc; use std::string::ToString; use std::vec::Vec; @@ -296,6 +296,22 @@ impl SqliteStore { Ok((item, witness)) } + /// Retrieves vault asset witnesses for the given vault keys, including emptiness proofs for + /// keys absent from the vault (which the executor needs when an asset is being added). + /// + /// The witnesses are opened against the account's vault tree in the forest, after verifying + /// that its root matches `vault_root` — the committed root the caller expects. + pub(crate) fn get_vault_asset_witnesses( + conn: &mut Connection, + account_id: AccountId, + vault_root: Word, + asset_ids: BTreeSet, + ) -> Result, StoreError> { + let db_tx = conn.transaction().into_store_error()?; + let smt_forest = ScopedAccountForest::new(SqliteForestBackend::new(&db_tx))?; + smt_forest.open_vault_asset_witnesses(account_id, vault_root, asset_ids) + } + pub(crate) fn get_account_addresses( conn: &mut Connection, account_id: AccountId, diff --git a/crates/sqlite-store/src/lib.rs b/crates/sqlite-store/src/lib.rs index e63b498197..c491ef0c1d 100644 --- a/crates/sqlite-store/src/lib.rs +++ b/crates/sqlite-store/src/lib.rs @@ -57,6 +57,8 @@ use rusqlite::Connection; use rusqlite::types::Value; use sql_error::SqlResultExt; +use crate::account::helpers::query_vault_assets; + mod account; mod builder; mod chain_data; @@ -468,24 +470,30 @@ impl Store for SqliteStore { .await } - async fn get_account_asset( + async fn get_account_assets(&self, account_id: AccountId) -> Result, StoreError> { + self.interact_with_connection(move |conn| query_vault_assets(conn, account_id)) + .await + } + + async fn get_vault_asset_witnesses( &self, account_id: AccountId, - asset_id: AssetId, - ) -> Result, StoreError> { + vault_root: Word, + asset_ids: BTreeSet, + ) -> Result, StoreError> { self.interact_with_connection(move |conn| { - SqliteStore::get_account_asset(conn, account_id, asset_id) + SqliteStore::get_vault_asset_witnesses(conn, account_id, vault_root, asset_ids) }) .await } - async fn get_account_storage( + async fn get_account_asset( &self, account_id: AccountId, - filter: AccountStorageFilter, - ) -> Result { + asset_id: AssetId, + ) -> Result, StoreError> { self.interact_with_connection(move |conn| { - SqliteStore::get_account_storage(conn, account_id, &filter) + SqliteStore::get_account_asset(conn, account_id, asset_id) }) .await } @@ -502,6 +510,17 @@ impl Store for SqliteStore { .await } + async fn get_account_storage( + &self, + account_id: AccountId, + filter: AccountStorageFilter, + ) -> Result { + self.interact_with_connection(move |conn| { + SqliteStore::get_account_storage(conn, account_id, &filter) + }) + .await + } + async fn get_addresses_by_account_id( &self, account_id: AccountId,