diff --git a/Cargo.lock b/Cargo.lock index ccf20ce1..4625dca5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5306,11 +5306,17 @@ dependencies = [ "contract-build", "contract-extrinsics", "duct", + "flate2", "ink_env", + "mockito", + "reqwest 0.12.4", + "serde", + "serde_json", "sp-core", "sp-weights", "subxt", "subxt-signer", + "tar", "tempfile", "thiserror", "tokio", diff --git a/Cargo.toml b/Cargo.toml index ce592f15..f8f7ccc0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,10 +24,12 @@ assert_cmd = "2.0.14" predicates = "3.1.0" dirs = "5.0" env_logger = "0.11.1" +flate2 = "1.0.30" duct = "0.13" git2 = { version = "0.18", features = ["vendored-openssl"] } log = "0.4.20" mockito = "1.4.0" +tar = "0.4.40" tempfile = "3.10" thiserror = "1.0.58" diff --git a/README.md b/README.md index 82d33f39..ffd11bef 100644 --- a/README.md +++ b/README.md @@ -145,20 +145,14 @@ Build the Smart Contract: pop build contract -p ./my_contract ``` -To deploy a Smart Contract you need a chain running. For testing purposes you can simply spawn a contract node: - -```sh -pop up contracts-node -``` - -> :information_source: We plan to automate this in the future. - Deploy and instantiate the Smart Contract: ```sh pop up contract -p ./my_contract --constructor new --args "false" --suri //Alice ``` +> :information_source: If you don't specify a live chain, `pop` will automatically spawn a local node for testing purposes. + Some of the options available are: - Specify the contract `constructor `to use, which in this example is `new()`. @@ -198,10 +192,11 @@ pop call contract -p ./my_contract --contract $INSTANTIATED_CONTRACT_ADDRESS --m ## E2E testing For end-to-end testing you will need to have a Substrate node with `pallet contracts`. -Pop provides the latest version out-of-the-box by running: +You do not need to run it in the background since the node is started for each test independently. +To install the latest version: -```sh -pop up contracts-node +``` +cargo install contracts-node --git https://github.com/paritytech/substrate-contracts-node.git ``` If you want to run any other node with `pallet-contracts` you need to change `CONTRACTS_NODE` environment variable: diff --git a/crates/pop-cli/src/commands/up/contract.rs b/crates/pop-cli/src/commands/up/contract.rs index d988dbcf..fa714b1b 100644 --- a/crates/pop-cli/src/commands/up/contract.rs +++ b/crates/pop-cli/src/commands/up/contract.rs @@ -2,10 +2,10 @@ use anyhow::anyhow; use clap::Args; -use cliclack::{clear_screen, intro, log, outro, outro_cancel}; +use cliclack::{clear_screen, confirm, intro, log, outro, outro_cancel}; use pop_contracts::{ build_smart_contract, dry_run_gas_estimate_instantiate, instantiate_smart_contract, - parse_hex_bytes, set_up_deployment, UpOpts, + is_chain_alive, parse_hex_bytes, run_contracts_node, set_up_deployment, UpOpts, }; use sp_core::Bytes; use sp_weights::Weight; @@ -48,8 +48,11 @@ pub struct UpContractCommand { /// e.g. /// - for a dev account "//Alice" /// - with a password "//Alice///SECRET_PASSWORD" - #[clap(name = "suri", long, short)] + #[clap(name = "suri", long, short, default_value = "//Alice")] suri: String, + /// Before start a local node, do not ask the user for confirmation. + #[clap(short('y'), long)] + skip_confirm: bool, } impl UpContractCommand { pub(crate) async fn execute(&self) -> anyhow::Result<()> { @@ -68,9 +71,28 @@ impl UpContractCommand { log::success(result.to_string())?; } + if !is_chain_alive(self.url.clone()).await? { + if !self.skip_confirm { + if !confirm(format!( + "The chain \"{}\" is not live. Would you like pop to start a local node in the background for testing?", + self.url.to_string() + )) + .interact()? + { + outro_cancel("You need to specify a live chain to deploy the contract.")?; + return Ok(()); + } + } + let process = run_contracts_node(crate::cache()?).await?; + log::success("Local node started successfully in the background.")?; + log::warning(format!("NOTE: The contracts node is running in the background with process ID {}. Please close it manually when done testing.", process.id()))?; + } + // if build exists then proceed intro(format!("{}: Deploy a smart contract", style(" Pop CLI ").black().on_magenta()))?; + println!("{}: Deploying a smart contract", style(" Pop CLI ").black().on_magenta()); + let instantiate_exec = set_up_deployment(UpOpts { path: self.path.clone(), constructor: self.constructor.clone(), diff --git a/crates/pop-cli/src/commands/up/contracts_node.rs b/crates/pop-cli/src/commands/up/contracts_node.rs deleted file mode 100644 index 074ff470..00000000 --- a/crates/pop-cli/src/commands/up/contracts_node.rs +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 - -use clap::Args; -use cliclack::{clear_screen, intro, set_theme}; -use duct::cmd; - -use crate::style::{style, Theme}; - -const BIN_NAME: &str = "substrate-contracts-node"; - -#[derive(Args)] -pub(crate) struct ContractsNodeCommand; -impl ContractsNodeCommand { - pub(crate) async fn execute(&self) -> anyhow::Result<()> { - clear_screen()?; - intro(format!("{}: Launch a contracts node", style(" Pop CLI ").black().on_magenta()))?; - set_theme(Theme); - - let cache = crate::cache()?; - let cached_file = cache.join("bin").join(BIN_NAME); - if !cached_file.exists() { - cmd( - "cargo", - vec!["install", "--root", cache.display().to_string().as_str(), "contracts-node"], - ) - .run()?; - } - cmd(cached_file.display().to_string().as_str(), Vec::<&str>::new()).run()?; - Ok(()) - } -} diff --git a/crates/pop-cli/src/commands/up/mod.rs b/crates/pop-cli/src/commands/up/mod.rs index 3ae9c039..ab6f7d34 100644 --- a/crates/pop-cli/src/commands/up/mod.rs +++ b/crates/pop-cli/src/commands/up/mod.rs @@ -2,8 +2,6 @@ #[cfg(feature = "contract")] mod contract; -#[cfg(feature = "contract")] -mod contracts_node; #[cfg(feature = "parachain")] mod parachain; @@ -26,8 +24,4 @@ pub(crate) enum UpCommands { /// Deploy a smart contract to a node. #[clap(alias = "c")] Contract(contract::UpContractCommand), - #[cfg(feature = "contract")] - /// Deploy a contracts node. - #[clap(alias = "n")] - ContractsNode(contracts_node::ContractsNodeCommand), } diff --git a/crates/pop-cli/src/main.rs b/crates/pop-cli/src/main.rs index 6544907e..1ab3a192 100644 --- a/crates/pop-cli/src/main.rs +++ b/crates/pop-cli/src/main.rs @@ -98,8 +98,6 @@ async fn main() -> Result<()> { up::UpCommands::Parachain(cmd) => cmd.execute().await.map(|_| Value::Null), #[cfg(feature = "contract")] up::UpCommands::Contract(cmd) => cmd.execute().await.map(|_| Value::Null), - #[cfg(feature = "contract")] - up::UpCommands::ContractsNode(cmd) => cmd.execute().await.map(|_| Value::Null), }, #[cfg(feature = "contract")] Commands::Test(args) => match &args.command { diff --git a/crates/pop-contracts/Cargo.toml b/crates/pop-contracts/Cargo.toml index aad20b46..137444c6 100644 --- a/crates/pop-contracts/Cargo.toml +++ b/crates/pop-contracts/Cargo.toml @@ -12,6 +12,12 @@ repository.workspace = true [dependencies] anyhow.workspace = true duct.workspace = true +flate2.workspace = true +reqwest.workspace = true +serde_json.workspace = true +serde.workspace = true +tar.workspace = true +tempfile.workspace = true thiserror.workspace = true tokio.workspace = true url.workspace = true @@ -27,4 +33,5 @@ contract-build.workspace = true contract-extrinsics.workspace = true [dev-dependencies] -tempfile.workspace = true +mockito.workspace = true + diff --git a/crates/pop-contracts/src/errors.rs b/crates/pop-contracts/src/errors.rs index dc579fdb..235765df 100644 --- a/crates/pop-contracts/src/errors.rs +++ b/crates/pop-contracts/src/errors.rs @@ -29,4 +29,22 @@ pub enum Error { #[error("Failed to parse hex encoded bytes: {0}")] HexParsing(String), + + #[error("Failed to install {0}")] + InstallContractsNode(String), + + #[error("Failed to run {0}")] + UpContractsNode(String), + + #[error("Unsupported platform: {os}")] + UnsupportedPlatform { os: &'static str }, + + #[error("Anyhow error: {0}")] + AnyhowError(#[from] anyhow::Error), + + #[error("HTTP error: {0}")] + HttpError(#[from] reqwest::Error), + + #[error("a git error occurred: {0}")] + Git(String), } diff --git a/crates/pop-contracts/src/lib.rs b/crates/pop-contracts/src/lib.rs index ea388f28..b3821496 100644 --- a/crates/pop-contracts/src/lib.rs +++ b/crates/pop-contracts/src/lib.rs @@ -17,4 +17,7 @@ pub use test::{test_e2e_smart_contract, test_smart_contract}; pub use up::{ dry_run_gas_estimate_instantiate, instantiate_smart_contract, set_up_deployment, UpOpts, }; -pub use utils::signer::parse_hex_bytes; +pub use utils::{ + contracts_node::{is_chain_alive, run_contracts_node}, + signer::parse_hex_bytes, +}; diff --git a/crates/pop-contracts/src/utils/contracts_node.rs b/crates/pop-contracts/src/utils/contracts_node.rs new file mode 100644 index 00000000..6df9eab8 --- /dev/null +++ b/crates/pop-contracts/src/utils/contracts_node.rs @@ -0,0 +1,163 @@ +use crate::{errors::Error, utils::git::GitHub}; +use contract_extrinsics::{RawParams, RpcRequest}; +use flate2::read::GzDecoder; +use std::{ + env::consts::OS, + io::{Seek, SeekFrom, Write}, + path::PathBuf, + process::{Child, Command}, + time::Duration, +}; +use tar::Archive; +use tempfile::tempfile; +use tokio::time::sleep; + +const SUBSTRATE_CONTRACT_NODE: &str = "https://github.com/paritytech/substrate-contracts-node"; +const BIN_NAME: &str = "substrate-contracts-node"; +const STABLE_VERSION: &str = "v0.41.0"; + +/// Checks if the specified node is alive and responsive. +/// +/// # Arguments +/// +/// * `url` - Endpoint of the node. +/// +pub async fn is_chain_alive(url: url::Url) -> Result { + let request = RpcRequest::new(&url).await; + match request { + Ok(request) => { + let params = RawParams::new(&[])?; + let result = request.raw_call("system_health", params).await; + match result { + Ok(_) => Ok(true), + Err(_) => Ok(false), + } + }, + Err(_) => Ok(false), + } +} + +/// Runs the latest version of the `substracte-contracts-node` in the background. +/// +/// # Arguments +/// +/// * `cache` - The path where the binary will be stored. +/// +pub async fn run_contracts_node(cache: PathBuf) -> Result { + let cached_file = cache.join(release_folder_by_target()?).join(BIN_NAME); + if !cached_file.exists() { + let archive = archive_name_by_target()?; + + let latest_version = latest_contract_node_release().await?; + let releases_url = + format!("{SUBSTRATE_CONTRACT_NODE}/releases/download/{latest_version}/{archive}"); + // Download archive + let response = reqwest::get(releases_url.as_str()).await?.error_for_status()?; + let mut file = tempfile()?; + file.write_all(&response.bytes().await?)?; + file.seek(SeekFrom::Start(0))?; + // Extract contents + let tar = GzDecoder::new(file); + let mut archive = Archive::new(tar); + archive.unpack(cache.clone())?; + } + let process = Command::new(cached_file.display().to_string().as_str()).spawn()?; + + // Wait 5 secs until the node is ready + sleep(Duration::from_millis(5000)).await; + Ok(process) +} + +async fn latest_contract_node_release() -> Result { + let repo = GitHub::parse(SUBSTRATE_CONTRACT_NODE)?; + match repo.get_latest_releases().await { + Ok(releases) => { + // Fetching latest releases + for release in releases { + if !release.prerelease { + return Ok(release.tag_name); + } + } + // It should never reach this point, but in case we download a default version of polkadot + Ok(STABLE_VERSION.to_string()) + }, + // If an error with GitHub API return the STABLE_VERSION + Err(_) => Ok(STABLE_VERSION.to_string()), + } +} + +fn archive_name_by_target() -> Result { + match OS { + "macos" => Ok(format!("{}-mac-universal.tar.gz", BIN_NAME)), + "linux" => Ok(format!("{}-linux.tar.gz", BIN_NAME)), + _ => Err(Error::UnsupportedPlatform { os: OS }), + } +} +fn release_folder_by_target() -> Result<&'static str, Error> { + match OS { + "macos" => Ok("artifacts/substrate-contracts-node-mac"), + "linux" => Ok("artifacts/substrate-contracts-node-linux"), + _ => Err(Error::UnsupportedPlatform { os: OS }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::{Error, Result}; + + #[tokio::test] + async fn test_latest_polkadot_release() -> Result<()> { + let version = latest_contract_node_release().await?; + // Result will change all the time to the current version, check at least starts with v + assert!(version.starts_with("v")); + Ok(()) + } + #[tokio::test] + async fn release_folder_by_target_works() -> Result<()> { + let path = release_folder_by_target(); + if cfg!(target_os = "macos") { + assert_eq!(path?, "artifacts/substrate-contracts-node-mac"); + } else if cfg!(target_os = "linux") { + assert_eq!(path?, "artifacts/substrate-contracts-node-linux"); + } else { + assert!(path.is_err()) + } + Ok(()) + } + #[tokio::test] + async fn folder_path_by_target() -> Result<()> { + let archive = archive_name_by_target(); + if cfg!(target_os = "macos") { + assert_eq!(archive?, "substrate-contracts-node-mac-universal.tar.gz"); + } else if cfg!(target_os = "linux") { + assert_eq!(archive?, "substrate-contracts-node-linux.tar.gz"); + } else { + assert!(archive.is_err()) + } + Ok(()) + } + + #[tokio::test] + async fn is_chain_alive_works() -> Result<(), Error> { + let local_url = url::Url::parse("ws://localhost:9944")?; + assert!(!is_chain_alive(local_url).await?); + let polkadot_url = url::Url::parse("wss://polkadot-rpc.dwellir.com")?; + assert!(is_chain_alive(polkadot_url).await?); + Ok(()) + } + + #[tokio::test] + async fn run_contracts_node_works() -> Result<(), Error> { + let local_url = url::Url::parse("ws://localhost:9944")?; + assert!(!is_chain_alive(local_url.clone()).await?); + // Run the contracts node + let temp_dir = tempfile::tempdir().expect("Could not create temp dir"); + let cache = temp_dir.path().join("cache"); + let mut process = run_contracts_node(cache).await?; + // Check if the node is alive + assert!(is_chain_alive(local_url).await?); + process.kill()?; + Ok(()) + } +} diff --git a/crates/pop-contracts/src/utils/git.rs b/crates/pop-contracts/src/utils/git.rs new file mode 100644 index 00000000..007bf228 --- /dev/null +++ b/crates/pop-contracts/src/utils/git.rs @@ -0,0 +1,141 @@ +use anyhow::Result; +use url::Url; + +use crate::errors::Error; +/// A helper for handling GitHub operations. +pub struct GitHub { + pub org: String, + pub name: String, + api: String, +} + +impl GitHub { + /// Parse URL of a github repository. + /// + /// # Arguments + /// + /// * `url` - the URL of the repository to clone. + pub fn parse(url: &str) -> Result { + let url = Url::parse(url)?; + Ok(Self { + org: Self::org(&url)?.into(), + name: Self::name(&url)?.into(), + api: "https://api.github.com".into(), + }) + } + + // Overrides the api base url for testing + #[cfg(test)] + fn with_api(mut self, api: impl Into) -> Self { + self.api = api.into(); + self + } + + /// Fetch the latest releases of the Github repository. + pub async fn get_latest_releases(&self) -> Result> { + static APP_USER_AGENT: &str = + concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); + let client = reqwest::ClientBuilder::new().user_agent(APP_USER_AGENT).build()?; + let url = self.api_releases_url(); + let response = client.get(url).send().await?; + Ok(response.json::>().await?) + } + + fn org(repo: &Url) -> Result<&str> { + let path_segments = repo + .path_segments() + .map(|c| c.collect::>()) + .expect("repository must have path segments"); + Ok(path_segments.get(0).ok_or(Error::Git( + "the organization (or user) is missing from the github url".to_string(), + ))?) + } + + pub(crate) fn name(repo: &Url) -> Result<&str> { + let path_segments = repo + .path_segments() + .map(|c| c.collect::>()) + .expect("repository must have path segments"); + Ok(path_segments + .get(1) + .ok_or(Error::Git("the repository name is missing from the github url".to_string()))?) + } + + fn api_releases_url(&self) -> String { + format!("{}/repos/{}/{}/releases", self.api, self.org, self.name) + } +} + +/// Represents the data of a GitHub release. +#[derive(Debug, PartialEq, serde::Deserialize)] +pub struct Release { + pub tag_name: String, + pub name: String, + pub prerelease: bool, + pub commit: Option, +} +#[cfg(test)] +mod tests { + use super::*; + use mockito::{Mock, Server}; + + const SUBSTRATE_CONTRACT_NODE: &str = "https://github.com/paritytech/substrate-contracts-node"; + + async fn releases_mock(mock_server: &mut Server, repo: &GitHub, payload: &str) -> Mock { + mock_server + .mock("GET", format!("/repos/{}/{}/releases", repo.org, repo.name).as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(payload) + .create_async() + .await + } + + #[tokio::test] + async fn test_get_latest_releases() -> Result<(), Box> { + let mut mock_server = Server::new_async().await; + + let expected_payload = r#"[{ + "tag_name": "v0.41.0", + "name": "v0.41.0", + "prerelease": false + }]"#; + let repo = GitHub::parse(SUBSTRATE_CONTRACT_NODE)?.with_api(&mock_server.url()); + let mock = releases_mock(&mut mock_server, &repo, expected_payload).await; + let latest_release = repo.get_latest_releases().await?; + assert_eq!( + latest_release[0], + Release { + tag_name: "v0.41.0".to_string(), + name: "v0.41.0".into(), + prerelease: false, + commit: None + } + ); + mock.assert_async().await; + Ok(()) + } + + #[test] + fn test_parse_org() -> Result<(), Box> { + assert_eq!(GitHub::parse(SUBSTRATE_CONTRACT_NODE)?.org, "paritytech"); + Ok(()) + } + + #[test] + fn test_parse_name() -> Result<(), Box> { + let url = Url::parse(SUBSTRATE_CONTRACT_NODE)?; + let name = GitHub::name(&url)?; + assert_eq!(name, "substrate-contracts-node"); + Ok(()) + } + + #[test] + fn test_get_releases_api_url() -> Result<(), Box> { + assert_eq!( + GitHub::parse(SUBSTRATE_CONTRACT_NODE)?.api_releases_url(), + "https://api.github.com/repos/paritytech/substrate-contracts-node/releases" + ); + Ok(()) + } +} diff --git a/crates/pop-contracts/src/utils/mod.rs b/crates/pop-contracts/src/utils/mod.rs index f05e35ee..063bd4dc 100644 --- a/crates/pop-contracts/src/utils/mod.rs +++ b/crates/pop-contracts/src/utils/mod.rs @@ -1,3 +1,5 @@ // SPDX-License-Identifier: GPL-3.0 +pub mod contracts_node; +pub mod git; pub mod helpers; pub mod signer;