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`";
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
}
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"
))),
}
}
}
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(())
}
fn is_account_identifier(asset: &str) -> bool {
asset.starts_with("0x") || Address::decode(asset).is_ok()
}
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),
}
}
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())
}
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)
}
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();
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!();
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!();
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.");
}
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}");
}
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}");
}
match patch.final_nonce() {
Some(nonce) => println!("New account nonce: {nonce}."),
None => println!("Account nonce will not be changed."),
}
Ok(())
}
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}");
}
},
}
}
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()
);
}
}
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 {
"0.".to_owned() + &"0".repeat(decimals as usize - len) + &units_str
} else {
let integer_part = &units_str[..len - decimals as usize];
let fractional_part = &units_str[len - decimals as usize..];
format!("{integer_part}.{fractional_part}")
}
}
#[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),
}
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));
}
let parts: Vec<&str> = decimal_str.split('.').collect();
if parts.len() > 2 {
return Err(TokenParseError::MultipleDecimalPoints);
}
for part in &parts {
part.parse::<u64>().map_err(TokenParseError::ParseU64)?;
}
let integer_part = parts[0];
let mut fractional_part = if parts.len() > 1 {
parts[1].trim_end_matches('0').to_string()
} else {
String::new()
};
if fractional_part.len() > n_decimals.into() {
return Err(TokenParseError::TooManyDecimals(n_decimals));
}
while fractional_part.len() < n_decimals.into() {
fractional_part.push('0');
}
let combined = format!("{}{}", integer_part, &fractional_part[0..n_decimals.into()]);
let units = combined.parse::<u64>().map_err(TokenParseError::ParseU64)?;
AssetAmount::new(units).map_err(TokenParseError::InvalidAmount)
}
#[derive(Debug, Deserialize)]
struct RawFaucetEntry {
pub address: String,
pub decimals: u8,
}
#[derive(Debug, Clone)]
struct FaucetTomlEntry {
pub account_id: AccountId,
pub decimals: u8,
}
#[derive(Debug)]
pub struct FaucetMetadataResolver {
toml: BTreeMap<String, FaucetTomlEntry>,
}
impl FaucetMetadataResolver {
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 })
}
pub async fn resolve_local<AUTH>(
&self,
client: &Client<AUTH>,
faucet_id: AccountId,
) -> Result<Option<FaucetMetadata>, CliError> {
if let Some((symbol, decimals)) = self.lookup_toml(&faucet_id) {
return Ok(Some(FaucetMetadata { symbol, decimals }));
}
let setting_key = faucet_metadata_setting_key(faucet_id);
Ok(client.get_setting::<FaucetMetadata>(setting_key).await?)
}
pub async fn resolve<AUTH>(
&self,
client: &Client<AUTH>,
faucet_id: AccountId,
) -> Result<Option<FaucetMetadata>, CliError> {
if let Some(meta) = self.resolve_local(client, faucet_id).await? {
return Ok(Some(meta));
}
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)
},
}
}
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()))
}
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)
},
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))
}
}
const FAUCET_METADATA_SETTING_PREFIX: &str = "faucet_metadata:";
fn faucet_metadata_setting_key(faucet_id: AccountId) -> String {
format!("{FAUCET_METADATA_SETTING_PREFIX}{}", faucet_id.to_hex())
}
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());
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);
}
#[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());
}
}