diff --git a/Cargo.lock b/Cargo.lock index 20af88f2f..1cdd90516 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -667,6 +667,7 @@ dependencies = [ "hiro-system-kit", "indoc", "mac_address", + "mockito", "ratatui", "reqwest", "rpassword", @@ -709,6 +710,7 @@ dependencies = [ "clarity-repl", "indoc", "libsecp256k1", + "log", "mockito", "pretty_assertions", "reqwest", @@ -735,6 +737,7 @@ dependencies = [ "clarity", "clarity-repl", "dirs", + "indoc", "js-sys", "libsecp256k1", "rpassword", diff --git a/README.md b/README.md index 0dde7726a..43efa1d56 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ Clarinet is the fastest way to build, test, and deploy smart contracts on the St gives you a local devnet, REPL, testing framework, and debugging tools to ship high-quality Clarity code with confidence. -- ๐Ÿง‘โ€๐Ÿ’ป **Leverage a powerful CLI:** Create new projects, manage your smart contracts and their - dependencies using clarinet requirements, and interact with your code through the built-in REPL. +- ๐Ÿง‘โ€๐Ÿ’ป **Leverage a powerful CLI:** Create new projects, manage your smart contracts with automatic + dependency detection, and interact with your code through the built-in REPL. - ๐Ÿงช **Write unit tests with the SDK:** Use the Clarinet SDK to write unit tests in a familiar JS environment and validate contract behavior. diff --git a/components/clarinet-cli/Cargo.toml b/components/clarinet-cli/Cargo.toml index cc1f2425f..f646b868b 100644 --- a/components/clarinet-cli/Cargo.toml +++ b/components/clarinet-cli/Cargo.toml @@ -56,6 +56,7 @@ tikv-jemallocator = { workspace = true } [dev-dependencies] futures = { workspace = true } +mockito = { workspace = true } tempfile = { workspace = true } toml = { workspace = true } tower = { version = "0.5", features = ["util"] } diff --git a/components/clarinet-cli/src/frontend/cli.rs b/components/clarinet-cli/src/frontend/cli.rs index 88c1d99e8..5423a9a6f 100644 --- a/components/clarinet-cli/src/frontend/cli.rs +++ b/components/clarinet-cli/src/frontend/cli.rs @@ -20,8 +20,7 @@ use clarinet_deployments::{ use clarinet_files::clarinetrc::ClarinetRC; use clarinet_files::devnet_diff::DevnetDiffConfig; use clarinet_files::{ - get_manifest_location, paths, NetworkManifest, ProjectManifest, RequirementConfig, - StacksNetwork, + get_manifest_location, paths, NetworkManifest, ProjectManifest, StacksNetwork, }; use clarinet_format::formatter::{self, ClarityFormatter}; use clarity::types::StacksEpochId; @@ -99,9 +98,6 @@ enum Command { /// Subcommands for working with contracts #[clap(subcommand, name = "contracts", aliases = &["contract"])] Contracts(Contracts), - /// Interact with contracts deployed on Mainnet - #[clap(subcommand, name = "requirements", aliases = &["requirement"])] - Requirements(Requirements), /// Manage contracts deployments on Simnet/Devnet/Testnet/Mainnet #[clap(subcommand, name = "deployments", aliases = &["deployment"])] Deployments(Deployments), @@ -260,13 +256,6 @@ enum Contracts { RemoveContract(RemoveContract), } -#[derive(Subcommand, PartialEq, Clone, Debug)] -enum Requirements { - /// Interact with contracts deployed on Mainnet - #[clap(name = "add", bin_name = "add")] - AddRequirement(AddRequirement), -} - #[allow(clippy::enum_variant_names)] #[derive(Subcommand, PartialEq, Clone, Debug)] enum Deployments { @@ -328,15 +317,6 @@ struct RemoveContract { pub manifest_path: Option, } -#[derive(Parser, PartialEq, Clone, Debug)] -struct AddRequirement { - /// Contract id (ex. "SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait") - pub contract_id: String, - /// Path to Clarinet.toml - #[clap(long = "manifest-path", short = 'm')] - pub manifest_path: Option, -} - #[derive(Parser, PartialEq, Clone, Debug)] struct CheckDeployments { /// Path to Clarinet.toml @@ -1106,31 +1086,6 @@ pub fn main() { } } }, - Command::Requirements(subcommand) => match subcommand { - Requirements::AddRequirement(cmd) => { - let manifest = load_manifest_or_exit(cmd.manifest_path, true); - - let change = TOMLEdition { - comment: format!( - "{} with requirement {}", - yellow!("Updated Clarinet.toml"), - green!("{}", cmd.contract_id) - ), - manifest_location: manifest.location, - contracts_to_rm: vec![], - contracts_to_add: HashMap::new(), - requirements_to_add: vec![RequirementConfig { - contract_id: cmd.contract_id, - }], - }; - if !execute_changes(vec![Changes::EditTOML(change)]) { - std::process::exit(1); - } - if clarinetrc.enable_hints.unwrap_or(true) { - display_post_check_hint(); - } - } - }, Command::Console(cmd) => { // Loop to handle `::reload` command loop { @@ -2074,10 +2029,6 @@ fn load_manifest(location: &Path) -> Option { /// Edit a TOML document directly, preserving comments and structure. fn edit_toml_document(mut doc: DocumentMut, options: &mut TOMLEdition) -> DocumentMut { - for req in options.requirements_to_add.drain(..) { - add_requirement_to_doc(&mut doc, &req.contract_id); - } - for (name, contract) in options.contracts_to_add.drain() { add_contract_to_doc(&mut doc, &name, &contract); } @@ -2089,45 +2040,6 @@ fn edit_toml_document(mut doc: DocumentMut, options: &mut TOMLEdition) -> Docume doc } -/// Add a requirement to the [[project.requirements]] array in the document. -fn add_requirement_to_doc(doc: &mut DocumentMut, contract_id: &str) { - use toml_edit::{ArrayOfTables, Item, Table}; - - // Ensure [project] table exists - let project = doc - .entry("project") - .or_insert(Item::Table(Table::new())) - .as_table_mut() - .expect("[project] should be a table"); - - // Ensure [[project.requirements]] array exists. - // If requirements = [] (an empty inline array), replace it with an array of tables. - if project - .get("requirements") - .is_some_and(|v| v.as_array().is_some_and(|a| a.is_empty())) - { - project["requirements"] = Item::ArrayOfTables(ArrayOfTables::new()); - } - - let requirements = project - .entry("requirements") - .or_insert(Item::ArrayOfTables(ArrayOfTables::new())) - .as_array_of_tables_mut() - .expect("[[project.requirements]] should be an array of tables"); - - // Check for duplicates - let already_exists = requirements - .iter() - .filter_map(|req| req.get("contract_id")?.as_str()) - .any(|id| id == contract_id); - - if !already_exists { - let mut new_req = Table::new(); - new_req["contract_id"] = toml_edit::value(contract_id); - requirements.push(new_req); - } -} - /// Add a contract to the [contracts.] section in the document. fn add_contract_to_doc(doc: &mut DocumentMut, name: &str, contract: &ClarityContract) { use toml_edit::{Item, Table}; @@ -2550,16 +2462,21 @@ mod tests { /// Helper to check if a requirement exists in the TOML fn has_requirement(content: &str, contract_id: &str) -> bool { let doc: DocumentMut = content.parse().expect("Failed to parse TOML"); - if let Some(project) = doc.get("project").and_then(|p| p.as_table()) { - if let Some(requirements) = project.get("requirements") { - if let Some(arr) = requirements.as_array_of_tables() { - return arr.iter().any(|req| { - req.get("contract_id") - .and_then(|v| v.as_str()) - .map(|s| s == contract_id) - .unwrap_or(false) - }); - } + let Some(project) = doc.get("project").and_then(|p| p.as_table()) else { + return false; + }; + if let Some(arr) = project + .get("requirements") + .and_then(|v| v.as_array_of_tables()) + { + if arr.iter().any(|entry| { + entry + .get("contract_id") + .and_then(|v| v.as_str()) + .map(|s| s == contract_id) + .unwrap_or(false) + }) { + return true; } } false @@ -2664,153 +2581,6 @@ mod tests { ); } - #[test] - fn test_add_requirement_preserves_comments() { - let input = indoc! {r#" - [project] - name = "test-project" - description = "A test project" - authors = ["Test Author"] - telemetry = false - - # This comment should survive - - [contracts.my-contract] - path = "contracts/my-contract.clar" - clarity_version = 2 - epoch = "latest" - "#}; - - let mut doc: DocumentMut = input.parse().expect("Failed to parse TOML"); - add_requirement_to_doc( - &mut doc, - "SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait", - ); - - let output = doc.to_string(); - - // Verify the requirement was added - assert!( - has_requirement( - &output, - "SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait" - ), - "Requirement should be added" - ); - - // Verify comment is preserved - assert!( - contains_comment(&output, "This comment should survive"), - "Comment should be preserved" - ); - - // Verify existing settings are preserved - assert!( - has_toml_value(&output, "project.name", "test-project"), - "Project name should be preserved" - ); - assert!( - has_toml_value(&output, "project.telemetry", "false"), - "Telemetry setting should be preserved" - ); - - // Verify contract is still there - assert!( - has_contract(&output, "my-contract"), - "Contract should be preserved" - ); - } - - #[test] - fn test_add_requirement_with_empty_requirements_array() { - let input = indoc! {r#" - [project] - name = 'project-template' - requirements = [] - "#}; - - let mut doc: DocumentMut = input.parse().expect("Failed to parse TOML"); - add_requirement_to_doc( - &mut doc, - "SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait", - ); - - let output = doc.to_string(); - - assert!( - has_requirement( - &output, - "SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait" - ), - "Requirement should be added when requirements was an empty array" - ); - - assert!( - has_toml_value(&output, "project.name", "project-template"), - "Project name should be preserved" - ); - } - - #[test] - fn test_add_requirement_does_not_duplicate() { - let input = indoc! {r#" - [project] - name = "test-project" - - [[project.requirements]] - contract_id = "SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait" - "#}; - - let mut doc: DocumentMut = input.parse().expect("Failed to parse TOML"); - - // Try to add the same requirement again - add_requirement_to_doc( - &mut doc, - "SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait", - ); - - let output = doc.to_string(); - - // Count how many times the contract_id appears - let count = output.matches("nft-trait").count(); - assert_eq!(count, 1, "Requirement should not be duplicated"); - } - - #[test] - fn test_add_multiple_requirements() { - let input = indoc! {r#" - [project] - name = "test-project" - - [[project.requirements]] - contract_id = "SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait" - "#}; - - let mut doc: DocumentMut = input.parse().expect("Failed to parse TOML"); - add_requirement_to_doc( - &mut doc, - "SP3K8BC0PPEVCV7NZ6QSRWPQ2JE9E5B6N3PA0KBR9.another-trait", - ); - - let output = doc.to_string(); - - // Both requirements should exist - assert!( - has_requirement( - &output, - "SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait" - ), - "Original requirement should be preserved" - ); - assert!( - has_requirement( - &output, - "SP3K8BC0PPEVCV7NZ6QSRWPQ2JE9E5B6N3PA0KBR9.another-trait" - ), - "New requirement should be added" - ); - } - #[test] fn test_edit_simple_nft_example() { // Test with the actual simple-nft example content diff --git a/components/clarinet-cli/src/generate/changes.rs b/components/clarinet-cli/src/generate/changes.rs index 54e0a1bc7..48e14b258 100644 --- a/components/clarinet-cli/src/generate/changes.rs +++ b/components/clarinet-cli/src/generate/changes.rs @@ -1,7 +1,6 @@ use std::collections::HashMap; use std::path::PathBuf; -use clarinet_files::RequirementConfig; use clarity_repl::repl::ClarityContract; #[derive(Clone, Debug)] @@ -29,7 +28,6 @@ pub struct TOMLEdition { pub manifest_location: PathBuf, pub contracts_to_add: HashMap, pub contracts_to_rm: Vec, - pub requirements_to_add: Vec, } #[derive(Clone, Debug)] diff --git a/components/clarinet-cli/src/generate/contract.rs b/components/clarinet-cli/src/generate/contract.rs index dce79da6f..9de5d1109 100644 --- a/components/clarinet-cli/src/generate/contract.rs +++ b/components/clarinet-cli/src/generate/contract.rs @@ -75,7 +75,6 @@ impl GetChangesForRmContract { manifest_location, contracts_to_rm, contracts_to_add: HashMap::new(), - requirements_to_add: vec![], }; self.changes.push(Changes::EditTOML(change)); } @@ -219,7 +218,6 @@ impl GetChangesForNewContract { manifest_location, contracts_to_rm: vec![], contracts_to_add, - requirements_to_add: vec![], }; self.changes.push(Changes::EditTOML(change)); } diff --git a/components/clarinet-cli/src/lsp/mod.rs b/components/clarinet-cli/src/lsp/mod.rs index a765759f4..980fea561 100644 --- a/components/clarinet-cli/src/lsp/mod.rs +++ b/components/clarinet-cli/src/lsp/mod.rs @@ -170,32 +170,78 @@ fn test_opening_counter_manifest_should_return_fresh_analysis() { #[test] fn test_opening_simple_nft_manifest_should_return_fresh_analysis() { + use std::fs; use std::sync::mpsc::channel; use clarity_lsp::backend::LspNotification; use crossbeam_channel::unbounded; + use indoc::indoc; use crate::lsp::native_bridge::LspResponse; + const REQUIREMENT_ID: &str = "SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait"; + const NFT_TRAIT: &str = indoc! {r#" + (define-trait nft-trait + ( + (get-last-token-id () (response uint uint)) + (get-token-uri (uint) (response (optional (string-ascii 256)) uint)) + (get-owner (uint) (response (optional principal) uint)) + (transfer (uint principal principal) (response bool uint)) + ) + ) + "#}; + + let project = tempfile::tempdir().expect("Unable to create temporary project"); + let contracts_dir = project.path().join("contracts"); + let settings_dir = project.path().join("settings"); + let requirements_dir = project.path().join(".cache").join("requirements"); + fs::create_dir_all(&contracts_dir).expect("Unable to create contracts directory"); + fs::create_dir_all(&settings_dir).expect("Unable to create settings directory"); + fs::create_dir_all(&requirements_dir).expect("Unable to create requirements cache"); + fs::write( + project.path().join("Clarinet.toml"), + include_str!("../../examples/simple-nft/Clarinet.toml"), + ) + .expect("Unable to write manifest"); + fs::write( + settings_dir.join("Devnet.toml"), + include_str!("../../examples/simple-nft/settings/Devnet.toml"), + ) + .expect("Unable to write Devnet settings"); + fs::write( + contracts_dir.join("simple-nft.clar"), + include_str!("../../examples/simple-nft/contracts/simple-nft.clar"), + ) + .expect("Unable to write contract"); + fs::write( + requirements_dir.join(format!("{REQUIREMENT_ID}.clar")), + NFT_TRAIT, + ) + .expect("Unable to write cached requirement"); + fs::write( + requirements_dir.join(format!("{REQUIREMENT_ID}.json")), + r#"{"epoch":"Epoch20","clarity_version":"Clarity1"}"#, + ) + .expect("Unable to write cached requirement metadata"); + let (notification_tx, notification_rx) = unbounded(); let (_request_tx, request_rx) = unbounded(); let (response_tx, response_rx) = channel(); native_bridge::spawn_language_server(notification_rx, request_rx, response_tx); - let mut manifest_location = std::env::current_dir().expect("Unable to get current dir"); - manifest_location.push("examples"); - manifest_location.push("simple-nft"); - manifest_location.push("Clarinet.toml"); - + let manifest_location = project.path().join("Clarinet.toml"); let _ = notification_tx.send(LspNotification::ManifestOpened(manifest_location)); let response = response_rx.recv().expect("Unable to get response"); let LspResponse::Notification(response) = response else { panic!("Unable to get response") }; - - assert_eq!(response.aggregated_diagnostics.len(), 1); + assert_eq!( + response.aggregated_diagnostics.len(), + 1, + "unexpected LSP response: {response:?}" + ); let (_, diags_0) = &response.aggregated_diagnostics[0]; - // the counter project should emit 4 warnings and 4 notes coming from counter.clar + // The simple NFT project emits 4 warnings and 4 notes. assert_eq!(diags_0.len(), 8); } diff --git a/components/clarinet-cli/tests/cli.rs b/components/clarinet-cli/tests/cli.rs index 1a236cf07..53b2acf66 100644 --- a/components/clarinet-cli/tests/cli.rs +++ b/components/clarinet-cli/tests/cli.rs @@ -4,7 +4,7 @@ use std::process::Command; use clarinet_deployments::load_deployment; use clarinet_deployments::types::TransactionSpecification; -use clarinet_files::{ProjectManifest, ProjectManifestFile, StacksNetwork}; +use clarinet_files::StacksNetwork; use clarinet_lib::deployments::generate_devnet_deployment; use clarinet_lib::frontend::cli::load_manifest_or_exit; use indoc::{formatdoc, indoc}; @@ -40,14 +40,6 @@ fn create_project_with_env_simnet(project_name: &str) -> (tempfile::TempDir, std (temp_dir, project_path) } -#[track_caller] -fn parse_manifest(project_dir: &Path) -> ProjectManifest { - let manifest_path = project_dir.join("Clarinet.toml"); - let manifest_str = fs::read_to_string(&manifest_path).expect("Failed to read Clarinet.toml"); - let manifest_file: ProjectManifestFile = toml::from_str(&manifest_str).unwrap(); - ProjectManifest::from_project_manifest_file(manifest_file, &manifest_path, false).unwrap() -} - #[track_caller] fn create_new_project(project_name: &str) -> tempfile::TempDir { let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); @@ -171,28 +163,6 @@ fn test_contract_new() { } } -#[test] -fn test_requirement_add() { - let project_name = "test_requirement_add"; - let temp_dir = create_new_project(project_name); - let project_path = temp_dir.path().join(project_name); - let requirement_name = "SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.sip-010-trait-ft-standard"; - let status = Command::new(env!("CARGO_BIN_EXE_clarinet")) - .args(["requirement", "add", requirement_name]) - .current_dir(&project_path) - .status(); - assert!(status.unwrap().success()); - - let manifest = parse_manifest(&project_path); - let found = manifest - .project - .requirements - .iter() - .flatten() - .any(|c| c.contract_id == requirement_name); - assert!(found, "Requirement not found in manifest"); -} - #[test] fn test_formatter_check() { let project_name = "test_formatter_check"; diff --git a/components/clarinet-cli/tests/console.rs b/components/clarinet-cli/tests/console.rs index bded6356b..9c0a52aed 100644 --- a/components/clarinet-cli/tests/console.rs +++ b/components/clarinet-cli/tests/console.rs @@ -1,6 +1,46 @@ use std::io::Write; use std::process::{Command, Stdio}; +use mockito::{Matcher, Server, ServerGuard}; + +fn mock_remote_node(network_id: u32, height: u32) -> ServerGuard { + let mut server = Server::new(); + + server + .mock("GET", Matcher::Any) + .with_status(404) + .expect_at_least(0) + .create(); + server + .mock("GET", "/v2/info") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(format!( + r#"{{"network_id": {network_id}, "stacks_tip_height": {height}}}"# + )) + .create(); + server + .mock("GET", format!("/extended/v2/blocks/{height}").as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + serde_json::json!({ + "height": height, + "burn_block_height": 882262, + "tenure_height": 184037, + "block_time": 1735934294, + "burn_block_time": 1735451504, + "hash": "0xaff3b535a135348ed00023ec1bdc3da9005253a9ce80a4906ade03ea6685d342", + "index_block_hash": "0x201cf66636e693d95998b40ddd0cbe038432806046eed11866052f15a9fa8fc5", + "burn_block_hash": "0x57f3e2bd4519e4263353bf6b7614a9cee7f2d36fe61409852d42e41afe5e6cad", + }) + .to_string(), + ) + .create(); + + server +} + fn run_console_command(args: &[&str], commands: &[&str]) -> Vec { let temp_dir = tempfile::tempdir().unwrap(); let mut child = Command::new(env!("CARGO_BIN_EXE_clarinet")) @@ -25,8 +65,12 @@ fn run_console_command(args: &[&str], commands: &[&str]) -> Vec { assert!(output.status.success(), "Console command failed"); let stdout_str = String::from_utf8_lossy(&output.stdout); - // always skip the first 3 lines (console instructions) - stdout_str.lines().skip(3).map(|s| s.to_string()).collect() + // Skip console instructions and remove interactive prompts from command output. + stdout_str + .lines() + .skip(3) + .map(|line| line.strip_prefix(">> ").unwrap_or(line).to_string()) + .collect() } #[test] @@ -39,12 +83,14 @@ fn can_set_epoch_in_empty_session() { #[test] fn can_init_console_with_mxs() { - // testnet โ€” height 50000 is in Epoch 4.0 on the krypton testnet + // Testnet โ€” height 50000 is in Epoch 4.0 on the krypton testnet. + let testnet_server = mock_remote_node(0x8000_0000, 50000); + let testnet_url = testnet_server.url(); let output = run_console_command( &[ "--enable-remote-data", "--remote-data-api-url", - "https://api.testnet.hiro.so", + &testnet_url, "--remote-data-initial-height", "50000", ], @@ -58,12 +104,14 @@ fn can_init_console_with_mxs() { assert_eq!(output[1], "true"); assert_eq!(output[2], "false"); - // mainnet + // Mainnet. + let mainnet_server = mock_remote_node(1, 907820); + let mainnet_url = mainnet_server.url(); let output = run_console_command( &[ "--enable-remote-data", "--remote-data-api-url", - "https://api.hiro.so", + &mainnet_url, "--remote-data-initial-height", "907820", ], diff --git a/components/clarinet-deployments/Cargo.toml b/components/clarinet-deployments/Cargo.toml index fa4773cc9..b2709464e 100644 --- a/components/clarinet-deployments/Cargo.toml +++ b/components/clarinet-deployments/Cargo.toml @@ -10,6 +10,7 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } yaml_serde = { workspace = true } strum = { workspace = true, features = ["derive"] } +log = "0.4" reqwest = { workspace = true } stacks-common = { workspace = true, default-features = false } diff --git a/components/clarinet-deployments/src/lib.rs b/components/clarinet-deployments/src/lib.rs index ba33804a7..4aefd4ac9 100644 --- a/components/clarinet-deployments/src/lib.rs +++ b/components/clarinet-deployments/src/lib.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; pub mod diagnostic_digest; #[cfg(not(target_arch = "wasm32"))] @@ -29,7 +29,7 @@ use clarity_repl::repl::boot::{ use clarity_repl::repl::post_conditions::PostConditionCheck; use clarity_repl::repl::session::{AnnotatedExecutionResult, CallKind, ExecutionResultMap}; use clarity_repl::repl::{ - ClarityCodeSource, ClarityContract, ClarityInterpreter, ContractDeployer, Session, + ClarityCodeSource, ClarityContract, ClarityInterpreter, ContractDeployer, Epoch, Session, SessionSettings, }; use clarity_repl::ueprint; @@ -982,181 +982,393 @@ pub async fn generate_default_deployment_with_cache( } } + let project_root = &manifest.root_dir; + let mut queue = VecDeque::new(); let mut contract_epochs = HashMap::new(); + // Read user contract sources early so we can auto-detect external dependencies + // before populating the requirements queue. + let sources: HashMap = match file_accessor { + None => { + let mut sources = HashMap::new(); + for contract_config in manifest.contracts.values() { + let contract_location = + project_root.join(contract_config.expect_contract_path_as_str()); + let source = paths::read_content_as_utf8(&contract_location).map_err(|_| { + format!("unable to find contract at {}", contract_location.display()) + })?; + sources.insert(contract_location.to_string_lossy().into_owned(), source); + } + sources + } + Some(file_accessor) => { + let contracts_location = manifest + .contracts + .values() + .map(|contract_config| { + let contract_location = + project_root.join(contract_config.expect_contract_path_as_str()); + contract_location.to_string_lossy().into_owned() + }) + .collect(); + file_accessor.read_files(contracts_location).await? + } + }; + + // Build lightweight user-contract ASTs (skip_analysis: true) to detect + // external contract references that need to be loaded as requirements. + let mut user_contract_asts: BTreeMap< + QualifiedContractIdentifier, + (ClarityVersion, ContractAST), + > = BTreeMap::new(); + for (name, contract_config) in manifest.contracts.iter() { + let Ok(contract_name) = ContractName::try_from(name.to_string()) else { + continue; // error will be surfaced in the full build below + }; + let deployer_account = match &contract_config.deployer { + ContractDeployer::DefaultDeployer => default_deployer, + ContractDeployer::LabeledDeployer(label) => { + let Some(d) = network_manifest.accounts.get(label) else { + continue; + }; + d + } + _ => continue, + }; + let Ok(sender) = PrincipalData::parse_standard_principal(&deployer_account.stx_address) + else { + continue; + }; + let contract_id = QualifiedContractIdentifier::new(sender.clone(), contract_name); + let contract_location = project_root.join(contract_config.expect_contract_path_as_str()); + let Some(source) = sources.get(contract_location.to_string_lossy().as_ref()) else { + continue; + }; + let source = if environment == Environment::OnChain { + match remove_env_simnet(source) { + Ok(Some(clean)) => clean, + _ => source.clone(), + } + } else { + source.clone() + }; + // Reuse a cached AST if the source is unchanged โ€” avoids a redundant + // parse in LSP incremental rebuilds, which already hold fully-analysed + // ASTs from the previous pass. + let cache_key = (contract_location.clone(), environment); + let content_hash = compute_content_hash(&source); + let epoch_id = match contract_config.epoch { + Epoch::Specific(e) => e, + Epoch::Latest => DEFAULT_EPOCH, + }; + let ast = match cached_asts + .as_ref() + .and_then(|c| c.get(&cache_key)) + .filter(|e| e.matches(&content_hash, contract_config.clarity_version, epoch_id)) + { + Some(cached) => cached.ast.clone(), + None => { + let contract = ClarityContract { + code_source: ClarityCodeSource::ContractInMemory(source), + deployer: ContractDeployer::Address(deployer_account.stx_address.clone()), + name: name.clone(), + clarity_version: contract_config.clarity_version, + epoch: contract_config.epoch.clone(), + skip_analysis: true, + }; + let (ast, _, _) = interpreter.build_ast(&contract); + ast + } + }; + user_contract_asts.insert(contract_id, (contract_config.clarity_version, ast)); + } + // Build the ASTs / DependencySet for requirements - step required for Simnet/Devnet/Testnet/Mainnet - if let Some(ref requirements) = manifest.project.requirements { + { + let requirements = &manifest.project.requirements; let mut emulated_contracts_publish = HashMap::new(); let mut requirements_publish = HashMap::new(); - // Load all the requirements - // Some requirements are explicitly listed, some are discovered as we compute the ASTs. - for requirement in requirements.iter() { - let contract_id = QualifiedContractIdentifier::parse(&requirement.contract_id) - .map_err(|_e| format!("malformatted contract_id: {}", requirement.contract_id))?; + // Contracts that could not be retrieved and were not declared explicitly. + // They are left for the full contract analysis pass to diagnose and must + // never be retried, or an unavailable requirement would keep producing + // new work on every pass. + let mut unresolved_requirements: HashSet = HashSet::new(); + + // Parse explicit requirements once โ€” build the set and seed the queue in + // a single pass. Iterating in reverse so push_front preserves declaration order. + let mut explicit_ids: HashSet = HashSet::new(); + for entry in requirements.iter().rev() { + let contract_id = + QualifiedContractIdentifier::parse(&entry.contract_id).map_err(|_| { + format!( + "malformatted contract_id in requirements: {}", + entry.contract_id + ) + })?; + explicit_ids.insert(contract_id.clone()); queue.push_front(contract_id); } - while let Some(contract_id) = queue.pop_front() { - if requirements_deps.contains_key(&contract_id) { - continue; + // Auto-detection is iterative: loading a requirement can reveal new + // dependencies in the user contracts. For example, a contract literal + // passed as a trait argument is only identifiable once the callee's + // signature has been loaded. Repeat discovery and loading until a pass + // finds nothing new. + // Under simnet remote data the remote node holds all contracts already; + // skip discovery to avoid fetching and discarding sources unnecessarily. + loop { + if simnet_remote_data { + break; } + let (inferable, non_inferable) = match ASTDependencyDetector::detect_dependencies( + &user_contract_asts, + &requirements_data, + ) { + Ok(inferable) => (inferable, Vec::new()), + Err((inferable, non_inferable)) => (inferable, non_inferable), + }; - // On testnet, an sBTC requirement is published as a real transaction - // remapped to the sBTC testnet deployer, so it has to be retrieved - // like any other requirement: the boot copies seeded into - // `requirements_data` above carry no publish specification. - let cached_requirement = if matches!(network, StacksNetwork::Testnet) - && contract_id.issuer.to_string() == SBTC_MAINNET_ADDRESS + // Loading a requirement can reveal new dependencies even when + // auto-detection found nothing new (e.g. a callee whose signature + // makes a trait argument identifiable only after it is loaded). + // Keep scanning until a pass loads nothing. + let mut loaded_requirement = false; + for contract_id in inferable + .values() + .flat_map(|dependencies| { + dependencies + .iter() + .map(|dependency| dependency.contract_id.clone()) + }) + .chain(non_inferable) { - None - } else { - requirements_data.remove(&contract_id) - }; + // On testnet, sBTC entries are pre-seeded into `requirements_data` + // for signature lookup only; they still need to be fetched and + // published, so don't treat the pre-seeded AST as "done". + let is_sbtc_mainnet_deployer = + contract_id.issuer.to_string() == SBTC_MAINNET_ADDRESS; + let already_loaded = requirements_data.contains_key(&contract_id) + && !(matches!(network, StacksNetwork::Testnet) && is_sbtc_mainnet_deployer); + if boot_contracts_ids.contains(&contract_id) + || user_contract_asts.contains_key(&contract_id) + || explicit_ids.contains(&contract_id) + || already_loaded + || requirements_deps.contains_key(&contract_id) + || unresolved_requirements.contains(&contract_id) + { + continue; + } + queue.push_back(contract_id); + } - // Did we already get the source in a prior cycle? - let (clarity_version, ast) = match cached_requirement { - Some(requirement_data) => requirement_data, - None => { - // Download the code - let (mut source, epoch, clarity_version, contract_location) = - requirements::retrieve_contract( - &contract_id, - &manifest.project.cache_location, - &file_accessor, - api_base_url, - ) - .await?; - - contract_epochs.insert(contract_id.clone(), epoch); - - // Build the struct representing the requirement in the deployment - if matches!(network, StacksNetwork::Simnet) { - if !simnet_remote_data { - // Same rewrite and gate as project contracts - // below, applied before the AST is built. - let remap_principals = if environment == Environment::Simnet { - remap_source_boot_principals(&mut source) - } else { - BTreeMap::new() + while let Some(contract_id) = queue.pop_front() { + if requirements_deps.contains_key(&contract_id) + || unresolved_requirements.contains(&contract_id) + { + continue; + } + + let is_sbtc_mainnet_deployer = + contract_id.issuer.to_string() == SBTC_MAINNET_ADDRESS; + + // On testnet, an sBTC requirement is published as a real transaction + // remapped to the sBTC testnet deployer, so it has to be retrieved + // like any other requirement: the boot copies seeded into + // `requirements_data` above carry no publish specification. + let cached_requirement = + if matches!(network, StacksNetwork::Testnet) && is_sbtc_mainnet_deployer { + None + } else { + requirements_data.remove(&contract_id) + }; + + // Did we already get the source in a prior cycle? + let (clarity_version, ast) = match cached_requirement { + Some(requirement_data) => requirement_data, + None => { + // Download the code. Missing inferred requirements are left + // for the full contract analysis pass to diagnose. + let (mut source, epoch, clarity_version, contract_location) = + match requirements::retrieve_contract( + &contract_id, + &manifest.project.cache_location, + &file_accessor, + api_base_url, + ) + .await + { + Ok(contract) => contract, + Err(error) if explicit_ids.contains(&contract_id) => { + return Err(error) + } + Err(_) => { + // Remember the failure so the dependency is + // neither retried nor re-enqueued later. + unresolved_requirements.insert(contract_id); + continue; + } }; - let data = EmulatedContractPublishSpecification { - contract_name: contract_id.name.clone(), - emulated_sender: contract_id.issuer.clone(), + + loaded_requirement = true; + contract_epochs.insert(contract_id.clone(), epoch); + + // Build the struct representing the requirement in the deployment + if matches!(network, StacksNetwork::Simnet) { + if !simnet_remote_data { + let remap_principals = if environment == Environment::Simnet { + remap_source_boot_principals(&mut source) + } else { + BTreeMap::new() + }; + let data = EmulatedContractPublishSpecification { + contract_name: contract_id.name.clone(), + emulated_sender: contract_id.issuer.clone(), + source: source.clone(), + location: contract_location, + clarity_version, + skip_analysis: true, + remap_principals, + }; + + emulated_contracts_publish.insert(contract_id.clone(), data); + } + } else if matches!(network, StacksNetwork::Devnet) { + let mut remap_principals = BTreeMap::new(); + remap_principals.insert( + contract_id.issuer.clone(), + default_deployer_address.clone(), + ); + + let data = RequirementPublishSpecification { + contract_id: contract_id.clone(), + remap_sender: default_deployer_address.clone(), source: source.clone(), location: contract_location, - clarity_version, - skip_analysis: true, + cost: deployment_fee_rate * source.len() as u64, remap_principals, + clarity_version, }; - - emulated_contracts_publish.insert(contract_id.clone(), data); - } - } else if matches!(network, StacksNetwork::Devnet) { - let mut remap_principals = BTreeMap::new(); - remap_principals - .insert(contract_id.issuer.clone(), default_deployer_address.clone()); - - let data = RequirementPublishSpecification { - contract_id: contract_id.clone(), - remap_sender: default_deployer_address.clone(), - source: source.clone(), - location: contract_location, - cost: deployment_fee_rate * source.len() as u64, - remap_principals, - clarity_version, - }; - requirements_publish.insert(contract_id.clone(), data); - } else if matches!(network, StacksNetwork::Testnet) { - let mut remap_sender = default_deployer_address.clone(); - let mut remap_principals = BTreeMap::new(); - remap_principals - .insert(contract_id.issuer.clone(), default_deployer_address.clone()); - - // Remap sBTC mainnet address to testnet address - if contract_id.issuer.to_string() == SBTC_MAINNET_ADDRESS { - remap_sender = SBTC_TESTNET_ADDRESS_PRINCIPAL.clone(); + requirements_publish.insert(contract_id.clone(), data); + } else if matches!(network, StacksNetwork::Testnet) { + let mut remap_sender = default_deployer_address.clone(); + let mut remap_principals = BTreeMap::new(); remap_principals.insert( contract_id.issuer.clone(), - SBTC_TESTNET_ADDRESS_PRINCIPAL.clone(), + default_deployer_address.clone(), ); + + // Remap sBTC mainnet address to testnet address + if is_sbtc_mainnet_deployer { + remap_sender = SBTC_TESTNET_ADDRESS_PRINCIPAL.clone(); + remap_principals.insert( + contract_id.issuer.clone(), + SBTC_TESTNET_ADDRESS_PRINCIPAL.clone(), + ); + } + + let data = RequirementPublishSpecification { + contract_id: contract_id.clone(), + remap_sender, + source: source.clone(), + location: contract_location, + cost: deployment_fee_rate * source.len() as u64, + remap_principals, + clarity_version, + }; + requirements_publish.insert(contract_id.clone(), data); } - let data = RequirementPublishSpecification { - contract_id: contract_id.clone(), - remap_sender, - source: source.clone(), - location: contract_location, - cost: deployment_fee_rate * source.len() as u64, - remap_principals, + // Compute the AST + let contract = ClarityContract { + code_source: ClarityCodeSource::ContractInMemory(source), + name: contract_id.name.to_string(), + deployer: ContractDeployer::ContractIdentifier(contract_id.clone()), clarity_version, + epoch: clarity_repl::repl::Epoch::Specific(epoch), + skip_analysis: true, }; - requirements_publish.insert(contract_id.clone(), data); + let (ast, _, _) = interpreter.build_ast(&contract); + (clarity_version, ast) } + }; - // Compute the AST - let contract = ClarityContract { - code_source: ClarityCodeSource::ContractInMemory(source), - name: contract_id.name.to_string(), - deployer: ContractDeployer::ContractIdentifier(contract_id.clone()), - clarity_version, - epoch: clarity_repl::repl::Epoch::Specific(epoch), - skip_analysis: true, - }; - let (ast, _, _) = interpreter.build_ast(&contract); - (clarity_version, ast) - } - }; - - // Detect the eventual dependencies for this AST - let mut contract_data = BTreeMap::new(); - - contract_data.insert(contract_id.clone(), (clarity_version, ast)); - let dependencies = - ASTDependencyDetector::detect_dependencies(&contract_data, &requirements_data); - let (_, ast) = contract_data - .remove(&contract_id) - .expect("unable to retrieve ast"); - - // Extract the known / unknown dependencies - match dependencies { - Ok(inferable_dependencies) => { - if inferable_dependencies.len() > 1 { - clarity_repl::ueprint!( - "warning: inferable_dependencies contains more than one entry" - ); + // Detect the eventual dependencies for this AST + let mut contract_data = BTreeMap::new(); + + contract_data.insert(contract_id.clone(), (clarity_version, ast)); + let dependencies = + ASTDependencyDetector::detect_dependencies(&contract_data, &requirements_data); + let (_, ast) = contract_data + .remove(&contract_id) + .expect("unable to retrieve ast"); + + // Extract the known / unknown dependencies + match dependencies { + Ok(inferable_dependencies) => { + if inferable_dependencies.len() > 1 { + clarity_repl::ueprint!( + "warning: inferable_dependencies contains more than one entry" + ); + } + // We submitted a HashMap with one contract, so we have at most one result in the `inferable_dependencies` map. + // We will extract and keep the associated data (source, ast, deps). + if let Some((contract_id, dependencies)) = + inferable_dependencies.into_iter().next() + { + for dependency in dependencies.iter() { + queue.push_back(dependency.contract_id.clone()); + } + requirements_deps.insert(contract_id.clone(), dependencies); + requirements_data.insert(contract_id.clone(), (clarity_version, ast)); + } } - // We submitted a HashMap with one contract, so we have at most one result in the `inferable_dependencies` map. - // We will extract and keep the associated data (source, ast, deps). - if let Some((contract_id, dependencies)) = - inferable_dependencies.into_iter().next() - { - for dependency in dependencies.iter() { - queue.push_back(dependency.contract_id.clone()); + Err((mut inferable_dependencies, non_inferable_dependencies)) => { + // Some dependencies could not be resolved from + // `requirements_data`. Re-enqueue the present contract so it + // is re-examined once its missing dependencies are loaded, + // and keep the source in memory to avoid useless disk access. + for dependencies in inferable_dependencies.values() { + for dependency in dependencies.iter() { + queue.push_back(dependency.contract_id.clone()); + } } - requirements_deps.insert(contract_id.clone(), dependencies); requirements_data.insert(contract_id.clone(), (clarity_version, ast)); - } - } - Err((inferable_dependencies, non_inferable_dependencies)) => { - // In the case of unknown dependencies, we were unable to construct an exhaustive list of dependencies. - // As such, we will re-enqueue the present (front) and push all the unknown contract_ids in front of it, - // and we will keep the source in memory to avoid useless disk access. - for dependencies in inferable_dependencies.values() { - for dependency in dependencies.iter() { - queue.push_back(dependency.contract_id.clone()); + + // Retry only unresolved dependencies that can still be + // retrieved. Once every one of them has failed, this contract + // can never become inferable; re-enqueueing it would loop + // forever. + let retryable: Vec = + non_inferable_dependencies + .into_iter() + .filter(|id| { + !unresolved_requirements.contains(id) + && !requirements_deps.contains_key(id) + }) + .collect(); + if retryable.is_empty() { + // Keep the dependencies that were resolved so the + // contract is still deployed and ordered. + let resolved = inferable_dependencies + .remove(&contract_id) + .unwrap_or_default(); + requirements_deps.insert(contract_id.clone(), resolved); + } else { + queue.push_front(contract_id); + for unresolved in retryable { + queue.push_front(unresolved); + } } } - requirements_data.insert(contract_id.clone(), (clarity_version, ast)); - queue.push_front(contract_id); + }; + } - for non_inferable_contract_id in non_inferable_dependencies.into_iter() { - queue.push_front(non_inferable_contract_id); - } - } - }; + if !loaded_requirement { + break; + } } // Avoid listing requirements as deployment transactions to the deployment specification on Mainnet @@ -1184,9 +1396,9 @@ pub async fn generate_default_deployment_with_cache( if matches!(network, StacksNetwork::Simnet) { for contract_id in ordered_contracts_ids.iter() { - let data = emulated_contracts_publish - .remove(contract_id) - .unwrap_or_else(|| panic!("unable to retrieve contract: {contract_id}")); + let Some(data) = emulated_contracts_publish.remove(contract_id) else { + continue; // requirement was loaded for AST only (e.g. boot contract) + }; let tx = TransactionSpecification::EmulatedContractPublish(data); add_transaction_to_epoch( &mut transactions, @@ -1196,9 +1408,9 @@ pub async fn generate_default_deployment_with_cache( } } else if matches!(network, StacksNetwork::Devnet | StacksNetwork::Testnet) { for contract_id in ordered_contracts_ids.iter() { - let data = requirements_publish - .remove(contract_id) - .unwrap_or_else(|| panic!("unable to retrieve contract: {contract_id}")); + let Some(data) = requirements_publish.remove(contract_id) else { + continue; // requirement was loaded for AST only (e.g. sBTC on testnet pre-seeded boot copy) + }; let tx = TransactionSpecification::RequirementPublish(data); add_transaction_to_epoch( &mut transactions, @@ -1265,34 +1477,6 @@ pub async fn generate_default_deployment_with_cache( let mut contracts = HashMap::new(); let mut contracts_sources = HashMap::new(); - let project_root = &manifest.root_dir; - let sources: HashMap = match file_accessor { - None => { - let mut sources = HashMap::new(); - for contract_config in manifest.contracts.values() { - let contract_location = - project_root.join(contract_config.expect_contract_path_as_str()); - let source = paths::read_content_as_utf8(&contract_location).map_err(|_| { - format!("unable to find contract at {}", contract_location.display()) - })?; - sources.insert(contract_location.to_string_lossy().into_owned(), source); - } - sources - } - Some(file_accessor) => { - let contracts_location = manifest - .contracts - .values() - .map(|contract_config| { - let contract_location = - project_root.join(contract_config.expect_contract_path_as_str()); - contract_location.to_string_lossy().into_owned() - }) - .collect(); - file_accessor.read_files(contracts_location).await? - } - }; - for (name, contract_config) in manifest.contracts.iter() { let Ok(contract_name) = ContractName::try_from(name.to_string()) else { return Err(format!("unable to use {name} as a valid contract name")); diff --git a/components/clarinet-deployments/src/requirements.rs b/components/clarinet-deployments/src/requirements.rs index bc23ce519..1862f3bb1 100644 --- a/components/clarinet-deployments/src/requirements.rs +++ b/components/clarinet-deployments/src/requirements.rs @@ -60,6 +60,7 @@ pub async fn retrieve_contract( let metadata: ContractMetadata = serde_json::from_str(&metadata_json) .map_err(|e| format!("Unable to parse metadata file: {e}"))?; + log::debug!("requirement cache hit: {contract_deployer}.{contract_name}"); return Ok(( contract_source, metadata.epoch, @@ -73,6 +74,7 @@ pub async fn retrieve_contract( .is_mainnet(); let api_base_url = api_base_url.unwrap_or_else(|| default_api_base_url(is_mainnet)); + log::debug!("fetching requirement {contract_deployer}.{contract_name} from {api_base_url}"); let contract = fetch_contract(api_base_url, &contract_deployer, &contract_name).await?; let epoch = epoch_for_height(is_mainnet, contract.block_height); diff --git a/components/clarinet-deployments/tests/auto_detection_issues.rs b/components/clarinet-deployments/tests/auto_detection_issues.rs new file mode 100644 index 000000000..135f18906 --- /dev/null +++ b/components/clarinet-deployments/tests/auto_detection_issues.rs @@ -0,0 +1,813 @@ +//! Regression tests for requirement auto-detection. + +use std::fs; +use std::path::Path; + +use clarinet_deployments::generate_default_deployment; +use clarinet_deployments::types::TransactionSpecification; +use clarinet_files::{ProjectManifest, StacksNetwork}; +use clarinet_utils::DEFAULT_DEPLOYER_MNEMONIC as TEST_MNEMONIC; +use clarity_repl::utils::Environment; +use indoc::formatdoc; +use mockito::{Server, ServerGuard}; +use tempfile::TempDir; + +/// External contract deployer used by test fixtures. +const EXTERNAL_DEPLOYER: &str = "SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9"; + +/// Mainnet sBTC deployer. +const SBTC_MAINNET_DEPLOYER: &str = "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4"; + +/// Dependency-free requirement source. +const PLAIN_SOURCE: &str = "(define-read-only (get-one) (ok u1))"; + +/// Write a project with one contract and the supplied requirements. +fn write_project(root: &Path, contract_source: &str, requirements_toml: &str) { + fs::create_dir_all(root.join("settings")).unwrap(); + fs::create_dir_all(root.join("contracts")).unwrap(); + + fs::write( + root.join("settings/Testnet.toml"), + formatdoc!( + r#" + [network] + name = "testnet" + deployment_fee_rate = 10 + + [accounts.deployer] + mnemonic = "{TEST_MNEMONIC}" + "# + ), + ) + .unwrap(); + + fs::write( + root.join("Clarinet.toml"), + formatdoc!( + r#" + [project] + name = "auto-detection-test" + authors = [] + description = "" + telemetry = false + cache_dir = "./.cache" + {requirements_toml} + + [contracts.caller] + path = "contracts/caller.clar" + clarity_version = 3 + epoch = "3.0" + "# + ), + ) + .unwrap(); + + fs::write(root.join("contracts/caller.clar"), contract_source).unwrap(); +} + +/// Serve contract sources from a local mock API. +async fn mock_contracts(entries: &[(&str, &str, &str)]) -> ServerGuard { + let mut server = Server::new_async().await; + for (deployer, name, source) in entries { + server + .mock( + "GET", + format!("/extended/v1/contract/{deployer}.{name}").as_str(), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + serde_json::json!({ + "source_code": source, + "block_height": 175232, + "clarity_version": 3 + }) + .to_string(), + ) + // Auto-detection may or may not reach a given contract; a mock that + // goes unused must not fail the test. + .expect_at_least(0) + .create_async() + .await; + } + server +} + +/// Return requirement contract IDs from a generated testnet plan. +async fn testnet_requirement_publishes(root: &Path, api_url: &str) -> Vec { + let manifest = ProjectManifest::from_location(&root.join("Clarinet.toml"), false).unwrap(); + let (deployment, _artifacts, _) = generate_default_deployment( + &manifest, + &StacksNetwork::Testnet, + false, + None, + Some(api_url), + Environment::OnChain, + ) + .await + .expect("testnet deployment plan should be generated"); + + deployment + .plan + .batches + .iter() + .flat_map(|batch| &batch.transactions) + .filter_map(|tx| match tx { + TransactionSpecification::RequirementPublish(spec) => { + Some(spec.contract_id.to_string()) + } + _ => None, + }) + .collect() +} + +/// Trait references are included as requirements. +#[tokio::test] +async fn trait_references_are_auto_detected() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + + // Neither trait is declared explicitly. + write_project( + root, + &format!( + "(impl-trait '{EXTERNAL_DEPLOYER}.nft-trait.nft-trait)\n\ + (use-trait ft '{EXTERNAL_DEPLOYER}.ft-trait.sip-010-trait)\n\ + (define-read-only (get-owner (id uint)) (ok none))\n" + ), + "", + ); + + let server = mock_contracts(&[ + ( + EXTERNAL_DEPLOYER, + "nft-trait", + "(define-trait nft-trait ((get-owner (uint) (response (optional principal) uint))))", + ), + ( + EXTERNAL_DEPLOYER, + "ft-trait", + "(define-trait sip-010-trait ((transfer (uint principal principal) (response bool uint))))", + ), + ]) + .await; + + let published = testnet_requirement_publishes(root, &server.url()).await; + + for name in ["nft-trait", "ft-trait"] { + assert!( + published.contains(&format!("{EXTERNAL_DEPLOYER}.{name}")), + "{name} is referenced by the project and should be auto-detected and \ + published as a requirement; got {published:?}" + ); + } +} + +/// Unresolvable inferred references are left for contract analysis to report. +#[tokio::test] +async fn unresolvable_reference_does_not_abort_the_plan() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + + write_project( + root, + &format!("(define-public (go) (contract-call? '{EXTERNAL_DEPLOYER}.typo get-x))\n"), + "", + ); + + // Simulate a contract that does not exist. + let mut server = Server::new_async().await; + server + .mock( + "GET", + format!("/extended/v1/contract/{EXTERNAL_DEPLOYER}.typo").as_str(), + ) + .with_status(404) + .create_async() + .await; + + let manifest = ProjectManifest::from_location(&root.join("Clarinet.toml"), false).unwrap(); + let result = generate_default_deployment( + &manifest, + &StacksNetwork::Testnet, + false, + None, + Some(&server.url()), + Environment::OnChain, + ) + .await; + + assert!( + result.is_ok(), + "an unresolvable auto-detected reference must not abort plan generation, \ + it should be left to the analysis pass to report; got {:?}", + result.err() + ); +} + +/// An unresolvable dependency of a loaded requirement must not loop forever. +/// +/// `callee` is auto-detected and retrieved, but the contract it calls cannot be. +/// Resolution must stop retrying the missing contract (and re-enqueueing +/// `callee`) instead of spinning indefinitely. +#[tokio::test] +async fn unresolvable_dependency_of_requirement_terminates() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + + write_project( + root, + &format!("(define-public (go) (contract-call? '{EXTERNAL_DEPLOYER}.callee get-one))\n"), + "", + ); + + let callee_source = + format!("(define-read-only (get-one) (contract-call? '{EXTERNAL_DEPLOYER}.missing nope))"); + let mut server = mock_contracts(&[(EXTERNAL_DEPLOYER, "callee", callee_source.as_str())]).await; + server + .mock( + "GET", + format!("/extended/v1/contract/{EXTERNAL_DEPLOYER}.missing").as_str(), + ) + .with_status(404) + .create_async() + .await; + + let manifest = ProjectManifest::from_location(&root.join("Clarinet.toml"), false).unwrap(); + let result = tokio::time::timeout( + std::time::Duration::from_secs(10), + generate_default_deployment( + &manifest, + &StacksNetwork::Testnet, + false, + None, + Some(&server.url()), + Environment::OnChain, + ), + ) + .await; + + assert!( + result.is_ok(), + "resolution must terminate instead of re-enqueueing an unresolvable dependency" + ); + assert!( + result.unwrap().is_ok(), + "an unresolvable dependency of a loaded requirement must not abort the plan" + ); +} + +/// Simnet-only references are excluded from on-chain deployment plans. +#[tokio::test] +async fn env_simnet_dependencies_stay_off_chain() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + + write_project( + root, + &format!( + "(define-public (real) (ok true))\n\ + ;; #[env(simnet)]\n\ + (define-public (test-only) (contract-call? '{EXTERNAL_DEPLOYER}.mock get-one))\n" + ), + "", + ); + + // The mock endpoint must never be called โ€” a simnet-only reference must + // not trigger a network fetch for an on-chain deployment. + let mut server = Server::new_async().await; + server + .mock( + "GET", + format!("/extended/v1/contract/{EXTERNAL_DEPLOYER}.mock").as_str(), + ) + .expect(0) + .create_async() + .await; + + let published = testnet_requirement_publishes(root, &server.url()).await; + + assert!( + !published.contains(&format!("{EXTERNAL_DEPLOYER}.mock")), + "a dependency referenced only from #[env(simnet)] code must not be \ + published to testnet; got {published:?}" + ); +} + +/// Loading an external callee's signature must reveal its trait argument dependencies. +/// +/// The initial scan discovers `callee`, but cannot identify `implementation` as +/// a dependency until it knows that `take` accepts a trait argument. Loading +/// `callee` does not currently trigger another scan of the user contract, so the +/// generated plan omits `implementation`. +/// +/// Re-scan user contracts after loading requirements, repeating discovery and +/// loading until no new dependencies are found. Track failed resolutions too, +/// so an unavailable dependency cannot keep this process running indefinitely. +#[tokio::test] +async fn external_trait_argument_is_auto_detected_after_loading_callee() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + + // Neither external contract is explicitly declared. The type of the second + // contract literal is only known once the callee's source has been loaded. + write_project( + root, + &formatdoc!( + " + (define-public (go) + (contract-call? '{EXTERNAL_DEPLOYER}.callee take + '{EXTERNAL_DEPLOYER}.implementation)) + " + ), + "", + ); + + let server = mock_contracts(&[ + ( + EXTERNAL_DEPLOYER, + "callee", + "(define-trait reader ((get-one () (response uint uint)))) + (define-public (take (target )) + (contract-call? target get-one))", + ), + ( + EXTERNAL_DEPLOYER, + "implementation", + "(impl-trait .callee.reader) + (define-read-only (get-one) (ok u1))", + ), + ]) + .await; + + let published = testnet_requirement_publishes(root, &server.url()).await; + + assert!( + published.contains(&format!("{EXTERNAL_DEPLOYER}.callee")), + "the directly called external contract should be published; got {published:?}" + ); + assert!( + published.contains(&format!("{EXTERNAL_DEPLOYER}.implementation")), + "loading the callee should reveal that its trait argument is another \ + requirement to publish; got {published:?}" + ); +} + +/// Loading an explicitly declared callee's signature must also trigger a rescan. +/// +/// Here `callee` is listed in `[[project.requirements]]`, so it is filtered out +/// of auto-detection and its trait argument `implementation` is only revealed +/// once the callee has been loaded. Discovery must not stop just because no new +/// dependency was auto-detected on the first pass. +#[tokio::test] +async fn trait_argument_is_auto_detected_after_loading_explicit_callee() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + + write_project( + root, + &formatdoc!( + " + (define-public (go) + (contract-call? '{EXTERNAL_DEPLOYER}.callee take + '{EXTERNAL_DEPLOYER}.implementation)) + " + ), + &formatdoc!( + r#" + + [[project.requirements]] + contract_id = "{EXTERNAL_DEPLOYER}.callee" + "# + ), + ); + + let server = mock_contracts(&[ + ( + EXTERNAL_DEPLOYER, + "callee", + "(define-trait reader ((get-one () (response uint uint)))) + (define-public (take (target )) + (contract-call? target get-one))", + ), + ( + EXTERNAL_DEPLOYER, + "implementation", + "(impl-trait .callee.reader) + (define-read-only (get-one) (ok u1))", + ), + ]) + .await; + + let published = testnet_requirement_publishes(root, &server.url()).await; + + assert!( + published.contains(&format!("{EXTERNAL_DEPLOYER}.callee")), + "the declared requirement should be published; got {published:?}" + ); + assert!( + published.contains(&format!("{EXTERNAL_DEPLOYER}.implementation")), + "loading the explicitly declared callee should reveal its trait \ + argument as another requirement to publish; got {published:?}" + ); +} + +/// A contract that appears in `[[project.requirements]]` AND is referenced by +/// user code via `use-trait` must appear exactly once in the plan. +#[tokio::test] +async fn explicit_requirement_not_duplicated_when_also_auto_detected() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + + write_project( + root, + &format!( + "(use-trait nft '{EXTERNAL_DEPLOYER}.nft-trait.nft-trait)\n\ + (define-read-only (noop) (ok none))\n" + ), + &formatdoc!( + r#" + [[project.requirements]] + contract_id = "{EXTERNAL_DEPLOYER}.nft-trait" + "# + ), + ); + + let server = mock_contracts(&[( + EXTERNAL_DEPLOYER, + "nft-trait", + "(define-trait nft-trait ((get-owner (uint) (response (optional principal) uint))))", + )]) + .await; + + let published = testnet_requirement_publishes(root, &server.url()).await; + let count = published + .iter() + .filter(|id| *id == &format!("{EXTERNAL_DEPLOYER}.nft-trait")) + .count(); + assert_eq!( + count, 1, + "nft-trait must appear exactly once even when both explicitly declared and \ + auto-detected from user code; got {published:?}" + ); +} + +/// When the same contract is in `[[project.requirements]]` AND auto-detected, +/// a fetch failure is a hard error (not silently skipped). Explicit entries +/// promote auto-detected contracts to required: if the address is wrong or the +/// contract is unavailable, plan generation fails rather than continuing without it. +#[tokio::test] +async fn explicit_requirement_fetch_failure_aborts_plan() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + + write_project( + root, + &format!( + "(use-trait nft '{EXTERNAL_DEPLOYER}.nft-trait.nft-trait)\n\ + (define-read-only (noop) (ok none))\n" + ), + &formatdoc!( + r#" + [[project.requirements]] + contract_id = "{EXTERNAL_DEPLOYER}.nft-trait" + "# + ), + ); + + // Server returns 404 โ€” the contract cannot be retrieved. + let mut server = Server::new_async().await; + server + .mock( + "GET", + format!("/extended/v1/contract/{EXTERNAL_DEPLOYER}.nft-trait").as_str(), + ) + .with_status(404) + .create_async() + .await; + + let manifest = ProjectManifest::from_location(&root.join("Clarinet.toml"), false).unwrap(); + let result = generate_default_deployment( + &manifest, + &StacksNetwork::Testnet, + false, + None, + Some(&server.url()), + Environment::OnChain, + ) + .await; + + assert!( + result.is_err(), + "an explicit requirement that cannot be fetched must abort plan generation, \ + not be silently skipped like an auto-detected one; got Ok" + ); +} + +/// An explicit `[[project.requirements]]` entry must not prevent auto-detection +/// of other externally-referenced contracts. +#[tokio::test] +async fn explicit_requirement_does_not_suppress_auto_detection_of_others() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + + // nft-trait is explicit; ft-trait is only auto-detected via use-trait. + write_project( + root, + &format!( + "(use-trait ft '{EXTERNAL_DEPLOYER}.ft-trait.sip-010-trait)\n\ + (define-read-only (noop) (ok none))\n" + ), + &formatdoc!( + r#" + [[project.requirements]] + contract_id = "{EXTERNAL_DEPLOYER}.nft-trait" + "# + ), + ); + + let server = mock_contracts(&[ + ( + EXTERNAL_DEPLOYER, + "nft-trait", + "(define-trait nft-trait ((get-owner (uint) (response (optional principal) uint))))", + ), + ( + EXTERNAL_DEPLOYER, + "ft-trait", + "(define-trait sip-010-trait ((transfer (uint principal principal) (response bool uint))))", + ), + ]) + .await; + + let published = testnet_requirement_publishes(root, &server.url()).await; + + assert!( + published.contains(&format!("{EXTERNAL_DEPLOYER}.nft-trait")), + "the explicitly declared nft-trait should be published; got {published:?}" + ); + assert!( + published.contains(&format!("{EXTERNAL_DEPLOYER}.ft-trait")), + "ft-trait referenced by user code must be auto-detected even though \ + a different explicit requirement is also declared; got {published:?}" + ); +} + +/// On simnet, an explicit requirement and an auto-detected requirement (both +/// loaded from the local cache) must both appear in the generated plan. +#[tokio::test] +async fn simnet_explicit_and_auto_detected_requirements_both_deployed() { + const SECOND_DEPLOYER: &str = "SP3K8BC0PPEVCV7NZ6QSRWPQ2JE9E5B6N3PA0KBR9"; + + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + + // external-a is explicit; external-b is only auto-detected via contract-call?. + write_project( + root, + &formatdoc!( + " + (define-public (go) + (contract-call? '{SECOND_DEPLOYER}.external-b get-one)) + " + ), + &formatdoc!( + r#" + [[project.requirements]] + contract_id = "{EXTERNAL_DEPLOYER}.external-a" + "# + ), + ); + + // Simnet generation reads Devnet.toml for network settings. + fs::write( + root.join("settings/Devnet.toml"), + formatdoc!( + r#" + [network] + name = "devnet" + deployment_fee_rate = 10 + + [accounts.deployer] + mnemonic = "{TEST_MNEMONIC}" + balance = 100_000_000_000_000 + "# + ), + ) + .unwrap(); + + // Write cache files so simnet generation can load both without network access. + // Use string concatenation (not PathBuf::with_extension) since the contract + // name may contain dots that would be misinterpreted as file extensions. + let cache = root.join(".cache/requirements"); + fs::create_dir_all(&cache).unwrap(); + for (deployer, name) in [ + (EXTERNAL_DEPLOYER, "external-a"), + (SECOND_DEPLOYER, "external-b"), + ] { + let stem = format!("{deployer}.{name}"); + fs::write(cache.join(format!("{stem}.clar")), PLAIN_SOURCE).unwrap(); + fs::write( + cache.join(format!("{stem}.json")), + r#"{"epoch":"Epoch30","clarity_version":"Clarity3"}"#, + ) + .unwrap(); + } + + let manifest = ProjectManifest::from_location(&root.join("Clarinet.toml"), false).unwrap(); + let (deployment, _, _) = generate_default_deployment( + &manifest, + &StacksNetwork::Simnet, + false, + None, + None, + Environment::Simnet, + ) + .await + .expect("simnet deployment plan should be generated"); + + let emulated: Vec = deployment + .plan + .batches + .iter() + .flat_map(|b| &b.transactions) + .filter_map(|tx| match tx { + TransactionSpecification::EmulatedContractPublish(spec) => { + Some(format!("{}.{}", spec.emulated_sender, spec.contract_name)) + } + _ => None, + }) + .collect(); + + assert!( + emulated.contains(&format!("{EXTERNAL_DEPLOYER}.external-a")), + "the explicitly declared external-a should be in the simnet plan; got {emulated:?}" + ); + assert!( + emulated.contains(&format!("{SECOND_DEPLOYER}.external-b")), + "the auto-detected external-b should be in the simnet plan; got {emulated:?}" + ); +} + +/// An auto-detected reference to an sBTC contract must produce a +/// RequirementPublish on testnet. +#[tokio::test] +async fn auto_detected_sbtc_is_published_on_testnet() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + + // Reference sbtc-token without any explicit [[project.requirements]] entry. + write_project( + root, + &formatdoc!( + " + (define-read-only (balance) + (contract-call? '{SBTC_MAINNET_DEPLOYER}.sbtc-token get-balance tx-sender)) + " + ), + "", + ); + + let server = mock_contracts(&[(SBTC_MAINNET_DEPLOYER, "sbtc-token", PLAIN_SOURCE)]).await; + + let published = testnet_requirement_publishes(root, &server.url()).await; + + assert!( + published.contains(&format!("{SBTC_MAINNET_DEPLOYER}.sbtc-token")), + "auto-detected sbtc-token must be published on testnet even though its AST \ + is pre-seeded for boot setup; got {published:?}" + ); +} + +/// A direct `contract-call?` (not trait-mediated) is auto-detected and +/// published as a requirement. +#[tokio::test] +async fn direct_contract_call_is_auto_detected() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + + write_project( + root, + &format!( + "(define-public (call-ext) \ + (contract-call? '{EXTERNAL_DEPLOYER}.helper get-one))" + ), + "", + ); + + let server = mock_contracts(&[(EXTERNAL_DEPLOYER, "helper", PLAIN_SOURCE)]).await; + + let published = testnet_requirement_publishes(root, &server.url()).await; + + assert!( + published.contains(&format!("{EXTERNAL_DEPLOYER}.helper")), + "a directly called external contract must be auto-detected and published \ + as a requirement; got {published:?}" + ); +} + +/// Declaring an sBTC token requirement does not imply unrelated requirements. +#[tokio::test] +async fn sbtc_token_requirement_does_not_pull_in_sbtc_deposit() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + + write_project( + root, + "(define-read-only (noop) (ok true))\n", + &formatdoc!( + r#" + + [[project.requirements]] + contract_id = "{SBTC_MAINNET_DEPLOYER}.sbtc-token" + "# + ), + ); + + let mut server = mock_contracts(&[(SBTC_MAINNET_DEPLOYER, "sbtc-token", PLAIN_SOURCE)]).await; + // sbtc-deposit must never be fetched โ€” declaring sbtc-token must not + // implicitly pull in unrelated sBTC contracts. + server + .mock( + "GET", + format!("/extended/v1/contract/{SBTC_MAINNET_DEPLOYER}.sbtc-deposit").as_str(), + ) + .expect(0) + .create_async() + .await; + + let published = testnet_requirement_publishes(root, &server.url()).await; + + assert!( + !published.contains(&format!("{SBTC_MAINNET_DEPLOYER}.sbtc-deposit")), + "only the declared sbtc-token requirement should be published, but \ + sbtc-deposit was added too; got {published:?}" + ); +} + +/// `contract-hash?` with a literal principal is a static dependency. +/// +/// The AST visitor's `visit_contract_hash` implementation must register the +/// referenced contract as a dependency so it appears in the generated plan. +/// `contract-hash?` requires Clarity 4 (Epoch 3.3+). +#[tokio::test] +async fn contract_hash_literal_is_auto_detected() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + + // write_project hardcodes Clarity 3 / Epoch 3.0; write the manifest manually + // so we can use Clarity 4 / Epoch 3.3, where contract-hash? is available. + fs::create_dir_all(root.join("settings")).unwrap(); + fs::create_dir_all(root.join("contracts")).unwrap(); + + fs::write( + root.join("settings/Testnet.toml"), + formatdoc!( + r#" + [network] + name = "testnet" + deployment_fee_rate = 10 + + [accounts.deployer] + mnemonic = "{TEST_MNEMONIC}" + "# + ), + ) + .unwrap(); + + fs::write( + root.join("Clarinet.toml"), + formatdoc!( + r#" + [project] + name = "contract-hash-test" + authors = [] + description = "" + telemetry = false + cache_dir = "./.cache" + + [contracts.caller] + path = "contracts/caller.clar" + clarity_version = 4 + epoch = "3.3" + "# + ), + ) + .unwrap(); + + fs::write( + root.join("contracts/caller.clar"), + format!("(define-read-only (hash) (contract-hash? '{EXTERNAL_DEPLOYER}.hasher))"), + ) + .unwrap(); + + let server = mock_contracts(&[(EXTERNAL_DEPLOYER, "hasher", PLAIN_SOURCE)]).await; + + let published = testnet_requirement_publishes(root, &server.url()).await; + + assert!( + published.contains(&format!("{EXTERNAL_DEPLOYER}.hasher")), + "a contract referenced via contract-hash? with a literal principal must \ + be auto-detected and published as a requirement; got {published:?}" + ); +} diff --git a/components/clarinet-files/Cargo.toml b/components/clarinet-files/Cargo.toml index 90183c003..0238111da 100644 --- a/components/clarinet-files/Cargo.toml +++ b/components/clarinet-files/Cargo.toml @@ -33,6 +33,9 @@ wasm-bindgen-futures = { workspace = true } [features] json_schema = ["schemars", "clarity-repl/json_schema"] +[dev-dependencies] +indoc = { workspace = true } + [lib] name = "clarinet_files" path = "src/lib.rs" diff --git a/components/clarinet-files/src/project_manifest.rs b/components/clarinet-files/src/project_manifest.rs index 8de54a4b9..d9f79a1ba 100644 --- a/components/clarinet-files/src/project_manifest.rs +++ b/components/clarinet-files/src/project_manifest.rs @@ -197,7 +197,10 @@ pub struct ProjectConfig { pub authors: Vec, pub description: String, pub telemetry: bool, - pub requirements: Option>, + // The custom `Serialize` impl omits an empty requirements list, so a config + // without requirements must deserialize its own JSON without the field. + #[serde(default)] + pub requirements: Vec, #[serde(rename = "cache_dir")] pub cache_location: PathBuf, #[serde(skip_deserializing)] @@ -221,7 +224,7 @@ impl Serialize for ProjectConfig { map.serialize_entry("authors", &self.authors)?; map.serialize_entry("telemetry", &self.telemetry)?; map.serialize_entry("cache_dir", &self.cache_location.to_string_lossy())?; - if self.requirements.is_some() { + if !self.requirements.is_empty() { map.serialize_entry("requirements", &self.requirements)?; } if !self.override_boot_contracts_source.is_empty() { @@ -239,7 +242,7 @@ impl Serialize for ProjectConfig { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] #[cfg_attr(feature = "json_schema", derive(JsonSchema))] pub struct RequirementConfig { - /// Contract identifier (e.g., SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait) + /// Contract identifier of the required contract, e.g. `SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait` pub contract_id: String, } @@ -416,7 +419,7 @@ impl ProjectManifest { let project = ProjectConfig { name: project_name, - requirements: None, + requirements: Vec::new(), description: project_manifest_file .project .description @@ -441,16 +444,17 @@ impl ProjectManifest { let mut config_requirements: Vec = Vec::new(); if let Some(TomlValue::Array(requirements)) = project_manifest_file.project.requirements { - for link_settings in requirements.iter() { - if let TomlValue::Table(link_settings) = link_settings { - let contract_id = match link_settings.get("contract_id") { - Some(TomlValue::String(contract_id)) => contract_id.to_string(), - _ => continue, + for item in requirements.iter() { + if let TomlValue::Table(table) = item { + let Some(TomlValue::String(contract_id)) = table.get("contract_id") else { + continue; }; - config_requirements.push(RequirementConfig { contract_id }); + config_requirements.push(RequirementConfig { + contract_id: contract_id.clone(), + }); } } - }; + } if let Some(TomlValue::Table(contracts)) = project_manifest_file.contracts { for (contract_name, contract_settings) in contracts.iter() { @@ -502,7 +506,7 @@ impl ProjectManifest { config.contracts = config_contracts; config.contracts_settings = contracts_settings; - config.project.requirements = Some(config_requirements); + config.project.requirements = config_requirements; Ok(config) } @@ -752,6 +756,66 @@ mod tests { .contains_key("pox-x")); } + /// Empty requirements must survive a project config's JSON round trip. + /// + /// The custom serializer omits an empty requirements list, but changing the + /// field from `Option>` to `Vec<_>` makes the derived deserializer + /// require it. A config loaded from a manifest without requirements therefore + /// fails to deserialize its own JSON with `missing field requirements`. + /// Add `#[serde(default)]` to `ProjectConfig::requirements` so an omitted + /// field deserializes as an empty vector. + #[test] + fn test_empty_requirements_json_roundtrip() { + let manifest_file: ProjectManifestFile = toml::from_str( + r#" +[project] +name = "test-project" +telemetry = false +"#, + ) + .unwrap(); + let location = PathBuf::from("/tmp/clarinet.toml"); + let manifest = + ProjectManifest::from_project_manifest_file(manifest_file, &location, false).unwrap(); + assert!(manifest.project.requirements.is_empty()); + + let json = serde_json::to_string(&manifest.project).unwrap(); + let restored: ProjectConfig = serde_json::from_str(&json) + .expect("a project config without requirements must deserialize its own JSON"); + + assert!(restored.requirements.is_empty()); + assert_eq!(restored.name, manifest.project.name); + } + + #[test] + fn test_requirements_parsing() { + let manifest_str = indoc::indoc! {r#" + [project] + name = "test-project" + telemetry = false + + [[project.requirements]] + contract_id = "SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait" + + [[project.requirements]] + contract_id = "SP3K8BC0PPEVCV7NZ6QSRWPQ2JE9E5B6N3PA0KBR9.sip010-trait" + "#}; + let manifest_file: ProjectManifestFile = toml::from_str(manifest_str).unwrap(); + let location = PathBuf::from("/tmp/clarinet.toml"); + let manifest = + ProjectManifest::from_project_manifest_file(manifest_file, &location, false).unwrap(); + + assert_eq!(manifest.project.requirements.len(), 2); + assert_eq!( + manifest.project.requirements[0].contract_id, + "SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait" + ); + assert_eq!( + manifest.project.requirements[1].contract_id, + "SP3K8BC0PPEVCV7NZ6QSRWPQ2JE9E5B6N3PA0KBR9.sip010-trait" + ); + } + #[test] fn test_warning_message_for_invalid_boot_contract() { let manifest_toml = toml! { diff --git a/components/clarity-repl/src/analysis/ast_dependency_detector.rs b/components/clarity-repl/src/analysis/ast_dependency_detector.rs index 520c46a33..99eeaede7 100644 --- a/components/clarity-repl/src/analysis/ast_dependency_detector.rs +++ b/components/clarity-repl/src/analysis/ast_dependency_detector.rs @@ -137,6 +137,25 @@ impl Ord for Dependency { } } +/// Walk `expr` recursively and collect every literal contract principal found +/// anywhere in the tree into `dependencies`. +/// +/// Used for trait-typed arguments where the actual contract may be buried +/// inside wrapper expressions (`begin`, `unwrap-panic`, `some`, `match`, `get`, +/// `let`, etc.) without the need to enumerate every possible form. +fn collect_contract_principals( + expr: &SymbolicExpression, + dependencies: &mut BTreeSet, +) { + if let Some(Value::Principal(PrincipalData::Contract(contract))) = expr.match_literal_value() { + dependencies.insert(contract.clone()); + } else if let Some(children) = expr.match_list() { + for child in children { + collect_contract_principals(child, dependencies); + } + } +} + fn deep_check_callee_type( arg_type: &TypeSignature, expr: &SymbolicExpression, @@ -145,11 +164,7 @@ fn deep_check_callee_type( match arg_type { TypeSignature::CallableType(CallableSubtype::Trait(_)) | TypeSignature::TraitReferenceType(_) => { - if let Some(Value::Principal(PrincipalData::Contract(contract))) = - expr.match_literal_value() - { - dependencies.insert(contract.clone()); - } + collect_contract_principals(expr, dependencies); } TypeSignature::OptionalType(inner_type) => { if let Some(expr) = expr.match_list().and_then(|l| l.get(1)) { @@ -157,11 +172,19 @@ fn deep_check_callee_type( } } TypeSignature::ResponseType(inner_type) => { - if let Some(expr) = expr.match_list().and_then(|l| l.get(1)) { - deep_check_callee_type(&inner_type.0, expr, dependencies); - } - if let Some(expr) = expr.match_list().and_then(|l| l.get(2)) { - deep_check_callee_type(&inner_type.1, expr, dependencies); + // Select the success or error type from the constructor name, then + // recurse into element 1. + if let Some(list) = expr.match_list() { + let constructor = list.first().and_then(|e| e.match_atom()); + let payload = list.get(1); + if let (Some(constructor), Some(payload)) = (constructor, payload) { + let arg_type = if constructor.as_str() == "err" { + &inner_type.1 + } else { + &inner_type.0 + }; + deep_check_callee_type(arg_type, payload, dependencies); + } } } TypeSignature::TupleType(inner_type) => { @@ -393,6 +416,11 @@ impl<'a> ASTDependencyDetector<'a> { return; } + // Ignore self-references. + if from == to { + return; + } + if let Some(set) = self.dependencies.get_mut(from) { set.add_dependency(to.clone(), self.top_level); } else { @@ -491,6 +519,19 @@ impl<'a> ASTDependencyDetector<'a> { let mut dependencies = BTreeSet::new(); for (i, arg_type) in arg_types.iter().enumerate() { if let Some(expr) = args.get(i) { + // If a trait-typed argument is a named constant, resolve it. + if matches!( + arg_type, + TypeSignature::CallableType(CallableSubtype::Trait(_)) + | TypeSignature::TraitReferenceType(_) + ) { + if let Some(name) = expr.match_atom() { + if let Some(contract_id) = self.get_contract_constant(name) { + dependencies.insert(contract_id.clone()); + continue; + } + } + } deep_check_callee_type(arg_type, expr, &mut dependencies); } } @@ -742,6 +783,27 @@ impl<'a> ASTVisitor<'a> for ASTDependencyDetector<'a> { } } else if let Some(contract_constant) = self.get_contract_constant(callable) { self.add_dependency(self.current_contract.unwrap(), contract_constant); + // Also detect trait-typed argument dependencies when the callee's + // function type is known. Skip when there are no arguments, since + // there is nothing to check for trait types. + if !args.is_empty() { + let dependencies = if let Some(arg_types) = self + .defined_functions + .get(&(contract_constant, function_name)) + { + self.check_callee_type(arg_types, args) + } else { + self.add_pending_function_check( + self.current_contract.unwrap(), + (contract_constant, function_name), + args, + ); + BTreeSet::new() + }; + for dependency in dependencies { + self.add_dependency(self.current_contract.unwrap(), &dependency); + } + } } true } @@ -764,6 +826,22 @@ impl<'a> ASTVisitor<'a> for ASTDependencyDetector<'a> { true } + fn visit_contract_hash( + &mut self, + _expr: &'a SymbolicExpression, + input: &'a SymbolicExpression, + ) -> bool { + // `contract-hash?` reads the referenced contract's stored hash, so the + // contract must be published before the caller. A literal principal is + // the only statically-identifiable form. + if let Some(Value::Principal(PrincipalData::Contract(contract))) = + input.match_literal_value() + { + self.add_dependency(self.current_contract.unwrap(), contract); + } + true + } + fn visit_use_trait( &mut self, expr: &'a SymbolicExpression, @@ -1699,4 +1777,168 @@ mod tests { other => panic!("expected IncorrectContractHeight, got {other:?}"), } } + + // Helpers shared by the trait-arg-in-expression tests below. + fn setup_trait_callee( + session: &Session, + contracts: &mut BTreeMap, + ) -> (QualifiedContractIdentifier, QualifiedContractIdentifier) { + #[rustfmt::skip] + let callee_snippet = indoc!(" + (define-trait reader ((get-one () (response uint uint)))) + (define-public (take (target )) + (contract-call? target get-one)) + ").to_string(); + let callee = deploy_snippet(session, &callee_snippet, Some("callee"), contracts); + + #[rustfmt::skip] + let impl_snippet = indoc!(" + (define-public (get-one) (ok u1)) + ").to_string(); + let implementation = + deploy_snippet(session, &impl_snippet, Some("implementation"), contracts); + + (callee, implementation) + } + + fn assert_impl_dep( + contracts: &BTreeMap, + caller: &QualifiedContractIdentifier, + implementation: &QualifiedContractIdentifier, + ) { + let dependencies = + ASTDependencyDetector::detect_dependencies(contracts, &BTreeMap::new()).unwrap(); + assert!( + dependencies[caller] + .has_dependency(implementation) + .is_some(), + "expected .implementation to be detected as a dependency of .caller" + ); + } + + #[test] + fn trait_arg_constant() { + // (define-constant target .implementation) + // (contract-call? .callee take target) + let session = Session::new_without_boot_contracts(SessionSettings::default()); + let mut contracts = BTreeMap::new(); + let (_, implementation) = setup_trait_callee(&session, &mut contracts); + + #[rustfmt::skip] + let snippet = indoc!(" + (define-constant target .implementation) + (define-public (go) + (contract-call? .callee take target)) + ").to_string(); + let caller = deploy_snippet(&session, &snippet, Some("caller"), &mut contracts); + + assert_impl_dep(&contracts, &caller, &implementation); + } + + #[test] + fn trait_arg_begin() { + // (contract-call? .callee take (begin .implementation)) + let session = Session::new_without_boot_contracts(SessionSettings::default()); + let mut contracts = BTreeMap::new(); + let (_, implementation) = setup_trait_callee(&session, &mut contracts); + + let snippet = "(define-public (go) (contract-call? .callee take (begin .implementation)))" + .to_string(); + let caller = deploy_snippet(&session, &snippet, Some("caller"), &mut contracts); + + assert_impl_dep(&contracts, &caller, &implementation); + } + + #[test] + fn trait_arg_unwrap_panic() { + // (contract-call? .callee take (unwrap-panic (some .implementation))) + let session = Session::new_without_boot_contracts(SessionSettings::default()); + let mut contracts = BTreeMap::new(); + let (_, implementation) = setup_trait_callee(&session, &mut contracts); + + let snippet = "(define-public (go) (contract-call? .callee take (unwrap-panic (some .implementation))))".to_string(); + let caller = deploy_snippet(&session, &snippet, Some("caller"), &mut contracts); + + assert_impl_dep(&contracts, &caller, &implementation); + } + + #[test] + fn trait_arg_default_to() { + // (contract-call? .callee take (default-to .implementation (some .implementation))) + let session = Session::new_without_boot_contracts(SessionSettings::default()); + let mut contracts = BTreeMap::new(); + let (_, implementation) = setup_trait_callee(&session, &mut contracts); + + let snippet = "(define-public (go) (contract-call? .callee take (default-to .implementation (some .implementation))))".to_string(); + let caller = deploy_snippet(&session, &snippet, Some("caller"), &mut contracts); + + assert_impl_dep(&contracts, &caller, &implementation); + } + + #[test] + fn trait_arg_match() { + // (contract-call? .callee take (match (some .implementation) x x .implementation)) + let session = Session::new_without_boot_contracts(SessionSettings::default()); + let mut contracts = BTreeMap::new(); + let (_, implementation) = setup_trait_callee(&session, &mut contracts); + + let snippet = "(define-public (go) (contract-call? .callee take (match (some .implementation) x x .implementation)))".to_string(); + let caller = deploy_snippet(&session, &snippet, Some("caller"), &mut contracts); + + assert_impl_dep(&contracts, &caller, &implementation); + } + + #[test] + fn trait_arg_tuple_get() { + // (contract-call? .callee take (get target { target: .implementation })) + let session = Session::new_without_boot_contracts(SessionSettings::default()); + let mut contracts = BTreeMap::new(); + let (_, implementation) = setup_trait_callee(&session, &mut contracts); + + let snippet = "(define-public (go) (contract-call? .callee take (get target { target: .implementation })))".to_string(); + let caller = deploy_snippet(&session, &snippet, Some("caller"), &mut contracts); + + assert_impl_dep(&contracts, &caller, &implementation); + } + + #[test] + fn trait_arg_constant_callee() { + // (define-constant c .callee) + // (contract-call? c take .implementation) + let session = Session::new_without_boot_contracts(SessionSettings::default()); + let mut contracts = BTreeMap::new(); + let (_, implementation) = setup_trait_callee(&session, &mut contracts); + + #[rustfmt::skip] + let snippet = indoc!(" + (define-constant c .callee) + (define-public (go) + (contract-call? c take .implementation)) + ").to_string(); + let caller = deploy_snippet(&session, &snippet, Some("caller"), &mut contracts); + + assert_impl_dep(&contracts, &caller, &implementation); + } + + #[test] + fn contract_hash_self_reference() { + // (contract-hash? .self-contract) inside self-contract must not register + // a self-dependency โ€” add_dependency already guards against from == to. + let session = Session::new_without_boot_contracts(SessionSettings::default()); + let mut contracts = BTreeMap::new(); + let snippet = "(define-read-only (get-hash) (contract-hash? .self-contract))".to_string(); + let self_contract = + deploy_snippet(&session, &snippet, Some("self-contract"), &mut contracts); + + let dependencies = + ASTDependencyDetector::detect_dependencies(&contracts, &BTreeMap::new()).unwrap(); + assert_eq!( + dependencies + .get(&self_contract) + .map(|d| d.len()) + .unwrap_or(0), + 0, + "contract-hash? on self must not register a self-dependency" + ); + } } diff --git a/components/clarity-repl/src/analysis/ast_visitor.rs b/components/clarity-repl/src/analysis/ast_visitor.rs index 0cc840cfc..3183b2884 100644 --- a/components/clarity-repl/src/analysis/ast_visitor.rs +++ b/components/clarity-repl/src/analysis/ast_visitor.rs @@ -700,9 +700,15 @@ pub trait ASTVisitor<'a> { self.traverse_restrict_assets(expr, owner, raw_allowances, body) } + AllowanceWithFt | AllowanceWithNft => { + // Three evaluated arguments: contract principal, + // asset-name expression, and amount/token-ID + // expression. Traverse all so contract-calls + // nested in the latter two are discovered. + args.iter().all(|arg| self.traverse_expr(arg)) + && self.visit_allowance(expr, args.get(0).unwrap_or(&DEFAULT_EXPR)) + } AllowanceWithStx - | AllowanceWithFt - | AllowanceWithNft | AllowanceWithStacking | AllowanceAll | AllowanceWithStaking