diff --git a/CHANGELOG.md b/CHANGELOG.md index 63f6cd2f11..ce4cce3ad6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ * [BREAKING][rust] The workspace MSRV and the pinned toolchain are raised to Rust 1.98. Building the client now requires a 1.98 or newer compiler. The declared MSRV of 1.96 could not build the locked dependency graph, whose Miden packages require 1.96.1 ([#2471](https://github.com/0xMiden/rust-sdk/issues/2471)). +### Enhancements + +* [FEATURE][cli] `call` now takes an `account-id` argument as a bech32 address as well as a hex id, matching the spellings the rest of the CLI accepts for an account. This applies to the `account-id` type itself and to the faucet half of an `asset` token ([#2179](https://github.com/0xMiden/rust-sdk/pull/2179)). + ## 0.16.0 (2026-09-07) ### Breaking Changes diff --git a/bin/miden-cli/src/codecs/account_id.rs b/bin/miden-cli/src/codecs/account_id.rs index 2242be8d69..b6c02d8e3b 100644 --- a/bin/miden-cli/src/codecs/account_id.rs +++ b/bin/miden-cli/src/codecs/account_id.rs @@ -1,8 +1,8 @@ //! The `account-id` codec for typed `call` rendering. //! //! `account-id` felts are validated with protocol-level rules, so the CLI registers this codec (via -//! [`TypedProcInfo::with_scalar_codec`]) to encode one hex token into the two stack felts the -//! procedure expects and render the returned felts back as `account-id(0x..)`. +//! [`TypedProcInfo::with_scalar_codec`]) to encode one account ID token, hex or bech32, into the +//! two stack felts the procedure expects and render the returned felts back as `account-id(0x..)`. //! //! [`TypedProcInfo::with_scalar_codec`]: miden_client::vm::typed::TypedProcInfo::with_scalar_codec @@ -10,13 +10,9 @@ use miden_client::Felt; use miden_client::account::AccountId; use miden_client::vm::typed::{MIDEN_CORE_TYPES, TypedError, WitScalarCodec}; -use crate::codecs::invalid_scalar; +use crate::codecs::{ACCOUNT_ID_WIT_NAME, parse_account_id_token}; -/// Bare WIT type name the typed encoder matches this codec against, regardless of the package and -/// version in the full type name (e.g. `miden:base/core-types@1.0.0/account-id`). -const ACCOUNT_ID_WIT_NAME: &str = "account-id"; - -/// Encodes and renders the WIT `account-id` type: one hex token, two stack felts. +/// Encodes and renders the WIT `account-id` type: one token, hex or bech32, two stack felts. pub struct AccountIdCodec; impl WitScalarCodec for AccountIdCodec { @@ -29,8 +25,7 @@ impl WitScalarCodec for AccountIdCodec { } fn encode(&self, token: &str) -> Result, TypedError> { - let id = AccountId::from_hex(token) - .map_err(|err| invalid_scalar(ACCOUNT_ID_WIT_NAME, token, &err))?; + let id = parse_account_id_token(token)?; Ok(vec![id.prefix().into(), id.suffix()]) } @@ -55,16 +50,21 @@ impl WitScalarCodec for AccountIdCodec { #[cfg(test)] mod tests { + use miden_client::address::{Address, NetworkId}; + use super::*; + /// A valid account ID, used in both spellings. + const HEX_ID: &str = "0xaa0000000000bb110000cc000000dd"; + #[test] fn account_id_one_hex_token_roundtrips() { let codec = AccountIdCodec; - let hex = "0xaa0000000000bb110000cc000000dd"; + let hex = HEX_ID; + let id = AccountId::from_hex(hex).unwrap(); // Compared against the felts the account id itself carries: a round-trip alone would also // pass if `encode` and `decode` had the two fields the same way around. - let id = AccountId::from_hex(hex).unwrap(); let expected = [Felt::from(id.prefix()), id.suffix()]; assert_eq!(codec.encode(hex).unwrap(), expected); @@ -78,8 +78,34 @@ mod tests { } #[test] - fn invalid_account_id_token_is_rejected() { - let err = AccountIdCodec.encode("not-hex").unwrap_err(); - assert!(matches!(err, TypedError::InvalidScalar { .. })); + fn a_bech32_token_encodes_to_the_same_felts_as_its_hex_spelling() { + // `call` resolves its target through `parse_account_id`, which takes bech32, so an argument + // of the same type has to reach the same account from either spelling. + let id = AccountId::from_hex(HEX_ID).unwrap(); + let bech32 = Address::new(id).encode(NetworkId::Testnet); + + assert_eq!( + AccountIdCodec.encode(&bech32).unwrap(), + AccountIdCodec.encode(&id.to_hex()).unwrap() + ); + } + + #[test] + fn an_invalid_account_id_token_is_rejected() { + // A `0x` token is read as hex and anything else as a bech32 address, so a token that is + // neither has to be rejected under both readings. + let valid_bech32 = + Address::new(AccountId::from_hex(HEX_ID).unwrap()).encode(NetworkId::Testnet); + let tokens = [ + "not-hex", + "0xnothex", + // Valid bech32 up to the last character, which breaks its checksum. + &valid_bech32[..valid_bech32.len() - 1], + ]; + + for token in tokens { + let err = AccountIdCodec.encode(token).unwrap_err(); + assert!(matches!(err, TypedError::InvalidScalar { .. }), "token '{token}' accepted"); + } } } diff --git a/bin/miden-cli/src/codecs/asset.rs b/bin/miden-cli/src/codecs/asset.rs index 7adc51647e..d51fe4168f 100644 --- a/bin/miden-cli/src/codecs/asset.rs +++ b/bin/miden-cli/src/codecs/asset.rs @@ -4,15 +4,14 @@ //! [`Asset::as_elements`]. The CLI registers this codec so an asset argument can be given as a //! single `::` token instead of two raw word hexes, and so a returned asset //! renders back the same way. The token form matches the one the rest of the CLI takes for fungible -//! assets, minus the token symbol and address spellings: resolving those needs the client, and a -//! codec only sees the text. +//! assets, minus the token symbol: resolving one needs the client's faucet metadata, and a codec +//! only sees the text. -use miden_client::account::AccountId; use miden_client::asset::{Asset, FungibleAsset}; use miden_client::vm::typed::{MIDEN_CORE_TYPES, TypedError, WitScalarCodec}; use miden_client::{Felt, Word}; -use crate::codecs::invalid_scalar; +use crate::codecs::{invalid_scalar, parse_account_id_token}; /// Bare WIT type name the typed encoder matches this codec against (e.g. the leaf of /// `miden:base/core-types@1.0.0/asset`). @@ -38,8 +37,9 @@ impl WitScalarCodec for AssetCodec { let amount: u64 = amount.parse().map_err(|e: core::num::ParseIntError| { invalid_scalar(ASSET_WIT_NAME, token, &format!("invalid amount: {e}")) })?; - let faucet_id = - AccountId::from_hex(faucet).map_err(|e| invalid_scalar(ASSET_WIT_NAME, token, &e))?; + // The faucet takes the same spellings as any other account ID argument, and reports under + // its own type: a bad faucet is a bad account ID, not a bad asset. + let faucet_id = parse_account_id_token(faucet)?; let asset: Asset = FungibleAsset::new(faucet_id, amount) .map_err(|e| invalid_scalar(ASSET_WIT_NAME, token, &e))? .into(); @@ -70,6 +70,8 @@ fn malformed_asset(reason: &'static str) -> TypedError { #[cfg(test)] mod tests { + use miden_client::account::AccountId; + use miden_client::address::{Address, NetworkId}; use miden_client::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET; use super::*; @@ -92,6 +94,17 @@ mod tests { assert_eq!(AssetCodec.decode(&felts).unwrap(), format!("asset({token})")); } + #[test] + fn a_bech32_faucet_encodes_to_the_same_felts_as_its_hex_spelling() { + let id = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).unwrap(); + let bech32 = format!("100::{}", Address::new(id).encode(NetworkId::Testnet)); + + assert_eq!( + AssetCodec.encode(&bech32).unwrap(), + AssetCodec.encode(&faucet_token(100)).unwrap() + ); + } + #[test] fn a_token_with_a_single_colon_is_rejected() { let err = AssetCodec.encode(&faucet_token(100).replace("::", ":")).unwrap_err(); diff --git a/bin/miden-cli/src/codecs/mod.rs b/bin/miden-cli/src/codecs/mod.rs index 561fc3451d..ed1aa7dae1 100644 --- a/bin/miden-cli/src/codecs/mod.rs +++ b/bin/miden-cli/src/codecs/mod.rs @@ -5,8 +5,8 @@ //! it ships the two codecs it can write itself, `word` and `felt`, and leaves the trait for the //! rest. //! -//! `account-id` and `asset` are the rest. `AccountId::from_hex` says what a valid id is, and -//! `Asset` says what a valid asset is, so both codecs live on this side. +//! `account-id` and `asset` are the rest. `AccountId` says what a valid id is, and `Asset` says +//! what a valid asset is, so both codecs live on this side. //! //! [`with_cli_codecs`] registers them in one place, so the commands that render typed signatures do //! not know the individual types. @@ -14,6 +14,8 @@ //! [`WitScalarCodec`]: miden_client::vm::typed::WitScalarCodec //! [`TypedProcInfo`]: miden_client::vm::typed::TypedProcInfo +use miden_client::account::AccountId; +use miden_client::address::{Address, AddressId}; use miden_client::vm::typed::{TypedError, TypedProcInfo}; mod account_id; @@ -22,6 +24,38 @@ mod asset; pub use account_id::AccountIdCodec; pub use asset::AssetCodec; +/// Bare WIT type name of the core `account-id` type, regardless of the package and version in the +/// full type name (e.g. `miden:base/core-types@1.0.0/account-id`). It is both what the typed +/// encoder matches [`AccountIdCodec`] against and the label every account ID token is read under, +/// including the faucet half of an `asset`. +pub(crate) const ACCOUNT_ID_WIT_NAME: &str = "account-id"; + +/// Reads an account ID written either way the rest of the CLI takes one: as full hex, or as a +/// bech32 address naming an account ID. Both spellings reach the CLI in one command line, since +/// `call` resolves its target through [`parse_account_id`], so an argument that takes an account ID +/// has to accept the same two. +/// +/// [`parse_account_id`]: crate::utils::parse_account_id +pub(crate) fn parse_account_id_token(token: &str) -> Result { + // The prefix picks the spelling, so a mistyped hex ID is reported as bad hex rather than as a + // bad bech32 address. + if token.starts_with("0x") { + return AccountId::from_hex(token) + .map_err(|err| invalid_scalar(ACCOUNT_ID_WIT_NAME, token, &err)); + } + + let (_, address) = + Address::decode(token).map_err(|err| invalid_scalar(ACCOUNT_ID_WIT_NAME, token, &err))?; + match address.id() { + AddressId::AccountId(id) => Ok(id), + _ => Err(invalid_scalar( + ACCOUNT_ID_WIT_NAME, + token, + "the address doesn't name an account ID", + )), + } +} + /// Builds the `InvalidScalar` error a codec returns when it can't parse `token`. Shared so every /// codec reports the same error shape from one place. pub(crate) fn invalid_scalar( diff --git a/bin/miden-cli/src/commands/call.rs b/bin/miden-cli/src/commands/call.rs index 3a4451afd1..7c36580141 100644 --- a/bin/miden-cli/src/commands/call.rs +++ b/bin/miden-cli/src/commands/call.rs @@ -152,7 +152,7 @@ impl CallCmd { } // The output stack only holds MIN_STACK_DEPTH felts. - if let Some(n) = call_code.result_felts + if let Some(n) = call_code.typed.as_ref().and_then(TypedProcInfo::output_felt_count) && n > MIN_STACK_DEPTH { return Err(CliError::InvalidArgument(format!( @@ -196,11 +196,11 @@ impl CallCmd { _ => None, }; - let (args, result_felts) = if let Some(typed) = &typed { + let args = if let Some(typed) = &typed { println!("Signature: {typed}\n"); // Checks the argument count as well, and names the procedure and both counts when it is // wrong, so there is nothing to check here first. - (typed.encode_args(&self.args)?, typed.output_felt_count()) + typed.encode_args(&self.args)? } else { println!("Signature: {name}(...) [no type info]\n"); println!( @@ -208,7 +208,7 @@ impl CallCmd { argument is passed as one field element, the argument count is not checked, and \ the result is printed as a stack dump." ); - (encode_raw_args(&self.args)?, None) + encode_raw_args(&self.args)? }; // The account's code is loaded from the client's store at VM runtime, so the library @@ -216,13 +216,7 @@ impl CallCmd { // to resolve `call.` to a known procedure — otherwise it emits a "phantom target" // warning. Dynamic linking provides that resolution without embedding the library bytes. let builder = client.code_builder().with_dynamically_linked_package(&package)?; - Ok(CallCode { - builder, - digest, - args, - typed, - result_felts, - }) + Ok(CallCode { builder, digest, args, typed }) } /// Resolves the call from a hex digest. Nothing describes the procedure, so each argument is @@ -249,7 +243,6 @@ impl CallCmd { digest, args: encode_raw_args(&self.args)?, typed: None, - result_felts: None, }) } } @@ -257,36 +250,33 @@ impl CallCmd { // HELPERS // ================================================================================================ -/// Resolved call code: the linked builder, the procedure digest, the encoded arguments, the type -/// information used to render the result when the package describes it, and the stack width of the -/// results when known. +/// Resolved call code: the linked builder, the procedure digest, the encoded arguments, and the +/// type information the arguments were encoded against, which the result is rendered with as well. struct CallCode { builder: CodeBuilder, digest: Word, args: Vec, typed: Option, - result_felts: Option, } /// Prints the values the procedure returned, rendered as their declared types when the package /// describes them and as raw stack felts otherwise. -fn print_call_result( - output_stack: &[Felt; MIN_STACK_DEPTH], - typed: Option<&TypedProcInfo>, -) -> Result<(), CliError> { - match typed { - // A procedure that returns nothing has no result to show; anything else that cannot be - // rendered is an error, since a raw stack dump would hide that the result is not a valid - // value of its type. - Some(typed) => { - if let Some(rendered) = typed.decode_result(output_stack.as_slice())? { - println!("Result: {rendered}"); - } - }, +fn print_call_result(output_stack: &[Felt; MIN_STACK_DEPTH], typed: Option<&TypedProcInfo>) { + let Some(typed) = typed else { // Nothing says where the results end, so the dump runs to the last non-zero value. - None => print_executed_program_stack(output_stack, None), + print_executed_program_stack(output_stack, None); + return; + }; + + match typed.decode_result(output_stack.as_slice()) { + // A procedure that returns nothing has no result to show. + Ok(None) => {}, + Ok(Some(rendered)) => println!("Result: {rendered}"), + Err(err) => { + println!("The result is not a valid value of the procedure's return type: {err}"); + print_executed_program_stack(output_stack, typed.output_felt_count()); + }, } - Ok(()) } /// Runs a remote call via FPI. FPI cannot mutate the foreign account, so there is no state delta to @@ -299,7 +289,7 @@ async fn run_remote_call( call_code: CallCode, advice_entries: Vec<(Word, Vec)>, ) -> Result<(), CliError> { - let CallCode { builder, digest, args, typed, .. } = call_code; + let CallCode { builder, digest, args, typed } = call_code; let tx_script = build_fpi_script(builder, target_id, digest, &args).map_err(|err| match err { TransactionRequestError::ForeignProcedureInputsTooLong { max, actual } => { @@ -322,7 +312,7 @@ async fn run_remote_call( ) .await?; - print_call_result(&output_stack, typed.as_ref())?; + print_call_result(&output_stack, typed.as_ref()); println!("\nA call on an account read from the network can only read it; no state delta."); Ok(()) @@ -336,7 +326,7 @@ async fn run_local_call( call_code: CallCode, advice_entries: Vec<(Word, Vec)>, ) -> Result<(), CliError> { - let CallCode { builder, digest, args, typed, .. } = call_code; + let CallCode { builder, digest, args, typed } = call_code; let tx_script = generate_tx_script(builder, &digest, &args)?; // 1) Read-only execution to get return values. @@ -348,7 +338,7 @@ async fn run_local_call( BTreeMap::new(), ) .await?; - print_call_result(&output_stack, typed.as_ref())?; + print_call_result(&output_stack, typed.as_ref()); // 2) Transaction execution to get the state delta. let tx_request = TransactionRequestBuilder::new() @@ -491,9 +481,11 @@ fn resolve_procedure_export<'a>( if proc.signature.as_ref().is_some_and(|sig| sig.abi.is_wasm_canonical_abi()) { return Ok(proc); } - // Hand-written MASM carries no signature the caller can encode against, but it is still - // callable with raw field elements. Keep it and go on looking: the manifest is free to - // write the Component Model export after this one, and that one is worth more. + // Any other match is the fallback: an export carrying no signature at all (hand-written + // MASM), or one that describes a lowering rather than the values the caller passes (`Fast`, + // `C`). Each of those is still callable with raw field elements. Keep the first one and go + // on looking: the manifest is free to write the Component Model export after it, and that + // one is worth more. untyped.get_or_insert(proc); } @@ -654,12 +646,8 @@ mod tests { // name. Were it found, its `i32` return would be printed as a value. let manifest = manifest_with_exports(&[lowered_form()]); - let err = resolve_procedure_export(&manifest, "increment-by").unwrap_err(); - assert_eq!( - err.to_string(), - "invalid argument: Procedure 'increment-by' not found. Available:\n \ - ::\"miden:counter/counter@0.1.0\"::cc::\"miden:counter/counter@0.1.0#increment-by\"" - ); + // The message itself is pinned by `an_unknown_procedure_lists_the_whole_export_surface`. + assert!(resolve_procedure_export(&manifest, "increment-by").is_err()); } #[test] @@ -670,20 +658,6 @@ mod tests { assert_eq!(export.signature, interface_form().1); } - #[test] - fn a_hand_written_masm_export_does_not_shadow_the_component_model_one() { - // A MASM `increment_by` matches the query by name, but `call` needs the Component Model - // signature: only that one describes the values the user passes and reads. - let masm = - || ("::mix::increment_by", Some(FunctionType::new(CallConv::Fast, [], [Type::U32]))); - for exports in [[interface_form(), masm()], [masm(), interface_form()]] { - let manifest = manifest_with_exports(&exports); - - let export = resolve_procedure_export(&manifest, "increment_by").unwrap(); - assert_eq!(export.signature, interface_form().1); - } - } - #[test] fn an_unknown_procedure_lists_the_whole_export_surface() { let manifest = manifest_with_exports(&[interface_form(), lowered_form()]); @@ -698,13 +672,29 @@ mod tests { } #[test] - fn an_export_without_a_signature_is_still_resolved() { - // MASM written by hand: the export has the name we ask for, but no type info. It is still - // callable with raw field elements, so it has to resolve rather than be rejected. - let manifest = manifest_with_exports(&[("::mix::\"increment-by\"", None)]); - - let export = resolve_procedure_export(&manifest, "increment-by").unwrap(); - assert_eq!(export.signature, None); + fn an_untyped_export_is_resolved_but_never_shadows_the_component_model_one() { + // Every export that is not the Component Model one takes the fallback path, whether it + // carries no signature at all (hand-written MASM) or one describing a lowering. Each is + // still callable with raw field elements, so it resolves on its own, and each must lose to + // the Component Model export in whichever order the manifest writes the two. + let untyped_forms = [ + ("::mix::\"increment-by\"", None), + ("::mix::increment_by", Some(FunctionType::new(CallConv::Fast, [], [Type::U32]))), + ]; + + for untyped in untyped_forms { + let manifest = manifest_with_exports(slice::from_ref(&untyped)); + let export = resolve_procedure_export(&manifest, "increment-by").unwrap(); + assert_eq!(export.signature, untyped.1, "{} did not resolve alone", untyped.0); + + for exports in + [[untyped.clone(), interface_form()], [interface_form(), untyped.clone()]] + { + let manifest = manifest_with_exports(&exports); + let export = resolve_procedure_export(&manifest, "increment-by").unwrap(); + assert_eq!(export.signature, interface_form().1, "{} won", untyped.0); + } + } } /// The Goldilocks field modulus, `2^64 - 2^32 + 1`. The first value with no felt of its own. @@ -721,34 +711,26 @@ mod tests { } #[test] - fn a_raw_argument_at_the_field_modulus_is_rejected() { - // The modulus is what an unchecked `u64` argument would silently wrap around to. - let err = encode_raw_args(&[FIELD_MODULUS.to_string()]).unwrap_err(); - - assert_eq!( - err.to_string(), - format!("invalid argument: Argument '{FIELD_MODULUS}' is too large for a felt.") - ); - } - - #[test] - fn a_raw_hex_argument_is_rejected() { - // The typed path writes a `felt` in decimal and reserves `0x` for wider values, so the - // untyped path cannot take hex either: it would work only until the procedure is given a - // signature. - let err = encode_raw_args(&["0xff".to_string()]).unwrap_err(); - - assert_eq!(err.to_string(), "invalid argument: Invalid argument '0xff'. Expected a felt."); - } + fn a_raw_argument_that_is_not_a_decimal_felt_is_rejected() { + let cases = [ + // What an unchecked `u64` argument would silently wrap around to. + ( + FIELD_MODULUS.to_string(), + format!("invalid argument: Argument '{FIELD_MODULUS}' is too large for a felt."), + ), + // The typed path writes a `felt` in decimal and reserves `0x` for wider values, so the + // untyped path cannot take hex either: it would work only until the procedure is given + // a signature. + ( + "0xff".to_string(), + "invalid argument: Invalid argument '0xff'. Expected a felt.".to_string(), + ), + ]; - #[test] - fn the_component_model_export_wins_over_an_untyped_one_written_before_it() { - // The untyped export is seen first, but it is only the fallback: resolution has to go on - // and take the Component Model one. - let manifest = - manifest_with_exports(&[("::mix::\"increment-by\"", None), interface_form()]); + for (arg, expected) in cases { + let err = encode_raw_args(slice::from_ref(&arg)).unwrap_err(); - let export = resolve_procedure_export(&manifest, "increment-by").unwrap(); - assert_eq!(export.signature, interface_form().1); + assert_eq!(err.to_string(), expected, "argument '{arg}'"); + } } } diff --git a/docs/external/src/rust-client/cli/index.md b/docs/external/src/rust-client/cli/index.md index 4a6e092bc6..dfda192062 100644 --- a/docs/external/src/rust-client/cli/index.md +++ b/docs/external/src/rust-client/cli/index.md @@ -458,10 +458,12 @@ Arguments are passed positionally after the target, one token per value in the p | integers (`u8`…`u128`, `i8`…`i128`) | decimal, range-checked against the type | `-1` | | `bool` | `true`, `false`, `1` or `0` | `true` | | `word` | hex | `0x00..` | -| `account-id` | hex account ID | `0x4614b8bf575eab71455e97bd394e90` | -| `asset` | `::`, fungible only | `100::0xabcdef0123456789` | +| `account-id` | hex account ID, or a bech32 address naming one | `0x4614b8bf575eab71455e97bd394e90` | +| `asset` | `::`, fungible only, the faucet in either account ID spelling | `100::0xabcdef0123456789` | | records and fixed arrays | one token per field, in order | `3 4` for `point { x, y }` | +An `account-id` argument takes the same two spellings the target does, so both can be written the same way in one command line. A hex prefix of a tracked account is not one of them: resolving a prefix reads the client's store, and an argument is read on its own. + Only procedures exported from a WIT interface carry a signature. A procedure without one is still called, with one raw field element per argument written in decimal (a `0x` hex literal is not accepted); the argument count is not checked and the result is printed as a stack dump. The arguments are pushed onto the stack so that the first one ends up on top, and together they may occupy at most 16 stack values — that is all a called procedure can see.