diff --git a/crates/cli/src/machine.rs b/crates/cli/src/machine.rs index 71d377f0d00a3..d9330a5437995 100644 --- a/crates/cli/src/machine.rs +++ b/crates/cli/src/machine.rs @@ -150,7 +150,7 @@ pub fn bail_machine_diagnostic( /// the report's cause chain; the [`ExitCode`] returned by /// [`ExitCode::from`] uses the same signals. pub fn report_machine_error(report: &eyre::Report) { - let message = format!("{report}"); + let message = format!("{report:#}"); let envelope = JsonEnvelope::error(JsonMessage::error(diagnostic_code_for_report(report), message)); let _ = print_json(&envelope); diff --git a/crates/common/src/tempo/mod.rs b/crates/common/src/tempo/mod.rs index ef8d0212bd453..0928daff3030f 100644 --- a/crates/common/src/tempo/mod.rs +++ b/crates/common/src/tempo/mod.rs @@ -59,6 +59,31 @@ impl TempoSponsor { tx: &mut N::TransactionRequest, sender: Address, ) -> Result + where + N::TransactionRequest: FoundryTransactionBuilder, + { + let preview = self.attach_inner::(tx, sender, true).await?; + Ok(preview) + } + + /// Same as [`Self::attach_and_print`] but does not write the preview to stderr. + pub async fn attach_silent( + &self, + tx: &mut N::TransactionRequest, + sender: Address, + ) -> Result + where + N::TransactionRequest: FoundryTransactionBuilder, + { + self.attach_inner::(tx, sender, false).await + } + + async fn attach_inner( + &self, + tx: &mut N::TransactionRequest, + sender: Address, + print: bool, + ) -> Result where N::TransactionRequest: FoundryTransactionBuilder, { @@ -82,7 +107,9 @@ impl TempoSponsor { valid_after: tx.valid_after().map(|v| v.get()), digest, }; - preview.print()?; + if print { + preview.print()?; + } let signature = if let Some(signature) = self.signature { signature diff --git a/crates/forge/src/args.rs b/crates/forge/src/args.rs index c927b82258981..db8c4f24623c2 100644 --- a/crates/forge/src/args.rs +++ b/crates/forge/src/args.rs @@ -103,7 +103,10 @@ pub fn run_command(args: Forge) -> Result<()> { CacheSubcommands::Clean(cmd) => cmd.run(), CacheSubcommands::Ls(cmd) => cmd.run(), }, - ForgeSubcommand::Create(cmd) => global.block_on(cmd.run()), + ForgeSubcommand::Create(cmd) => { + cmd.reject_machine_unsupported_flags()?; + global.block_on(cmd.run()) + } ForgeSubcommand::Update(cmd) => cmd.run(), ForgeSubcommand::Install(cmd) => global.block_on(cmd.run()), ForgeSubcommand::Remove(cmd) => cmd.run(), @@ -269,9 +272,11 @@ mod tests { } /// Every adopted command must pin its exact `command_id`, output mode, - /// and schema refs. A drift in any of those is an agent-contract break. + /// schema refs, `side_effects`, and `reads_stdin`. A drift in any of + /// those is an agent-contract break. #[test] fn registered_commands_pin_stable_ids() { + use foundry_cli::introspect::SideEffects; let cmd = ::command(); let doc = build_document(&cmd, ®ISTRY); fn find<'a>( @@ -295,41 +300,69 @@ mod tests { .unwrap_or_else(|| panic!("{id} missing from forge introspect")) }; - // (command_id, expected output_mode, expected result_schema_ref, - // expected event_schema_ref). `session_schema_ref` must be absent - // for every adopted command in this PR. - let pins: &[(&str, OutputMode, &str, Option<&str>)] = &[ - ("forge.build", OutputMode::Envelope, "foundry:forge.build@v1", None), - ( - "forge.test", - OutputMode::Stream, - "foundry:forge.test@v1", - Some("foundry:forge.test.event@v1"), - ), - ( - "forge.script", - OutputMode::Stream, - "foundry:forge.script@v1", - Some("foundry:forge.script.event@v1"), - ), + struct Pin { + id: &'static str, + mode: OutputMode, + result_ref: &'static str, + event_ref: Option<&'static str>, + side_effects: SideEffects, + reads_stdin: bool, + } + + let pins = &[ + Pin { + id: "forge.build", + mode: OutputMode::Envelope, + result_ref: "foundry:forge.build@v1", + event_ref: None, + side_effects: SideEffects::FsWrite, + reads_stdin: false, + }, + Pin { + id: "forge.create", + mode: OutputMode::Envelope, + result_ref: "foundry:forge.create@v1", + event_ref: None, + side_effects: SideEffects::ChainWrite, + reads_stdin: false, + }, + Pin { + id: "forge.test", + mode: OutputMode::Stream, + result_ref: "foundry:forge.test@v1", + event_ref: Some("foundry:forge.test.event@v1"), + side_effects: SideEffects::None, + reads_stdin: false, + }, + Pin { + id: "forge.script", + mode: OutputMode::Stream, + result_ref: "foundry:forge.script@v1", + event_ref: Some("foundry:forge.script.event@v1"), + side_effects: SideEffects::ChainWrite, + reads_stdin: false, + }, ]; - for (id, mode, result_ref, event_ref) in pins { + for pin in pins { + let id = pin.id; let info = lookup(id); - assert_eq!(info.capabilities.output_mode, *mode, "{id} output_mode drift"); + assert_eq!(info.capabilities.output_mode, pin.mode, "{id} output_mode drift"); assert_eq!( info.capabilities.result_schema_ref.as_deref(), - Some(*result_ref), + Some(pin.result_ref), "{id} result_schema_ref drift" ); assert_eq!( info.capabilities.event_schema_ref.as_deref(), - *event_ref, + pin.event_ref, "{id} event_schema_ref drift" ); assert_eq!( info.capabilities.session_schema_ref, None, "{id} must not declare session_schema_ref" ); + assert_eq!(info.capabilities.side_effects, pin.side_effects, "{id} side_effects drift"); + assert_eq!(info.capabilities.reads_stdin, pin.reads_stdin, "{id} reads_stdin drift"); } } } diff --git a/crates/forge/src/cmd/create.rs b/crates/forge/src/cmd/create.rs index 9b26c7465fcc9..cfbe028124f03 100644 --- a/crates/forge/src/cmd/create.rs +++ b/crates/forge/src/cmd/create.rs @@ -12,6 +12,7 @@ use clap::{Parser, ValueHint}; use eyre::{Context, ContextCompat, Result}; use forge_verify::{RetryArgs, VerifierArgs, VerifyArgs}; use foundry_cli::{ + json::{JsonEnvelope, print_json}, opts::{BuildOpts, EthereumOpts, EtherscanOpts, TransactionOpts}, utils::{ LoadConfig, ResolvedLane, find_contract_artifacts, maybe_print_resolved_lane, @@ -40,12 +41,23 @@ use foundry_config::{ use foundry_wallets::{ BrowserWalletOpts, TempoAccessKeyConfig, WalletSigner, wallet_browser::signer::BrowserSigner, }; +use serde::Serialize; use serde_json::json; use std::{borrow::Borrow, marker::PhantomData, path::PathBuf, sync::Arc, time::Duration}; use tempo_alloy::{TempoNetwork, contracts::precompiles::DEFAULT_FEE_TOKEN}; merge_impl_figment_convert!(CreateArgs, build, eth); +/// `forge create --machine` payload. +#[derive(Clone, Debug, Serialize)] +struct CreateData { + contract: String, + broadcast: bool, + deployer: String, + deployed_to: Option, + tx_hash: Option, +} + /// CLI arguments for `forge create`. #[derive(Clone, Debug, Parser)] pub struct CreateArgs { @@ -114,6 +126,29 @@ pub struct CreateArgs { } impl CreateArgs { + /// Rejects flags whose stdout shape conflicts with the envelope contract. + pub fn reject_machine_unsupported_flags(&self) -> Result<()> { + if !foundry_cli::is_machine() { + return Ok(()); + } + let unsupported = [ + ("--verify", self.verify), + ("--show-standard-json-input", self.show_standard_json_input), + ("--browser", self.browser.browser), + ] + .into_iter() + .filter_map(|(name, on)| on.then_some(name)) + .collect::>(); + if !unsupported.is_empty() { + foundry_cli::machine::bail_machine_usage(format!( + "`forge create` under `--machine` does not yet support {}; \ + run without `--machine` or omit those flags.", + unsupported.join(", ") + )); + } + Ok(()) + } + /// Executes the command to create a contract pub async fn run(mut self) -> Result<()> { let (signer, tempo_access_key) = self.eth.wallet.maybe_signer().await?; @@ -149,8 +184,11 @@ impl CreateArgs { { let mut config = self.load_config()?; - // Install missing dependencies. - if install::install_missing_dependencies(&mut config).await && config.auto_detect_remappings + // Install missing dependencies. Skipped under `--machine` so the + // installer's stdout can't corrupt the single-envelope contract. + if !foundry_cli::is_machine() + && install::install_missing_dependencies(&mut config).await + && config.auto_detect_remappings { // need to re-configure here to also catch additional remappings config = self.load_config()?; @@ -165,7 +203,11 @@ impl CreateArgs { project.find_contract_path(&self.contract.name)? }; - let output = compile::compile_target(&target_path, &project, shell::is_json())?; + let output = compile::compile_target( + &target_path, + &project, + shell::is_json() || foundry_cli::is_machine(), + )?; let (abi, bin, id) = find_contract_artifacts(output, &target_path, &self.contract.name)?; @@ -196,7 +238,9 @@ impl CreateArgs { constructor_args.as_deref().unwrap_or(&self.constructor_args), )? } else { - if !self.constructor_args.is_empty() || self.constructor_args_path.is_some() { + if (!self.constructor_args.is_empty() || self.constructor_args_path.is_some()) + && !foundry_cli::is_machine() + { sh_warn!( "`{}` has no constructor; ignoring provided constructor arguments", self.contract.name @@ -446,7 +490,12 @@ impl CreateArgs { deployer.tx.set_nonce(provider.get_transaction_count(deployer_address).await?); } - maybe_print_resolved_lane(resolved_lane.as_ref(), deployer.tx.nonce().unwrap_or_default())?; + if !foundry_cli::is_machine() { + maybe_print_resolved_lane( + resolved_lane.as_ref(), + deployer.tx.nonce().unwrap_or_default(), + )?; + } if let Some((_, ref ak)) = tempo_keychain { deployer @@ -511,7 +560,15 @@ impl CreateArgs { } if dry_run { - if shell::is_json() { + if foundry_cli::is_machine() { + print_json(&JsonEnvelope::success(CreateData { + contract: self.contract.name.clone(), + broadcast: false, + deployer: deployer_address.to_string(), + deployed_to: None, + tx_hash: None, + }))?; + } else if shell::is_json() { let output = json!({ "contract": self.contract.name, "transaction": &deployer.tx, @@ -538,67 +595,97 @@ impl CreateArgs { let tempo_sponsor = self.tx.tempo.sponsor_config().await?; if let Some(sponsor) = &tempo_sponsor { - sponsor.attach_and_print::(&mut deployer.tx, deployer_address).await?; + if foundry_cli::is_machine() { + sponsor.attach_silent::(&mut deployer.tx, deployer_address).await?; + } else { + sponsor.attach_and_print::(&mut deployer.tx, deployer_address).await?; + } } - // Deploy the actual contract - let (deployed_contract, receipt) = if let Some(browser) = browser_signer { - // Browser wallet signs and sends the transaction - let tx_hash = browser.send_transaction_via_browser(deployer.tx).await?; - - // Wait for the transaction to be confirmed, then fetch the receipt. - provider - .watch_pending_transaction(alloy_provider::PendingTransactionConfig::new(tx_hash)) - .await? - .await?; - - let receipt = provider - .get_transaction_receipt(tx_hash) - .await? - .ok_or_else(|| eyre::eyre!("could not get transaction receipt for {tx_hash}"))?; + // Deploy the actual contract. + let deploy_result: Result<_> = async { + if let Some(browser) = browser_signer { + // Browser wallet signs and sends the transaction + let tx_hash = browser.send_transaction_via_browser(deployer.tx).await?; + + // Wait for the transaction to be confirmed, then fetch the receipt. + provider + .watch_pending_transaction(alloy_provider::PendingTransactionConfig::new( + tx_hash, + )) + .await? + .await?; + + let receipt = + provider.get_transaction_receipt(tx_hash).await?.ok_or_else(|| { + eyre::eyre!("could not get transaction receipt for {tx_hash}") + })?; + + if !receipt.status() { + eyre::bail!("deployment transaction failed (receipt status 0): {tx_hash}"); + } - if !receipt.status() { - eyre::bail!("deployment transaction failed (receipt status 0): {tx_hash}"); + let address = receipt + .contract_address() + .ok_or_else(|| eyre::eyre!("contract was not deployed"))?; + + Ok((address, receipt)) + } else if let Some((signer, ak)) = tempo_keychain { + // Tempo keychain mode: sign with access key provisioning and send raw + let raw_tx = deployer + .tx + .sign_with_access_key( + &provider, + &signer, + ak.wallet_address, + ak.key_address, + ak.key_authorization.as_ref(), + ) + .await?; + + let receipt = provider + .send_raw_transaction(&raw_tx) + .await? + .with_required_confirmations(1) + .with_timeout(Some(Duration::from_secs(timeout))) + .get_receipt() + .await?; + + let address = receipt + .contract_address() + .ok_or_else(|| eyre::eyre!("contract was not deployed"))?; + + Ok((address, receipt)) + } else { + Ok(deployer.send_with_receipt().await?) } - - let address = receipt - .contract_address() - .ok_or_else(|| eyre::eyre!("contract was not deployed"))?; - - (address, receipt) - } else if let Some((signer, ak)) = tempo_keychain { - // Tempo keychain mode: sign with access key provisioning and send raw - let raw_tx = deployer - .tx - .sign_with_access_key( - &provider, - &signer, - ak.wallet_address, - ak.key_address, - ak.key_authorization.as_ref(), - ) - .await?; - - let receipt = provider - .send_raw_transaction(&raw_tx) - .await? - .with_required_confirmations(1) - .with_timeout(Some(Duration::from_secs(timeout))) - .get_receipt() - .await?; - - let address = receipt - .contract_address() - .ok_or_else(|| eyre::eyre!("contract was not deployed"))?; - - (address, receipt) - } else { - deployer.send_with_receipt().await? + } + .await; + + let (deployed_contract, receipt) = match deploy_result { + Ok(v) => v, + Err(e) if foundry_cli::is_machine() => { + foundry_cli::machine::bail_machine_diagnostic( + foundry_cli::diagnostic::chain::BROADCAST_FAILED, + foundry_cli::exit_code::ExitCode::GenericError, + format!("{e:#}"), + ); + } + Err(e) => return Err(e), }; let address = deployed_contract; let tx_hash = receipt.transaction_hash(); - if shell::is_json() { + if foundry_cli::is_machine() { + print_json(&JsonEnvelope::success(CreateData { + contract: self.contract.name.clone(), + broadcast: true, + deployer: deployer_address.to_string(), + deployed_to: Some(address.to_string()), + tx_hash: Some(tx_hash.to_string()), + }))?; + return Ok(()); + } else if shell::is_json() { let output = json!({ "deployer": deployer_address.to_string(), "deployedTo": address.to_string(), diff --git a/crates/forge/src/introspect.rs b/crates/forge/src/introspect.rs index 80b8b8db2342f..e0b1b21c572f3 100644 --- a/crates/forge/src/introspect.rs +++ b/crates/forge/src/introspect.rs @@ -10,6 +10,9 @@ use foundry_cli::introspect::{ /// Stable schema id for the `forge build` envelope payload. pub const BUILD_RESULT_SCHEMA: &str = "foundry:forge.build@v1"; +/// Schema id for the `forge create` envelope payload. +pub const CREATE_RESULT_SCHEMA: &str = "foundry:forge.create@v1"; + /// Stable schema id for `forge test` stream event records. pub const TEST_EVENT_SCHEMA: &str = "foundry:forge.test.event@v1"; /// Stable schema id for the terminal `forge test` envelope payload. @@ -51,6 +54,20 @@ static ENTRIES: &[RegistryEntry] = &[ exit_codes: &[], }, }, + RegistryEntry { + path: &["create"], + meta: CommandMeta { + command_id: Some("forge.create"), + capabilities: CapabilityMeta { + output_mode: OutputMode::Envelope, + result_schema_ref: Some(CREATE_RESULT_SCHEMA), + requires_project: true, + side_effects: SideEffects::ChainWrite, + ..CapabilityMeta::NONE + }, + exit_codes: &[], + }, + }, RegistryEntry { path: &["script"], meta: CommandMeta { diff --git a/crates/forge/tests/cli/create.rs b/crates/forge/tests/cli/create.rs index 053d29454bbd4..e66ff1206a199 100644 --- a/crates/forge/tests/cli/create.rs +++ b/crates/forge/tests/cli/create.rs @@ -535,3 +535,178 @@ Error: deployment transaction failed (receipt status 0): [..] "#]]); }); + +// `forge --machine create` dry-run emits a single envelope with +// `broadcast: false` and no `deployed_to` / `tx_hash`. +forgetest_async!(machine_mode_dry_run_emits_envelope, |prj, cmd| { + foundry_test_utils::util::initialize(prj.root()); + prj.initialize_default_contracts(); + + let (_api, handle) = spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let wallet = handle.dev_wallets().next().unwrap(); + let pk = hex::encode(wallet.credential().to_bytes()); + + let assert = cmd + .forge_fuse() + .args([ + "--machine", + "create", + format!("./src/{TEMPLATE_CONTRACT}.sol:{TEMPLATE_CONTRACT}").as_str(), + "--rpc-url", + rpc.as_str(), + "--private-key", + pk.as_str(), + ]) + .assert_success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let envelope: serde_json::Value = + serde_json::from_str(stdout.trim()).expect("stdout is exactly one JSON envelope"); + + assert_eq!(envelope["success"], true); + assert_eq!(envelope["errors"], serde_json::json!([])); + assert_eq!(envelope["warnings"], serde_json::json!([])); + assert_eq!(envelope["data"]["contract"], TEMPLATE_CONTRACT); + assert_eq!(envelope["data"]["broadcast"], false); + assert!(envelope["data"]["deployed_to"].is_null()); + assert!(envelope["data"]["tx_hash"].is_null()); + foundry_test_utils::agent_schema::validate_envelope_data(&envelope, "foundry:forge.create@v1"); + + let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap(); + assert!(stderr.is_empty(), "stderr must be empty under --machine, got: {stderr}"); +}); + +// `forge --machine create --broadcast` emits an envelope with the +// deployed address and transaction hash from the anvil receipt. +forgetest_async!(machine_mode_broadcast_emits_envelope, |prj, cmd| { + foundry_test_utils::util::initialize(prj.root()); + prj.initialize_default_contracts(); + + let (_api, handle) = spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let wallet = handle.dev_wallets().next().unwrap(); + let pk = hex::encode(wallet.credential().to_bytes()); + + let assert = cmd + .forge_fuse() + .args([ + "--machine", + "create", + format!("./src/{TEMPLATE_CONTRACT}.sol:{TEMPLATE_CONTRACT}").as_str(), + "--rpc-url", + rpc.as_str(), + "--private-key", + pk.as_str(), + "--broadcast", + ]) + .assert_success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let envelope: serde_json::Value = + serde_json::from_str(stdout.trim()).expect("stdout is exactly one JSON envelope"); + + assert_eq!(envelope["success"], true); + assert_eq!(envelope["data"]["contract"], TEMPLATE_CONTRACT); + assert_eq!(envelope["data"]["broadcast"], true); + let deployer = envelope["data"]["deployer"].as_str().expect("deployer is a string"); + let deployed_to = envelope["data"]["deployed_to"].as_str().expect("deployed_to is a string"); + let tx_hash = envelope["data"]["tx_hash"].as_str().expect("tx_hash is a string"); + assert!(deployer.starts_with("0x") && deployer.len() == 42, "{deployer}"); + assert!(deployed_to.starts_with("0x") && deployed_to.len() == 42, "{deployed_to}"); + assert!(tx_hash.starts_with("0x") && tx_hash.len() == 66, "{tx_hash}"); + foundry_test_utils::agent_schema::validate_envelope_data(&envelope, "foundry:forge.create@v1"); + + let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap(); + assert!(stderr.is_empty(), "stderr must be empty under --machine, got: {stderr}"); +}); + +// `forge --machine create --broadcast` against a reverting constructor +// emits a typed `chain.broadcast_failed` error envelope. +forgetest_async!(flaky_machine_mode_broadcast_failed_emits_envelope, |prj, cmd| { + let (_api, handle) = spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let wallet = handle.dev_wallets().next().unwrap(); + let pk = hex::encode(wallet.credential().to_bytes()); + + prj.add_source( + "RevertingContract.sol", + r#" +contract RevertingContract { + constructor() { + revert("deployment failed"); + } +} + "#, + ); + + let assert = cmd + .forge_fuse() + .args([ + "--machine", + "create", + "./src/RevertingContract.sol:RevertingContract", + "--rpc-url", + rpc.as_str(), + "--private-key", + pk.as_str(), + "--broadcast", + "--gas-limit", + "1000000", + ]) + .assert_failure(); + assert_eq!(assert.get_output().status.code(), Some(1)); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let envelope: serde_json::Value = + serde_json::from_str(stdout.trim()).expect("error envelope on stdout"); + + assert_eq!(envelope["success"], false); + assert!(envelope["data"].is_null(), "data must be null: {envelope}"); + assert_eq!(envelope["warnings"], serde_json::json!([])); + assert_eq!(envelope["errors"][0]["code"], "chain.broadcast_failed"); + let msg = envelope["errors"][0]["message"].as_str().unwrap_or(""); + assert!(msg.contains("receipt status 0"), "missing cause-chain detail: {envelope}"); + foundry_test_utils::agent_schema::validate("foundry:envelope@v1", &envelope); + + let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap(); + assert!(stderr.is_empty(), "stderr must be empty under --machine, got: {stderr}"); +}); + +// `forge --machine create --verify` is rejected with a typed +// `cli.usage.invalid` envelope and exit code 2. +forgetest_async!(machine_mode_rejects_unsupported_flags, |prj, cmd| { + foundry_test_utils::util::initialize(prj.root()); + prj.initialize_default_contracts(); + + let (_api, handle) = spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let wallet = handle.dev_wallets().next().unwrap(); + let pk = hex::encode(wallet.credential().to_bytes()); + + let assert = cmd + .forge_fuse() + .args([ + "--machine", + "create", + format!("./src/{TEMPLATE_CONTRACT}.sol:{TEMPLATE_CONTRACT}").as_str(), + "--rpc-url", + rpc.as_str(), + "--private-key", + pk.as_str(), + "--verify", + ]) + .assert_failure(); + assert_eq!(assert.get_output().status.code(), Some(2)); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let envelope: serde_json::Value = + serde_json::from_str(stdout.trim()).expect("error envelope on stdout"); + + assert_eq!(envelope["success"], false); + assert!(envelope["data"].is_null(), "data must be null: {envelope}"); + assert_eq!(envelope["warnings"], serde_json::json!([])); + assert_eq!(envelope["errors"][0]["code"], "cli.usage.invalid"); + let msg = envelope["errors"][0]["message"].as_str().unwrap_or(""); + assert!(msg.contains("--verify"), "missing --verify mention: {envelope}"); + foundry_test_utils::agent_schema::validate("foundry:envelope@v1", &envelope); + + let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap(); + assert!(stderr.is_empty(), "stderr must be empty under --machine, got: {stderr}"); +}); diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs index 48c5c7607491f..1e8dba2f7eeca 100644 --- a/crates/script/src/lib.rs +++ b/crates/script/src/lib.rs @@ -331,7 +331,7 @@ impl ScriptArgs { Err(e) if machine_mode => foundry_cli::machine::bail_machine_diagnostic( foundry_cli::diagnostic::script::BROADCAST_FAILED, foundry_cli::ExitCode::GenericError, - format!("broadcast failed: {e}"), + format!("broadcast failed: {e:#}"), ), Err(e) => return Err(e), }; @@ -541,7 +541,7 @@ impl ScriptArgs { Err(e) if machine_mode => foundry_cli::machine::bail_machine_diagnostic( foundry_cli::diagnostic::script::BROADCAST_FAILED, foundry_cli::ExitCode::GenericError, - format!("broadcast failed: {e}"), + format!("broadcast failed: {e:#}"), ), Err(e) => return Err(e), }; diff --git a/crates/test-utils/src/agent_schema.rs b/crates/test-utils/src/agent_schema.rs index 837c812c2a5a5..c33cf2911a117 100644 --- a/crates/test-utils/src/agent_schema.rs +++ b/crates/test-utils/src/agent_schema.rs @@ -26,6 +26,7 @@ const SCHEMA_FILES: &[(&str, &str)] = &[ ("foundry:cast.keccak@v1", "cast.keccak.v1.json"), ("foundry:cast.4byte@v1", "cast.4byte.v1.json"), ("foundry:forge.build@v1", "forge.build.v1.json"), + ("foundry:forge.create@v1", "forge.create.v1.json"), ("foundry:forge.test@v1", "forge.test.v1.json"), ("foundry:forge.test.event@v1", "forge.test.event.v1.json"), ("foundry:forge.script@v1", "forge.script.v1.json"), diff --git a/docs/agents/schemas/forge.create.v1.json b/docs/agents/schemas/forge.create.v1.json new file mode 100644 index 0000000000000..9068bd4637d06 --- /dev/null +++ b/docs/agents/schemas/forge.create.v1.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "foundry:forge.create@v1", + "title": "forge create result payload", + "description": "Shape of the `data` field in the `forge create --machine` terminal envelope.", + "type": "object", + "required": ["contract", "broadcast", "deployer", "deployed_to", "tx_hash"], + "additionalProperties": false, + "properties": { + "contract": { + "type": "string", + "description": "Contract identifier name (the `` from `:`).", + "minLength": 1 + }, + "broadcast": { + "type": "boolean", + "description": "True when the deployment transaction was broadcast; false for dry-run." + }, + "deployer": { + "type": "string", + "description": "0x-prefixed deployer EOA address.", + "pattern": "^0x[0-9a-fA-F]{40}$" + }, + "deployed_to": { + "type": ["string", "null"], + "description": "0x-prefixed deployed contract address. Null on dry-run.", + "pattern": "^0x[0-9a-fA-F]{40}$" + }, + "tx_hash": { + "type": ["string", "null"], + "description": "0x-prefixed deployment transaction hash. Null on dry-run.", + "pattern": "^0x[0-9a-fA-F]{64}$" + } + }, + "allOf": [ + { + "if": { "properties": { "broadcast": { "const": true } }, "required": ["broadcast"] }, + "then": { + "required": ["deployed_to", "tx_hash"], + "properties": { + "deployed_to": { "type": "string" }, + "tx_hash": { "type": "string" } + } + } + }, + { + "if": { "properties": { "broadcast": { "const": false } }, "required": ["broadcast"] }, + "then": { + "properties": { + "deployed_to": { "const": null }, + "tx_hash": { "const": null } + } + } + } + ] +}