diff --git a/.github/scripts/tempo-check.sh b/.github/scripts/tempo-check.sh index 0de494fb7823c..4a5b0cb316a73 100755 --- a/.github/scripts/tempo-check.sh +++ b/.github/scripts/tempo-check.sh @@ -212,7 +212,7 @@ echo "$KC_INFO" | grep -q "secp256k1" echo -e "\n=== CAST KEYCHAIN: KEY-INFO --json ===" KC_INFO_JSON=$(cast keychain info "$ADDR" "$KC_KEY_ADDR" --rpc-url "$ETH_RPC_URL" --json) -echo "$KC_INFO_JSON" | jq -e '.signatureType == "secp256k1"' +echo "$KC_INFO_JSON" | jq -e '.data.signatureType == "secp256k1"' echo -e "\n=== CAST KEYCHAIN: AUTHORIZE WITH LIMIT ===" kc_limited_json="$(cast wallet new --json)" diff --git a/crates/cast/src/args.rs b/crates/cast/src/args.rs index a3ae71b3d3768..9be01663a4a5b 100644 --- a/crates/cast/src/args.rs +++ b/crates/cast/src/args.rs @@ -6,7 +6,7 @@ use crate::{ traces::identifier::SignaturesIdentifier, tx::CastTxSender, }; -use alloy_dyn_abi::{DynSolValue, ErrorExt, EventExt}; +use alloy_dyn_abi::{ErrorExt, EventExt}; use alloy_eips::eip7702::SignedAuthorization; use alloy_ens::{ProviderEnsExt, namehash}; use alloy_network::Ethereum; @@ -17,12 +17,12 @@ use clap::CommandFactory; use clap_complete::generate; use eyre::{Result, WrapErr}; use foundry_cli::{ - json::print_json_success, + json::{print_json_object, print_json_value_or_scalar, print_list, print_scalar, print_tokens}, utils::{self, LoadConfig}, }; use foundry_common::{ abi::{get_error, get_event}, - fmt::{format_tokens, format_uint_exp, serialize_value_as_json}, + fmt::format_uint_exp, fs, provider::ProviderBuilder, selectors::{ @@ -242,11 +242,24 @@ pub async fn run_command(args: CastArgs) -> Result<()> { // envelope CastSubcommand::AbiEncodeEvent { sig, args } => { let log_data = SimpleCast::abi_encode_event(&sig, &args)?; - for (i, topic) in log_data.topics().iter().enumerate() { - sh_println!("[topic{}]: {}", i, topic)?; - } - if !log_data.data.is_empty() { - sh_println!("[data]: {}", hex::encode_prefixed(log_data.data))?; + if shell::is_json() { + #[derive(serde::Serialize)] + struct EncodedEvent { + topics: Vec, + data: String, + } + let encoded = EncodedEvent { + topics: log_data.topics().iter().map(|t| t.to_string()).collect(), + data: hex::encode_prefixed(&log_data.data), + }; + print_json_object(encoded)?; + } else { + for (i, topic) in log_data.topics().iter().enumerate() { + sh_println!("[topic{}]: {}", i, topic)?; + } + if !log_data.data.is_empty() { + sh_println!("[data]: {}", hex::encode_prefixed(log_data.data))?; + } } } CastSubcommand::DecodeCalldata { sig, calldata, file } => { @@ -398,7 +411,8 @@ pub async fn run_command(args: CastArgs) -> Result<()> { CastSubcommand::Block { block, full, fields, raw, rpc, network } => { let config = rpc.load_config()?; // Can use either --raw or specify raw as a field - let output = if raw || fields.contains(&"raw".into()) { + let is_raw_block = raw || fields.contains(&"raw".into()); + let output = if is_raw_block { match network { #[cfg(feature = "optimism")] Some(NetworkVariant::Optimism) => { @@ -431,8 +445,7 @@ pub async fn run_command(args: CastArgs) -> Result<()> { .block(block.unwrap_or(BlockId::Number(Latest)), full, fields) .await? }; - // JSON: Output is already formatted by `Cast::block()` - sh_println!("{output}")?; + print_json_value_or_scalar(output)?; } CastSubcommand::BlockNumber { rpc, block } => { let config = rpc.load_config()?; @@ -505,14 +518,11 @@ pub async fn run_command(args: CastArgs) -> Result<()> { let out = SimpleCast::disassemble(&hex::decode(bytecode)?)?; print_scalar(out)?; } - // TODO(json): tabular multi-row output, needs array envelope CastSubcommand::Selectors { bytecode, resolve } => { let bytecode = stdin::unwrap_line(bytecode)?; let functions = SimpleCast::extract_functions(&bytecode)?; - let max_args_len = functions.iter().map(|r| r.1.len()).max().unwrap_or(0); - let max_mutability_len = functions.iter().map(|r| r.2.len()).max().unwrap_or(0); - let resolve_results = if resolve { + let resolve_results: Vec = if resolve { let selectors = functions .iter() .map(|&(selector, ..)| SelectorKind::Function(selector)) @@ -522,15 +532,41 @@ pub async fn run_command(args: CastArgs) -> Result<()> { } else { vec![] }; - for (pos, (selector, arguments, state_mutability)) in functions.into_iter().enumerate() - { - if resolve { - let resolved = &resolve_results[pos]; - sh_println!( - "{selector}\t{arguments:max_args_len$}\t{state_mutability:max_mutability_len$}\t{resolved}" - )? - } else { - sh_println!("{selector}\t{arguments:max_args_len$}\t{state_mutability}")? + + if shell::is_json() { + #[derive(serde::Serialize)] + struct SelectorInfo { + selector: String, + arguments: String, + state_mutability: String, + #[serde(skip_serializing_if = "Option::is_none")] + resolved: Option, + } + let infos: Vec = functions + .into_iter() + .enumerate() + .map(|(pos, (selector, arguments, state_mutability))| SelectorInfo { + selector: selector.to_string(), + arguments, + state_mutability: state_mutability.to_string(), + resolved: resolve_results.get(pos).cloned(), + }) + .collect(); + print_json_object(infos)?; + } else { + let max_args_len = functions.iter().map(|r| r.1.len()).max().unwrap_or(0); + let max_mutability_len = functions.iter().map(|r| r.2.len()).max().unwrap_or(0); + for (pos, (selector, arguments, state_mutability)) in + functions.into_iter().enumerate() + { + if resolve { + let resolved = &resolve_results[pos]; + sh_println!( + "{selector}\t{arguments:max_args_len$}\t{state_mutability:max_mutability_len$}\t{resolved}" + )? + } else { + sh_println!("{selector}\t{arguments:max_args_len$}\t{state_mutability}")? + } } } } @@ -660,8 +696,7 @@ pub async fn run_command(args: CastArgs) -> Result<()> { .await? } }; - // JSON: Output is already formatted by `Cast::transaction()` - sh_println!("{output}")?; + print_json_value_or_scalar(output)?; } // 4Byte @@ -674,8 +709,8 @@ pub async fn run_command(args: CastArgs) -> Result<()> { print_list(&sigs)?; } - // TODO(json): multiple candidates + interactive selection + decoded tokens, needs - // structured envelope + // JSON envelope intentionally unsupported: output combines an interactive selector + // disambiguation step with decoded token output; no single stable shape exists. CastSubcommand::FourByteCalldata { calldata } => { let calldata = stdin::unwrap_line(calldata)?; @@ -716,7 +751,8 @@ pub async fn run_command(args: CastArgs) -> Result<()> { } print_list(&sigs)?; } - // TODO(json): external API response printed via .describe(), needs structured envelope + // JSON envelope intentionally unsupported: output is a human-readable summary from an + // external selector registry API with no stable machine-readable schema. CastSubcommand::UploadSignature { signatures } => { let signatures = stdin::unwrap_vec(signatures)?; let ParsedSignatures { signatures, abis } = parse_signatures(signatures); @@ -886,60 +922,6 @@ pub async fn run_command(args: CastArgs) -> Result<()> { CastSubcommand::Trace(cmd) => cmd.run().await?, }; - /// Prints a scalar value: JSON envelope in `--json` mode, plain text otherwise. - fn print_scalar(value: impl serde::Serialize + std::fmt::Display) -> Result<()> { - if shell::is_json() { - print_json_success(value)?; - } else { - sh_println!("{value}")?; - } - Ok(()) - } - - /// Prints a list of serializable items: JSON envelope wrapping an array in `--json` mode, - /// one item per line otherwise. - fn print_list(items: &[T]) -> Result<()> { - if shell::is_json() { - print_json_success(items)?; - } else { - for item in items { - sh_println!("{item}")?; - } - } - Ok(()) - } - - /// Wraps a serializable object in the JSON envelope in `--json` mode, otherwise pretty-prints - /// it as JSON. Used for objects that have no human-readable `Display` format. - fn print_json_object(value: T) -> Result<()> { - if shell::is_json() { - print_json_success(value)?; - } else { - sh_println!("{}", serde_json::to_string_pretty(&value)?)?; - } - Ok(()) - } - - /// Prints slice of tokens using [`format_tokens`] or [`serialize_value_as_json`] depending - /// whether the shell is in JSON mode. - /// - /// This is included here to avoid a cyclic dependency between `fmt` and `common`. - fn print_tokens(tokens: &[DynSolValue]) -> Result<()> { - if shell::is_json() { - let values = tokens - .iter() - .cloned() - .map(|t| serialize_value_as_json(t, None)) - .collect::>>()?; - print_json_success(values)?; - } else { - format_tokens(tokens).for_each(|t| { - let _ = sh_println!("{t}"); - }); - } - Ok(()) - } - Ok(()) } diff --git a/crates/cast/src/cmd/erc20.rs b/crates/cast/src/cmd/erc20.rs index bddc0939ed215..f49075ea2822d 100644 --- a/crates/cast/src/cmd/erc20.rs +++ b/crates/cast/src/cmd/erc20.rs @@ -15,6 +15,7 @@ use alloy_signer::{Signature, Signer}; use alloy_sol_types::sol; use clap::Parser; use foundry_cli::{ + json::{print_json_success, print_scalar}, opts::RpcOpts, utils::{LoadConfig, get_chain, get_provider}, }; @@ -504,9 +505,9 @@ impl Erc20Subcommand { .await?; if shell::is_json() { - sh_println!("{}", serde_json::to_string(&allowance.to_string())?)? + print_json_success(allowance.to_string())?; } else { - sh_println!("{}", format_uint_exp(allowance))? + sh_println!("{}", format_uint_exp(allowance))?; } } Self::Balance { token, owner, block, .. } => { @@ -521,9 +522,9 @@ impl Erc20Subcommand { .await?; if shell::is_json() { - sh_println!("{}", serde_json::to_string(&balance.to_string())?)? + print_json_success(balance.to_string())?; } else { - sh_println!("{}", format_uint_exp(balance))? + sh_println!("{}", format_uint_exp(balance))?; } } Self::Name { token, block, .. } => { @@ -536,11 +537,7 @@ impl Erc20Subcommand { .call() .await?; - if shell::is_json() { - sh_println!("{}", serde_json::to_string(&name)?)? - } else { - sh_println!("{}", name)? - } + print_scalar(name)?; } Self::Symbol { token, block, .. } => { let provider = get_provider(&config)?; @@ -552,11 +549,7 @@ impl Erc20Subcommand { .call() .await?; - if shell::is_json() { - sh_println!("{}", serde_json::to_string(&symbol)?)? - } else { - sh_println!("{}", symbol)? - } + print_scalar(symbol)?; } Self::Decimals { token, block, .. } => { let provider = get_provider(&config)?; @@ -567,11 +560,7 @@ impl Erc20Subcommand { .block(block.unwrap_or_default()) .call() .await?; - if shell::is_json() { - sh_println!("{}", serde_json::to_string(&decimals)?)? - } else { - sh_println!("{}", decimals)? - } + print_scalar(decimals)?; } Self::TotalSupply { token, block, .. } => { let provider = get_provider(&config)?; @@ -584,7 +573,7 @@ impl Erc20Subcommand { .await?; if shell::is_json() { - sh_println!("{}", serde_json::to_string(&total_supply.to_string())?)? + print_json_success(total_supply.to_string())?; } else { sh_println!("{}", format_uint_exp(total_supply))? } diff --git a/crates/cast/src/cmd/estimate.rs b/crates/cast/src/cmd/estimate.rs index 405266b60457a..5b6d042940ee7 100644 --- a/crates/cast/src/cmd/estimate.rs +++ b/crates/cast/src/cmd/estimate.rs @@ -7,6 +7,7 @@ use alloy_rpc_types::BlockId; use clap::Parser; use eyre::Result; use foundry_cli::{ + json::print_scalar, opts::{RpcOpts, TransactionOpts}, utils::{LoadConfig, parse_ether_value}, }; @@ -130,9 +131,9 @@ impl EstimateArgs { let gas_price_wei = provider.get_gas_price().await?; let cost = gas_price_wei * gas as u128; let cost_eth = cost as f64 / 1e18; - sh_println!("{cost_eth}")?; + print_scalar(cost_eth)?; } else { - sh_println!("{gas}")?; + print_scalar(gas)?; } Ok(()) } diff --git a/crates/cast/src/cmd/find_block.rs b/crates/cast/src/cmd/find_block.rs index 19aa9e3ff6abb..76705b059d80f 100644 --- a/crates/cast/src/cmd/find_block.rs +++ b/crates/cast/src/cmd/find_block.rs @@ -3,6 +3,7 @@ use alloy_provider::Provider; use clap::Parser; use eyre::Result; use foundry_cli::{ + json::print_scalar, opts::RpcOpts, utils::{self, LoadConfig}, }; @@ -79,7 +80,7 @@ impl FindBlockArgs { } matching_block.unwrap_or(low_block) }; - sh_println!("{block_num}")?; + print_scalar(block_num)?; Ok(()) } diff --git a/crates/cast/src/cmd/interface.rs b/crates/cast/src/cmd/interface.rs index 2e2051822dafc..6c7f2817401f4 100644 --- a/crates/cast/src/cmd/interface.rs +++ b/crates/cast/src/cmd/interface.rs @@ -85,27 +85,39 @@ impl InterfaceArgs { // Retrieve interfaces from the array of ABIs. let interfaces = get_interfaces(abis, config)?; - // Print result or write to file. - let res = if shell::is_json() { - // Format as JSON. - interfaces.iter().map(|iface| &iface.json_abi).format("\n").to_string() - } else { - // Format as Solidity. - format!( - "// SPDX-License-Identifier: UNLICENSED\n\ - pragma solidity {pragma};\n\n\ - {}", - interfaces.iter().map(|iface| &iface.source).format("\n") - ) - }; - if let Some(loc) = output_location { + let res = if shell::is_json() { + let abis = interfaces + .iter() + .map(|iface| serde_json::from_str::(&iface.json_abi)) + .collect::, _>>()?; + serde_json::to_string_pretty(&abis)? + } else { + format!( + "// SPDX-License-Identifier: UNLICENSED\n\ + pragma solidity {pragma};\n\n\ + {}", + interfaces.iter().map(|iface| &iface.source).format("\n") + ) + }; if let Some(parent) = loc.parent() { fs::create_dir_all(parent)?; } fs::write(&loc, res)?; - sh_println!("Saved interface at {}", loc.display())?; + sh_status!("Saved interface at {}", loc.display())?; + } else if shell::is_json() { + let abis = interfaces + .iter() + .map(|iface| serde_json::from_str::(&iface.json_abi)) + .collect::, _>>()?; + foundry_cli::json::print_json_object(abis)?; } else { + let res = format!( + "// SPDX-License-Identifier: UNLICENSED\n\ + pragma solidity {pragma};\n\n\ + {}", + interfaces.iter().map(|iface| &iface.source).format("\n") + ); sh_print!("{res}")?; } diff --git a/crates/cast/src/cmd/keychain.rs b/crates/cast/src/cmd/keychain.rs index 88f75aad8957b..e0dbe46f67941 100644 --- a/crates/cast/src/cmd/keychain.rs +++ b/crates/cast/src/cmd/keychain.rs @@ -13,6 +13,7 @@ use chrono::DateTime; use clap::Parser; use eyre::Result; use foundry_cli::{ + json::print_json_object, opts::{RpcOpts, TempoOpts, TransactionOpts}, utils::LoadConfig, }; @@ -725,14 +726,14 @@ impl KeychainPolicySubcommand { fn run_list() -> Result<()> { let keys_file = load_keys_file()?; - if keys_file.keys.is_empty() { - sh_println!("No keys found in keys.toml.")?; + if shell::is_json() { + let entries: Vec<_> = keys_file.keys.iter().map(key_entry_to_json).collect(); + print_json_object(entries)?; return Ok(()); } - if shell::is_json() { - let entries: Vec<_> = keys_file.keys.iter().map(key_entry_to_json).collect(); - sh_println!("{}", serde_json::to_string_pretty(&entries)?)?; + if keys_file.keys.is_empty() { + sh_println!("No keys found in keys.toml.")?; return Ok(()); } @@ -759,8 +760,8 @@ fn run_show(wallet_address: Address) -> Result<()> { } if shell::is_json() { - let json: Vec<_> = entries.iter().map(|e| key_entry_to_json(e)).collect(); - sh_println!("{}", serde_json::to_string_pretty(&json)?)?; + let entries_json: Vec<_> = entries.iter().map(|e| key_entry_to_json(e)).collect(); + print_json_object(entries_json)?; return Ok(()); } @@ -882,7 +883,7 @@ async fn run_inspect( "limits": limits.iter().map(inspected_limit_to_json).collect::>(), "allowed_calls": allowed_calls_to_json(&allowed_calls), }); - sh_println!("{}", serde_json::to_string_pretty(&json)?)?; + print_json_object(json)?; return Ok(()); } @@ -932,7 +933,7 @@ async fn run_check(wallet_address: Address, key_address: Address, rpc: RpcOpts) "enforce_limits": info.enforceLimits, "is_revoked": info.isRevoked, }); - sh_println!("{}", serde_json::to_string_pretty(&json)?)?; + print_json_object(json)?; return Ok(()); } @@ -2549,8 +2550,7 @@ fn finalize_doctor(steps: Vec, context: DoctorContext) -> Result<()> }; if shell::is_json() { - let json = serde_json::json!({ - "schema_version": 1, + foundry_cli::json::print_json_success(serde_json::json!({ "context": context, "steps": steps, "status": status, @@ -2558,8 +2558,7 @@ fn finalize_doctor(steps: Vec, context: DoctorContext) -> Result<()> "healthy": healthy, "warning_count": warning_count, "failure_count": failure_count, - }); - sh_println!("{}", serde_json::to_string_pretty(&json)?)?; + }))?; } else { for step in &steps { print_doctor_step(step)?; diff --git a/crates/cast/src/cmd/logs.rs b/crates/cast/src/cmd/logs.rs index 9ce651303e180..7cf9ec9c5917a 100644 --- a/crates/cast/src/cmd/logs.rs +++ b/crates/cast/src/cmd/logs.rs @@ -98,6 +98,8 @@ impl LogsArgs { return Ok(()); } + // JSON envelope intentionally unsupported for streaming: --subscribe emits NDJSON events + // continuously; a terminal JsonEnvelope is pointless. // FIXME: this is a hotfix for // currently the alloy `eth_subscribe` impl does not work with all transports, so we use // the builtin transport here for now diff --git a/crates/cast/src/cmd/mktx.rs b/crates/cast/src/cmd/mktx.rs index 67178cd093d7b..415bd52c00a6b 100644 --- a/crates/cast/src/cmd/mktx.rs +++ b/crates/cast/src/cmd/mktx.rs @@ -11,6 +11,7 @@ use alloy_signer::{Signature, Signer}; use clap::Parser; use eyre::Result; use foundry_cli::{ + json::print_scalar, opts::{EthereumOpts, TransactionOpts}, utils::{LoadConfig, maybe_print_resolved_lane, resolve_lane}, }; @@ -146,12 +147,12 @@ impl MakeTxArgs { let hash = tx.compute_sponsor_hash(from).ok_or_else(|| { eyre::eyre!("This network does not support sponsored transactions") })?; - sh_println!("{hash:?}")?; + print_scalar(format!("{hash:?}"))?; return Ok(()); } if let Some(ts) = expires_at { - sh_println!("Transaction expires at unix timestamp {ts}")?; + sh_status!("Transaction expires at unix timestamp {ts}")?; } if raw_unsigned { @@ -179,7 +180,7 @@ impl MakeTxArgs { } let raw_tx = hex::encode_prefixed(tx.build_unsigned()?.encoded_for_signing()); - sh_println!("{raw_tx}")?; + print_scalar(raw_tx)?; return Ok(()); } @@ -193,7 +194,7 @@ impl MakeTxArgs { } let signed_tx = provider.sign_transaction(tx).await?; - sh_println!("{signed_tx}")?; + print_scalar(signed_tx)?; return Ok(()); } @@ -212,8 +213,8 @@ impl MakeTxArgs { let tx = tx.build(&EthereumWallet::new(signer)).await?; - let signed_tx = hex::encode(tx.encoded_2718()); - sh_println!("0x{signed_tx}")?; + let signed_tx = format!("0x{}", hex::encode(tx.encoded_2718())); + print_scalar(signed_tx)?; Ok(()) } diff --git a/crates/cast/src/cmd/rpc.rs b/crates/cast/src/cmd/rpc.rs index 8883c3fbb5be2..f8de7c4a5aa8e 100644 --- a/crates/cast/src/cmd/rpc.rs +++ b/crates/cast/src/cmd/rpc.rs @@ -1,8 +1,7 @@ use crate::Cast; use clap::Parser; use eyre::Result; -use foundry_cli::{opts::RpcOpts, utils, utils::LoadConfig}; -use foundry_common::shell; +use foundry_cli::{json::print_json_object, opts::RpcOpts, utils, utils::LoadConfig}; use itertools::Itertools; /// CLI arguments for `cast rpc`. @@ -55,12 +54,8 @@ impl RpcArgs { let provider = utils::get_provider(&config)?; let result = Cast::new(provider).rpc(&method, params).await?; - if shell::is_json() { - let result: serde_json::Value = serde_json::from_str(&result)?; - sh_println!("{}", serde_json::to_string_pretty(&result)?)?; - } else { - sh_println!("{}", result)?; - } + let result: serde_json::Value = serde_json::from_str(&result)?; + print_json_object(result)?; Ok(()) } } diff --git a/crates/cast/src/cmd/send.rs b/crates/cast/src/cmd/send.rs index 54d9c02a6c14d..c7ebc90955f60 100644 --- a/crates/cast/src/cmd/send.rs +++ b/crates/cast/src/cmd/send.rs @@ -453,7 +453,8 @@ where let cast = CastTxSender::new(provider); if sync { - // Send transaction and wait for receipt synchronously + // JSON envelope not supported: N::ReceiptResponse is generic over Display but not + // Serialize; adding Serialize would ripple across all network-generic callers. let receipt = cast.send_sync(tx).await?; sh_println!("{receipt}")?; } else { diff --git a/crates/cast/src/cmd/txpool.rs b/crates/cast/src/cmd/txpool.rs index 2947295ff4d98..8ccaffda57987 100644 --- a/crates/cast/src/cmd/txpool.rs +++ b/crates/cast/src/cmd/txpool.rs @@ -2,6 +2,7 @@ use alloy_primitives::Address; use alloy_provider::ext::TxPoolApi; use clap::Parser; use foundry_cli::{ + json::print_json_object, opts::RpcOpts, utils::{self, LoadConfig}, }; @@ -41,25 +42,25 @@ impl TxPoolSubcommands { let config = args.load_config()?; let provider = utils::get_provider(&config)?; let content = provider.txpool_content().await?; - sh_println!("{}", serde_json::to_string_pretty(&content)?)?; + print_json_object(content)?; } Self::ContentFrom { from, args } => { let config = args.load_config()?; let provider = utils::get_provider(&config)?; let content = provider.txpool_content_from(from).await?; - sh_println!("{}", serde_json::to_string_pretty(&content)?)?; + print_json_object(content)?; } Self::Inspect { args } => { let config = args.load_config()?; let provider = utils::get_provider(&config)?; let inspect = provider.txpool_inspect().await?; - sh_println!("{}", serde_json::to_string_pretty(&inspect)?)?; + print_json_object(inspect)?; } Self::Status { args } => { let config = args.load_config()?; let provider = utils::get_provider(&config)?; let status = provider.txpool_status().await?; - sh_println!("{}", serde_json::to_string_pretty(&status)?)?; + print_json_object(status)?; } }; diff --git a/crates/cast/src/cmd/vaddr/create.rs b/crates/cast/src/cmd/vaddr/create.rs index 531be8169d9bb..02fb609bb8ff3 100644 --- a/crates/cast/src/cmd/vaddr/create.rs +++ b/crates/cast/src/cmd/vaddr/create.rs @@ -10,7 +10,10 @@ use crate::{ use alloy_primitives::{Address, B256}; use alloy_signer::Signer; use eyre::Result; -use foundry_cli::utils::{LoadConfig, get_chain}; +use foundry_cli::{ + json::print_json_success, + utils::{LoadConfig, get_chain}, +}; use foundry_common::{provider::ProviderBuilder, shell}; use rand::{RngCore, SeedableRng, rngs::StdRng}; use serde_json::json; @@ -95,20 +98,17 @@ pub(super) async fn run( virtual_addresses.push((user_tag, vaddr)); } - if shell::is_json() { - sh_println!( - "{}", - serde_json::to_string_pretty(&json!({ - "salt": format!("{}", output.salt), - "registration_hash": format!("{}", output.registration_hash), - "master_id": format!("{}", output.master_id), - "virtual_addresses": virtual_addresses.iter().map(|(tag, addr)| json!({ - "tag": format!("{tag}"), - "address": format!("{addr}"), - })).collect::>(), - }))? - )?; - } else { + let payload = json!({ + "salt": format!("{}", output.salt), + "registration_hash": format!("{}", output.registration_hash), + "master_id": format!("{}", output.master_id), + "virtual_addresses": virtual_addresses.iter().map(|(tag, addr)| json!({ + "tag": format!("{tag}"), + "address": format!("{addr}"), + })).collect::>(), + }); + + if !shell::is_json() { sh_println!( "Salt: {} Registration hash: {} @@ -124,10 +124,19 @@ Master ID: {}", } if no_register { + if shell::is_json() { + print_json_success(payload)?; + } return Ok(()); } - register(owner, output.salt, send_tx, tx_opts).await + register(owner, output.salt, send_tx, tx_opts).await?; + + if shell::is_json() { + print_json_success(payload)?; + } + + Ok(()) } async fn register( diff --git a/crates/cast/src/cmd/wallet/mod.rs b/crates/cast/src/cmd/wallet/mod.rs index b2378d8bfdc58..2775ab6de0c53 100644 --- a/crates/cast/src/cmd/wallet/mod.rs +++ b/crates/cast/src/cmd/wallet/mod.rs @@ -308,6 +308,8 @@ pub enum WalletSubcommands { } impl WalletSubcommands { + // NOTE: wallet subcommands use custom shell::is_json() branches with local output shapes. + // TODO: Full JsonEnvelope migration is deferred to a follow-up pass. pub async fn run(self) -> Result<()> { match self { Self::New { path, account_name, unsafe_password, number, password, force } => { diff --git a/crates/cast/src/lib.rs b/crates/cast/src/lib.rs index 4a490156b485f..4bc4b7c769d66 100644 --- a/crates/cast/src/lib.rs +++ b/crates/cast/src/lib.rs @@ -1134,8 +1134,19 @@ where let encoded = tx.as_ref().encoded_2718(); format!("0x{}", hex::encode(encoded)) } else if let Some(ref field) = field { - get_pretty_tx_attr::(&tx, field.as_str()) - .ok_or_else(|| eyre::eyre!("invalid tx field: {}", field.clone()))? + if let Some(value) = get_pretty_tx_attr::(&tx, field.as_str()) { + value + } else { + let tx_json = serde_json::to_value(&tx)?; + let value = tx_json + .get(field) + .ok_or_else(|| eyre::eyre!("invalid tx field: {}", field.clone()))?; + + match value { + serde_json::Value::String(value) => value.clone(), + value => value.to_string(), + } + } } else if shell::is_json() { // to_value first to sort json object keys serde_json::to_value(&tx)?.to_string() diff --git a/crates/cast/tests/cli/erc20.rs b/crates/cast/tests/cli/erc20.rs index 9119ba12f9b80..a03690f3c5166 100644 --- a/crates/cast/tests/cli/erc20.rs +++ b/crates/cast/tests/cli/erc20.rs @@ -557,7 +557,8 @@ forgetest_async!(erc20_balance_json, |prj, cmd| { .get_output() .stdout_lossy(); - let balance_str: String = serde_json::from_str(&output).expect("valid json string"); + let v: serde_json::Value = serde_json::from_str(&output).expect("valid json"); + let balance_str = v["data"].as_str().expect("string data"); let balance: U256 = balance_str.parse().unwrap(); assert_eq!(balance, U256::from(1_000_000_000_000_000_000_000u128)); }); @@ -599,7 +600,8 @@ forgetest_async!(erc20_allowance_json, |prj, cmd| { .get_output() .stdout_lossy(); - let allowance_str: String = serde_json::from_str(&output).expect("valid json string"); + let v: serde_json::Value = serde_json::from_str(&output).expect("valid json"); + let allowance_str = v["data"].as_str().expect("string data"); let allowance: U256 = allowance_str.parse().unwrap(); assert_eq!(allowance, approve_amount); }); @@ -616,8 +618,8 @@ forgetest_async!(erc20_metadata_json, |prj, cmd| { .assert_success() .get_output() .stdout_lossy(); - let name: String = serde_json::from_str(&output).expect("valid json string"); - assert_eq!(name, "Test Token"); + let v: serde_json::Value = serde_json::from_str(&output).expect("valid json"); + assert_eq!(v["data"].as_str().expect("string data"), "Test Token"); // Test symbol with --json let output = cmd @@ -626,8 +628,8 @@ forgetest_async!(erc20_metadata_json, |prj, cmd| { .assert_success() .get_output() .stdout_lossy(); - let symbol: String = serde_json::from_str(&output).expect("valid json string"); - assert_eq!(symbol, "TEST"); + let v: serde_json::Value = serde_json::from_str(&output).expect("valid json"); + assert_eq!(v["data"].as_str().expect("string data"), "TEST"); // Test decimals with --json let output = cmd @@ -636,8 +638,8 @@ forgetest_async!(erc20_metadata_json, |prj, cmd| { .assert_success() .get_output() .stdout_lossy(); - let decimals: u8 = output.trim().parse().expect("valid number"); - assert_eq!(decimals, 18); + let v: serde_json::Value = serde_json::from_str(&output).expect("valid json"); + assert_eq!(v["data"].as_u64().expect("numeric data"), 18); // Test totalSupply with --json let output = cmd @@ -646,7 +648,7 @@ forgetest_async!(erc20_metadata_json, |prj, cmd| { .assert_success() .get_output() .stdout_lossy(); - let total_supply_str: String = serde_json::from_str(&output).expect("valid json string"); - let total_supply: U256 = total_supply_str.parse().unwrap(); + let v: serde_json::Value = serde_json::from_str(&output).expect("valid json"); + let total_supply: U256 = v["data"].as_str().expect("string data").parse().unwrap(); assert_eq!(total_supply, U256::from(1_000_000_000_000_000_000_000u128)); }); diff --git a/crates/cast/tests/cli/keychain.rs b/crates/cast/tests/cli/keychain.rs index 88e9e16983cc5..0f2c5ebf82ae7 100644 --- a/crates/cast/tests/cli/keychain.rs +++ b/crates/cast/tests/cli/keychain.rs @@ -74,3 +74,24 @@ casttest!(keychain_authorize_sponsor_hash_json_is_object, async |_prj, cmd| { assert!(hash.starts_with("0x"), "sponsor_hash should be 0x-prefixed, got: {hash}"); assert_eq!(hash.len(), 66, "sponsor_hash should be 32-byte hex (66 chars), got: {hash}"); }); + +casttest!(keychain_doctor_json_keeps_report_schema_version, async |_prj, cmd| { + let output = cmd + .args([ + "keychain", + "doctor", + accounts::ADDR2, + "--root-account", + accounts::ADDR1, + "--rpc-url", + "http://127.0.0.1:1", + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let parsed: serde_json::Value = serde_json::from_str(output.trim()) + .expect("cast keychain doctor --json should emit valid JSON"); + assert_eq!(parsed["schema_version"], 1); +}); diff --git a/crates/cast/tests/cli/main.rs b/crates/cast/tests/cli/main.rs index f8dd15ffc6625..0f25d14b753e1 100644 --- a/crates/cast/tests/cli/main.rs +++ b/crates/cast/tests/cli/main.rs @@ -193,6 +193,31 @@ casttest!(block_raw, |_prj, cmd| { ); }); +casttest!(block_json_wraps_raw_and_scalar_field_outputs, |_prj, cmd| { + let eth_rpc_url = next_http_rpc_endpoint(); + + let raw_output = cmd + .args(["block", "22934900", "--rpc-url", eth_rpc_url.as_str(), "--raw", "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + let raw_envelope: serde_json::Value = serde_json::from_str(raw_output.trim()).unwrap(); + assert_eq!(raw_envelope["schema_version"], 1); + assert!(raw_envelope["success"].as_bool().unwrap()); + assert!(raw_envelope["data"].as_str().unwrap().starts_with("0x")); + + let field_output = cmd + .cast_fuse() + .args(["block", "0x123", "--field", "number", "--rpc-url", eth_rpc_url.as_str(), "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + let field_envelope: serde_json::Value = serde_json::from_str(field_output.trim()).unwrap(); + assert_eq!(field_envelope["schema_version"], 1); + assert!(field_envelope["success"].as_bool().unwrap()); + assert_eq!(field_envelope["data"], 291); +}); + casttest!(block_raw_tempo, |_prj, cmd| { // https://explore.tempo.xyz/block/8386710 let output = cmd @@ -1495,25 +1520,31 @@ casttest!(rpc_format_as_json, |_prj, cmd| { cmd.args(["rpc", "--rpc-url", eth_rpc_url.as_str(), "eth_getBlockByNumber", "0x123", "false", "--json"]) .assert_json_stdout(str![[r#" { - "hash": "0xc5dab4e189004a1312e9db43a40abb2de91ad7dd25e75880bf36016d8e9df524", - "parentHash": "0x7abfd11e862ccde76d6ea8ee20978aac26f4bcb55de1188cc0335be13e817017", - "sha3Uncles": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", - "miner": "0xbb7b8287f3f0a933474a79eae42cbca977791171", - "stateRoot": "0x3fe6bd17aa85376c7d566df97d9f2e536f37f7a87abb3a6f9e2891cf9442f2e4", - "transactionsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - "receiptsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "difficulty": "0x494433b31", - "number": "0x123", - "gasLimit": "0x1388", - "gasUsed": "0x0", - "timestamp": "0x55ba4564", - "extraData": "0x476574682f4c5649562f76312e302e302f6c696e75782f676f312e342e32", - "mixHash": "0x943056aa305aa6d22a3c06110942980342d1f4d4b11c17711961436a0f963ea0", - "nonce": "0x29d6547c196e00e0", - "size": "0x220", - "uncles": [], - "transactions": [] + "schema_version": 1, + "success": true, + "data": { + "hash": "0xc5dab4e189004a1312e9db43a40abb2de91ad7dd25e75880bf36016d8e9df524", + "parentHash": "0x7abfd11e862ccde76d6ea8ee20978aac26f4bcb55de1188cc0335be13e817017", + "sha3Uncles": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "miner": "0xbb7b8287f3f0a933474a79eae42cbca977791171", + "stateRoot": "0x3fe6bd17aa85376c7d566df97d9f2e536f37f7a87abb3a6f9e2891cf9442f2e4", + "transactionsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "receiptsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "difficulty": "0x494433b31", + "number": "0x123", + "gasLimit": "0x1388", + "gasUsed": "0x0", + "timestamp": "0x55ba4564", + "extraData": "0x476574682f4c5649562f76312e302e302f6c696e75782f676f312e342e32", + "mixHash": "0x943056aa305aa6d22a3c06110942980342d1f4d4b11c17711961436a0f963ea0", + "nonce": "0x29d6547c196e00e0", + "size": "0x220", + "uncles": [], + "transactions": [] + }, + "errors": [], + "warnings": [] } "#]]); @@ -2973,11 +3004,22 @@ casttest!(send_eip7702_multiple_auth, async |_prj, cmd| { .get_output() .stdout_lossy(); - let tx_json: serde_json::Value = serde_json::from_str(&tx_output).unwrap(); - let auth_list = tx_json["authorizationList"].as_array().unwrap(); + let tx_envelope: serde_json::Value = serde_json::from_str(&tx_output).unwrap(); + let auth_list = tx_envelope["data"]["authorizationList"].as_array().unwrap(); // Verify we have 2 authorizations assert_eq!(auth_list.len(), 2, "Expected 2 authorizations in the transaction"); + + let field_output = cmd + .cast_fuse() + .args(["tx", tx_hash, "authorizationList", "--rpc-url", &endpoint, "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + + let field_envelope: serde_json::Value = serde_json::from_str(field_output.trim()).unwrap(); + let field_auth_list = field_envelope["data"].as_array().unwrap(); + assert_eq!(field_auth_list.len(), 2, "Expected authorizationList field data to be an array"); }); // Test that multiple address-based authorizations are rejected @@ -4832,10 +4874,10 @@ casttest!(correct_json_serialization, |_prj, cmd| { [true, "0x0000000000000000000000000000000000000000000000000000000000000012"], [true, "0x0000000000000000000000000000000000000000000000000000000000000012"] ]]); - let decoded: serde_json::Value = + let output: serde_json::Value = serde_json::from_slice(&cmd.args(args).assert_success().get_output().stdout) .expect("not valid json"); - assert_eq!(decoded, expected_output); + assert_eq!(output, expected_output); }); // Test cast abi-encode-event with indexed parameters @@ -5272,7 +5314,8 @@ casttest!(vaddr_create_json_output, |_prj, cmd| { .get_output() .stdout_lossy(); - let v: serde_json::Value = serde_json::from_str(out.trim()).expect("valid JSON"); + let envelope: serde_json::Value = serde_json::from_str(out.trim()).expect("valid JSON"); + let v = &envelope["data"]; assert_eq!(v["salt"], "0x0000000000000000000000000000000000000000000000003ee0a78d00000000"); assert_eq!( v["registration_hash"], diff --git a/crates/cast/tests/cli/selectors.rs b/crates/cast/tests/cli/selectors.rs index 2cb8d0e6ac123..e05aacf05262d 100644 --- a/crates/cast/tests/cli/selectors.rs +++ b/crates/cast/tests/cli/selectors.rs @@ -130,6 +130,48 @@ casttest!(flaky_upload_signatures, |_prj, cmd| { ); }); +casttest!(selectors_json_envelope, |_prj, cmd| { + // bytecode with one function: 0x2125b65b / uint32,address,uint224 / pure + let bytecode = "6080604052348015600e575f80fd5b50600436106026575f3560e01c80632125b65b14602a575b5f80fd5b603a6035366004603c565b505050565b005b5f805f60608486031215604d575f80fd5b833563ffffffff81168114605f575f80fd5b925060208401356001600160a01b03811681146079575f80fd5b915060408401356001600160e01b03811681146093575f80fd5b80915050925092509256"; + + cmd.args(["selectors", bytecode]).assert_success().stdout_eq(str![[r#" +0x2125b65b uint32,address,uint224 pure + +"#]]); + + cmd.args(["--json"]).assert_success().stdout_eq(str![[r#" +{"schema_version":1,"success":true,"data":[{"selector":"0x2125b65b","arguments":"uint32,address,uint224","state_mutability":"pure"}],"errors":[],"warnings":[]} + +"#]]); +}); + +casttest!(abi_encode_event_json_envelope, |_prj, cmd| { + cmd.args([ + "abi-encode-event", + "Transfer(address indexed,address indexed,uint256)", + "0x0000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000002", + "1000", + ]) + .assert_success() + .stdout_eq(str![[r#" +[topic0]: 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef +[topic1]: 0x0000000000000000000000000000000000000000000000000000000000000001 +[topic2]: 0x0000000000000000000000000000000000000000000000000000000000000002 +[data]: 0x00000000000000000000000000000000000000000000000000000000000003e8 + +"#]]); + + // --json must precede the subcommand because `args` uses allow_hyphen_values + cmd.cast_fuse() + .args(["--json", "abi-encode-event", "Transfer(address indexed,address indexed,uint256)", "0x0000000000000000000000000000000000000001", "0x0000000000000000000000000000000000000002", "1000"]) + .assert_success() + .stdout_eq(str![[r#" +{"schema_version":1,"success":true,"data":{"topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000002"],"data":"0x00000000000000000000000000000000000000000000000000000000000003e8"},"errors":[],"warnings":[]} + +"#]]); +}); + // tests cast can decode event with provided signature casttest!(event_decode_with_sig, |_prj, cmd| { cmd.args(["decode-event", "--sig", "MyEvent(uint256,address)", "0x000000000000000000000000000000000000000000000000000000000000004e0000000000000000000000000000000000000000000000000000000000d0004f"]).assert_success().stdout_eq(str![[r#" diff --git a/crates/cli/src/json.rs b/crates/cli/src/json.rs index cebaa71925abf..a8d379591deb2 100644 --- a/crates/cli/src/json.rs +++ b/crates/cli/src/json.rs @@ -1,6 +1,11 @@ //! Shared JSON output primitives for Foundry CLIs. +use alloy_dyn_abi::DynSolValue; use eyre::Result; +use foundry_common::{ + fmt::{format_tokens, serialize_value_as_json}, + sh_println, shell, +}; use serde::{Deserialize, Serialize}; use serde_json::{Value, to_string}; use std::io::Write as _; @@ -130,6 +135,19 @@ impl JsonMessage { } } +/// Prints a serializable object: envelope-wrapped in `--json` mode, pretty-printed otherwise. +/// +/// Use this for objects that have no human-readable `Display` format (block data, RPC responses, +/// etc.). +pub fn print_json_object(value: T) -> Result<()> { + if foundry_common::shell::is_json() { + print_json_success(value) + } else { + sh_println!("{}", serde_json::to_string_pretty(&value)?)?; + Ok(()) + } +} + /// Prints a value as compact, single-line JSON to stdout. /// /// Bypasses the shell verbosity layer so `--quiet` cannot suppress structured @@ -154,6 +172,58 @@ pub fn print_json_success_with_warnings( print_json(&JsonEnvelope::success_with_warnings(data, warnings)) } +/// Prints command output that may already be JSON: parsed and envelope-wrapped in `--json` mode, +/// plain text otherwise. If the output is not valid JSON, it is wrapped as a scalar string. +pub fn print_json_value_or_scalar(value: impl AsRef + std::fmt::Display) -> Result<()> { + if shell::is_json() { + match serde_json::from_str::(value.as_ref()) { + Ok(value) => print_json_success(value), + Err(_) => print_json_success(value.as_ref()), + } + } else { + sh_println!("{value}")?; + Ok(()) + } +} + +/// Prints a scalar value: JSON envelope in `--json` mode, plain text otherwise. +pub fn print_scalar(value: impl Serialize + std::fmt::Display) -> Result<()> { + if shell::is_json() { + print_json_success(value) + } else { + sh_println!("{value}")?; + Ok(()) + } +} + +/// Prints a list of serializable items: JSON envelope wrapping an array in `--json` mode, +/// one item per line otherwise. +pub fn print_list(items: &[T]) -> Result<()> { + if shell::is_json() { + print_json_success(items) + } else { + for item in items { + sh_println!("{item}")?; + } + Ok(()) + } +} + +/// Prints ABI-decoded tokens: JSON envelope wrapping a value array in `--json` mode, +/// one formatted token per line otherwise. +pub fn print_tokens(tokens: &[DynSolValue]) -> Result<()> { + if shell::is_json() { + let values = tokens + .iter() + .cloned() + .map(|t| serialize_value_as_json(t, None)) + .collect::>>()?; + print_json_success(values) + } else { + format_tokens(tokens).try_for_each(|t| sh_println!("{t}")) + } +} + #[cfg(test)] mod tests { use super::*;