diff --git a/Cargo.lock b/Cargo.lock index 2d5b725bf12..86f2d4dea71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5743,6 +5743,7 @@ dependencies = [ "shell-words", "shlex", "signature 1.6.4", + "strum 0.26.3", "tabled", "tap", "telemetry-subscribers", diff --git a/crates/iota/Cargo.toml b/crates/iota/Cargo.toml index cdd190c361f..8f6435f5f12 100644 --- a/crates/iota/Cargo.toml +++ b/crates/iota/Cargo.toml @@ -45,6 +45,7 @@ serde_json.workspace = true serde_yaml.workspace = true shell-words = "1.1.0" signature.workspace = true +strum.workspace = true tabled.workspace = true tap.workspace = true tempfile.workspace = true diff --git a/crates/iota/src/client_commands.rs b/crates/iota/src/client_commands.rs index b297d6ddc3f..f0dfd52b394 100644 --- a/crates/iota/src/client_commands.rs +++ b/crates/iota/src/client_commands.rs @@ -3,7 +3,8 @@ // SPDX-License-Identifier: Apache-2.0 use std::{ - collections::{BTreeMap, btree_map::Entry}, + cmp::Eq, + collections::{BTreeMap, HashSet, btree_map::Entry}, fmt::{Debug, Display, Formatter, Write}, fs, path::{Path, PathBuf}, @@ -57,6 +58,7 @@ use iota_types::{ move_package::UpgradeCap, object::Owner, parse_iota_type_tag, + quorum_driver_types::ExecuteTransactionRequestType, signature::GenericSignature, transaction::{ SenderSignedData, Transaction, TransactionData, TransactionDataAPI, TransactionKind, @@ -72,6 +74,7 @@ use reqwest::StatusCode; use serde::Serialize; use serde_json::{Value, json}; use shared_crypto::intent::Intent; +use strum::EnumString; use tabled::{ builder::Builder as TableBuilder, settings::{ @@ -649,6 +652,13 @@ pub struct Opts { /// --signed-tx-bytes `. #[arg(long, required = false)] pub serialize_signed_transaction: bool, + + /// Select which fields of the response to display. + /// If not provided, all fields are displayed. + /// The fields are: effects, input, events, object_changes, + /// balance_changes. + #[clap(long, required = false, value_delimiter = ',', num_args = 0.., value_parser = parse_emit_option)] + pub emit: HashSet, } /// Global options with gas @@ -665,23 +675,40 @@ pub struct OptsWithGas { impl Opts { /// Uses the passed gas_budget for the gas budget variable and sets all - /// other flags to false. + /// other flags to false, and emit to an empty vector(defaulting to all emit + /// options). pub fn for_testing(gas_budget: u64) -> Self { Self { gas_budget: Some(gas_budget), dry_run: false, serialize_unsigned_transaction: false, serialize_signed_transaction: false, + emit: HashSet::new(), } } /// Uses the passed gas_budget for the gas budget variable, sets dry run to - /// true, and sets all other flags to false. + /// true, and sets all other flags to false, and emit to an empty + /// vector(defaulting to all emit options). pub fn for_testing_dry_run(gas_budget: u64) -> Self { Self { gas_budget: Some(gas_budget), dry_run: true, serialize_unsigned_transaction: false, serialize_signed_transaction: false, + emit: HashSet::new(), + } + } + + /// Uses the passed gas_budget for the gas budget variable, sets dry run to + /// false, and sets all other flags to false, and emit to the passed emit + /// vector. + pub fn for_testing_emit_options(gas_budget: u64, emit: HashSet) -> Self { + Self { + gas_budget: Some(gas_budget), + dry_run: false, + serialize_unsigned_transaction: false, + serialize_signed_transaction: false, + emit, } } } @@ -703,6 +730,30 @@ impl OptsWithGas { rest: Opts::for_testing_dry_run(gas_budget), } } + + /// Sets the gas object to gas, and uses the passed gas_budget for the gas + /// budget variable. Dry run is set to false, and emit to the passed emit + /// vector. All other flags are set to false. + pub fn for_testing_emit_options( + gas: Option, + gas_budget: u64, + emit: HashSet, + ) -> Self { + Self { + gas, + rest: Opts::for_testing_emit_options(gas_budget, emit), + } + } +} + +#[derive(Clone, Debug, EnumString, Hash, Eq, PartialEq)] +#[strum(serialize_all = "snake_case")] +pub enum EmitOption { + Effects, + Input, + Events, + ObjectChanges, + BalanceChanges, } #[derive(serde::Deserialize)] @@ -2930,18 +2981,18 @@ pub(crate) async fn dry_run_or_execute_or_serialize( )) } else { let transaction = Transaction::new(sender_signed_data); - let mut response = context.execute_transaction_may_fail(transaction).await?; - if let Some(effects) = response.effects.as_mut() { - prerender_clever_errors(effects, client.read_api()).await; - } - let effects = response.effects.as_ref().ok_or_else(|| { - anyhow!("Effects from IotaTransactionBlockResult should not be empty") - })?; - if let IotaExecutionStatus::Failure { error } = effects.status() { - return Err(anyhow!( - "Error executing transaction '{}': {error}", - response.digest - )); + let response = client + .quorum_driver_api() + .execute_transaction_block( + transaction, + opts_from_cli(opts.emit), + Some(ExecuteTransactionRequestType::WaitForLocalExecution), + ) + .await?; + + let errors: &Vec = response.errors.as_ref(); + if !errors.is_empty() { + return Err(anyhow!("Error executing transaction: {errors:#?}")); } Ok(IotaClientCommandResult::TransactionBlock(response)) } @@ -2959,3 +3010,28 @@ pub(crate) async fn prerender_clever_errors( } } } + +fn opts_from_cli(opts: HashSet) -> IotaTransactionBlockResponseOptions { + if opts.is_empty() { + IotaTransactionBlockResponseOptions::new() + .with_effects() + .with_input() + .with_events() + .with_object_changes() + .with_balance_changes() + } else { + IotaTransactionBlockResponseOptions { + show_input: opts.contains(&EmitOption::Input), + show_events: opts.contains(&EmitOption::Events), + show_object_changes: opts.contains(&EmitOption::ObjectChanges), + show_balance_changes: opts.contains(&EmitOption::BalanceChanges), + show_effects: opts.contains(&EmitOption::Effects), + show_raw_effects: false, + show_raw_input: false, + } + } +} + +fn parse_emit_option(s: &str) -> Result { + EmitOption::from_str(s).map_err(|_| format!("Invalid emit option: {}", s)) +} diff --git a/crates/iota/src/client_ptb/ptb.rs b/crates/iota/src/client_ptb/ptb.rs index f82fbe70636..b8c87ba9796 100644 --- a/crates/iota/src/client_ptb/ptb.rs +++ b/crates/iota/src/client_ptb/ptb.rs @@ -2,6 +2,8 @@ // Modifications Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::collections::HashSet; + use anyhow::{Error, anyhow, ensure}; use clap::{Args, ValueHint, arg}; use iota_json_rpc_types::{IotaExecutionStatus, IotaTransactionBlockEffectsAPI}; @@ -154,6 +156,7 @@ impl PTB { gas_budget: program_metadata.gas_budget.map(|x| x.value), serialize_unsigned_transaction: program_metadata.serialize_unsigned_set, serialize_signed_transaction: program_metadata.serialize_signed_set, + emit: HashSet::new(), }, }; diff --git a/crates/iota/tests/cli_tests.rs b/crates/iota/tests/cli_tests.rs index a82107f46c5..ff3d2922d45 100644 --- a/crates/iota/tests/cli_tests.rs +++ b/crates/iota/tests/cli_tests.rs @@ -9,8 +9,15 @@ use std::os::windows::fs::FileExt; #[cfg(not(msim))] use std::str::FromStr; use std::{ - collections::BTreeSet, env, fmt::Write, fs::read_dir, io::Read, net::SocketAddr, path::PathBuf, - str, thread, time::Duration, + collections::{BTreeSet, HashSet}, + env, + fmt::Write, + fs::read_dir, + io::Read, + net::SocketAddr, + path::PathBuf, + str, thread, + time::Duration, }; use expect_test::expect; @@ -18,7 +25,7 @@ use expect_test::expect; use iota::iota_commands::IndexerFeatureArgs; use iota::{ client_commands::{ - IotaClientCommandResult, IotaClientCommands, Opts, OptsWithGas, SwitchResponse, + EmitOption, IotaClientCommandResult, IotaClientCommands, Opts, OptsWithGas, SwitchResponse, estimate_gas_budget, }, client_ptb::ptb::PTB, @@ -2113,6 +2120,10 @@ async fn test_package_management_on_upgrade_command_conflict() -> Result<(), any // Purposely add a conflicting `published-at` address to the Move manifest. lines.insert(idx + 1, "published-at = \"0xbad\"".to_string()); let new = lines.join("\n"); + + #[cfg(target_os = "windows")] + move_toml.seek_write(new.as_bytes(), 0).unwrap(); + #[cfg(not(target_os = "windows"))] move_toml.write_at(new.as_bytes(), 0).unwrap(); // Create a new build config for the upgrade. Initialize its lock file to the @@ -2965,6 +2976,7 @@ async fn test_serialize_tx() -> Result<(), anyhow::Error> { dry_run: false, serialize_unsigned_transaction: true, serialize_signed_transaction: false, + emit: HashSet::new(), }, } .execute(context) @@ -2979,6 +2991,7 @@ async fn test_serialize_tx() -> Result<(), anyhow::Error> { dry_run: false, serialize_unsigned_transaction: false, serialize_signed_transaction: true, + emit: HashSet::new(), }, } .execute(context) @@ -2994,6 +3007,7 @@ async fn test_serialize_tx() -> Result<(), anyhow::Error> { dry_run: false, serialize_unsigned_transaction: false, serialize_signed_transaction: true, + emit: HashSet::new(), }, } .execute(context) @@ -3845,6 +3859,7 @@ async fn test_gas_estimation() -> Result<(), anyhow::Error> { dry_run: false, serialize_unsigned_transaction: false, serialize_signed_transaction: false, + emit: HashSet::new(), }, } .execute(context) @@ -4240,3 +4255,151 @@ async fn test_move_new() -> Result<(), anyhow::Error> { std::fs::remove_dir_all(package_name)?; Ok(()) } + +#[sim_test] +async fn test_call_command_emit_args() -> Result<(), anyhow::Error> { + // Publish the package + move_package::package_hooks::register_package_hooks(Box::new(IotaPackageHooks)); + let mut test_cluster = TestClusterBuilder::new().build().await; + let rgp = test_cluster.get_reference_gas_price().await; + let address = test_cluster.get_address_0(); + let context = &mut test_cluster.wallet; + let client = context.get_client().await?; + let object_refs = client + .read_api() + .get_owned_objects( + address, + Some(IotaObjectResponseQuery::new_with_options( + IotaObjectDataOptions::new() + .with_type() + .with_owner() + .with_previous_transaction(), + )), + None, + None, + ) + .await? + .data; + + let gas_obj_id = object_refs.first().unwrap().object().unwrap().object_id; + + // Provide path to well formed package sources + let mut package_path = PathBuf::from(TEST_DATA_DIR); + package_path.push("dummy_modules_upgrade"); + let build_config = BuildConfig::new_for_testing().config; + let resp = IotaClientCommands::Publish { + package_path: package_path.clone(), + build_config, + skip_dependency_verification: false, + with_unpublished_dependencies: false, + opts: OptsWithGas::for_testing(Some(gas_obj_id), rgp * TEST_ONLY_GAS_UNIT_FOR_PUBLISH), + } + .execute(context) + .await?; + + let effects = match resp { + IotaClientCommandResult::TransactionBlock(response) => response.effects.unwrap(), + _ => panic!("Expected TransactionBlock response"), + }; + + let package = effects + .created() + .iter() + .find(|refe| matches!(refe.owner, Owner::Immutable)) + .unwrap(); + + let start_call_result = IotaClientCommands::Call { + package: package.reference.object_id, + module: "trusted_coin".to_string(), + function: "f".to_string(), + type_args: vec![], + gas_price: None, + args: vec![], + opts: OptsWithGas::for_testing_emit_options( + None, + rgp * TEST_ONLY_GAS_UNIT_FOR_PUBLISH, + HashSet::from([EmitOption::BalanceChanges]), + ), + } + .execute(context) + .await?; + + if let Some(tx_block_response) = start_call_result.tx_block_response() { + // Assert Balance Changes are present in the response + assert!(tx_block_response.balance_changes.is_some()); + + // Assert every other field is not present in the response + assert!(tx_block_response.effects.is_none()); + assert!(tx_block_response.object_changes.is_none()); + assert!(tx_block_response.events.is_none()); + assert!(tx_block_response.transaction.is_none()); + } else { + panic!("Transaction block response is None"); + } + + // Make another call, this time with multiple emit args + let start_call_result = IotaClientCommands::Call { + package: package.reference.object_id, + module: "trusted_coin".to_string(), + function: "f".to_string(), + type_args: vec![], + gas_price: None, + args: vec![], + opts: OptsWithGas::for_testing_emit_options( + None, + rgp * TEST_ONLY_GAS_UNIT_FOR_PUBLISH, + HashSet::from([ + EmitOption::BalanceChanges, + EmitOption::Effects, + EmitOption::ObjectChanges, + ]), + ), + } + .execute(context) + .await?; + + start_call_result.print(true); + + // Assert Balance Changes, effects and object changes are present in the + // response + if let Some(tx_block_response) = start_call_result.tx_block_response() { + assert!(tx_block_response.balance_changes.is_some()); + assert!(tx_block_response.effects.is_some()); + assert!(tx_block_response.object_changes.is_some()); + assert!(tx_block_response.events.is_none()); + assert!(tx_block_response.transaction.is_none()); + } else { + panic!("Transaction block response is None"); + } + + // Make another call, this time with no emit args. This should return the full + // response + let start_call_result = IotaClientCommands::Call { + package: package.reference.object_id, + module: "trusted_coin".to_string(), + function: "f".to_string(), + type_args: vec![], + gas_price: None, + args: vec![], + opts: OptsWithGas::for_testing_emit_options( + None, + rgp * TEST_ONLY_GAS_UNIT_FOR_PUBLISH, + HashSet::new(), + ), + } + .execute(context) + .await?; + + // Assert all fields are present in the response + if let Some(tx_block_response) = start_call_result.tx_block_response() { + assert!(tx_block_response.balance_changes.is_some()); + assert!(tx_block_response.effects.is_some()); + assert!(tx_block_response.object_changes.is_some()); + assert!(tx_block_response.events.is_some()); + assert!(tx_block_response.transaction.is_some()); + } else { + panic!("Transaction block response is None"); + } + + Ok(()) +}