miden-client-cli 0.17.0-rc.1

The official command line client for interacting with the Miden network
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
use std::collections::{BTreeMap, BTreeSet};
use std::num::ParseIntError;
use std::path::PathBuf;

use miden_client::account::component::FungibleFaucet;
use miden_client::account::{AccountId, FaucetMetadata};
use miden_client::address::{Address, AddressId, NetworkId};
use miden_client::asset::{AssetAmount, FungibleAsset};
use miden_client::transaction::{ExecutedTransaction, InputNote};
use miden_client::vm::MIN_STACK_DEPTH;
use miden_client::{AssetError, Client, Felt, WORD_SIZE, Word};
use serde::Deserialize;

use super::{CLIENT_CONFIG_FILE_NAME, create_dynamic_table, get_account_with_id_prefix};
use crate::commands::account::DEFAULT_ACCOUNT_ID_KEY;
use crate::config::{CliConfig, get_global_miden_dir, get_local_miden_dir};
use crate::errors::CliError;

pub(crate) const SHARED_TOKEN_DOCUMENTATION: &str = "There are two accepted formats for the asset:
- `<AMOUNT>::<FAUCET_ID>` where `<AMOUNT>` is in the faucet base units.
- `<AMOUNT>::<TOKEN_SYMBOL>` where `<AMOUNT>` is a decimal number representing the quantity of
the token (specified to the precision allowed by the token's decimals), and `<TOKEN_SYMBOL>`
is a symbol tracked in the token symbol map file.

For example, `100::0xabcdef0123456789` or `1.23::TST`";

/// Returns a tracked Account ID matching a hex string or the default one defined in the Client
/// config.
pub(crate) async fn get_input_acc_id_by_prefix_or_default<AUTH>(
    client: &Client<AUTH>,
    account_id: Option<String>,
) -> Result<AccountId, CliError> {
    let account_id_str = if let Some(account_id_prefix) = account_id {
        account_id_prefix
    } else {
        client
            .get_setting(DEFAULT_ACCOUNT_ID_KEY.to_string())
            .await?
            .map(AccountId::to_hex)
            .ok_or(CliError::Input("No input account ID nor default account defined".to_string()))?
    };

    parse_account_id(client, &account_id_str).await
}

/// Parses a user provided account ID string and returns the corresponding `AccountId`.
///
/// `account_id` can fall into three categories:
///
/// - It's a hex prefix of an account ID of an account tracked by the client.
/// - It's a full hex account ID.
/// - It's a full bech32 address.
///
/// An address encodes the network it belongs to. An address from another network refers to another
/// chain, so it is rejected.
///
/// # Errors
///
/// - Will return a `IdPrefixFetchError` if the provided account ID string can't be parsed as an
///   `AccountId` and doesn't correspond to an account tracked by the client either.
/// - Will return a `CliError::Input` if the address belongs to another network.
pub(crate) async fn parse_account_id<AUTH>(
    client: &Client<AUTH>,
    account_id: &str,
) -> Result<AccountId, CliError> {
    if account_id.starts_with("0x") {
        if let Ok(account_id) = AccountId::from_hex(account_id) {
            return Ok(account_id);
        }

        Ok(get_account_with_id_prefix(client, account_id)
        .await
        .map_err(|_| CliError::Input(format!("Input account ID {account_id} is neither a valid Account ID nor a hex prefix of a known Account ID")))?
        .id())
    } else {
        let (address_network_id, address) = Address::decode(account_id)
            .map_err(|err| CliError::Input(format!("error parsing bech32 address: {err}")))?;
        validate_network_eq(&address_network_id, &client.network_id().await?)?;
        match address.id() {
            AddressId::AccountId(account_id_address) => Ok(account_id_address),
            _ => Err(CliError::Input(format!(
                "Input account ID {address:?} is not an ID based address"
            ))),
        }
    }
}

/// Rejects an address that belongs to a network other than the configured one.
pub(crate) fn validate_network_eq(
    address_network_id: &NetworkId,
    client_network_id: &NetworkId,
) -> Result<(), CliError> {
    if address_network_id != client_network_id {
        return Err(CliError::Input(format!(
            "Address network `{address_network_id}` does not match configured network `{client_network_id}`",
        )));
    }

    Ok(())
}

/// Returns true if the string can only be an account ID or an address, and not a token symbol.
fn is_account_identifier(asset: &str) -> bool {
    asset.starts_with("0x") || Address::decode(asset).is_ok()
}

/// Splits a `<ACCOUNT_ID>[:<PROCEDURE>]` target into its account ID and procedure parts.
///
/// Account IDs (hex or bech32) never contain a colon, so the first one separates the two. The
/// procedure is `None` when the target carries no colon; commands that require one reject that case
/// themselves.
pub(crate) fn split_procedure_target(target: &str) -> (&str, Option<&str>) {
    match target.split_once(':') {
        Some((account_id, procedure)) => (account_id, Some(procedure)),
        None => (target, None),
    }
}

/// Checks if either local or global configuration file exists.
pub(super) fn config_file_exists() -> Result<bool, CliError> {
    let local_miden_dir = get_local_miden_dir()?;
    if local_miden_dir.join(CLIENT_CONFIG_FILE_NAME).exists() {
        return Ok(true);
    }

    let global_miden_dir = get_global_miden_dir().map_err(|e| {
        CliError::Config(Box::new(e), "Failed to determine global config directory".to_string())
    })?;

    Ok(global_miden_dir.join(CLIENT_CONFIG_FILE_NAME).exists())
}

/// Returns the faucet metadata resolver using the config file.
pub fn load_faucet_metadata_resolver() -> Result<FaucetMetadataResolver, CliError> {
    let config = CliConfig::load()?;
    let network_id = config.rpc.endpoint.0.to_network_id();
    FaucetMetadataResolver::new(config.token_symbol_map_filepath, &network_id)
}

/// Prints the effects of an executed transaction: input notes, output notes, storage value changes,
/// storage map changes, vault changes, and the nonce change.
pub async fn print_executed_transaction<AUTH>(
    client: &Client<AUTH>,
    executed_tx: &ExecutedTransaction,
) -> Result<(), CliError> {
    println!("The transaction will have the following effects:\n");

    let patch = executed_tx.account_patch();

    // INPUT NOTES
    let input_note_ids = executed_tx.input_notes().iter().map(InputNote::id).collect::<Vec<_>>();
    if input_note_ids.is_empty() {
        println!("No notes will be consumed.");
    } else {
        println!("The following notes will be consumed:");
        for input_note_id in input_note_ids {
            println!("\t- {}", input_note_id.to_hex());
        }
    }
    println!();

    // OUTPUT NOTES
    let output_notes: Vec<_> = executed_tx.output_notes().iter().collect();
    if output_notes.is_empty() {
        println!("No notes will be created as a result of this transaction.");
    } else {
        println!("{} notes will be created as a result of this transaction:", output_notes.len());
        for note in &output_notes {
            println!("\t- {}", note.id().to_hex());
        }
    }
    println!();

    // STORAGE VALUES
    if patch.storage().values().next().is_some() {
        let mut table = create_dynamic_table(&["Storage Slot", "New Value"]);
        for (slot, value_patch) in patch.storage().values() {
            let new_value =
                value_patch.value().map_or_else(|| "removed".to_string(), |v| v.to_hex());
            table.add_row(vec![slot.to_string(), new_value]);
        }
        println!("Storage changes:");
        println!("{table}");
    } else {
        println!("Account Storage will not be changed.");
    }

    // STORAGE MAPS
    if patch.storage().maps().next().is_some() {
        let mut table = create_dynamic_table(&["Storage Slot", "Map Key", "New Value"]);
        for (slot, map_patch) in patch.storage().maps() {
            for (key, value) in map_patch.entries().into_iter().flat_map(|e| e.as_map().iter()) {
                table.add_row(vec![slot.to_string(), Word::from(*key).to_hex(), value.to_hex()]);
            }
        }
        println!("Storage map changes:");
        println!("{table}");
    }

    // VAULT
    //
    // The patch carries the new absolute value of each changed asset, cleared entries are listed as
    // removed.
    if patch.vault().is_empty() {
        println!("Account Vault will not be changed.");
    } else {
        let resolver = load_faucet_metadata_resolver()?;
        let mut table = create_dynamic_table(&["Asset Type", "Faucet ID", "New Amount"]);

        for asset in patch.vault().updated_assets() {
            match asset.as_fungible() {
                Some(fungible) => {
                    let (faucet_fmt, amount_fmt) =
                        resolver.format_fungible_asset(client, &fungible).await?;
                    table.add_row(vec!["Fungible Asset", &faucet_fmt, &amount_fmt]);
                },
                None => {
                    table.add_row(vec![
                        "Non Fungible Asset",
                        &asset.faucet_id().prefix().to_hex(),
                        "1",
                    ]);
                },
            }
        }

        for asset_id in patch.vault().removed_asset_ids() {
            table.add_row(vec![
                "Removed Asset",
                &asset_id.faucet_id().prefix().to_hex(),
                "removed",
            ]);
        }

        println!("Vault changes:");
        println!("{table}");
    }

    // NONCE
    match patch.final_nonce() {
        Some(nonce) => println!("New account nonce: {nonce}."),
        None => println!("Account nonce will not be changed."),
    }

    Ok(())
}

/// Prints the output stack from `execute_program`.
///
/// If `expected_results` is `Some(n)`, prints the top `n` values. If `None`, prints up to the last
/// non-zero value so trailing zero-padding is hidden.
pub fn print_executed_program_stack(
    stack: &[Felt; MIN_STACK_DEPTH],
    expected_results: Option<usize>,
) {
    let count = match expected_results {
        Some(n) => n,
        None => stack.iter().rposition(|v| v.as_canonical_u64() != 0).map_or(0, |pos| pos + 1),
    };

    match count {
        0 => println!("\nResult: 0"),
        1 => println!("\nResult: {}", stack[0]),
        _ => {
            println!("\nResult ({count} values):");
            for (i, val) in stack.iter().enumerate().take(count) {
                println!("  [{i}]: {val}");
            }
        },
    }
}

/// Prints the output stack as four 4-felt words with their hex encoding.
pub fn print_executed_program_stack_hex_words(stack: &[Felt; MIN_STACK_DEPTH]) {
    let last_word_start = MIN_STACK_DEPTH - WORD_SIZE;
    println!("Output stack:");
    for word_idx in (0..MIN_STACK_DEPTH).step_by(WORD_SIZE) {
        let word_idx_end = word_idx + WORD_SIZE - 1;
        let prefix = if word_idx == last_word_start {
            "└──"
        } else {
            "├──"
        };
        let word = [stack[word_idx], stack[word_idx + 1], stack[word_idx + 2], stack[word_idx + 3]];
        println!(
            "{prefix} {word_idx:2} - {word_idx_end:2}: {word:?} ({})",
            Word::from(word).to_hex()
        );
    }
}

// TOKEN AMOUNT CONVERSION
// ================================================================================================

/// Converts an amount in the faucet base units to the token's decimals.
///
/// This is meant for display purposes only.
pub(crate) fn base_units_to_tokens(units: AssetAmount, decimals: u8) -> String {
    let units_str = units.as_u64().to_string();
    let len = units_str.len();

    if decimals == 0 {
        return units_str;
    }

    if decimals as usize >= len {
        // Handle cases where the number of decimals is greater than the length of units
        "0.".to_owned() + &"0".repeat(decimals as usize - len) + &units_str
    } else {
        // Insert the decimal point at the correct position
        let integer_part = &units_str[..len - decimals as usize];
        let fractional_part = &units_str[len - decimals as usize..];
        format!("{integer_part}.{fractional_part}")
    }
}

/// Errors that can occur when parsing a token represented as a decimal number in a string into base
/// units.
#[derive(thiserror::Error, Debug)]
pub(crate) enum TokenParseError {
    #[error("Number of decimals {0} must be less than or equal to {max_decimals}", max_decimals = FungibleFaucet::MAX_DECIMALS)]
    MaxDecimals(u8),
    #[error("More than one decimal point")]
    MultipleDecimalPoints,
    #[error("Failed to parse u64")]
    ParseU64(#[source] ParseIntError),
    #[error("Amount has more than {0} decimal places")]
    TooManyDecimals(u8),
    #[error("Amount is not a valid asset amount")]
    InvalidAmount(#[source] AssetError),
}

/// Converts a decimal number, represented as a string, into an integer by shifting the decimal
/// point to the right by a specified number of decimal places.
pub(crate) fn tokens_to_base_units(
    decimal_str: &str,
    n_decimals: u8,
) -> Result<AssetAmount, TokenParseError> {
    if n_decimals > FungibleFaucet::MAX_DECIMALS {
        return Err(TokenParseError::MaxDecimals(n_decimals));
    }

    // Split the string on the decimal point
    let parts: Vec<&str> = decimal_str.split('.').collect();

    if parts.len() > 2 {
        return Err(TokenParseError::MultipleDecimalPoints);
    }

    // Validate that the parts are valid numbers
    for part in &parts {
        part.parse::<u64>().map_err(TokenParseError::ParseU64)?;
    }

    let integer_part = parts[0];

    // Trailing zeros carry no value and would change the decimal count.
    let mut fractional_part = if parts.len() > 1 {
        parts[1].trim_end_matches('0').to_string()
    } else {
        String::new()
    };

    // Check if the fractional part has more than N decimals
    if fractional_part.len() > n_decimals.into() {
        return Err(TokenParseError::TooManyDecimals(n_decimals));
    }

    // Add extra zeros if the fractional part is shorter than N decimals
    while fractional_part.len() < n_decimals.into() {
        fractional_part.push('0');
    }

    // Combine the integer and padded fractional part
    let combined = format!("{}{}", integer_part, &fractional_part[0..n_decimals.into()]);

    // Convert the combined string to an integer
    let units = combined.parse::<u64>().map_err(TokenParseError::ParseU64)?;

    AssetAmount::new(units).map_err(TokenParseError::InvalidAmount)
}

// FAUCET METADATA RESOLVER
// ================================================================================================

/// Raw TOML row as written by the user.
#[derive(Debug, Deserialize)]
struct RawFaucetEntry {
    pub address: String,
    pub decimals: u8,
}

/// Parsed entry — the address string has been normalized into a typed `AccountId`.
#[derive(Debug, Clone)]
struct FaucetTomlEntry {
    pub account_id: AccountId,
    pub decimals: u8,
}

/// Resolves faucet display metadata (symbol + decimals) for a given faucet `AccountId`.
///
/// Lookup walks three sources in priority order:
///
/// 1. The user's TOML symbol map (bech32 `address`).
/// 2. The client's settings store, populated from previous RPC fetches.
/// 3. A fresh RPC fetch from the network. Successful fetches are persisted back to the settings
///    store.
#[derive(Debug)]
pub struct FaucetMetadataResolver {
    toml: BTreeMap<String, FaucetTomlEntry>,
}

impl FaucetMetadataResolver {
    /// Creates a new instance of the [`FaucetMetadataResolver`] by loading the token symbol map
    /// file from the specified `token_symbol_map_filepath`. If the file doesn't exist, an empty map
    /// is created.
    ///
    /// Every entry must hold an address of the `network_id` network. An entry of another network
    /// names a faucet on another chain, so it is rejected when the map is loaded.
    pub fn new(
        token_symbol_map_filepath: PathBuf,
        network_id: &NetworkId,
    ) -> Result<Self, CliError> {
        let raw: BTreeMap<String, RawFaucetEntry> =
            match std::fs::read_to_string(token_symbol_map_filepath) {
                Ok(content) => toml::from_str(&content).map_err(|err| {
                    CliError::Config(
                        Box::new(err),
                        "Failed to parse token_symbol_map file".to_string(),
                    )
                })?,
                Err(err) if err.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(),
                Err(err) => {
                    return Err(CliError::Config(
                        Box::new(err),
                        "Failed to read token_symbol_map file".to_string(),
                    ));
                },
            };

        let mut parsed: BTreeMap<String, FaucetTomlEntry> = BTreeMap::new();
        let mut seen: BTreeSet<AccountId> = BTreeSet::new();
        for (symbol, entry) in raw {
            let account_id = parse_address(&entry.address, network_id).map_err(|err| {
                CliError::Config(
                    err.into(),
                    format!("Failed to parse `address` for token symbol {symbol}"),
                )
            })?;
            if !seen.insert(account_id) {
                return Err(CliError::Config(
                    format!(
                        "Faucet ID {} appears more than once in the token symbol map",
                        account_id.to_hex(),
                    )
                    .into(),
                    "Failed to parse token_symbol_map file".to_string(),
                ));
            }
            parsed.insert(symbol, FaucetTomlEntry { account_id, decimals: entry.decimals });
        }

        Ok(Self { toml: parsed })
    }

    /// Looks up `(symbol, decimals)` for a faucet using only local sources: the TOML map and the
    /// settings store. Returns `None` without performing any network request.
    pub async fn resolve_local<AUTH>(
        &self,
        client: &Client<AUTH>,
        faucet_id: AccountId,
    ) -> Result<Option<FaucetMetadata>, CliError> {
        // 1) TOML
        if let Some((symbol, decimals)) = self.lookup_toml(&faucet_id) {
            return Ok(Some(FaucetMetadata { symbol, decimals }));
        }
        // 2) settings store
        let setting_key = faucet_metadata_setting_key(faucet_id);
        Ok(client.get_setting::<FaucetMetadata>(setting_key).await?)
    }

    /// Looks up `(symbol, decimals)` for a faucet, walking TOML → settings store → RPC fetch. On
    /// RPC success, the result is persisted to the settings store.
    pub async fn resolve<AUTH>(
        &self,
        client: &Client<AUTH>,
        faucet_id: AccountId,
    ) -> Result<Option<FaucetMetadata>, CliError> {
        // 1) & 2) local sources (TOML + settings store)
        if let Some(meta) = self.resolve_local(client, faucet_id).await? {
            return Ok(Some(meta));
        }
        // 3) RPC fetch
        let setting_key = faucet_metadata_setting_key(faucet_id);
        match client.fetch_remote_token_metadata(faucet_id).await {
            Ok(Some(meta)) => {
                if let Err(err) = client.set_setting(setting_key, meta.clone()).await {
                    tracing::warn!(
                        "failed to persist faucet metadata for {}: {err}",
                        faucet_id.to_hex(),
                    );
                }
                Ok(Some(meta))
            },
            Ok(None) => Ok(None),
            Err(err) => {
                tracing::warn!("failed to fetch faucet metadata for {}: {err}", faucet_id.to_hex());
                Ok(None)
            },
        }
    }

    /// Formats a fungible asset using [`Self::resolve`]. On miss, returns `(<bech32 faucet
    /// address>, <base-unit amount>)`.
    pub async fn format_fungible_asset<AUTH>(
        &self,
        client: &Client<AUTH>,
        asset: &FungibleAsset,
    ) -> Result<(String, String), CliError> {
        if let Some(meta) = self.resolve(client, asset.faucet_id()).await? {
            return Ok((meta.symbol, base_units_to_tokens(asset.amount(), meta.decimals)));
        }
        let network_id = client.network_id().await?;
        let address_str = Address::new(asset.faucet_id()).encode(network_id);
        Ok((address_str, asset.amount().to_string()))
    }

    /// Parses a string representing a [`FungibleAsset`]. There are two accepted formats for the
    /// string:
    /// - `<AMOUNT>::<FAUCET_ID>` where `<AMOUNT>` is in the faucet base units and `<FAUCET_ID>` is
    ///   the faucet's account ID.
    /// - `<AMOUNT>::<FAUCET_ADDRESS>` where `<AMOUNT>` is in the faucet base units and
    ///   `<FAUCET_ADDRESS>` is the faucet address.
    /// - `<AMOUNT>::<TOKEN_SYMBOL>` where `<AMOUNT>` is a decimal number representing the quantity
    ///   of the token (specified to the precision allowed by the token's decimals), and
    ///   `<TOKEN_SYMBOL>` is a symbol tracked in the token symbol map file.
    ///
    /// Some examples of valid `arg` values are `100::mlcl1qru2e5yvx40ndgqqqzusrryr0ucyd0uj`,
    /// `100::0xabcdef0123456789` and `1.23::TST`.
    ///
    /// # Errors
    ///
    /// Will return an error if:
    /// - The provided `arg` doesn't match one of the expected formats.
    /// - A faucet ID was provided but the amount isn't in base units.
    /// - The amount has more than the allowed number of decimals.
    /// - The token symbol isn't present in the token symbol map file.
    pub async fn parse_fungible_asset<AUTH>(
        &self,
        client: &Client<AUTH>,
        arg: &str,
    ) -> Result<FungibleAsset, CliError> {
        let (amount, asset) = arg.split_once("::").ok_or(CliError::Parse(
            "separator `::` not found".into(),
            "Failed to parse amount and asset".to_string(),
        ))?;
        let (faucet_id, amount) = match parse_account_id(client, asset).await {
            Ok(faucet_id) => {
                let amount = amount.parse::<u64>().map_err(|err| {
                    CliError::Parse(err.into(), "Failed to parse u64".to_string())
                })?;
                (faucet_id, amount)
            },
            // A token symbol is never an account ID or an address, so the token symbol map cannot
            // resolve this asset. Report why the account ID is invalid.
            Err(err) if is_account_identifier(asset) => return Err(err),
            Err(_) => {
                let entry = self.toml.get(asset).ok_or(CliError::Config(
                    "Token symbol not found in the map file".to_string().into(),
                    asset.to_string(),
                ))?;
                let amount = tokens_to_base_units(amount, entry.decimals).map_err(|err| {
                    CliError::Parse(err.into(), "Failed to parse tokens to base units".to_string())
                })?;
                (entry.account_id, amount.as_u64())
            },
        };

        FungibleAsset::new(faucet_id, amount).map_err(CliError::Asset)
    }

    fn lookup_toml(&self, faucet_id: &AccountId) -> Option<(String, u8)> {
        self.toml
            .iter()
            .find(|(_, entry)| &entry.account_id == faucet_id)
            .map(|(symbol, entry)| (symbol.clone(), entry.decimals))
    }
}

/// Settings key prefix under which faucet display metadata is persisted.
const FAUCET_METADATA_SETTING_PREFIX: &str = "faucet_metadata:";

/// Returns the settings-store key under which the metadata for `faucet_id` is persisted.
fn faucet_metadata_setting_key(faucet_id: AccountId) -> String {
    format!("{FAUCET_METADATA_SETTING_PREFIX}{}", faucet_id.to_hex())
}

/// Parses a bech32 address from the token symbol map and checks that it belongs to `network_id`.
fn parse_address(address_str: &str, network_id: &NetworkId) -> Result<AccountId, String> {
    let (address_network_id, address) = Address::decode(address_str)
        .map_err(|err| format!("`{address_str}` is not a valid bech32 address: {err}"))?;
    validate_network_eq(&address_network_id, network_id).map_err(|err| err.to_string())?;
    if let AddressId::AccountId(account_id) = address.id() {
        return Ok(account_id);
    }
    Err(format!("address `{address_str}` does not encode an account ID"))
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeMap;

    use miden_client::account::AccountId;
    use miden_client::address::{Address, NetworkId};
    use miden_client::asset::AssetAmount;
    use miden_client::testing::account_id::ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET;

    use super::{
        FaucetMetadataResolver,
        RawFaucetEntry,
        TokenParseError,
        base_units_to_tokens,
        tokens_to_base_units,
    };

    fn amount(units: u64) -> AssetAmount {
        AssetAmount::new(units).unwrap()
    }

    #[test]
    fn convert_tokens_to_base_units() {
        assert_eq!(tokens_to_base_units("9223372.034707292160", 12).unwrap(), AssetAmount::MAX);
        assert_eq!(tokens_to_base_units("7531.2468", 8).unwrap(), amount(753_124_680_000));
        assert_eq!(tokens_to_base_units("7531.2468", 4).unwrap(), amount(75_312_468));
        assert_eq!(tokens_to_base_units("0", 3).unwrap(), AssetAmount::ZERO);
        assert_eq!(tokens_to_base_units("1234", 8).unwrap(), amount(123_400_000_000));
        assert_eq!(tokens_to_base_units("1", 0).unwrap(), amount(1));
        assert!(matches!(
            tokens_to_base_units("1.1", 0),
            Err(TokenParseError::TooManyDecimals(0))
        ),);
        assert!(matches!(
            tokens_to_base_units("18446744.073709551615", 11),
            Err(TokenParseError::TooManyDecimals(11))
        ),);
        assert!(matches!(tokens_to_base_units("123u3.23", 4), Err(TokenParseError::ParseU64(_))),);
        assert!(matches!(tokens_to_base_units("2.k3", 4), Err(TokenParseError::ParseU64(_))),);
        assert_eq!(tokens_to_base_units("12.345000", 4).unwrap(), amount(123_450));
        assert!(tokens_to_base_units("0.0001.00000001", 12).is_err());
        // Parses as a u64 but exceeds the maximum representable asset amount.
        assert!(matches!(
            tokens_to_base_units("18446744.073709551615", 12),
            Err(TokenParseError::InvalidAmount(_))
        ),);
    }

    #[test]
    fn convert_base_units_to_tokens() {
        assert_eq!(base_units_to_tokens(AssetAmount::MAX, 12), "9223372.034707292160");
        assert_eq!(base_units_to_tokens(amount(753_124_680_000), 8), "7531.24680000");
        assert_eq!(base_units_to_tokens(amount(75_312_468), 4), "7531.2468");
        assert_eq!(base_units_to_tokens(amount(75_312_468), 0), "75312468");
    }

    #[test]
    fn raw_faucet_entry_accepts_address_field() {
        let entries: BTreeMap<String, RawFaucetEntry> = toml::from_str(
            r#"BTC = { address = "mlcl1qru2e5yvx40ndgqqqzusrryr0ucyd0uj", decimals = 8 }"#,
        )
        .unwrap();

        assert_eq!(entries["BTC"].address, "mlcl1qru2e5yvx40ndgqqqzusrryr0ucyd0uj");
        assert_eq!(entries["BTC"].decimals, 8);
    }

    /// The token symbol map names faucets by address. An address of another network names a faucet
    /// on another chain, so the map must not load.
    #[test]
    fn faucet_metadata_resolver_rejects_address_from_another_network() {
        let faucet_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET).unwrap();
        let address = Address::new(faucet_id).encode(NetworkId::Testnet);
        let path = std::env::temp_dir().join("token_symbol_map_network_mismatch.toml");
        std::fs::write(&path, format!(r#"BTC = {{ address = "{address}", decimals = 8 }}"#))
            .unwrap();

        let result = FaucetMetadataResolver::new(path.clone(), &NetworkId::Mainnet);
        std::fs::remove_file(&path).unwrap();

        let err = result.unwrap_err();
        let source = std::error::Error::source(&err).unwrap().to_string();
        assert!(
            source.contains("does not match configured network"),
            "unexpected error: {source}"
        );
    }

    #[test]
    fn raw_faucet_entry_rejects_id_field() {
        let result = toml::from_str::<BTreeMap<String, RawFaucetEntry>>(
            r#"BTC = { id = "mlcl1qru2e5yvx40ndgqqqzusrryr0ucyd0uj", decimals = 8 }"#,
        );

        assert!(result.is_err());
    }
}