diff --git a/CHANGELOG.md b/CHANGELOG.md index 162a964d42..147eb35e47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - You can skip `--name` flag when using `account import` - a default name will be generated. - Addresses outputted when calling `sncast account create`, `sncast deploy` and `sncast declare` are now padded to 64 characters length and prefixed with `0x0` +- Voyager API support for sncast `verify` subcommand #### Changed diff --git a/crates/sncast/src/main.rs b/crates/sncast/src/main.rs index 84cd3d4c37..b958b09e8b 100644 --- a/crates/sncast/src/main.rs +++ b/crates/sncast/src/main.rs @@ -623,11 +623,13 @@ async fn run_async_command( ) .expect("Failed to build contract"); let result = starknet_commands::verify::verify( - verify.contract_address, + verify.contract_address_or_class_hash.contract_address, + verify.contract_address_or_class_hash.class_hash, verify.contract_name, verify.verifier, verify.network, verify.confirm_verification, + verify.custom_base_api_url, &package_metadata.manifest_path, &artifacts, ) diff --git a/crates/sncast/src/starknet_commands/verify.rs b/crates/sncast/src/starknet_commands/verify.rs deleted file mode 100644 index 557b8b0ab1..0000000000 --- a/crates/sncast/src/starknet_commands/verify.rs +++ /dev/null @@ -1,197 +0,0 @@ -use anyhow::{anyhow, Context, Result}; -use anyhow::{bail, Ok}; -use camino::Utf8PathBuf; -use clap::{Args, ValueEnum}; -use promptly::prompt; -use reqwest::StatusCode; -use scarb_api::StarknetContractArtifacts; -use serde::Serialize; -use sncast::response::structs::VerifyResponse; -use sncast::Network; -use starknet_types_core::felt::Felt; -use std::collections::HashMap; -use std::ffi::OsStr; -use std::{env, fmt}; -use walkdir::WalkDir; - -struct WalnutVerificationInterface { - network: Network, - workspace_dir: Utf8PathBuf, -} - -#[async_trait::async_trait] -trait VerificationInterface { - fn new(network: Network, workspace_dir: Utf8PathBuf) -> Self; - async fn verify(&self, contract_address: Felt, contract_name: String) - -> Result; - fn gen_explorer_url(&self) -> Result; -} - -#[async_trait::async_trait] -impl VerificationInterface for WalnutVerificationInterface { - fn new(network: Network, workspace_dir: Utf8PathBuf) -> Self { - WalnutVerificationInterface { - network, - workspace_dir, - } - } - - async fn verify( - &self, - contract_address: Felt, - contract_name: String, - ) -> Result { - // Read all files name along with their contents in a JSON format - // in the workspace dir recursively - // key is the file name and value is the file content - let mut file_data = serde_json::Map::new(); - - // Recursively read files and their contents in workspace directory - for entry in WalkDir::new(self.workspace_dir.clone()).follow_links(true) { - let entry = entry?; - let path = entry.path(); - if path.is_file() { - if let Some(extension) = path.extension() { - if extension == OsStr::new("cairo") || extension == OsStr::new("toml") { - let relative_path = path.strip_prefix(self.workspace_dir.clone())?; - let file_content = std::fs::read_to_string(path)?; - file_data.insert( - relative_path.to_string_lossy().into_owned(), - serde_json::Value::String(file_content), - ); - } - } - } - } - - // Serialize the JSON object to a JSON string - let source_code = serde_json::Value::Object(file_data); - - // Create the JSON payload with "contract name," "address," and "source_code" fields - let payload = VerificationPayload { - contract_name: contract_name.to_string(), - contract_address: contract_address.to_string(), - source_code, - }; - - // Serialize the payload to a JSON string for the POST request - let json_payload = serde_json::to_string(&payload)?; - - // Send the POST request to the explorer - let client = reqwest::Client::new(); - let api_res = client - .post(self.gen_explorer_url()?) - .header("Content-Type", "application/json") - .body(json_payload) - .send() - .await - .context("Failed to send request to verifier API")?; - - if api_res.status() == StatusCode::OK { - let message = api_res - .text() - .await - .context("Failed to read verifier API response")?; - Ok(VerifyResponse { message }) - } else { - let message = api_res.text().await.context("Failed to verify contract")?; - Err(anyhow!(message)) - } - } - - fn gen_explorer_url(&self) -> Result { - let api_base_url = - env::var("WALNUT_API_URL").unwrap_or_else(|_| "https://api.walnut.dev".to_string()); - let path = match self.network { - Network::Mainnet => "/v1/sn_main/verify", - Network::Sepolia => "/v1/sn_sepolia/verify", - }; - Ok(format!("{api_base_url}{path}")) - } -} - -#[derive(Args)] -#[command(about = "Verify a contract through a block explorer")] -pub struct Verify { - /// Address of a contract to be verified - #[clap(short = 'd', long)] - pub contract_address: Felt, - - /// Name of the contract that is being verified - #[clap(short, long)] - pub contract_name: String, - - /// Block explorer to use for the verification - #[clap(short, long, value_enum, default_value_t = Verifier::Walnut)] - pub verifier: Verifier, - - /// The network on which block explorer will do the verification - #[clap(short, long, value_enum)] - pub network: Network, - - /// Assume "yes" as answer to confirmation prompt and run non-interactively - #[clap(long, default_value = "false")] - pub confirm_verification: bool, - - /// Specifies scarb package to be used - #[clap(long)] - pub package: Option, -} - -#[derive(ValueEnum, Clone, Debug)] -pub enum Verifier { - Walnut, -} - -impl fmt::Display for Verifier { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match *self { - Verifier::Walnut => write!(f, "walnut"), - } - } -} - -#[derive(Serialize, Debug)] -struct VerificationPayload { - contract_name: String, - contract_address: String, - source_code: serde_json::Value, -} - -pub async fn verify( - contract_address: Felt, - contract_name: String, - verifier: Verifier, - network: Network, - confirm_verification: bool, - manifest_path: &Utf8PathBuf, - artifacts: &HashMap, -) -> Result { - // Let's ask confirmation - if !confirm_verification { - let prompt_text = - format!("You are about to submit the entire workspace's code to the third-party chosen verifier at {verifier}, and the code will be publicly available through {verifier}'s APIs. Are you sure? (Y/n)"); - let input: String = prompt(prompt_text)?; - - if !input.starts_with('Y') { - bail!("Verification aborted"); - } - } - - if !artifacts.contains_key(&contract_name) { - return Err(anyhow!("Contract named '{contract_name}' was not found")); - } - - // Build JSON Payload for the verification request - // get the parent dir of the manifest path - let workspace_dir = manifest_path - .parent() - .ok_or(anyhow!("Failed to obtain workspace dir"))?; - - match verifier { - Verifier::Walnut => { - let walnut = WalnutVerificationInterface::new(network, workspace_dir.to_path_buf()); - walnut.verify(contract_address, contract_name).await - } - } -} diff --git a/crates/sncast/src/starknet_commands/verify/base.rs b/crates/sncast/src/starknet_commands/verify/base.rs new file mode 100644 index 0000000000..b2cdeb485c --- /dev/null +++ b/crates/sncast/src/starknet_commands/verify/base.rs @@ -0,0 +1,95 @@ +use anyhow::{anyhow, Context, Result}; +use async_trait::async_trait; +use camino::Utf8PathBuf; +use reqwest::StatusCode; +use serde::Serialize; +use sncast::response::structs::VerifyResponse; +use starknet::core::types::Felt; +use std::ffi::OsStr; +use walkdir::WalkDir; + +fn read_workspace_files( + workspace_dir: Utf8PathBuf, +) -> Result> { + // Read all files name along with their contents in a JSON format + // in the workspace dir recursively + // key is the file name and value is the file content + let mut file_data = serde_json::Map::new(); + + // Recursively read files and their contents in workspace directory + for entry in WalkDir::new(workspace_dir.clone()).follow_links(true) { + let entry = entry?; + let path = entry.path(); + if path.is_file() { + if let Some(extension) = path.extension() { + if extension == OsStr::new("cairo") || extension == OsStr::new("toml") { + let relative_path = path.strip_prefix(workspace_dir.clone())?; + let file_content = std::fs::read_to_string(path)?; + file_data.insert( + relative_path.to_string_lossy().into_owned(), + serde_json::Value::String(file_content), + ); + } + } + } + } + Ok(file_data) +} + +async fn send_verification_request( + url: String, + payload: VerificationPayload, +) -> Result { + let json_payload = serde_json::to_string(&payload)?; + let client = reqwest::Client::new(); + let api_res = client + .post(url) + .header("Content-Type", "application/json") + .body(json_payload) + .send() + .await + .context("Failed to send request to verifier API")?; + + if api_res.status() == StatusCode::OK { + let message = api_res + .text() + .await + .context("Failed to read verifier API response")?; + Ok(VerifyResponse { message }) + } else { + let message = api_res.text().await.context("Failed to verify contract")?; + Err(anyhow!(message)) + } +} + +#[async_trait] +pub trait VerificationInterface { + fn explorer_url(&self) -> String; + + async fn verify( + &self, + workspace_dir: Utf8PathBuf, + contract_address: Option, + class_hash: Option, + contract_name: String, + ) -> Result { + let file_data = read_workspace_files(workspace_dir)?; + let source_code = serde_json::Value::Object(file_data); + let payload = VerificationPayload { + contract_name, + contract_address, + class_hash, + source_code, + }; + let url = self.explorer_url(); + send_verification_request(url, payload).await + } +} + +#[derive(Serialize, Debug)] +pub struct VerificationPayload { + pub contract_name: String, + pub contract_address: Option, + pub class_hash: Option, + pub source_code: serde_json::Value, +} diff --git a/crates/sncast/src/starknet_commands/verify/mod.rs b/crates/sncast/src/starknet_commands/verify/mod.rs new file mode 100644 index 0000000000..8f380c9b73 --- /dev/null +++ b/crates/sncast/src/starknet_commands/verify/mod.rs @@ -0,0 +1,138 @@ +use anyhow::{anyhow, bail, Result}; +use base::VerificationInterface; +use camino::Utf8PathBuf; +use clap::{Args, Parser, ValueEnum}; +use promptly::prompt; +use scarb_api::StarknetContractArtifacts; +use sncast::response::structs::VerifyResponse; +use sncast::Network; +use starknet::core::types::Felt; +use std::collections::HashMap; +use std::fmt; +use voyager::VoyagerVerificationInterface; +use walnut::WalnutVerificationInterface; + +pub mod base; +mod voyager; +mod walnut; + +#[derive(Args, Debug, Clone)] +#[group(required = true, multiple = false)] +pub struct ContractAddressOrClassHashGroup { + /// Contract address of the contract. Either this or class hash should be provided. + #[clap(short = 'd', long)] + pub contract_address: Option, + + /// Class hash of the contract. Either this or contract address should be provided. + #[clap(short = 'x', long)] + pub class_hash: Option, +} + +#[derive(Parser)] +#[command(about = "Verify a contract through a block explorer")] +pub struct Verify { + #[clap(flatten)] + pub contract_address_or_class_hash: ContractAddressOrClassHashGroup, + + /// Name of the contract that is being verified + #[clap(short, long)] + pub contract_name: String, + + /// Verification provider to be used + #[clap(short, long, value_enum)] + pub verifier: Verifier, + + /// The network in which the contract is deployed + #[clap(short, long, value_enum)] + pub network: Network, + + /// Automatic yes to confirmation prompts for verification + #[clap(long, default_value = "false")] + pub confirm_verification: bool, + + /// Specify package with the contract to be verified + #[clap(long)] + pub package: Option, + + // Custom api to be used as a verifier's base url. + #[clap(long)] + pub custom_base_api_url: Option, +} + +#[derive(ValueEnum, Clone, Debug)] +pub enum Verifier { + Walnut, + Voyager, +} + +impl fmt::Display for Verifier { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + Verifier::Walnut => write!(f, "walnut"), + Verifier::Voyager => write!(f, "voyager"), + } + } +} + +fn get_verifier( + verifier: Verifier, + network: Network, + custom_base_api_url: Option, +) -> Box { + match verifier { + Verifier::Walnut => Box::new(WalnutVerificationInterface::new( + network, + custom_base_api_url, + )), + Verifier::Voyager => Box::new(VoyagerVerificationInterface::new( + network, + custom_base_api_url, + )), + } +} + +// disable too many arguments clippy warning +#[allow(clippy::too_many_arguments)] +pub async fn verify( + contract_address: Option, + class_hash: Option, + contract_name: String, + verifier: Verifier, + network: Network, + confirm_verification: bool, + custom_base_api_url: Option, + manifest_path: &Utf8PathBuf, + artifacts: &HashMap, +) -> Result { + // Let's ask confirmation + if !confirm_verification { + let prompt_text = format!( + "You are about to submit the entire workspace's code to the third-party chosen verifier at {verifier}, and the code will be publicly available through {verifier}'s APIs. Are you sure? (Y/n)" + ); + let input: String = prompt(prompt_text)?; + + if !input.starts_with('Y') { + bail!("Verification aborted"); + } + } + + if !artifacts.contains_key(&contract_name) { + return Err(anyhow!("Contract named '{contract_name}' was not found")); + } + + // Build JSON Payload for the verification request + // get the parent dir of the manifest path + let workspace_dir = manifest_path + .parent() + .ok_or(anyhow!("Failed to obtain workspace dir"))?; + + let verifier = get_verifier(verifier, network, custom_base_api_url); + verifier + .verify( + workspace_dir.to_path_buf(), + contract_address, + class_hash, + contract_name, + ) + .await +} diff --git a/crates/sncast/src/starknet_commands/verify/voyager.rs b/crates/sncast/src/starknet_commands/verify/voyager.rs new file mode 100644 index 0000000000..7064ac609a --- /dev/null +++ b/crates/sncast/src/starknet_commands/verify/voyager.rs @@ -0,0 +1,28 @@ +use super::base::VerificationInterface; +use async_trait::async_trait; +use sncast::Network; + +pub struct VoyagerVerificationInterface { + pub base_url: String, +} + +impl VoyagerVerificationInterface { + pub fn new(network: Network, base_url: Option) -> Self { + let base_url = match base_url { + Some(custom_base_api_url) => custom_base_api_url.clone(), + None => match network { + Network::Mainnet => "https://api.voyager.online/beta".to_string(), + Network::Sepolia => "https://sepolia-api.voyager.online/beta".to_string(), + }, + }; + + VoyagerVerificationInterface { base_url } + } +} + +#[async_trait] +impl VerificationInterface for VoyagerVerificationInterface { + fn explorer_url(&self) -> String { + format!("{}/class-verify-v2", self.base_url) + } +} diff --git a/crates/sncast/src/starknet_commands/verify/walnut.rs b/crates/sncast/src/starknet_commands/verify/walnut.rs new file mode 100644 index 0000000000..d59dcf41b1 --- /dev/null +++ b/crates/sncast/src/starknet_commands/verify/walnut.rs @@ -0,0 +1,30 @@ +use super::base::VerificationInterface; +use async_trait::async_trait; +use sncast::Network; + +pub struct WalnutVerificationInterface { + pub base_url: String, + pub network: Network, +} + +impl WalnutVerificationInterface { + pub fn new(network: Network, base_url: Option) -> Self { + let base_url = match base_url { + Some(custom_base_api_url) => custom_base_api_url.clone(), + None => "https://api.walnut.dev".to_string(), + }; + + WalnutVerificationInterface { base_url, network } + } +} + +#[async_trait] +impl VerificationInterface for WalnutVerificationInterface { + fn explorer_url(&self) -> String { + let path = match self.network { + Network::Mainnet => "/v1/sn_main/verify", + Network::Sepolia => "/v1/sn_sepolia/verify", + }; + format!("{}{}", self.base_url, path) + } +} diff --git a/crates/sncast/tests/e2e/verify/mod.rs b/crates/sncast/tests/e2e/verify/mod.rs new file mode 100644 index 0000000000..c2c30141a3 --- /dev/null +++ b/crates/sncast/tests/e2e/verify/mod.rs @@ -0,0 +1,2 @@ +mod voyager; +mod walnut; diff --git a/crates/sncast/tests/e2e/verify/voyager.rs b/crates/sncast/tests/e2e/verify/voyager.rs new file mode 100644 index 0000000000..773396a3e4 --- /dev/null +++ b/crates/sncast/tests/e2e/verify/voyager.rs @@ -0,0 +1,495 @@ +use crate::helpers::constants::{ + ACCOUNT_FILE_PATH, CONTRACTS_DIR, MAP_CONTRACT_ADDRESS_SEPOLIA, MAP_CONTRACT_CLASS_HASH_SEPOLIA, +}; +use crate::helpers::fixtures::copy_directory_to_tempdir; +use crate::helpers::runner::runner; +use indoc::formatdoc; +use shared::test_utils::output_assert::{assert_stderr_contains, assert_stdout_contains}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[tokio::test] +async fn test_happy_case() { + let mock_server = MockServer::start().await; + + let verifier_response = "Contract successfully verified"; + + Mock::given(method("POST")) + .and(path("/class-verify-v2")) + .respond_with( + ResponseTemplate::new(200) + .append_header("content-type", "text/plain") + .set_body_string(verifier_response), + ) + .mount(&mock_server) + .await; + + let contract_path = copy_directory_to_tempdir(CONTRACTS_DIR.to_string() + "/map"); + + let custom_api_url = &mock_server.uri().clone(); + + let args = vec![ + "--accounts-file", + ACCOUNT_FILE_PATH, + "verify", + "--contract-address", + MAP_CONTRACT_ADDRESS_SEPOLIA, + "--contract-name", + "Map", + "--verifier", + "voyager", + "--network", + "sepolia", + "--custom-base-api-url", + custom_api_url, + ]; + + let snapbox = runner(&args).current_dir(contract_path.path()).stdin("Y"); + + let output = snapbox.assert().success(); + + assert_stdout_contains( + output, + formatdoc!( + r" + command: verify + message: {} + ", + verifier_response + ), + ); +} + +#[tokio::test] +async fn test_happy_case_class_hash() { + let mock_server = MockServer::start().await; + + let verifier_response = "Contract successfully verified"; + + Mock::given(method("POST")) + .and(path("/class-verify-v2")) + .respond_with( + ResponseTemplate::new(200) + .append_header("content-type", "text/plain") + .set_body_string(verifier_response), + ) + .mount(&mock_server) + .await; + + let custom_api_url = &mock_server.uri().clone(); + + let contract_path = copy_directory_to_tempdir(CONTRACTS_DIR.to_string() + "/map"); + + let args = vec![ + "--accounts-file", + ACCOUNT_FILE_PATH, + "verify", + "--class-hash", + MAP_CONTRACT_CLASS_HASH_SEPOLIA, + "--contract-name", + "Map", + "--verifier", + "voyager", + "--network", + "sepolia", + "--custom-base-api-url", + custom_api_url, + ]; + + let snapbox = runner(&args).current_dir(contract_path.path()).stdin("Y"); + + let output = snapbox.assert().success(); + + assert_stdout_contains( + output, + formatdoc!( + r" + command: verify + message: {} + ", + verifier_response + ), + ); +} + +#[tokio::test] +async fn test_failed_verification() { + let mock_server = MockServer::start().await; + + let verifier_response = "An error occurred during verification: contract class isn't declared"; + + Mock::given(method("POST")) + .and(path("/class-verify-v2")) + .respond_with( + ResponseTemplate::new(400) + .append_header("content-type", "text/plain") + .set_body_string(verifier_response), + ) + .mount(&mock_server) + .await; + + let custom_api_url = &mock_server.uri().clone(); + + let contract_path = copy_directory_to_tempdir(CONTRACTS_DIR.to_string() + "/map"); + + let args = vec![ + "--accounts-file", + ACCOUNT_FILE_PATH, + "verify", + "--contract-address", + MAP_CONTRACT_ADDRESS_SEPOLIA, + "--contract-name", + "Map", + "--verifier", + "voyager", + "--network", + "sepolia", + "--custom-base-api-url", + custom_api_url, + ]; + + let snapbox = runner(&args).current_dir(contract_path.path()).stdin("Y"); + + let output = snapbox.assert().success(); + + assert_stderr_contains( + output, + formatdoc!( + r" + command: verify + error: {} + ", + verifier_response + ), + ); +} + +#[tokio::test] +async fn test_verification_abort() { + let contract_path = copy_directory_to_tempdir(CONTRACTS_DIR.to_string() + "/map"); + + let args = vec![ + "--accounts-file", + ACCOUNT_FILE_PATH, + "verify", + "--contract-address", + MAP_CONTRACT_ADDRESS_SEPOLIA, + "--contract-name", + "nonexistent", + "--verifier", + "voyager", + "--network", + "sepolia", + ]; + + let snapbox = runner(&args).current_dir(contract_path.path()).stdin("n"); + + let output = snapbox.assert().success(); + + assert_stderr_contains( + output, + formatdoc!( + r" + command: verify + error: Verification aborted + " + ), + ); +} + +#[tokio::test] +async fn test_wrong_contract_name_passed() { + let contract_path = copy_directory_to_tempdir(CONTRACTS_DIR.to_string() + "/map"); + + let args = vec![ + "--accounts-file", + ACCOUNT_FILE_PATH, + "verify", + "--contract-address", + MAP_CONTRACT_ADDRESS_SEPOLIA, + "--contract-name", + "nonexistent", + "--verifier", + "voyager", + "--network", + "sepolia", + ]; + + let snapbox = runner(&args).current_dir(contract_path.path()).stdin("Y"); + + let output = snapbox.assert().success(); + + assert_stderr_contains( + output, + formatdoc!( + r" + command: verify + error: Contract named 'nonexistent' was not found + " + ), + ); +} + +#[tokio::test] +async fn test_no_class_hash_or_contract_address_provided() { + let contract_path = copy_directory_to_tempdir(CONTRACTS_DIR.to_string() + "/map"); + + let args = vec![ + "--accounts-file", + ACCOUNT_FILE_PATH, + "verify", + "--contract-name", + "Map", + "--verifier", + "voyager", + "--network", + "sepolia", + ]; + + let snapbox = runner(&args).current_dir(contract_path.path()).stdin("Y"); + + let output = snapbox.assert().failure(); + + assert_stderr_contains( + output, + formatdoc!( + r" + error: the following required arguments were not provided: + <--contract-address |--class-hash > + + Usage: sncast verify --contract-name --verifier --network <--contract-address |--class-hash > + + For more information, try '--help'." + ), + ); +} + +#[tokio::test] +async fn test_both_class_hash_or_contract_address_provided() { + let contract_path = copy_directory_to_tempdir(CONTRACTS_DIR.to_string() + "/map"); + + let args = vec![ + "--accounts-file", + ACCOUNT_FILE_PATH, + "verify", + "--contract-address", + MAP_CONTRACT_ADDRESS_SEPOLIA, + "--class-hash", + MAP_CONTRACT_CLASS_HASH_SEPOLIA, + "--contract-name", + "Map", + "--verifier", + "voyager", + "--network", + "sepolia", + ]; + + let snapbox = runner(&args).current_dir(contract_path.path()).stdin("Y"); + + let output = snapbox.assert().failure(); + + assert_stderr_contains( + output, + formatdoc!( + r" + error: the argument '--contract-address ' cannot be used with '--class-hash ' + Usage: sncast verify --contract-name --verifier --network <--contract-address |--class-hash >" + ), + ); +} + +#[tokio::test] +async fn test_happy_case_with_confirm_verification_flag() { + let mock_server = MockServer::start().await; + + let verifier_response = "Contract successfully verified"; + + Mock::given(method("POST")) + .and(path("/class-verify-v2")) + .respond_with( + ResponseTemplate::new(200) + .append_header("content-type", "text/plain") + .set_body_string(verifier_response), + ) + .mount(&mock_server) + .await; + + let custom_api_url = &mock_server.uri(); + + let contract_path = copy_directory_to_tempdir(CONTRACTS_DIR.to_string() + "/map"); + + let args = vec![ + "--accounts-file", + ACCOUNT_FILE_PATH, + "verify", + "--contract-address", + MAP_CONTRACT_ADDRESS_SEPOLIA, + "--contract-name", + "Map", + "--verifier", + "voyager", + "--network", + "sepolia", + "--custom-base-api-url", + custom_api_url, + "--confirm-verification", + ]; + + let snapbox = runner(&args).current_dir(contract_path.path()); + + let output = snapbox.assert().success(); + + assert_stdout_contains( + output, + formatdoc!( + r" + command: verify + message: {} + ", + verifier_response + ), + ); +} + +#[tokio::test] +async fn test_happy_case_specify_package() { + let mock_server = MockServer::start().await; + + let verifier_response = "Contract successfully verified"; + + Mock::given(method("POST")) + .and(path("/class-verify-v2")) + .respond_with( + ResponseTemplate::new(200) + .append_header("content-type", "text/plain") + .set_body_string(verifier_response), + ) + .mount(&mock_server) + .await; + + let custom_api_url = &mock_server.uri(); + + let tempdir = copy_directory_to_tempdir(CONTRACTS_DIR.to_string() + "/multiple_packages"); + + let args = vec![ + "--accounts-file", + ACCOUNT_FILE_PATH, + "verify", + "--contract-address", + MAP_CONTRACT_ADDRESS_SEPOLIA, + "--contract-name", + "supercomplexcode", + "--verifier", + "voyager", + "--network", + "sepolia", + "--package", + "main_workspace", + "--custom-base-api-url", + custom_api_url, + ]; + + let snapbox = runner(&args).current_dir(tempdir.path()).stdin("Y"); + + let output = snapbox.assert().success(); + + assert_stdout_contains( + output, + formatdoc!( + r" + command: verify + message: {} + ", + verifier_response + ), + ); +} + +#[tokio::test] +async fn test_worskpaces_package_specified_virtual_fibonacci() { + let mock_server = MockServer::start().await; + + let verifier_response = "Contract successfully verified"; + + Mock::given(method("POST")) + .and(path("/class-verify-v2")) + .respond_with( + ResponseTemplate::new(200) + .append_header("content-type", "text/plain") + .set_body_string(verifier_response), + ) + .mount(&mock_server) + .await; + + let custom_api_url = &mock_server.uri(); + + let tempdir = copy_directory_to_tempdir(CONTRACTS_DIR.to_string() + "/virtual_workspace"); + + let args = vec![ + "--accounts-file", + ACCOUNT_FILE_PATH, + "verify", + "--contract-address", + MAP_CONTRACT_ADDRESS_SEPOLIA, + "--contract-name", + "FibonacciContract", + "--verifier", + "voyager", + "--network", + "sepolia", + "--package", + "cast_fibonacci", + "--custom-base-api-url", + custom_api_url, + ]; + + let snapbox = runner(&args).current_dir(tempdir.path()).stdin("Y"); + + let output = snapbox.assert().success(); + + assert_stdout_contains( + output, + formatdoc!( + r" + command: verify + message: {} + ", + verifier_response + ), + ); +} + +#[tokio::test] +async fn test_worskpaces_package_no_contract() { + let tempdir = copy_directory_to_tempdir(CONTRACTS_DIR.to_string() + "/virtual_workspace"); + + let args = vec![ + "--accounts-file", + ACCOUNT_FILE_PATH, + "verify", + "--contract-address", + MAP_CONTRACT_ADDRESS_SEPOLIA, + "--contract-name", + "nonexistent", + "--verifier", + "voyager", + "--network", + "sepolia", + "--package", + "cast_addition", + ]; + + let snapbox = runner(&args).current_dir(tempdir.path()).stdin("Y"); + + let output = snapbox.assert().success(); + + assert_stderr_contains( + output, + formatdoc!( + r" + command: verify + error: Contract named 'nonexistent' was not found + " + ), + ); +} diff --git a/crates/sncast/tests/e2e/verify.rs b/crates/sncast/tests/e2e/verify/walnut.rs similarity index 65% rename from crates/sncast/tests/e2e/verify.rs rename to crates/sncast/tests/e2e/verify/walnut.rs index fac1f5f141..7217d7a636 100644 --- a/crates/sncast/tests/e2e/verify.rs +++ b/crates/sncast/tests/e2e/verify/walnut.rs @@ -1,4 +1,6 @@ -use crate::helpers::constants::{ACCOUNT_FILE_PATH, CONTRACTS_DIR, MAP_CONTRACT_ADDRESS_SEPOLIA}; +use crate::helpers::constants::{ + ACCOUNT_FILE_PATH, CONTRACTS_DIR, MAP_CONTRACT_ADDRESS_SEPOLIA, MAP_CONTRACT_CLASS_HASH_SEPOLIA, +}; use crate::helpers::fixtures::copy_directory_to_tempdir; use crate::helpers::runner::runner; use indoc::formatdoc; @@ -8,8 +10,6 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; #[tokio::test] async fn test_happy_case() { - let contract_path = copy_directory_to_tempdir(CONTRACTS_DIR.to_string() + "/map"); - let mock_server = MockServer::start().await; let verifier_response = "Contract successfully verified"; @@ -24,6 +24,10 @@ async fn test_happy_case() { .mount(&mock_server) .await; + let custom_api_url = &mock_server.uri(); + + let contract_path = copy_directory_to_tempdir(CONTRACTS_DIR.to_string() + "/map"); + let args = vec![ "--accounts-file", ACCOUNT_FILE_PATH, @@ -36,12 +40,11 @@ async fn test_happy_case() { "walnut", "--network", "sepolia", + "--custom-base-api-url", + custom_api_url, ]; - let snapbox = runner(&args) - .env("WALNUT_API_URL", mock_server.uri()) - .current_dir(contract_path.path()) - .stdin("Y"); + let snapbox = runner(&args).current_dir(contract_path.path()).stdin("Y"); let output = snapbox.assert().success(); @@ -58,9 +61,59 @@ async fn test_happy_case() { } #[tokio::test] -async fn test_failed_verification() { +async fn test_happy_case_class_hash() { + let mock_server = MockServer::start().await; + + let verifier_response = "Contract successfully verified"; + + Mock::given(method("POST")) + .and(path("/v1/sn_sepolia/verify")) + .respond_with( + ResponseTemplate::new(200) + .append_header("content-type", "text/plain") + .set_body_string(verifier_response), + ) + .mount(&mock_server) + .await; + + let custom_api_url = &mock_server.uri(); + let contract_path = copy_directory_to_tempdir(CONTRACTS_DIR.to_string() + "/map"); + let args = vec![ + "--accounts-file", + ACCOUNT_FILE_PATH, + "verify", + "--class-hash", + MAP_CONTRACT_CLASS_HASH_SEPOLIA, + "--contract-name", + "Map", + "--verifier", + "walnut", + "--network", + "sepolia", + "--custom-base-api-url", + custom_api_url, + ]; + + let snapbox = runner(&args).current_dir(contract_path.path()).stdin("Y"); + + let output = snapbox.assert().success(); + + assert_stdout_contains( + output, + formatdoc!( + r" + command: verify + message: {} + ", + verifier_response + ), + ); +} + +#[tokio::test] +async fn test_failed_verification() { let mock_server = MockServer::start().await; let verifier_response = "An error occurred during verification: contract class isn't declared"; @@ -75,6 +128,10 @@ async fn test_failed_verification() { .mount(&mock_server) .await; + let custom_api_url = &mock_server.uri(); + + let contract_path = copy_directory_to_tempdir(CONTRACTS_DIR.to_string() + "/map"); + let args = vec![ "--accounts-file", ACCOUNT_FILE_PATH, @@ -87,12 +144,11 @@ async fn test_failed_verification() { "walnut", "--network", "sepolia", + "--custom-base-api-url", + custom_api_url, ]; - let snapbox = runner(&args) - .env("WALNUT_API_URL", mock_server.uri()) - .current_dir(contract_path.path()) - .stdin("Y"); + let snapbox = runner(&args).current_dir(contract_path.path()).stdin("Y"); let output = snapbox.assert().success(); @@ -175,9 +231,75 @@ async fn test_wrong_contract_name_passed() { } #[tokio::test] -async fn test_happy_case_with_confirm_verification_flag() { +async fn test_no_class_hash_or_contract_address_provided() { let contract_path = copy_directory_to_tempdir(CONTRACTS_DIR.to_string() + "/map"); + let args = vec![ + "--accounts-file", + ACCOUNT_FILE_PATH, + "verify", + "--contract-name", + "Map", + "--verifier", + "walnut", + "--network", + "sepolia", + ]; + + let snapbox = runner(&args).current_dir(contract_path.path()).stdin("Y"); + + let output = snapbox.assert().failure(); + + assert_stderr_contains( + output, + formatdoc!( + r" + error: the following required arguments were not provided: + <--contract-address |--class-hash > + + Usage: sncast verify --contract-name --verifier --network <--contract-address |--class-hash > + + For more information, try '--help'." + ), + ); +} + +#[tokio::test] +async fn test_both_class_hash_or_contract_address_provided() { + let contract_path = copy_directory_to_tempdir(CONTRACTS_DIR.to_string() + "/map"); + + let args = vec![ + "--accounts-file", + ACCOUNT_FILE_PATH, + "verify", + "--contract-address", + MAP_CONTRACT_ADDRESS_SEPOLIA, + "--class-hash", + MAP_CONTRACT_CLASS_HASH_SEPOLIA, + "--contract-name", + "Map", + "--verifier", + "walnut", + "--network", + "sepolia", + ]; + + let snapbox = runner(&args).current_dir(contract_path.path()).stdin("Y"); + + let output = snapbox.assert().failure(); + + assert_stderr_contains( + output, + formatdoc!( + r" + error: the argument '--contract-address ' cannot be used with '--class-hash ' + Usage: sncast verify --contract-name --verifier --network <--contract-address |--class-hash >" + ), + ); +} + +#[tokio::test] +async fn test_happy_case_with_confirm_verification_flag() { let mock_server = MockServer::start().await; let verifier_response = "Contract successfully verified"; @@ -192,6 +314,10 @@ async fn test_happy_case_with_confirm_verification_flag() { .mount(&mock_server) .await; + let custom_api_url = &mock_server.uri(); + + let contract_path = copy_directory_to_tempdir(CONTRACTS_DIR.to_string() + "/map"); + let args = vec![ "--accounts-file", ACCOUNT_FILE_PATH, @@ -204,12 +330,12 @@ async fn test_happy_case_with_confirm_verification_flag() { "walnut", "--network", "sepolia", + "--custom-base-api-url", + custom_api_url, "--confirm-verification", ]; - let snapbox = runner(&args) - .env("WALNUT_API_URL", mock_server.uri()) - .current_dir(contract_path.path()); + let snapbox = runner(&args).current_dir(contract_path.path()); let output = snapbox.assert().success(); @@ -227,8 +353,6 @@ async fn test_happy_case_with_confirm_verification_flag() { #[tokio::test] async fn test_happy_case_specify_package() { - let tempdir = copy_directory_to_tempdir(CONTRACTS_DIR.to_string() + "/multiple_packages"); - let mock_server = MockServer::start().await; let verifier_response = "Contract successfully verified"; @@ -243,6 +367,10 @@ async fn test_happy_case_specify_package() { .mount(&mock_server) .await; + let custom_api_url = &mock_server.uri(); + + let tempdir = copy_directory_to_tempdir(CONTRACTS_DIR.to_string() + "/multiple_packages"); + let args = vec![ "--accounts-file", ACCOUNT_FILE_PATH, @@ -257,12 +385,11 @@ async fn test_happy_case_specify_package() { "sepolia", "--package", "main_workspace", + "--custom-base-api-url", + custom_api_url, ]; - let snapbox = runner(&args) - .env("WALNUT_API_URL", mock_server.uri()) - .current_dir(tempdir.path()) - .stdin("Y"); + let snapbox = runner(&args).current_dir(tempdir.path()).stdin("Y"); let output = snapbox.assert().success(); @@ -280,8 +407,6 @@ async fn test_happy_case_specify_package() { #[tokio::test] async fn test_worskpaces_package_specified_virtual_fibonacci() { - let tempdir = copy_directory_to_tempdir(CONTRACTS_DIR.to_string() + "/virtual_workspace"); - let mock_server = MockServer::start().await; let verifier_response = "Contract successfully verified"; @@ -296,6 +421,10 @@ async fn test_worskpaces_package_specified_virtual_fibonacci() { .mount(&mock_server) .await; + let custom_api_url = &mock_server.uri(); + + let tempdir = copy_directory_to_tempdir(CONTRACTS_DIR.to_string() + "/virtual_workspace"); + let args = vec![ "--accounts-file", ACCOUNT_FILE_PATH, @@ -310,12 +439,11 @@ async fn test_worskpaces_package_specified_virtual_fibonacci() { "sepolia", "--package", "cast_fibonacci", + "--custom-base-api-url", + custom_api_url, ]; - let snapbox = runner(&args) - .env("WALNUT_API_URL", mock_server.uri()) - .current_dir(tempdir.path()) - .stdin("Y"); + let snapbox = runner(&args).current_dir(tempdir.path()).stdin("Y"); let output = snapbox.assert().success(); diff --git a/design_documents/contract_verification.md b/design_documents/contract_verification.md index 982e28e0bf..9f415f1192 100644 --- a/design_documents/contract_verification.md +++ b/design_documents/contract_verification.md @@ -26,7 +26,7 @@ The `verify` command will perform following actions: #### Parameters -#### `--class-name` +#### `--contract-name` Required. Name of the contract to be submitted for verification. @@ -91,7 +91,7 @@ const url = `${voyager.testnet.url}/contract/` const payload = serde_json::json!({ "contract_address": "0x0", // this is optional if class_hash is provided "class_hash": "0x0", // this is optional if contract_address is provided - "class_name": "balance", + "contract_name": "balance", "source_code": { "Scarb.toml" : { """ diff --git a/docs/src/appendix/sncast/verify.md b/docs/src/appendix/sncast/verify.md index 7dcb27c67d..0e284fcaf5 100644 --- a/docs/src/appendix/sncast/verify.md +++ b/docs/src/appendix/sncast/verify.md @@ -3,19 +3,27 @@ Verify Cairo contract on a chosen verification provider. ## `--contract-address, -a ` Required. +Conflicts with: `--class-hash` The address of the contract that is to be verified. +## `--class-hash, -c ` +Required. +Conflicts with: `--contract-address` + +The class hash of the contract that is to be verified. + ## `--contract-name ` Required. -The name of the contract. The contract name is the part after the `mod` keyword in your contract file. +The name of the contract class. The contract name is the part after the `mod` keyword in your contract file. ## `--verifier, -v ` Optional. The verification provider to use for the verification. Possible values are: * `walnut` +* `voyager` ## `--network, -n ` Required. @@ -35,3 +43,9 @@ If supplied, a contract from this package will be used. Required if more than on Optional. If passed, assume "yes" as answer to confirmation prompt and run non-interactively. + + +## `--custom-base-api-url` +Optional. + +If supplied, will be used as the base url for the selected verifier. diff --git a/docs/src/starknet/verify.md b/docs/src/starknet/verify.md index a0dc9850ff..6d1e1d3237 100644 --- a/docs/src/starknet/verify.md +++ b/docs/src/starknet/verify.md @@ -2,7 +2,7 @@ ## Overview -Starknet Foundry `sncast` supports verifying Cairo contract classes with the `sncast verify` command by submitting the source code to a selected verification provider. Verification provides transparency, making the code accessible to users and aiding debugging tools. +Starknet Foundry `sncast` supports verifying Cairo contract classes with the `sncast verify` command by submitting the source code to a selected verification provider, selected via the `--verifier` flag. Verification provides transparency, making the code accessible to users and aiding debugging tools. The verification provider guarantees that the submitted source code aligns with the deployed contract class on the network by compiling the source code into Sierra bytecode and comparing it with the network-deployed Sierra bytecode. @@ -17,6 +17,10 @@ For detailed CLI description, see [verify command reference](../appendix/sncast/ Walnut is a tool for step-by-step debugging of Starknet transactions. You can learn more about Walnut here [walnut.dev](https://walnut.dev). Note that Walnut requires you to specify the Starknet version in your `Scarb.toml` config file. +### Voyager + +[Voyager](https://voyager.online) is a block explorer for Starknet. It gives developers immediate insight into the state of the Starknet network. Note that Voyager requires you to specify the Starknet version in your `Scarb.toml` config file. + ## Example First, ensure that you have created a `Scarb.toml` file for your contract (it should be present in the project directory or one of its parent directories). Make sure the contract has already been deployed on the network.