miden_client/account/mod.rs
1//! The `account` module provides types and client APIs for managing accounts within the Miden
2//! network.
3//!
4//! Accounts are foundational entities of the Miden protocol. They store assets and define rules for
5//! manipulating them. Once an account is registered with the client, its state will be updated
6//! accordingly, and validated against the network state on every sync.
7//!
8//! # Example
9//!
10//! To add a new account to the client's store, you might use the [`Client::add_account`] method as
11//! follows:
12//!
13//! ```rust
14//! # use miden_client::{
15//! # account::{Account, AccountBuilder, AccountBuilderSchemaCommitmentExt, AccountType, component::BasicWallet},
16//! # crypto::FeltRng
17//! # };
18//! # async fn add_new_account_example<AUTH>(
19//! # client: &mut miden_client::Client<AUTH>
20//! # ) -> Result<(), miden_client::ClientError> {
21//! # let random_seed = Default::default();
22//! let account = AccountBuilder::new(random_seed)
23//! .account_type(AccountType::Private)
24//! .with_component(BasicWallet)
25//! .build_with_schema_commitment()?;
26//!
27//! // Add the account to the client. The account already embeds its seed information.
28//! client.add_account(&account, false).await?;
29//! # Ok(())
30//! # }
31//! ```
32//!
33//! For more details on accounts, refer to the [Account] documentation.
34
35use alloc::collections::BTreeSet;
36use alloc::string::{String, ToString};
37use alloc::vec::Vec;
38
39pub use miden_objects::account_file::{AccountFile, AccountFileError};
40use miden_protocol::Felt;
41use miden_protocol::account::auth::PublicKey;
42pub use miden_protocol::account::{
43 Account,
44 AccountBuilder,
45 AccountCode,
46 AccountCodePatch,
47 AccountCodeUpgrade,
48 AccountComponent,
49 AccountComponentCode,
50 AccountDelta,
51 AccountHeader,
52 AccountId,
53 AccountIdPrefix,
54 AccountIdPrefixV1,
55 AccountIdV1,
56 AccountIdVersion,
57 AccountPatch,
58 AccountProcedureRoot,
59 AccountStorage,
60 AccountStoragePatch,
61 AccountType,
62 AccountUpdateDetails,
63 AccountVaultPatch,
64 PartialAccount,
65 PartialStorage,
66 PartialStorageMap,
67 RoleSymbol,
68 StorageMap,
69 StorageMapKey,
70 StorageMapKeyHash,
71 StorageMapPatch,
72 StorageMapPatchEntries,
73 StorageMapWitness,
74 StorageSlot,
75 StorageSlotContent,
76 StorageSlotId,
77 StorageSlotName,
78 StorageSlotPatch,
79 StorageSlotType,
80 StorageValuePatch,
81};
82pub use miden_protocol::address::{Address, AddressInterface, AddressType, NetworkId};
83use miden_protocol::asset::AssetVault;
84pub use miden_protocol::errors::{AccountIdError, AddressError, NetworkIdError};
85use miden_protocol::note::NoteTag;
86use miden_tx::utils::serde::{
87 ByteReader,
88 ByteWriter,
89 Deserializable,
90 DeserializationError,
91 Serializable,
92};
93
94/// Display-only metadata for a faucet account, persisted in the client's settings store.
95///
96/// Populated lazily by the CLI resolver from the on-chain token config of a public faucet and
97/// persisted under a `faucet_metadata:<faucet-id>` key.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct FaucetMetadata {
100 pub symbol: String,
101 pub decimals: u8,
102}
103
104impl Serializable for FaucetMetadata {
105 fn write_into<W: ByteWriter>(&self, target: &mut W) {
106 self.symbol.write_into(target);
107 target.write_u8(self.decimals);
108 }
109}
110
111impl Deserializable for FaucetMetadata {
112 fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
113 let symbol = String::read_from(source)?;
114 let decimals = source.read_u8()?;
115 Ok(Self { symbol, decimals })
116 }
117}
118
119/// Decodes a fungible faucet token config slot value into display metadata.
120///
121/// Returns `None` when the value does not describe a fungible faucet config the protocol would
122/// accept: the symbol must decode as a [`TokenSymbol`], and the decimals must be within
123/// [`FungibleFaucet::MAX_DECIMALS`], which is what [`FungibleFaucet`] enforces when the component
124/// is built.
125fn faucet_metadata_from_token_config(token_config: [Felt; 4]) -> Option<FaucetMetadata> {
126 let [_token_supply, _max_supply, decimals, symbol] = token_config;
127
128 let symbol = TokenSymbol::try_from(symbol).ok()?;
129 let decimals = u8::try_from(decimals.as_canonical_u64()).ok()?;
130 if decimals > FungibleFaucet::MAX_DECIMALS {
131 return None;
132 }
133
134 Some(FaucetMetadata { symbol: symbol.to_string(), decimals })
135}
136
137mod account_reader;
138pub use account_reader::AccountReader;
139/// Raw access to `miden-standards` account modules for items not curated by `miden-client`.
140pub use miden_standards::account as standards;
141use miden_standards::account::auth::{Approver, AuthSingleSig, NetworkAccount};
142use miden_standards::account::faucets::FungibleFaucet;
143pub use miden_standards::account::inspection::{
144 AccountBuilderSchemaCommitmentExt,
145 AccountSchemaCommitment,
146};
147// RE-EXPORTS
148// ================================================================================================
149pub use miden_standards::account::interface::{
150 AccountComponentInterface,
151 AccountComponentInterfaceExt,
152 AccountInterface,
153 AccountInterfaceExt,
154};
155use miden_standards::account::wallets::BasicWallet;
156
157use super::Client;
158use crate::asset::TokenSymbol;
159use crate::errors::ClientError;
160use crate::rpc::domain::account::GetAccountRequest;
161use crate::rpc::node::{EndpointError, GetAccountError};
162use crate::store::{AccountStatus, AccountStorageFilter, ClientAccountType};
163use crate::sync::{NoteTagRecord, NoteTagSource};
164
165pub mod component {
166 pub const MIDEN_PACKAGE_EXTENSION: &str = "masp";
167
168 pub use miden_protocol::account::auth::*;
169 pub use miden_protocol::account::component::{
170 FeltSchema,
171 InitStorageData,
172 InitStorageDataError,
173 MapSlotSchema,
174 SchemaRequirement,
175 SchemaType,
176 SchemaTypeError,
177 StorageSchema,
178 StorageSlotSchema,
179 StorageValueName,
180 StorageValueNameError,
181 ValueSlotSchema,
182 WordSchema,
183 WordValue,
184 };
185 pub use miden_protocol::account::{
186 AccountComponent,
187 AccountComponentMetadata,
188 AccountComponentName,
189 AccountProcedureRoot,
190 RoleSymbol,
191 };
192 pub use miden_standards::account::access::{
193 AccessControl,
194 Authority,
195 AuthorityError,
196 Ownable2Step,
197 Ownable2StepError,
198 Pausable,
199 PausableManager,
200 PausableStorage,
201 RoleBasedAccessControl,
202 };
203 pub use miden_standards::account::auth::*;
204 pub use miden_standards::account::components::StandardAccountComponent;
205 pub use miden_standards::account::faucets::{
206 Description,
207 ExternalLink,
208 FungibleFaucet,
209 FungibleFaucetBuilder,
210 FungibleFaucetError,
211 LogoURI,
212 NonFungibleFaucet,
213 TokenMetadata,
214 TokenMetadataError,
215 TokenName,
216 create_network_fungible_faucet,
217 create_singlesig_user_fungible_faucet,
218 };
219 pub use miden_standards::account::fees::{
220 BasicConstantFeePolicy,
221 FeePolicy,
222 FeePolicyError,
223 FeePolicyManager,
224 FeePolicyManagerBuilder,
225 };
226 pub use miden_standards::account::policies::{
227 AllowlistManager,
228 AllowlistStorage,
229 BasicAllowlist,
230 BasicBlocklist,
231 BlocklistManager,
232 BlocklistStorage,
233 BurnAllowAll,
234 BurnOwnerOnly,
235 BurnPolicy,
236 BurnPolicyError,
237 MinBurnAmount,
238 MintAllowAll,
239 MintOwnerOnly,
240 MintPolicy,
241 MintPolicyError,
242 TokenPolicyManager,
243 TokenPolicyManagerBuilder,
244 TransferAllowAll,
245 TransferPolicy,
246 TransferPolicyError,
247 };
248 pub use miden_standards::account::upgrade::UpgradeManager;
249 pub use miden_standards::account::wallets::BasicWallet;
250}
251
252// CLIENT METHODS
253// ================================================================================================
254
255/// This section of the [Client] contains methods for:
256///
257/// - **Account creation:** Use the [`AccountBuilder`] to construct new accounts, specifying account
258/// visibility (`AccountType::Public` / `AccountType::Private`) and attaching necessary components
259/// (e.g., basic wallet or fungible faucet). Prefer
260/// [`AccountBuilderSchemaCommitmentExt::build_with_schema_commitment`] so the account includes
261/// merged storage schema commitment metadata; use plain [`AccountBuilder::build`] only when you
262/// need to opt out. After creation, accounts can be added to the client.
263///
264/// - **Account tracking:** Accounts added via the client are persisted to the local store, where
265/// their state (including nonce, balance, and metadata) is updated upon every synchronization
266/// with the network.
267///
268/// - **Account registration:** On a network that enforces an account allowlist,
269/// [`Client::register_account`] binds an invitation code to a new account before its first
270/// transaction creates it on chain, and [`Client::is_account_allowed`] asks whether the network
271/// accepts the creation of an account.
272///
273/// - **Data retrieval:** The module also provides methods to fetch account-related data.
274impl<AUTH> Client<AUTH> {
275 // Mirror of node MAX_TAGS_PER_FETCH_REQUEST. NTL allows up to 128 tags per request.
276 pub const MAX_ACCOUNT_TAGS: usize = 128;
277
278 // ACCOUNT CREATION
279 // --------------------------------------------------------------------------------------------
280
281 /// Adds the provided [Account] in the store so it can start being tracked by the client.
282 ///
283 /// If the account is already being tracked and `overwrite` is set to `true`, the account will
284 /// be overwritten. Newly created accounts must embed their seed (`account.seed()` must return
285 /// `Some(_)`).
286 ///
287 /// # Errors
288 ///
289 /// - If the account is new but it does not contain the seed.
290 /// - If the account is already tracked and `overwrite` is set to `false`.
291 /// - If `overwrite` is set to `true` and the `account_data` nonce is lower than the one already
292 /// being tracked.
293 /// - If `overwrite` is set to `true` and the `account_data` commitment doesn't match the
294 /// network's account commitment.
295 pub async fn add_account(
296 &mut self,
297 account: &Account,
298 overwrite: bool,
299 ) -> Result<(), ClientError> {
300 self.add_account_inner(account, ClientAccountType::Native, overwrite).await
301 }
302
303 // ACCOUNT REGISTRATION
304 // --------------------------------------------------------------------------------------------
305
306 /// Binds an invitation code to a tracked account on the network allowlist.
307 ///
308 /// A network that enforces an account allowlist creates an account on chain only when the
309 /// account is registered. The first transaction of an account is what creates it, so the
310 /// account must be registered before that transaction is submitted.
311 /// [`Client::submit_new_transaction`] and [`BatchBuilder::submit`] ask the node first, and fail
312 /// with [`ClientError::AccountNotAllowlisted`] for an account the network does not accept. Only
313 /// account creation is gated: an account that already exists on chain is never checked, and
314 /// network accounts are exempt.
315 ///
316 /// The account must be tracked by the client, must not be deployed on chain yet, and must not
317 /// be a network account. The invitation code must exist on the node and must not be bound to
318 /// another account. A registration consumes the code, so the client asks the node first and
319 /// does not send the code for an account the node already allows.
320 ///
321 /// When the network operator runs a funding service, the node pays the registered account a
322 /// public P2ID note with the native asset. The node answers once the funding service queues the
323 /// note, before the note is committed. The note is not part of the response, and the client
324 /// does not see it until a [`Client::sync_state`] runs after the note is committed. The client
325 /// tracks the note tag of every account it owns, so that sync imports the note and
326 /// [`Client::get_consumable_notes`] lists it. Sync again until the note arrives. The account
327 /// then consumes the note in its first transaction. That transaction creates the account on
328 /// chain and pays its fee out of the received funds.
329 ///
330 /// # Errors
331 ///
332 /// - [`ClientError::AccountDataNotFound`] if the client does not track the account.
333 /// - [`ClientError::AccountIsNotNew`] if the account already exists on chain.
334 /// - [`ClientError::AccountIsNetworkAccount`] if the account is a network account. The node
335 /// admits network accounts without a code.
336 /// - [`ClientError::AccountAlreadyAllowed`] if the node already allows the account, because it
337 /// is registered or because the network does not enforce an allowlist. The code is not sent.
338 /// - [`ClientError::RpcError`] carrying a [`RegisterAccountError`] if the node rejects the
339 /// code or the account, or an `Unavailable` status if the funding failed. In the second
340 /// case the account stays registered, so a retry fails with
341 /// [`ClientError::AccountAlreadyAllowed`] and the account has to be funded another way.
342 ///
343 /// [`BatchBuilder::submit`]: crate::transaction::BatchBuilder::submit
344 /// [`RegisterAccountError`]: crate::rpc::RegisterAccountError
345 pub async fn register_account(
346 &self,
347 account_id: AccountId,
348 invitation_code: &str,
349 ) -> Result<(), ClientError> {
350 let (_, status) = self
351 .store
352 .get_account_header(account_id)
353 .await?
354 .ok_or(ClientError::AccountDataNotFound(account_id))?;
355 if !status.is_new() {
356 return Err(ClientError::AccountIsNotNew(account_id));
357 }
358
359 let account = self
360 .get_account(account_id)
361 .await?
362 .ok_or(ClientError::AccountDataNotFound(account_id))?;
363 // The node admits a network account without a code.
364 if NetworkAccount::new(account).is_ok() {
365 return Err(ClientError::AccountIsNetworkAccount(account_id));
366 }
367 // A registration consumes the code, so do not send it when the node already allows the
368 // account.
369 if self.is_account_allowed(account_id).await? {
370 return Err(ClientError::AccountAlreadyAllowed(account_id));
371 }
372
373 self.rpc_api.register_account(invitation_code, account_id).await?;
374
375 Ok(())
376 }
377
378 /// Returns whether the network lets `account_id` be created on chain.
379 ///
380 /// The node answers `true` when it does not enforce an account allowlist, or when the account
381 /// is registered. See [`Client::register_account`] for how an account gets registered.
382 pub async fn is_account_allowed(&self, account_id: AccountId) -> Result<bool, ClientError> {
383 Ok(self.rpc_api.is_account_allowed(account_id).await?)
384 }
385
386 /// Returns an error if `tag` is a new account tag and the client already tracks
387 /// [`Self::MAX_ACCOUNT_TAGS`] account tags.
388 async fn validate_can_track_more_account_tags(&self, tag: NoteTag) -> Result<(), ClientError> {
389 let tracked_tags: BTreeSet<NoteTag> = self
390 .store
391 .get_note_tags()
392 .await?
393 .into_iter()
394 .filter(|record| matches!(record.source, NoteTagSource::Account(_)))
395 .map(|record| record.tag)
396 .collect();
397 if !tracked_tags.contains(&tag) && tracked_tags.len() >= Self::MAX_ACCOUNT_TAGS {
398 return Err(ClientError::AccountTagLimitExceeded(tracked_tags.len()));
399 }
400
401 Ok(())
402 }
403
404 /// Returns whether a transaction against `account_id` creates an account that the network
405 /// allowlist gates.
406 ///
407 /// Only a new account is gated, and a network account is exempt. The answer is `false` for an
408 /// account that the client does not track.
409 pub(crate) async fn is_allowlist_gated(
410 &self,
411 account_id: AccountId,
412 ) -> Result<bool, ClientError> {
413 let Some((_, status)) = self.store.get_account_header(account_id).await? else {
414 return Ok(false);
415 };
416 if !status.is_new() {
417 return Ok(false);
418 }
419
420 let Some(account) = self.get_account(account_id).await? else {
421 return Ok(false);
422 };
423
424 Ok(NetworkAccount::new(account).is_err())
425 }
426
427 /// Inserts `account` into the store (or overwrites it if `overwrite` is true) and registers the
428 /// per-account note tag if `client_account_type` is [`ClientAccountType::Native`].
429 ///
430 /// Switching the [`ClientAccountType`] of an already-tracked account is not supported and
431 /// returns [`ClientError::AccountWatchedMismatch`].
432 async fn add_account_inner(
433 &mut self,
434 account: &Account,
435 client_account_type: ClientAccountType,
436 overwrite: bool,
437 ) -> Result<(), ClientError> {
438 if account.is_new() {
439 if account.seed().is_none() {
440 return Err(ClientError::AddNewAccountWithoutSeed);
441 }
442 } else {
443 // Ignore the seed since it's not a new account
444 if account.seed().is_some() {
445 tracing::warn!(
446 "Added an existing account and still provided a seed when it is not needed. It's possible that the account's file was incorrectly generated. The seed will be ignored."
447 );
448 }
449 }
450
451 let tracked_account = self.store.get_minimal_partial_account(account.id()).await?;
452
453 match tracked_account {
454 None => {
455 let default_address = Address::new(account.id());
456 if matches!(client_account_type, ClientAccountType::Native) {
457 self.validate_can_track_more_account_tags(default_address.to_note_tag())
458 .await?;
459 }
460
461 self.store
462 .insert_account(account, default_address.clone(), client_account_type)
463 .await
464 .map_err(ClientError::StoreError)?;
465
466 if matches!(client_account_type, ClientAccountType::Native) {
467 // Set the default address note tag so sync pulls notes.
468 let default_address_note_tag = default_address.to_note_tag();
469 let note_tag_record =
470 NoteTagRecord::with_account_source(default_address_note_tag, account.id());
471 self.store.add_note_tag(note_tag_record).await?;
472 }
473
474 Ok(())
475 },
476 Some(tracked_account) => {
477 if !overwrite {
478 // Only overwrite the account if the flag is set to `true`
479 return Err(ClientError::AccountAlreadyTracked(account.id()));
480 }
481
482 if client_account_type != tracked_account.client_account_type() {
483 // Switching between Watched and Native after the account is tracked is not
484 // supported: the per-account note tag and any client-side state derived from
485 // that mode are set up at insertion time and not migrated on the fly.
486 return Err(ClientError::AccountWatchedMismatch(account.id()));
487 }
488
489 if tracked_account.nonce().as_canonical_u64() > account.nonce().as_canonical_u64() {
490 // If the new account is older than the one being tracked, return an error
491 return Err(ClientError::AccountNonceTooLow);
492 }
493
494 if tracked_account.is_locked() {
495 // If the tracked account is locked, check that the account commitment matches
496 // the one in the network
497 let network_account_commitment = self
498 .rpc_api
499 .get_account(account.id(), GetAccountRequest::new())
500 .await?
501 .1
502 .account_commitment();
503 if network_account_commitment != account.to_commitment() {
504 return Err(ClientError::AccountCommitmentMismatch(
505 network_account_commitment,
506 ));
507 }
508 }
509
510 self.store.update_account(account).await?;
511
512 Ok(())
513 },
514 }
515 }
516
517 /// Imports an account from the network to the client's store. The account needs to be public
518 /// and be tracked by the network, it will be fetched by its ID. If the account was already
519 /// being tracked by the client, its state will be overwritten.
520 ///
521 /// To import an account as watched (state-tracking only, no note sync), use
522 /// [`Self::import_watched_account_by_id`] instead. Switching an already-tracked account between
523 /// Native and Watched is not supported.
524 ///
525 /// # Errors
526 /// - If the account is not found on the network.
527 /// - If the account is private.
528 /// - If the account is already tracked as watched.
529 /// - There was an error sending the request to the network.
530 pub async fn import_account_by_id(&mut self, account_id: AccountId) -> Result<(), ClientError> {
531 let account = self.fetch_public_account(account_id).await?;
532 self.add_account_inner(&account, ClientAccountType::Native, true).await
533 }
534
535 /// Starts watching an on-chain account ([`ClientAccountType::Watched`]).
536 ///
537 /// Like [`Self::import_account_by_id`], the account is fetched from the network by its ID.
538 /// Unlike `import_account_by_id`, the account is added without registering its derived note
539 /// tag: `sync_state` will keep the account's commitment, nonce and storage up to date but will
540 /// **not** pull notes targeted at it.
541 ///
542 /// If the account is already being tracked as watched its state is overwritten. Switching an
543 /// already-tracked native account to watched is not supported.
544 ///
545 /// # Errors
546 /// - If the account is not found on the network.
547 /// - If the account is private.
548 /// - If the account is already tracked as native.
549 /// - There was an error sending the request to the network.
550 pub async fn import_watched_account_by_id(
551 &mut self,
552 account_id: AccountId,
553 ) -> Result<(), ClientError> {
554 let account = self.fetch_public_account(account_id).await?;
555 self.add_account_inner(&account, ClientAccountType::Watched, true).await
556 }
557
558 // ACCOUNT WITNESS PREFETCHING
559 // --------------------------------------------------------------------------------------------
560
561 /// Registers an account whose account witness [`Client::sync_chain`] keeps up to date, so that
562 /// transactions using it as a foreign account resolve the witness locally. This trades one
563 /// request per transaction for one per sync.
564 ///
565 /// A [`ForeignAccount::Private`](crate::transaction::ForeignAccount) needs nothing else, since
566 /// the caller supplies the account data. A
567 /// [`ForeignAccount::Public`](crate::transaction::ForeignAccount) additionally has to be
568 /// tracked by this client, so that its code, storage and vault come from the store as well;
569 /// registering an untracked public account costs a request per sync and saves none.
570 ///
571 /// The witness is fetched by the next sync, not by this call. Transactions assume that a sync
572 /// ran after the account was registered.
573 ///
574 /// The account is not validated against the network here. The sync fails while a registered
575 /// account has no witness that the node can return, so an account that is not in the account
576 /// tree blocks the sync until it is unregistered.
577 ///
578 /// Registering an already registered account is a no-op and keeps any cached witness.
579 ///
580 /// Returns `true` if the account was not registered before this call.
581 pub async fn track_account_witness(&self, account_id: AccountId) -> Result<bool, ClientError> {
582 self.store.track_account_witness(account_id).await.map_err(Into::into)
583 }
584
585 /// Stops keeping the account's witness up to date and drops the cached one.
586 ///
587 /// Returns `true` if the account was registered. Transactions using it keep working, falling
588 /// back to fetching the witness from the node.
589 pub async fn untrack_account_witness(
590 &self,
591 account_id: AccountId,
592 ) -> Result<bool, ClientError> {
593 self.store.untrack_account_witness(account_id).await.map_err(Into::into)
594 }
595
596 /// Returns the IDs of every account registered via [`Client::track_account_witness`].
597 pub async fn tracked_account_witnesses(&self) -> Result<Vec<AccountId>, ClientError> {
598 self.store.tracked_account_witnesses().await.map_err(Into::into)
599 }
600
601 /// Fetches a public [`Account`] from the network, returning a typed error when the account
602 /// doesn't exist on chain or is private.
603 async fn fetch_public_account(&self, account_id: AccountId) -> Result<Account, ClientError> {
604 let fetched_account =
605 self.rpc_api.get_account_details(account_id).await.map_err(|err| {
606 match err.endpoint_error() {
607 Some(EndpointError::GetAccount(GetAccountError::AccountNotFound)) => {
608 ClientError::AccountNotFoundOnChain(account_id)
609 },
610 _ => ClientError::RpcError(err),
611 }
612 })?;
613
614 fetched_account.ok_or(ClientError::AccountIsPrivate(account_id))
615 }
616
617 /// Fetches a public faucet's display metadata from the network.
618 ///
619 /// Uses [`get_account`](crate::rpc::NodeRpcClient::get_account) with a minimal request so that
620 /// the node does not return vault data. The faucet's token config lives in a single value slot,
621 /// which is always present in the returned storage header.
622 ///
623 /// Returns:
624 /// - `Ok(Some(_))` — the account is public and its token config storage slot decoded.
625 /// - `Ok(None)` — the account is private, not on chain, or the storage slot does not parse
626 /// as a token config. Caller should fall back to a raw display.
627 /// - `Err(_)` — transport-level RPC error.
628 pub async fn fetch_remote_token_metadata(
629 &self,
630 faucet_id: AccountId,
631 ) -> Result<Option<FaucetMetadata>, ClientError> {
632 let proof = match self.rpc_api.get_account(faucet_id, GetAccountRequest::new()).await {
633 Ok((_, proof)) => proof,
634 Err(err) => match err.endpoint_error() {
635 Some(EndpointError::GetAccount(
636 GetAccountError::AccountNotFound | GetAccountError::AccountNotPublic,
637 )) => return Ok(None),
638 _ => return Err(ClientError::RpcError(err)),
639 },
640 };
641
642 let Some(storage_header) = proof.storage_header() else {
643 return Ok(None);
644 };
645
646 let Some(slot_header) =
647 storage_header.find_slot_header_by_name(FungibleFaucet::token_config_slot())
648 else {
649 return Ok(None);
650 };
651
652 Ok(faucet_metadata_from_token_config(*slot_header.value()))
653 }
654
655 /// Adds an [`Address`] to the associated [`AccountId`], alongside its derived [`NoteTag`]. If
656 /// the account is tracked as watched, the note tag is not registered.
657 ///
658 /// # Errors
659 /// - If the account is not found on the network.
660 /// - If the address is already being tracked.
661 pub async fn add_address(
662 &mut self,
663 address: Address,
664 account_id: AccountId,
665 ) -> Result<(), ClientError> {
666 let network_id = self.rpc_api.get_network_id().await?;
667 let address_bench32 = address.encode(network_id);
668 if self.store.get_addresses_by_account_id(account_id).await?.contains(&address) {
669 return Err(ClientError::AddressAlreadyTracked(address_bench32));
670 }
671
672 let tracked_account = self.store.get_minimal_partial_account(account_id).await?;
673 match tracked_account {
674 None => Err(ClientError::AccountDataNotFound(account_id)),
675 Some(tracked_account) => {
676 if !tracked_account.is_watched() {
677 self.validate_can_track_more_account_tags(address.to_note_tag()).await?;
678 }
679 self.store.insert_address(address.clone(), account_id).await?;
680 // Watched accounts intentionally have no derived note tag registered to avoid sync
681 // state pulling notes for them.
682 if !tracked_account.is_watched() {
683 let derived_note_tag: NoteTag = address.to_note_tag();
684 let note_tag_record =
685 NoteTagRecord::with_account_source(derived_note_tag, account_id);
686 self.store.add_note_tag(note_tag_record).await?;
687 }
688 Ok(())
689 },
690 }
691 }
692
693 /// Removes an [`Address`] from the associated [`AccountId`], alongside its derived [`NoteTag`].
694 ///
695 /// Returns `true` if the address was tracked. If it wasn't, this is a no-op: the derived tag is
696 /// left in place, since it may have been registered by something other than this address.
697 pub async fn remove_address(
698 &mut self,
699 address: Address,
700 account_id: AccountId,
701 ) -> Result<bool, ClientError> {
702 let derived_note_tag = address.to_note_tag();
703 let note_tag_record = NoteTagRecord::with_account_source(derived_note_tag, account_id);
704 if !self.store.remove_address(address).await? {
705 return Ok(false);
706 }
707 // Remove the note tag if no other address are associated with it.
708 let addresses = self.store.get_addresses_by_account_id(account_id).await?;
709 if addresses.iter().all(|address| address.to_note_tag() != derived_note_tag) {
710 self.store.remove_note_tag(note_tag_record).await?;
711 }
712 Ok(true)
713 }
714
715 // ACCOUNT DATA RETRIEVAL
716 // --------------------------------------------------------------------------------------------
717
718 /// Retrieves the asset vault for a specific account.
719 ///
720 /// To check the balance for a single asset, use [`Client::account_reader`] instead.
721 pub async fn get_account_vault(
722 &self,
723 account_id: AccountId,
724 ) -> Result<AssetVault, ClientError> {
725 self.store.get_account_vault(account_id).await.map_err(ClientError::StoreError)
726 }
727
728 /// Retrieves the whole account storage for a specific account.
729 ///
730 /// To only load a specific slot, use [`Client::account_reader`] instead.
731 pub async fn get_account_storage(
732 &self,
733 account_id: AccountId,
734 ) -> Result<AccountStorage, ClientError> {
735 self.store
736 .get_account_storage(account_id, AccountStorageFilter::All)
737 .await
738 .map_err(ClientError::StoreError)
739 }
740
741 /// Retrieves the account code for a specific account.
742 ///
743 /// Returns `None` if the account is not found.
744 pub async fn get_account_code(
745 &self,
746 account_id: AccountId,
747 ) -> Result<Option<AccountCode>, ClientError> {
748 self.store.get_account_code(account_id).await.map_err(ClientError::StoreError)
749 }
750
751 /// Returns a list of [`AccountHeader`] of all accounts stored in the database along with their
752 /// statuses.
753 ///
754 /// Said accounts' state is the state after the last performed sync.
755 pub async fn get_account_headers(
756 &self,
757 ) -> Result<Vec<(AccountHeader, AccountStatus)>, ClientError> {
758 self.store.get_account_headers().await.map_err(Into::into)
759 }
760
761 /// Returns the [`AccountHeader`] of the account with the specified ID along with its status, or
762 /// `None` if the account isn't tracked by the client.
763 ///
764 /// Said account's state is the state after the last performed sync.
765 pub async fn get_account_header(
766 &self,
767 account_id: AccountId,
768 ) -> Result<Option<(AccountHeader, AccountStatus)>, ClientError> {
769 self.store.get_account_header(account_id).await.map_err(Into::into)
770 }
771
772 /// Retrieves the full [`Account`] object from the store, returning `None` if not found.
773 ///
774 /// This method loads the complete account state including vault, storage, and code — including
775 /// building the vault's Merkle tree. For lazy access that fetches only the data you need
776 /// (existence checks, single fields, storage items), use [`Client::account_reader`] instead.
777 pub async fn get_account(&self, account_id: AccountId) -> Result<Option<Account>, ClientError> {
778 match self.store.get_account(account_id).await? {
779 Some(record) => Ok(Some(record.try_into()?)),
780 None => Ok(None),
781 }
782 }
783
784 /// Creates an [`AccountReader`] for lazy access to account data.
785 ///
786 /// The `AccountReader` provides lazy access to account state - each method call fetches fresh
787 /// data from storage, ensuring you always see the current state.
788 ///
789 /// For loading the full [`Account`] object, use [`Client::get_account`] instead.
790 ///
791 /// # Example
792 /// ```ignore
793 /// let reader = client.account_reader(account_id);
794 ///
795 /// // Each call fetches fresh data
796 /// let nonce = reader.nonce().await?;
797 /// let balance = reader.get_balance(faucet_id).await?;
798 ///
799 /// // Storage access is integrated
800 /// let value = reader.get_storage_item("my_slot").await?;
801 /// let (map_value, witness) = reader.get_storage_map_witness("balances", key).await?;
802 /// ```
803 pub fn account_reader(&self, account_id: AccountId) -> AccountReader {
804 AccountReader::new(self.store.clone(), account_id)
805 }
806
807 /// Prunes historical account states for the specified account up to the given nonce.
808 ///
809 /// Deletes all historical entries with `replaced_at_nonce <= up_to_nonce` and any orphaned
810 /// account code.
811 ///
812 /// Returns the total number of rows deleted, including historical entries and orphaned account
813 /// code.
814 pub async fn prune_account_history(
815 &self,
816 account_id: AccountId,
817 up_to_nonce: Felt,
818 ) -> Result<usize, ClientError> {
819 Ok(self.store.prune_account_history(account_id, up_to_nonce).await?)
820 }
821}
822
823// UTILITY FUNCTIONS
824// ================================================================================================
825
826/// Builds an regular account ID from the provided parameters. The ID may be used along
827/// `Client::import_account_by_id` to import a public account from the network (provided that the
828/// used seed is known).
829///
830/// This function currently supports accounts composed of the [`BasicWallet`] component and one of
831/// the supported authentication schemes ([`AuthSingleSig`]).
832///
833/// # Arguments
834/// - `init_seed`: Initial seed used to create the account. This is the seed passed to
835/// [`AccountBuilder::new`].
836/// - `public_key`: Public key of the account used for the authentication component.
837/// - `account_visibility`: Public/private visibility of the account.
838///
839/// # Errors
840/// - If the account cannot be built.
841pub fn build_wallet_id(
842 init_seed: [u8; 32],
843 public_key: &PublicKey,
844 account_visibility: AccountType,
845) -> Result<AccountId, ClientError> {
846 let auth_scheme = public_key.auth_scheme();
847 let auth_component: AccountComponent =
848 AuthSingleSig::new(Approver::new(public_key.to_commitment(), auth_scheme)).into();
849
850 let account = AccountBuilder::new(init_seed)
851 .account_type(account_visibility)
852 .with_component(auth_component)
853 .with_component(BasicWallet)
854 .build_with_schema_commitment()?;
855
856 Ok(account.id())
857}
858
859#[cfg(test)]
860mod schema_commitment_tests {
861 use miden_protocol::EMPTY_WORD;
862 use miden_protocol::account::auth::AuthSecretKey;
863 use miden_standards::account::inspection::AccountSchemaCommitment;
864
865 use super::{
866 AccountBuilder,
867 AccountBuilderSchemaCommitmentExt,
868 AccountType,
869 Approver,
870 AuthSingleSig,
871 BasicWallet,
872 };
873 use crate::auth::AuthSchemeId;
874
875 #[test]
876 fn wallet_build_includes_schema_commitment_metadata_slot() {
877 let key = AuthSecretKey::new_falcon512_poseidon2();
878 let account = AccountBuilder::new([2u8; 32])
879 .account_type(AccountType::Private)
880 .with_component(AuthSingleSig::new(Approver::new(
881 key.public_key().to_commitment(),
882 AuthSchemeId::Falcon512Poseidon2,
883 )))
884 .with_component(BasicWallet)
885 .build_with_schema_commitment()
886 .expect("build_with_schema_commitment");
887
888 let commitment = account
889 .storage()
890 .get_item(AccountSchemaCommitment::schema_commitment_slot())
891 .expect("schema commitment slot");
892 assert_ne!(commitment, EMPTY_WORD);
893 }
894}
895
896#[cfg(test)]
897mod faucet_metadata_tests {
898 use miden_protocol::Felt;
899
900 use super::{FungibleFaucet, TokenSymbol, faucet_metadata_from_token_config};
901
902 /// Builds a token config slot value carrying the given decimals and the symbol "TST".
903 fn token_config(decimals: u32) -> [Felt; 4] {
904 [
905 Felt::from(0u32),
906 Felt::from(0u32),
907 Felt::from(decimals),
908 TokenSymbol::new("TST").unwrap().as_element(),
909 ]
910 }
911
912 #[test]
913 fn decodes_a_config_within_the_protocol_bounds() {
914 let metadata = faucet_metadata_from_token_config(token_config(8)).unwrap();
915
916 assert_eq!(metadata.symbol, "TST");
917 assert_eq!(metadata.decimals, 8);
918 }
919
920 #[test]
921 fn accepts_the_maximum_supported_decimals() {
922 let max = u32::from(FungibleFaucet::MAX_DECIMALS);
923 let metadata = faucet_metadata_from_token_config(token_config(max)).unwrap();
924
925 assert_eq!(metadata.decimals, FungibleFaucet::MAX_DECIMALS);
926 }
927
928 #[test]
929 fn rejects_decimals_above_the_maximum() {
930 let above_max = u32::from(FungibleFaucet::MAX_DECIMALS) + 1;
931
932 assert!(faucet_metadata_from_token_config(token_config(above_max)).is_none());
933 assert!(faucet_metadata_from_token_config(token_config(200)).is_none());
934 }
935
936 #[test]
937 fn rejects_decimals_that_do_not_fit_a_u8() {
938 assert!(faucet_metadata_from_token_config(token_config(300)).is_none());
939 }
940
941 #[test]
942 fn rejects_a_symbol_that_is_not_a_token_symbol() {
943 let mut config = token_config(8);
944 config[3] = Felt::from(0u32);
945
946 assert!(faucet_metadata_from_token_config(config).is_none());
947 }
948}