From 5fac3dadaeecd1dc1d9ac68ee7f06a0a3a2991bc Mon Sep 17 00:00:00 2001 From: Raul Jordan Date: Mon, 1 Jul 2024 16:59:16 -0500 Subject: [PATCH] Reproducible Verification of Stylus Programs (#43) * port over verify repro * verify working * add items * bump versions * add arch * get toolchains out * empty private key done * provide paths * clippy * clippy * added info * Update check/src/verify.rs Co-authored-by: Joshua Colvin * ci * Revert "ci" This reverts commit 2e8d785a715227aec6ad60ec537aede7ee738829. --------- Co-authored-by: Joshua Colvin --- .github/workflows/check.yml | 2 +- Cargo.lock | 27 +++--- Cargo.toml | 4 +- README.md | 21 ++++ check/Cargo.toml | 2 + check/src/check.rs | 56 +++++++---- check/src/deploy.rs | 63 +++++++----- check/src/docker.rs | 85 +++++++++++++++++ check/src/main.rs | 67 +++++++++++-- check/src/project.rs | 184 +++++++++++++++++++++++++++++++++++- check/src/verify.rs | 71 ++++++++++++++ check/src/wallet.rs | 3 + main/src/main.rs | 20 +++- 13 files changed, 537 insertions(+), 68 deletions(-) create mode 100644 check/src/docker.rs create mode 100644 check/src/verify.rs diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 562d891..7780c2f 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -27,7 +27,7 @@ jobs: strategy: fail-fast: false matrix: - toolchain: [stable, beta] + toolchain: [stable] steps: - uses: actions/checkout@v3 with: diff --git a/Cargo.lock b/Cargo.lock index 6bfee2a..6739b34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -634,7 +634,7 @@ dependencies = [ [[package]] name = "cargo-stylus" -version = "0.3.1" +version = "0.3.2" dependencies = [ "cargo-stylus-util", "clap", @@ -643,7 +643,7 @@ dependencies = [ [[package]] name = "cargo-stylus-cgen" -version = "0.3.1" +version = "0.3.2" dependencies = [ "alloy-json-abi", "clap", @@ -654,7 +654,7 @@ dependencies = [ [[package]] name = "cargo-stylus-check" -version = "0.3.1" +version = "0.3.2" dependencies = [ "alloy-ethers-typecast", "alloy-json-abi", @@ -668,10 +668,12 @@ dependencies = [ "clap", "ethers", "eyre", + "glob", "hex", "lazy_static", "serde", "serde_json", + "tempfile", "thiserror", "tiny-keccak", "tokio", @@ -680,14 +682,14 @@ dependencies = [ [[package]] name = "cargo-stylus-example" -version = "0.3.1" +version = "0.3.2" dependencies = [ "clap", ] [[package]] name = "cargo-stylus-replay" -version = "0.3.1" +version = "0.3.2" dependencies = [ "alloy-primitives 0.7.2", "cargo-stylus-util", @@ -707,7 +709,7 @@ dependencies = [ [[package]] name = "cargo-stylus-util" -version = "0.3.1" +version = "0.3.2" dependencies = [ "ethers", "eyre", @@ -2430,9 +2432,9 @@ checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" [[package]] name = "libc" -version = "0.2.152" +version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" [[package]] name = "libloading" @@ -3433,9 +3435,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.30" +version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "322394588aaf33c24007e8bb3238ee3e4c5c09c084ab32bc73890b99ff326bca" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ "bitflags 2.4.2", "errno", @@ -3993,13 +3995,12 @@ checksum = "69758bda2e78f098e4ccb393021a0963bb3442eac05f135c30f61b7370bbafae" [[package]] name = "tempfile" -version = "3.9.0" +version = "3.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01ce4141aa927a6d1bd34a041795abd0db1cccba5d5f24b009f694bdf3a1f3fa" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" dependencies = [ "cfg-if", "fastrand", - "redox_syscall", "rustix", "windows-sys 0.52.0", ] diff --git a/Cargo.toml b/Cargo.toml index 122c4d0..87e2cec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" [workspace.package] authors = ["Offchain Labs"] -version = "0.3.1" +version = "0.3.2" edition = "2021" homepage = "https://arbitrum.io" license = "MIT OR Apache-2.0" @@ -34,4 +34,4 @@ parking_lot = "0.12.1" sneks = "0.1.2" # members -cargo-stylus-util = { path = "util", version = "0.3.1" } +cargo-stylus-util = { path = "util", version = "0.3.2" } diff --git a/README.md b/README.md index 7e0e7b1..6f9ee6c 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,27 @@ Usage: cargo stylus deploy [OPTIONS] See `--help` for all available flags and default values. +## Verifying Stylus Programs + +**cargo stylus verify** + +Verifies that a deployed smart contract is identical to that produced by the +current project. Since Stylus smart contracts include a hash of all project +files, this additionally verifies that code comments and other files are +identical. To ensure build reproducibility, if a program is to be verified, +it should be both deployed and verified using `cargo stylus reproducible`. + +See `--help` for all available flags and default values. + +## Reproducibly Deploying and Verifying + +**cargo stylus reproducible** + +Runs a `cargo stylus` command in a Docker container to ensure build +reproducibility. + +See `--help` for all available flags and default values. + ## Deploying Non-Rust WASM Projects The Stylus tool can also be used to deploy non-Rust, WASM projects to Stylus by specifying the WASM file directly with the `--wasm-file` flag to any of the cargo stylus commands. diff --git a/check/Cargo.toml b/check/Cargo.toml index f223bc1..c428200 100644 --- a/check/Cargo.toml +++ b/check/Cargo.toml @@ -31,3 +31,5 @@ tiny-keccak = { version = "2.0.2", features = ["keccak"] } thiserror = "1.0.47" tokio.workspace = true wasmer = "3.1.0" +glob = "0.3.1" +tempfile = "3.10.1" diff --git a/check/src/check.rs b/check/src/check.rs index 771168f..c0e8bac 100644 --- a/check/src/check.rs +++ b/check/src/check.rs @@ -47,13 +47,13 @@ sol! { /// Checks that a program is valid and can be deployed onchain. /// Returns whether the WASM is already up-to-date and activated onchain, and the data fee. pub async fn check(cfg: &CheckConfig) -> Result { - if cfg.endpoint == "https://stylus-testnet.arbitrum.io/rpc" { + if cfg.common_cfg.endpoint == "https://stylus-testnet.arbitrum.io/rpc" { let version = "cargo stylus version 0.2.1".to_string().red(); bail!("The old Stylus testnet is no longer supported.\nPlease downgrade to {version}",); } - let verbose = cfg.verbose; - let wasm = cfg.build_wasm().wrap_err("failed to build wasm")?; + let verbose = cfg.common_cfg.verbose; + let (wasm, project_hash) = cfg.build_wasm().wrap_err("failed to build wasm")?; if verbose { greyln!("reading wasm file at {}", wasm.to_string_lossy().lavender()); @@ -65,55 +65,79 @@ pub async fn check(cfg: &CheckConfig) -> Result { if verbose { greyln!("wasm size: {}", format_file_size(wasm.len(), 96, 128)); - greyln!("connecting to RPC: {}", &cfg.endpoint.lavender()); + greyln!("connecting to RPC: {}", &cfg.common_cfg.endpoint.lavender()); } // check if the program already exists - let provider = sys::new_provider(&cfg.endpoint)?; + let provider = sys::new_provider(&cfg.common_cfg.endpoint)?; let codehash = alloy_primitives::keccak256(&code); if program_exists(codehash, &provider).await? { - return Ok(ProgramCheck::Active(code)); + return Ok(ProgramCheck::Active { code, project_hash }); } let address = cfg.program_address.unwrap_or(H160::random()); let fee = check_activate(code.clone().into(), address, &provider).await?; let visual_fee = format_data_fee(fee).unwrap_or("???".red()); greyln!("wasm data fee: {visual_fee}"); - Ok(ProgramCheck::Ready(code, fee)) + Ok(ProgramCheck::Ready { + code, + fee, + project_hash, + }) } /// Whether a program is active, or needs activation. #[derive(PartialEq)] pub enum ProgramCheck { /// Program already exists onchain. - Active(Vec), + Active { + code: Vec, + project_hash: [u8; 32], + }, /// Program can be activated with the given data fee. - Ready(Vec, U256), + Ready { + code: Vec, + fee: U256, + project_hash: [u8; 32], + }, } impl ProgramCheck { pub fn code(&self) -> &[u8] { match self { - Self::Active(code) => code, - Self::Ready(code, _) => code, + Self::Active { code, .. } => code, + Self::Ready { code, .. } => code, + } + } + + pub fn project_hash(&self) -> &[u8; 32] { + match self { + Self::Active { project_hash, .. } => project_hash, + Self::Ready { project_hash, .. } => project_hash, } } pub fn suggest_fee(&self) -> U256 { match self { - Self::Active(_) => U256::default(), - Self::Ready(_, data_fee) => data_fee * U256::from(120) / U256::from(100), + Self::Active { .. } => U256::default(), + Self::Ready { fee, .. } => fee * U256::from(120) / U256::from(100), } } } impl CheckConfig { - fn build_wasm(&self) -> Result { + fn build_wasm(&self) -> Result<(PathBuf, [u8; 32])> { if let Some(wasm) = self.wasm_file.clone() { - return Ok(wasm); + return Ok((wasm, [0u8; 32])); } - project::build_dylib(BuildConfig::new(self.rust_stable)) + let cfg = BuildConfig::new(self.common_cfg.rust_stable); + let project_hash = project::hash_files( + self.common_cfg.source_files_for_project_hash.clone(), + cfg.clone(), + )?; + let wasm = project::build_dylib(cfg)?; + Ok((wasm, project_hash)) } } diff --git a/check/src/deploy.rs b/check/src/deploy.rs index 7f2792d..8326dfb 100644 --- a/check/src/deploy.rs +++ b/check/src/deploy.rs @@ -49,9 +49,9 @@ pub async fn deploy(cfg: DeployConfig) -> Result<()> { } let program = run!(check::check(&cfg.check_config), "cargo stylus check failed"); - let verbose = cfg.check_config.verbose; + let verbose = cfg.check_config.common_cfg.verbose; - let client = sys::new_provider(&cfg.check_config.endpoint)?; + let client = sys::new_provider(&cfg.check_config.common_cfg.endpoint)?; let chain_id = run!(client.get_chainid(), "failed to get chain id"); let wallet = cfg.auth.wallet().wrap_err("failed to load wallet")?; @@ -65,7 +65,7 @@ pub async fn deploy(cfg: DeployConfig) -> Result<()> { let data_fee = program.suggest_fee(); - if let ProgramCheck::Ready(..) = &program { + if let ProgramCheck::Ready { .. } = &program { // check balance early let balance = run!(client.get_balance(sender, None), "failed to get balance"); let balance = alloy_ethers_typecast::ethers_u256_to_alloy(balance); @@ -83,11 +83,13 @@ pub async fn deploy(cfg: DeployConfig) -> Result<()> { } } - let contract = cfg.deploy_contract(program.code(), sender, &client).await?; + let contract = cfg + .deploy_contract(program.code(), program.project_hash(), sender, &client) + .await?; match program { - ProgramCheck::Ready(..) => cfg.activate(sender, contract, data_fee, &client).await?, - ProgramCheck::Active(_) => greyln!("wasm already activated!"), + ProgramCheck::Ready { .. } => cfg.activate(sender, contract, data_fee, &client).await?, + ProgramCheck::Active { .. } => greyln!("wasm already activated!"), } Ok(()) } @@ -96,33 +98,22 @@ impl DeployConfig { async fn deploy_contract( &self, code: &[u8], + project_hash: &[u8; 32], sender: H160, client: &SignerClient, ) -> Result { - let mut init_code = Vec::with_capacity(42 + code.len()); - init_code.push(0x7f); // PUSH32 - init_code.extend(AU256::from(code.len()).to_be_bytes::<32>()); - init_code.push(0x80); // DUP1 - init_code.push(0x60); // PUSH1 - init_code.push(0x2a); // 42 the prelude length - init_code.push(0x60); // PUSH1 - init_code.push(0x00); - init_code.push(0x39); // CODECOPY - init_code.push(0x60); // PUSH1 - init_code.push(0x00); - init_code.push(0xf3); // RETURN - init_code.extend(code); + let init_code = program_deployment_calldata(code, project_hash); let tx = Eip1559TransactionRequest::new() .from(sender) .data(init_code); - let verbose = self.check_config.verbose; + let verbose = self.check_config.common_cfg.verbose; let gas = client .estimate_gas(&TypedTransaction::Eip1559(tx.clone()), None) .await?; - if self.check_config.verbose || self.estimate_gas { + if self.check_config.common_cfg.verbose || self.estimate_gas { greyln!("deploy gas estimate: {}", format_gas(gas)); } if self.estimate_gas { @@ -143,6 +134,8 @@ impl DeployConfig { } else { greyln!("deployed code at address: {address}"); } + let tx_hash = receipt.transaction_hash.debug_lavender(); + greyln!("Deployment tx hash: {tx_hash}"); Ok(contract) } @@ -153,7 +146,7 @@ impl DeployConfig { data_fee: AU256, client: &SignerClient, ) -> Result<()> { - let verbose = self.check_config.verbose; + let verbose = self.check_config.common_cfg.verbose; let data_fee = alloy_ethers_typecast::alloy_u256_to_ethers(data_fee); let program: Address = contract.to_fixed_bytes().into(); @@ -170,7 +163,7 @@ impl DeployConfig { .await .map_err(|e| eyre!("did not estimate correctly: {e}"))?; - if self.check_config.verbose || self.estimate_gas { + if self.check_config.common_cfg.verbose || self.estimate_gas { greyln!("activation gas estimate: {}", format_gas(gas)); } if self.estimate_gas { @@ -204,7 +197,7 @@ impl DeployConfig { let tx = client.send_transaction(tx, None).await?; let tx_hash = tx.tx_hash(); - let verbose = self.check_config.verbose; + let verbose = self.check_config.common_cfg.verbose; if verbose { greyln!("sent {name} tx: {}", tx_hash.debug_lavender()); @@ -219,6 +212,28 @@ impl DeployConfig { } } +/// Prepares an EVM bytecode prelude for contract creation. +pub fn program_deployment_calldata(code: &[u8], hash: &[u8; 32]) -> Vec { + let mut code_len = [0u8; 32]; + U256::from(code.len()).to_big_endian(&mut code_len); + let mut deploy: Vec = vec![]; + deploy.push(0x7f); // PUSH32 + deploy.extend(code_len); + deploy.push(0x80); // DUP1 + deploy.push(0x60); // PUSH1 + deploy.push(42 + 1 + 32); // prelude + version + hash + deploy.push(0x60); // PUSH1 + deploy.push(0x00); + deploy.push(0x39); // CODECOPY + deploy.push(0x60); // PUSH1 + deploy.push(0x00); + deploy.push(0xf3); // RETURN + deploy.push(0x00); // version + deploy.extend(hash); + deploy.extend(code); + deploy +} + fn format_gas(gas: U256) -> String { let gas: u64 = gas.try_into().unwrap_or(u64::MAX); let text = format!("{gas} gas"); diff --git a/check/src/docker.rs b/check/src/docker.rs new file mode 100644 index 0000000..1b17e90 --- /dev/null +++ b/check/src/docker.rs @@ -0,0 +1,85 @@ +// Copyright 2023-2024, Offchain Labs, Inc. +// For licensing, see https://github.com/OffchainLabs/cargo-stylus/blob/main/licenses/COPYRIGHT.md + +use std::io::Write; +use std::process::{Command, Stdio}; + +use eyre::{bail, eyre, Result}; + +fn version_to_image_name(version: &str) -> String { + format!("cargo-stylus-{}", version) +} + +fn image_exists(name: &str) -> Result { + let output = Command::new("docker") + .arg("images") + .arg(name) + .output() + .map_err(|e| eyre!("failed to execute Docker command: {e}"))?; + Ok(output.stdout.iter().filter(|c| **c == b'\n').count() > 1) +} + +fn create_image(version: &str) -> Result<()> { + let name = version_to_image_name(version); + if image_exists(&name)? { + return Ok(()); + } + let mut child = Command::new("docker") + .arg("build") + .arg("-t") + .arg(name) + .arg(".") + .arg("-f-") + .stdin(Stdio::piped()) + .spawn() + .map_err(|e| eyre!("failed to execure Docker command: {e}"))?; + write!( + child.stdin.as_mut().unwrap(), + "\ + FROM rust:{} as builder\n\ + RUN rustup target add wasm32-unknown-unknown + RUN rustup target add wasm32-wasi + RUN rustup target add aarch64-unknown-linux-gnu + RUN cargo install cargo-stylus + RUN cargo install --force cargo-stylus-check + RUN cargo install --force cargo-stylus-replay + RUN cargo install --force cargo-stylus-cgen + ", + version + )?; + child.wait().map_err(|e| eyre!("wait failed: {e}"))?; + Ok(()) +} + +fn run_in_docker_container(version: &str, command_line: &[&str]) -> Result<()> { + let name = version_to_image_name(version); + if !image_exists(&name)? { + bail!("Docker image {name} doesn't exist"); + } + let dir = + std::env::current_dir().map_err(|e| eyre!("failed to find current directory: {e}"))?; + Command::new("docker") + .arg("run") + .arg("--network") + .arg("host") + .arg("-w") + .arg("/source") + .arg("-v") + .arg(format!("{}:/source", dir.as_os_str().to_str().unwrap())) + .arg(name) + .args(command_line) + .spawn() + .map_err(|e| eyre!("failed to execure Docker command: {e}"))? + .wait() + .map_err(|e| eyre!("wait failed: {e}"))?; + Ok(()) +} + +pub fn run_reproducible(version: &str, command_line: &[String]) -> Result<()> { + let mut command = vec!["cargo", "stylus"]; + for s in command_line.iter() { + command.push(s); + } + create_image(version)?; + run_in_docker_container(version, &command) +} diff --git a/check/src/main.rs b/check/src/main.rs index 25b49eb..18d1c26 100644 --- a/check/src/main.rs +++ b/check/src/main.rs @@ -10,10 +10,12 @@ use tokio::runtime::Builder; mod check; mod constants; mod deploy; +mod docker; mod export_abi; mod macros; mod new; mod project; +mod verify; mod wallet; #[derive(Parser, Debug)] @@ -52,25 +54,54 @@ enum Apis { /// Deploy a contract. #[command(alias = "d")] Deploy(DeployConfig), + /// Build in a Docker container to ensure reproducibility. + /// + /// Specify the Rust version to use, followed by the cargo stylus subcommand. + /// Example: `cargo stylus reproducible 1.77 check` + Reproducible { + /// Rust version to use. + #[arg()] + rust_version: String, + + /// Stylus subcommand. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + stylus: Vec, + }, + /// Verify the deployment of a Stylus program. + #[command(alias = "v")] + Verify(VerifyConfig), } #[derive(Args, Clone, Debug)] -struct CheckConfig { +struct CommonConfig { /// Arbitrum RPC endpoint. #[arg(short, long, default_value = "https://sepolia-rollup.arbitrum.io/rpc")] endpoint: String, - /// The WASM to check (defaults to any found in the current directory). - #[arg(long)] - wasm_file: Option, - /// Where to deploy and activate the program (defaults to a random address). - #[arg(long)] - program_address: Option, /// Whether to use stable Rust. #[arg(long)] rust_stable: bool, /// Whether to print debug info. #[arg(long)] verbose: bool, + /// The path to source files to include in the project hash, which + /// is included in the contract deployment init code transaction + /// to be used for verification of deployment integrity. + /// If not provided, all .rs files and Cargo.toml and Cargo.lock files + /// in project's directory tree are included. + #[arg(long)] + source_files_for_project_hash: Vec, +} + +#[derive(Args, Clone, Debug)] +pub struct CheckConfig { + #[command(flatten)] + common_cfg: CommonConfig, + /// The WASM to check (defaults to any found in the current directory). + #[arg(long)] + wasm_file: Option, + /// Where to deploy and activate the program (defaults to a random address). + #[arg(long)] + program_address: Option, } #[derive(Args, Clone, Debug)] @@ -85,6 +116,16 @@ struct DeployConfig { estimate_gas: bool, } +#[derive(Args, Clone, Debug)] +pub struct VerifyConfig { + #[command(flatten)] + common_cfg: CommonConfig, + + /// Hash of the deployment transaction. + #[arg(long)] + deployment_tx: String, +} + #[derive(Clone, Debug, Args)] #[clap(group(ArgGroup::new("key").required(true).args(&["private_key_path", "private_key", "keystore_path"])))] struct AuthOpts { @@ -128,6 +169,18 @@ async fn main_impl(args: Opts) -> Result<()> { Apis::Deploy(config) => { run!(deploy::deploy(config).await, "failed to deploy"); } + Apis::Reproducible { + rust_version, + stylus, + } => { + run!( + docker::run_reproducible(&rust_version, &stylus), + "failed reproducible run" + ); + } + Apis::Verify(config) => { + run!(verify::verify(config).await, "failed to verify"); + } } Ok(()) } diff --git a/check/src/project.rs b/check/src/project.rs index fce5bcf..7a06dab 100644 --- a/check/src/project.rs +++ b/check/src/project.rs @@ -7,17 +7,26 @@ use crate::{ }; use brotli2::read::BrotliEncoder; use cargo_stylus_util::{color::Color, sys}; -use eyre::{eyre, Result, WrapErr}; -use std::{env::current_dir, fs, io::Read, path::PathBuf, process}; +use eyre::{bail, eyre, Result, WrapErr}; +use glob::glob; +use std::process::Command; +use std::{ + env::current_dir, + fs, + io::Read, + path::{Path, PathBuf}, + process, +}; +use tiny_keccak::{Hasher, Keccak}; -#[derive(Default, PartialEq)] +#[derive(Default, Clone, PartialEq)] pub enum OptLevel { #[default] S, Z, } -#[derive(Default)] +#[derive(Default, Clone)] pub struct BuildConfig { pub opt_level: OptLevel, pub stable: bool, @@ -112,6 +121,120 @@ pub fn build_dylib(cfg: BuildConfig) -> Result { Ok(wasm_file_path) } +fn all_paths(root_dir: &Path, source_file_patterns: Vec) -> Result> { + let mut files = Vec::::new(); + let mut directories = Vec::::new(); + directories.push(root_dir.to_path_buf()); // Using `from` directly + + let glob_paths = expand_glob_patterns(source_file_patterns)?; + + while let Some(dir) = directories.pop() { + for entry in fs::read_dir(&dir) + .map_err(|e| eyre!("Unable to read directory {}: {e}", dir.display()))? + { + let entry = entry.map_err(|e| eyre!("Error finding file in {}: {e}", dir.display()))?; + let path = entry.path(); + + if path.is_dir() { + if path.ends_with("target") || path.ends_with(".git") { + continue; // Skip "target" and ".git" directories + } + directories.push(path); + } else if path.file_name().map_or(false, |f| { + // If the user has has specified a list of source file patterns, check if the file + // matches the pattern. + if !glob_paths.is_empty() { + for glob_path in glob_paths.iter() { + if glob_path == &path { + return true; + } + } + false + } else { + // Otherwise, by default include all rust files, Cargo.toml and Cargo.lock files. + f == "Cargo.toml" || f == "Cargo.lock" || f.to_string_lossy().ends_with(".rs") + } + }) { + files.push(path); + } + } + } + Ok(files) +} + +pub fn hash_files(source_file_patterns: Vec, cfg: BuildConfig) -> Result<[u8; 32]> { + let mut keccak = Keccak::v256(); + let mut cmd = Command::new("cargo"); + if !cfg.stable { + cmd.arg("+nightly"); + } + cmd.arg("--version"); + let output = cmd + .output() + .map_err(|e| eyre!("failed to execute cargo command: {e}"))?; + if !output.status.success() { + bail!("cargo version command failed"); + } + keccak.update(&output.stdout); + if cfg.opt_level == OptLevel::Z { + keccak.update(&[0]); + } else { + keccak.update(&[1]); + } + + let mut buf = vec![0u8; 0x100000]; + + let mut hash_file = |filename: &Path| -> Result<()> { + keccak.update(&(filename.as_os_str().len() as u64).to_be_bytes()); + keccak.update(filename.as_os_str().as_encoded_bytes()); + let mut file = std::fs::File::open(filename) + .map_err(|e| eyre!("failed to open file {}: {e}", filename.display()))?; + keccak.update(&file.metadata().unwrap().len().to_be_bytes()); + loop { + let bytes_read = file + .read(&mut buf) + .map_err(|e| eyre!("Unable to read file {}: {e}", filename.display()))?; + if bytes_read == 0 { + break; + } + keccak.update(&buf[..bytes_read]); + } + Ok(()) + }; + + let mut paths = all_paths(PathBuf::from(".").as_path(), source_file_patterns)?; + paths.sort(); + + for filename in paths.iter() { + println!( + "File used for deployment hash: {}", + filename.as_os_str().to_string_lossy() + ); + hash_file(filename)?; + } + + let mut hash = [0u8; 32]; + keccak.finalize(&mut hash); + println!( + "Project hash computed on deployment: {:?}", + hex::encode(hash) + ); + Ok(hash) +} + +fn expand_glob_patterns(patterns: Vec) -> Result> { + let mut files_to_include = Vec::new(); + for pattern in patterns { + let paths = glob(&pattern) + .map_err(|e| eyre!("Failed to read glob pattern '{}': {}", pattern, e))?; + for path_result in paths { + let path = path_result.map_err(|e| eyre!("Error processing path: {}", e))?; + files_to_include.push(path); + } + } + Ok(files_to_include) +} + /// Reads a WASM file at a specified path and returns its brotli compressed bytes. pub fn compress_wasm(wasm: &PathBuf) -> Result<(Vec, Vec)> { let wasm = @@ -130,3 +253,56 @@ pub fn compress_wasm(wasm: &PathBuf) -> Result<(Vec, Vec)> { Ok((wasm.to_vec(), contract_code)) } + +#[cfg(test)] +mod test { + use super::*; + use std::fs::{self, File}; + use std::io::Write; + use tempfile::tempdir; + + #[test] + fn test_all_paths() -> Result<()> { + let dir = tempdir()?; + let dir_path = dir.path(); + + let files = vec!["file.rs", "ignore.me", "Cargo.toml", "Cargo.lock"]; + for file in files.iter() { + let file_path = dir_path.join(file); + let mut file = File::create(&file_path)?; + writeln!(file, "Test content")?; + } + + let dirs = vec!["nested", ".git", "target"]; + for d in dirs.iter() { + let subdir_path = dir_path.join(d); + if !subdir_path.exists() { + fs::create_dir(&subdir_path)?; + } + } + + let nested_dir = dir_path.join("nested"); + let nested_file = nested_dir.join("nested.rs"); + if !nested_file.exists() { + File::create(&nested_file)?; + } + + let found_files = all_paths( + dir_path, + vec![format!( + "{}/{}", + dir_path.as_os_str().to_string_lossy(), + "**/*.rs" + )], + )?; + + // Check that the correct files are included + assert!(found_files.contains(&dir_path.join("file.rs"))); + assert!(found_files.contains(&nested_dir.join("nested.rs"))); + assert!(!found_files.contains(&dir_path.join("ignore.me"))); + assert!(!found_files.contains(&dir_path.join("Cargo.toml"))); // Not matching *.rs + assert_eq!(found_files.len(), 2, "Should only find 2 Rust files."); + + Ok(()) + } +} diff --git a/check/src/verify.rs b/check/src/verify.rs new file mode 100644 index 0000000..aa73713 --- /dev/null +++ b/check/src/verify.rs @@ -0,0 +1,71 @@ +// Copyright 2023-2024, Offchain Labs, Inc. +// For licensing, see https://github.com/OffchainLabs/cargo-stylus/blob/main/licenses/COPYRIGHT.md + +#![allow(clippy::println_empty_string)] + +use std::path::PathBuf; + +use eyre::{bail, eyre}; + +use ethers::middleware::Middleware; +use ethers::types::H256; + +use serde::{Deserialize, Serialize}; + +use crate::{check, deploy, project, CheckConfig, VerifyConfig}; +use cargo_stylus_util::{color::Color, sys}; + +#[derive(Debug, Deserialize, Serialize)] +struct RpcResult { + input: String, +} + +pub async fn verify(cfg: VerifyConfig) -> eyre::Result<()> { + let provider = sys::new_provider(&cfg.common_cfg.endpoint)?; + let hash = cargo_stylus_util::text::decode0x(cfg.deployment_tx)?; + if hash.len() != 32 { + bail!("Invalid hash"); + } + let Some(result) = provider + .get_transaction(H256::from_slice(&hash)) + .await + .map_err(|e| eyre!("RPC failed: {e}"))? + else { + bail!("No code at address"); + }; + + let output = sys::new_command("cargo") + .arg("clean") + .output() + .map_err(|e| eyre!("failed to execute cargo clean: {e}"))?; + if !output.status.success() { + bail!("cargo clean command failed"); + } + let check_cfg = CheckConfig { + common_cfg: cfg.common_cfg.clone(), + wasm_file: None, + program_address: None, + }; + let _ = check::check(&check_cfg) + .await + .map_err(|e| eyre!("Stylus checks failed: {e}"))?; + let build_cfg = project::BuildConfig { + opt_level: project::OptLevel::default(), + stable: cfg.common_cfg.rust_stable, + rebuild: false, + }; + let wasm_file: PathBuf = project::build_dylib(build_cfg.clone()) + .map_err(|e| eyre!("could not build project to WASM: {e}"))?; + let (_, init_code) = project::compress_wasm(&wasm_file)?; + let hash = project::hash_files(cfg.common_cfg.source_files_for_project_hash, build_cfg)?; + let deployment_data = deploy::program_deployment_calldata(&init_code, &hash); + if deployment_data == *result.input { + println!("Verified - program matches local project's file hashes"); + } else { + println!( + "{} - program deployment did not verify against local project's file hashes", + "FAILED".red() + ); + } + Ok(()) +} diff --git a/check/src/wallet.rs b/check/src/wallet.rs index 55fb461..adcda45 100644 --- a/check/src/wallet.rs +++ b/check/src/wallet.rs @@ -18,6 +18,9 @@ impl AuthOpts { } if let Some(key) = &self.private_key { + if key.is_empty() { + return Err(eyre!("empty private key")); + } return wallet!(key); } diff --git a/main/src/main.rs b/main/src/main.rs index 601afb0..2759935 100644 --- a/main/src/main.rs +++ b/main/src/main.rs @@ -38,6 +38,12 @@ enum Subcommands { /// Trace a transaction. #[command()] Trace, + /// Verify the deployment of a Stylus program against a local project. + #[command(alias = "v")] + Verify, + /// Run cargo stylus commands in a Docker container for reproducibility. + #[command()] + Reproducible, /// Generate C code. #[command()] CGen, @@ -52,7 +58,19 @@ struct Binary<'a> { const COMMANDS: &[Binary] = &[ Binary { name: "cargo-stylus-check", - apis: &["new", "export-abi", "check", "deploy", "n", "x", "c", "d"], + apis: &[ + "new", + "export-abi", + "check", + "deploy", + "verify", + "reproducible", + "n", + "x", + "c", + "d", + "v", + ], rust_flags: None, }, Binary {