use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::path::PathBuf;
use std::slice;
use clap::Parser;
use miden_client::account::AccountId;
use miden_client::assembly::CodeBuilder;
use miden_client::keystore::Keystore;
use miden_client::rpc::domain::account::AccountStorageRequirements;
use miden_client::transaction::{
AdviceInputs,
ForeignAccount,
TransactionRequestBuilder,
TransactionRequestError,
TransactionScript,
build_fpi_script,
};
use miden_client::vm::typed::TypedProcInfo;
use miden_client::vm::{
ExecutionError,
MIN_STACK_DEPTH,
OperationError,
PackageExport,
PackageManifest,
ProcedureExport,
error_code_from_msg,
};
use miden_client::{Client, ClientError, Felt, TransactionExecutorError, Word};
use crate::advice_inputs::load_advice_map_from_file;
use crate::codecs::with_cli_codecs;
use crate::commands::account::DEFAULT_ACCOUNT_ID_KEY;
use crate::commands::new_account::load_packages;
use crate::config::CliConfig;
use crate::errors::CliError;
use crate::utils::{
parse_account_id,
print_executed_program_stack,
print_executed_transaction,
split_procedure_target,
};
const EMPTY_TRANSACTION_ASSERTION: &str =
"executed transaction neither changed the account state, nor consumed any notes";
#[derive(Debug, Clone, Parser)]
#[command(
about = "Call a procedure on an account and display the result and state delta. Accounts \
that aren't tracked locally are read from the network and the call is read-only."
)]
pub struct CallCmd {
#[arg(
value_name = "ACCOUNT_ID:PROCEDURE",
long_help = "Account and procedure in the form `<ACCOUNT_ID>:<PROCEDURE>`.\n\n\
The procedure name is matched against the package's exports with `_` and `-` \
treated as equivalent, so it can be written in either snake_case or \
kebab-case (e.g. `get_count` matches the WIT export `get-count`)."
)]
target: String,
#[arg(value_name = "args")]
args: Vec<String>,
#[arg(long, short)]
package: Option<PathBuf>,
#[arg(long, short, long_help = crate::advice_inputs::INPUTS_PATH_LONG_HELP)]
inputs_path: Option<PathBuf>,
}
impl CallCmd {
pub async fn execute<AUTH: Keystore + Sync + 'static>(
&self,
client: Client<AUTH>,
) -> Result<(), CliError> {
if client.get_sync_height().await? == 0.into() {
return Err(CliError::NotSynced);
}
let cli_config = CliConfig::load()?;
let (account_str, procedure) = split_procedure_target(&self.target);
let procedure = procedure.ok_or_else(|| {
CliError::InvalidArgument(format!(
"Expected `<ACCOUNT_ID>:<PROCEDURE>`, got '{}'.",
self.target
))
})?;
let target_id = parse_account_id(&client, account_str).await?;
let call_code = self.resolve_call_code(&client, &cli_config, procedure)?;
let advice_entries = match &self.inputs_path {
Some(path) => load_advice_map_from_file(path)?,
None => vec![],
};
let call_target = resolve_call_target(&client, target_id).await?;
match call_target {
CallTarget::Local(account_id) => {
run_local_call(&client, account_id, call_code, advice_entries).await
},
CallTarget::Remote { target_id, executor_id, foreign_account } => {
run_remote_call(
&client,
target_id,
executor_id,
foreign_account,
call_code,
advice_entries,
)
.await
},
}
}
fn resolve_call_code<AUTH: Keystore + Sync + 'static>(
&self,
client: &Client<AUTH>,
cli_config: &CliConfig,
procedure: &str,
) -> Result<CallCode, CliError> {
let call_code = match &self.package {
Some(pkg_path) => self.resolve_from_package(client, cli_config, pkg_path, procedure)?,
None => self.resolve_from_digest(client, procedure)?,
};
if call_code.args.len() > MIN_STACK_DEPTH {
return Err(CliError::InvalidArgument(format!(
"A procedure takes at most {MIN_STACK_DEPTH} input values; got {}.",
call_code.args.len()
)));
}
if let Some(n) = call_code.result_felts
&& n > MIN_STACK_DEPTH
{
return Err(CliError::InvalidArgument(format!(
"Procedure '{procedure}' returns {n} values; only up to {MIN_STACK_DEPTH} \
can be read from the output stack."
)));
}
Ok(call_code)
}
fn resolve_from_package<AUTH: Keystore + Sync + 'static>(
&self,
client: &Client<AUTH>,
cli_config: &CliConfig,
pkg_path: &PathBuf,
procedure: &str,
) -> Result<CallCode, CliError> {
let package = load_packages(cli_config, slice::from_ref(pkg_path))?
.pop()
.expect("load_packages returns one package per path");
let export = resolve_procedure_export(&package.manifest, procedure)?;
let digest = export.digest;
let name = export.path.last().ok_or_else(|| {
CliError::InvalidArgument(format!(
"The export matching '{procedure}' has an empty path, so it names no procedure."
))
})?;
let typed = match export.signature.clone() {
Some(signature) if signature.abi.is_wasm_canonical_abi() => {
Some(with_cli_codecs(TypedProcInfo::new(name, signature)?))
},
_ => None,
};
let (args, result_felts) = if let Some(typed) = &typed {
println!("Signature: {typed}\n");
(typed.encode_args(&self.args)?, typed.output_felt_count())
} else {
println!("Signature: {name}(...) [no type info]\n");
println!(
"Warning: the package does not describe the types of '{procedure}', so each \
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)
};
let builder = client.code_builder().with_dynamically_linked_package(&package)?;
Ok(CallCode {
builder,
digest,
args,
typed,
result_felts,
})
}
fn resolve_from_digest<AUTH: Keystore + Sync + 'static>(
&self,
client: &Client<AUTH>,
procedure: &str,
) -> Result<CallCode, CliError> {
let digest = Word::try_from(procedure).map_err(|_| {
CliError::InvalidArgument(format!(
"'{procedure}' is not a hex digest. Pass `--package <FILE>.masp` to \
call a procedure by name, or give its hex digest to call without a \
package."
))
})?;
println!(
"No `--package` provided; output will be raw felts. Pass \
`--package <FILE>.masp` for typed output."
);
Ok(CallCode {
builder: client.code_builder(),
digest,
args: encode_raw_args(&self.args)?,
typed: None,
result_felts: None,
})
}
}
struct CallCode {
builder: CodeBuilder,
digest: Word,
args: Vec<Felt>,
typed: Option<TypedProcInfo>,
result_felts: Option<usize>,
}
fn print_call_result(
output_stack: &[Felt; MIN_STACK_DEPTH],
typed: Option<&TypedProcInfo>,
) -> Result<(), CliError> {
match typed {
Some(typed) => {
if let Some(rendered) = typed.decode_result(output_stack.as_slice())? {
println!("Result: {rendered}");
}
},
None => print_executed_program_stack(output_stack, None),
}
Ok(())
}
async fn run_remote_call<AUTH: Keystore + Sync + 'static>(
client: &Client<AUTH>,
target_id: AccountId,
executor_id: AccountId,
foreign_account: Box<ForeignAccount>,
call_code: CallCode,
advice_entries: Vec<(Word, Vec<Felt>)>,
) -> Result<(), CliError> {
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 } => {
CliError::InvalidArgument(format!(
"A call on an account read from the network takes at most {max} input felts; \
got {actual}"
))
},
other => {
CliError::Transaction(other.into(), "Failed to build the call script".to_string())
},
})?;
let output_stack = client
.execute_program(
executor_id,
tx_script,
AdviceInputs::default().with_map(advice_entries),
BTreeMap::from([(target_id, *foreign_account)]),
)
.await?;
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(())
}
async fn run_local_call<AUTH: Keystore + Sync + 'static>(
client: &Client<AUTH>,
account_id: AccountId,
call_code: CallCode,
advice_entries: Vec<(Word, Vec<Felt>)>,
) -> Result<(), CliError> {
let CallCode { builder, digest, args, typed, .. } = call_code;
let tx_script = generate_tx_script(builder, &digest, &args)?;
let output_stack = client
.execute_program(
account_id,
tx_script.clone(),
AdviceInputs::default().with_map(advice_entries.clone()),
BTreeMap::new(),
)
.await?;
print_call_result(&output_stack, typed.as_ref())?;
let tx_request = TransactionRequestBuilder::new()
.custom_script(tx_script)
.extend_advice_map(advice_entries)
.build()
.map_err(|err| {
CliError::Transaction(err.into(), "Failed to build transaction".to_string())
})?;
match client.execute_transaction(account_id, tx_request).await {
Ok(tx_result) => {
print_executed_transaction(client, tx_result.executed_transaction()).await?;
},
Err(e) => report_failed_delta(&e),
}
Ok(())
}
enum CallTarget {
Local(AccountId),
Remote {
target_id: AccountId,
executor_id: AccountId,
foreign_account: Box<ForeignAccount>,
},
}
async fn resolve_call_target<AUTH: Keystore + Sync + 'static>(
client: &Client<AUTH>,
target_id: AccountId,
) -> Result<CallTarget, CliError> {
if let Some((_, status)) = client.get_account_header(target_id).await? {
if status.is_locked() {
return Err(CliError::InvalidArgument(format!(
"Account {target_id} is locked: its local state doesn't match the network's, so \
the call can't run on it."
)));
}
return Ok(CallTarget::Local(target_id));
}
let foreign_account = ForeignAccount::public(target_id, AccountStorageRequirements::default())
.map_err(|err| match err {
TransactionRequestError::InvalidForeignAccountId(_) => {
CliError::InvalidArgument(format!(
"Account {target_id} isn't tracked locally and its state isn't public, so it \
can't be read from the network."
))
},
other => CliError::InvalidArgument(format!(
"Account {target_id} can't be read from the network: {other}"
)),
})?;
let executor_id = pick_local_executor(client).await?;
println!(
"Account {target_id} isn't tracked locally; reading its state from the network and \
running the call from your account {executor_id}."
);
Ok(CallTarget::Remote {
target_id,
executor_id,
foreign_account: Box::new(foreign_account),
})
}
async fn pick_local_executor<AUTH: Keystore + Sync + 'static>(
client: &Client<AUTH>,
) -> Result<AccountId, CliError> {
let default_id: Option<AccountId> =
client.get_setting(DEFAULT_ACCOUNT_ID_KEY.to_string()).await?;
if let Some(default_id) = default_id
&& let Some((_, status)) = client.get_account_header(default_id).await?
&& !status.is_locked()
{
return Ok(default_id);
}
let local_accounts = client.get_account_headers().await?;
local_accounts
.iter()
.find(|(_, status)| !status.is_locked())
.map(|(header, _)| header.id())
.ok_or_else(|| {
CliError::InvalidArgument(
"Calling an account that isn't tracked locally needs one of your own accounts to \
run the call from, and none is usable. Create one with `miden-client new-wallet` \
and re-run."
.to_string(),
)
})
}
fn resolve_procedure_export<'a>(
manifest: &'a PackageManifest,
procedure_name: &str,
) -> Result<&'a ProcedureExport, CliError> {
let target = procedure_name.replace('_', "-");
let mut available = Vec::new();
let mut untyped = None;
for export in manifest.exports() {
let PackageExport::Procedure(proc) = export else {
continue;
};
available.push(format!(" {}", proc.path));
if export.name().replace('_', "-") != target {
continue;
}
if proc.signature.as_ref().is_some_and(|sig| sig.abi.is_wasm_canonical_abi()) {
return Ok(proc);
}
untyped.get_or_insert(proc);
}
untyped.ok_or_else(|| {
CliError::InvalidArgument(format!(
"Procedure '{procedure_name}' not found. Available:\n{}",
available.join("\n")
))
})
}
fn report_failed_delta(error: &ClientError) {
let is_empty_transaction = matches!(
error,
ClientError::TransactionExecutorError(
TransactionExecutorError::TransactionProgramExecutionFailed(
ExecutionError::OperationError {
err: OperationError::FailedAssertion { err_code, .. },
..
},
),
) if *err_code == error_code_from_msg(EMPTY_TRANSACTION_ASSERTION)
);
if is_empty_transaction {
println!();
println!("The transaction was rejected because it had no effects:\n");
println!("No notes were consumed.");
println!();
println!("Account Storage was not changed.");
println!("Account Vault was not changed.");
println!("Account nonce was not changed.");
return;
}
let mut report = String::new();
let mut cause = std::error::Error::source(error);
while let Some(err) = cause {
writeln!(report, " caused by: {err}").unwrap();
cause = err.source();
}
println!("\n(Could not compute state delta: {error})");
print!("{report}");
}
fn encode_raw_args(args: &[String]) -> Result<Vec<Felt>, CliError> {
args.iter()
.map(|arg| {
let value: u64 = arg.parse().map_err(|_| {
CliError::InvalidArgument(format!("Invalid argument '{arg}'. Expected a felt."))
})?;
Felt::try_from(value).map_err(|_| {
CliError::InvalidArgument(format!("Argument '{arg}' is too large for a felt."))
})
})
.collect()
}
fn generate_tx_script(
code_builder: CodeBuilder,
digest: &Word,
args: &[Felt],
) -> Result<TransactionScript, CliError> {
let mut script = String::from("use miden::core::sys\n\n@transaction_script\npub proc main\n");
for arg in args.iter().rev() {
writeln!(script, " push.{arg}").unwrap();
}
writeln!(script, " call.{}", digest.to_hex()).unwrap();
script.push_str(" exec.sys::truncate_stack\n");
script.push_str("end\n");
Ok(code_builder.compile_tx_script(&script)?)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use miden_mast_package::PathBuf;
use midenc_hir_type::{CallConv, FunctionType, Type};
use super::*;
fn manifest_with_exports(exports: &[(&str, Option<FunctionType>)]) -> PackageManifest {
let exports = exports.iter().map(|(path, signature)| {
let path: Arc<_> = path.parse::<PathBuf>().expect("path should parse").into();
PackageExport::Procedure(ProcedureExport::new(
path,
None,
Word::default(),
signature.clone(),
))
});
PackageManifest::new(exports).expect("manifest should be valid")
}
fn interface_form() -> (&'static str, Option<FunctionType>) {
(
"::\"miden:counter/counter@0.1.0\"::\"increment-by\"",
Some(FunctionType::new(CallConv::ComponentModel, [Type::Felt], [Type::Felt])),
)
}
fn lowered_form() -> (&'static str, Option<FunctionType>) {
(
"::\"miden:counter/counter@0.1.0\"::cc::\"miden:counter/counter@0.1.0#increment-by\"",
Some(FunctionType::new(CallConv::C, [Type::Felt], [Type::I32])),
)
}
#[test]
fn the_interface_form_wins_over_the_lowered_one() {
for exports in [[interface_form(), lowered_form()], [lowered_form(), 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 a_lowered_name_is_not_reachable_by_the_bare_procedure_name() {
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\""
);
}
#[test]
fn an_underscore_query_finds_a_kebab_export() {
let manifest = manifest_with_exports(&[interface_form(), lowered_form()]);
let export = resolve_procedure_export(&manifest, "increment_by").unwrap();
assert_eq!(export.signature, interface_form().1);
}
#[test]
fn a_hand_written_masm_export_does_not_shadow_the_component_model_one() {
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()]);
let err = resolve_procedure_export(&manifest, "no-such-proc").unwrap_err();
assert_eq!(
err.to_string(),
"invalid argument: Procedure 'no-such-proc' not found. Available:\n \
::\"miden:counter/counter@0.1.0\"::\"increment-by\"\n \
::\"miden:counter/counter@0.1.0\"::cc::\"miden:counter/counter@0.1.0#increment-by\""
);
}
#[test]
fn an_export_without_a_signature_is_still_resolved() {
let manifest = manifest_with_exports(&[("::mix::\"increment-by\"", None)]);
let export = resolve_procedure_export(&manifest, "increment-by").unwrap();
assert_eq!(export.signature, None);
}
const FIELD_MODULUS: u64 = 18_446_744_069_414_584_321;
#[test]
fn raw_arguments_are_read_as_decimal_felts() {
let args = ["0", "10", (FIELD_MODULUS - 1).to_string().as_str()].map(String::from);
let encoded = encode_raw_args(&args).unwrap();
let expected = [0, 10, FIELD_MODULUS - 1].map(|v| Felt::new(v).unwrap());
assert_eq!(encoded, expected);
}
#[test]
fn a_raw_argument_at_the_field_modulus_is_rejected() {
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() {
let err = encode_raw_args(&["0xff".to_string()]).unwrap_err();
assert_eq!(err.to_string(), "invalid argument: Invalid argument '0xff'. Expected a felt.");
}
#[test]
fn the_component_model_export_wins_over_an_untyped_one_written_before_it() {
let manifest =
manifest_with_exports(&[("::mix::\"increment-by\"", None), interface_form()]);
let export = resolve_procedure_export(&manifest, "increment-by").unwrap();
assert_eq!(export.signature, interface_form().1);
}
}