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