Skip to main content

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}