Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 41 additions & 15 deletions bin/miden-cli/src/codecs/account_id.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
//! 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

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 {
Expand All @@ -29,8 +25,7 @@ impl WitScalarCodec for AccountIdCodec {
}

fn encode(&self, token: &str) -> Result<Vec<Felt>, 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()])
}

Expand All @@ -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);

Expand All @@ -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");
}
}
}
25 changes: 19 additions & 6 deletions bin/miden-cli/src/codecs/asset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,14 @@
//! [`Asset::as_elements`]. The CLI registers this codec so an asset argument can be given as a
//! single `<AMOUNT>::<FAUCET_ID>` 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`).
Expand All @@ -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();
Expand Down Expand Up @@ -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::*;
Expand All @@ -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();
Expand Down
38 changes: 36 additions & 2 deletions bin/miden-cli/src/codecs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@
//! 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.
//!
//! [`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;
Expand All @@ -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<AccountId, TypedError> {
// 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(
Expand Down
Loading
Loading