diff --git a/.github/docker/Dockerfile.build b/.github/docker/Dockerfile.build index cc9f3d4..71f07fd 100644 --- a/.github/docker/Dockerfile.build +++ b/.github/docker/Dockerfile.build @@ -57,4 +57,4 @@ RUN /go/bin/yamlfmt -lint .github/workflows/*.yaml .github/workflows/*.yml .gith RUN cargo check RUN cargo +nightly fmt --all -- --check -RUN cargo test +RUN cargo test diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000..b4416f9 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,34 @@ +name: Integration Tests +on: + pull_request: + branches: + - '**' + +concurrency: + group: "integration-tests" + cancel-in-progress: true + +permissions: read-all + +jobs: + integration-tests: + name: Setup Toolchain and Test + runs-on: ubuntu-latest-m + permissions: + id-token: "write" + contents: "read" + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Nix With Bonsol Binary Cache + uses: DeterminateSystems/nix-installer-action@main + with: + extra-conf: | + extra-substituters = https://bonsol.cachix.org + extra-trusted-public-keys = bonsol.cachix.org-1:yz7vi1rCPW1BpqoszdJvf08HZxQ/5gPTPxft4NnT74A= + - name: Setup Toolchain, Build and Test + run: | + nix develop --command bash -c " + cargo build && + cargo test --features integration -- --nocapture + " diff --git a/CHANGELOG.md b/CHANGELOG.md index bc54d5c..b6848b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changed * `bonsol` cli option requirements and error messages updated for added clarity +### Added +* `bonsol estimate` for estimating execution cost of bonsol programs. + ### Fixed * **Breaking**: `execute_v1` interface instruction now uses the new `InputRef` to improve CU usage. * Adds a callback struct to use the input_hash and committed_outputs from the callback program ergonomically. diff --git a/Cargo.lock b/Cargo.lock index 311008b..d8396db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -855,6 +855,22 @@ dependencies = [ "serde_json", ] +[[package]] +name = "assert_cmd" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1835b7f27878de8525dc71410b5a31cdcc5f230aed5ba5df968e09c201b23d" +dependencies = [ + "anstyle", + "bstr", + "doc-comment", + "libc", + "predicates 3.1.2", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "assert_matches" version = "1.5.0" @@ -1189,12 +1205,14 @@ name = "bonsol-cli" version = "0.2.1" dependencies = [ "anyhow", + "assert_cmd", "atty", "bincode", "bonsol-interface", "bonsol-prover", "bonsol-sdk", "byte-unit", + "bytemuck", "bytes", "cargo_toml 0.20.5", "clap 4.5.20", @@ -1202,10 +1220,13 @@ dependencies = [ "indicatif", "num-traits", "object_store", + "predicates 3.1.2", "rand 0.8.5", "reqwest", "risc0-binfmt", + "risc0-circuit-rv32im", "risc0-zkvm", + "risc0-zkvm-platform", "serde", "serde_json", "sha2 0.10.8", @@ -1534,6 +1555,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40723b8fb387abc38f4f4a37c09073622e41dd12327033091ef8950659e6dc0c" dependencies = [ "memchr", + "regex-automata 0.4.8", "serde", ] @@ -4034,7 +4056,7 @@ dependencies = [ "fragile", "lazy_static", "mockall_derive", - "predicates", + "predicates 2.1.5", "predicates-tree", ] @@ -4888,6 +4910,20 @@ dependencies = [ "regex", ] +[[package]] +name = "predicates" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e9086cc7640c29a356d1a29fd134380bee9d8f79a17410aa76e7ad295f42c97" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + [[package]] name = "predicates-core" version = "1.0.8" @@ -8764,6 +8800,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" +[[package]] +name = "wait-timeout" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 76f6823..c1bba3a 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -11,13 +11,16 @@ path = "src/main.rs" [features] mac = ["risc0-zkvm/metal"] linux = ["risc0-zkvm/cuda"] +integration = [] [dependencies] anyhow = "1.0.86" atty = "0.2.14" bincode = "1.3.3" +bonsol-interface.workspace = true bonsol-prover = { path = "../prover" } bonsol-sdk = { path = "../sdk" } +bytemuck = "1.15.0" hex = "0.4.3" byte-unit = "4.0.19" bytes = "1.4.0" @@ -34,7 +37,9 @@ reqwest = { version = "0.11.26", features = [ "native-tls-vendored", ] } risc0-binfmt = { workspace = true } -risc0-zkvm = { workspace = true, features = ["prove"] } +risc0-zkvm = { workspace = true, default-features = false, features = ["prove", "std"] } +risc0-zkvm-platform = { git = "https://github.com/anagrambuild/risc0", branch = "v1.0.1-bonsai-fix" } +risc0-circuit-rv32im = { git = "https://github.com/anagrambuild/risc0", branch = "v1.0.1-bonsai-fix" } serde = { version = "1.0.197", features = ["derive"] } serde_json = "1.0.104" sha2 = "0.10.6" @@ -46,4 +51,6 @@ tera = "1.17.1" thiserror = "1.0.65" tokio = { version = "1.38.0", features = ["full"] } -bonsol-interface.workspace = true +[dev-dependencies] +assert_cmd = "2.0.16" +predicates = "3.1.2" diff --git a/cli/README.md b/cli/README.md index 56b2206..36c93f1 100644 --- a/cli/README.md +++ b/cli/README.md @@ -58,3 +58,20 @@ todo ### Prove todo + +### Estimate + +You can estimate the number of cycles and segments using risc0 emulation to step through an ELF by passing the `estimate` command the path to a manifest.json and an inputs file (if required). + +``` +bonsol -k ./keypair.json -u http://localhost:8899 estimate \ + --manifest-path program/manifest.json \ + --input-file program/inputs.json \ + --max-cycles 16777216 # this is the default + +# Example Output: +# +# User cycles: 3380 +# Total cycles: 65536 +# Segments: 1 +``` diff --git a/cli/src/command.rs b/cli/src/command.rs index 45ec694..b0efffc 100644 --- a/cli/src/command.rs +++ b/cli/src/command.rs @@ -227,6 +227,7 @@ pub enum Command { )] auto_confirm: bool, }, + #[command(about = "Build a ZK program")] Build { #[arg( help = "The path to a ZK program folder containing a Cargo.toml", @@ -235,6 +236,25 @@ pub enum Command { )] zk_program_path: String, }, + #[command(about = "Estimate the execution cost of a ZK RISC0 program")] + Estimate { + #[arg( + help = "The path to the program's manifest file (manifest.json)", + short = 'm', + long + )] + manifest_path: String, + + #[arg(help = "The path to the program input file", short = 'i', long)] + input_file: Option, + + #[arg( + help = "Set the maximum number of cycles [default: 16777216u64]", + short = 'c', + long + )] + max_cycles: Option, + }, Execute { #[arg(short = 'f', long)] execution_request_file: Option, @@ -296,6 +316,11 @@ pub enum ParsedCommand { Build { zk_program_path: String, }, + Estimate { + manifest_path: String, + input_file: Option, + max_cycles: Option, + }, Execute { execution_request_file: Option, @@ -351,6 +376,15 @@ impl TryFrom for ParsedCommand { ), }), Command::Build { zk_program_path } => Ok(ParsedCommand::Build { zk_program_path }), + Command::Estimate { + manifest_path, + input_file, + max_cycles, + } => Ok(ParsedCommand::Estimate { + manifest_path, + input_file, + max_cycles, + }), Command::Execute { execution_request_file, program_id, diff --git a/cli/src/estimate.rs b/cli/src/estimate.rs new file mode 100644 index 0000000..1c12595 --- /dev/null +++ b/cli/src/estimate.rs @@ -0,0 +1,74 @@ +//! Bare bones upper bound estimator that uses the rv32im +//! emulation utils for fast lookups in the opcode list +//! to extract the cycle count from an elf. + +use anyhow::Result; +use risc0_binfmt::{MemoryImage, Program}; +use risc0_zkvm::{ExecutorEnv, ExecutorImpl, Session, GUEST_MAX_MEM}; +use risc0_zkvm_platform::PAGE_SIZE; + +pub fn estimate(elf: E, env: ExecutorEnv) -> Result<()> { + let session = get_session(elf, env)?; + println!( + "User cycles: {}\nTotal cycles: {}\nSegments: {}", + session.user_cycles, + session.total_cycles, + session.segments.len() + ); + + Ok(()) +} + +/// Get the total number of cycles by stepping through the ELF using emulation +/// tools from the risc0_circuit_rv32im module. +pub fn get_session(elf: E, env: ExecutorEnv) -> Result { + Ok(ExecutorImpl::new(env, elf.mk_image()?)?.run()?) +} + +/// Helper trait for loading an image from an elf. +pub trait MkImage { + fn mk_image(self) -> Result; +} +impl<'a> MkImage for &'a [u8] { + fn mk_image(self) -> Result { + let program = Program::load_elf(self, GUEST_MAX_MEM as u32)?; + MemoryImage::new(&program, PAGE_SIZE as u32) + } +} + +#[cfg(test)] +mod estimate_tests { + use anyhow::Result; + use risc0_binfmt::MemoryImage; + use risc0_circuit_rv32im::prove::emu::{ + exec::DEFAULT_SEGMENT_LIMIT_PO2, + testutil::{basic as basic_test_program, DEFAULT_SESSION_LIMIT}, + }; + use risc0_zkvm::{ExecutorEnv, PAGE_SIZE}; + + use super::MkImage; + use crate::estimate; + + impl MkImage for MemoryImage { + fn mk_image(self) -> Result { + Ok(self) + } + } + + #[test] + fn estimate_basic() { + let program = basic_test_program(); + let mut env = &mut ExecutorEnv::builder(); + env = env + .segment_limit_po2(DEFAULT_SEGMENT_LIMIT_PO2 as u32) + .session_limit(DEFAULT_SESSION_LIMIT); + let image = MemoryImage::new(&program, PAGE_SIZE as u32) + .expect("failed to create image from basic program"); + let res = estimate::get_session(image, env.build().unwrap()); + + assert_eq!( + res.ok().and_then(|session| Some(session.total_cycles)), + Some(16384) + ); + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index f5819d3..11cdea1 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,22 +1,31 @@ +use std::fs; use std::io::{self, Read}; use std::path::Path; use atty::Stream; use bonsol_sdk::BonsolClient; use clap::Parser; +use common::{execute_get_inputs, ZkProgramManifest}; +use risc0_circuit_rv32im::prove::emu::exec::DEFAULT_SEGMENT_LIMIT_PO2; +use risc0_circuit_rv32im::prove::emu::testutil::DEFAULT_SESSION_LIMIT; +use risc0_zkvm::ExecutorEnv; use solana_sdk::signature::read_keypair_file; use solana_sdk::signer::Signer; use crate::command::{BonsolCli, ParsedBonsolCli, ParsedCommand}; use crate::common::{sol_check, try_load_from_config}; -use crate::error::BonsolCliError; +use crate::error::{BonsolCliError, ZkManifestError}; mod build; mod deploy; +mod estimate; mod execute; mod init; mod prove; +#[cfg(all(test, feature = "integration"))] +mod tests; + pub mod command; pub mod common; pub(crate) mod error; @@ -59,6 +68,42 @@ async fn main() -> anyhow::Result<()> { } deploy::deploy(rpc, keypair, deploy_args).await } + ParsedCommand::Estimate { + manifest_path, + input_file, + max_cycles, + } => { + let manifest_file = fs::File::open(Path::new(&manifest_path)).map_err(|err| { + BonsolCliError::ZkManifestError(ZkManifestError::FailedToOpen { + manifest_path: manifest_path.clone(), + err, + }) + })?; + let manifest: ZkProgramManifest = + serde_json::from_reader(manifest_file).map_err(|err| { + BonsolCliError::ZkManifestError(ZkManifestError::FailedDeserialization { + manifest_path, + err, + }) + })?; + let elf = fs::read(&manifest.binary_path).map_err(|err| { + BonsolCliError::ZkManifestError(ZkManifestError::FailedToLoadBinary { + binary_path: manifest.binary_path.clone(), + err, + }) + })?; + let mut env = &mut ExecutorEnv::builder(); + env = env + .segment_limit_po2(DEFAULT_SEGMENT_LIMIT_PO2 as u32) + .session_limit(max_cycles.or(DEFAULT_SESSION_LIMIT)); + + if input_file.is_some() { + let inputs = execute_get_inputs(input_file, None)?; + let inputs: Vec<&str> = inputs.iter().map(|i| i.data.as_str()).collect(); + env = env.write(&inputs.as_slice())?; + } + estimate::estimate(elf.as_slice(), env.build()?) + } ParsedCommand::Execute { execution_request_file, program_id, diff --git a/cli/src/tests.rs b/cli/src/tests.rs new file mode 100644 index 0000000..597d93a --- /dev/null +++ b/cli/src/tests.rs @@ -0,0 +1,32 @@ +use std::path::Path; + +use assert_cmd::Command; + +mod estimate; + +pub(crate) fn bonsol_cmd() -> Command { + let mut cmd = Command::cargo_bin("bonsol").unwrap(); + // the test directory must be the project root + cmd.current_dir(Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap()); + cmd +} + +pub(crate) fn bonsol_build() -> Command { + let mut cmd = bonsol_cmd(); + let keypair = cmd + .get_current_dir() + .unwrap() + .join("cli") + .join("src") + .join("tests") + .join("test_data") + .join("test_id.json"); + cmd.args(&[ + "--keypair", + keypair.to_str().unwrap(), + "--rpc-url", + "http://localhost:8899", + ]) + .arg("build"); + cmd +} diff --git a/cli/src/tests/estimate.rs b/cli/src/tests/estimate.rs new file mode 100644 index 0000000..9a782ae --- /dev/null +++ b/cli/src/tests/estimate.rs @@ -0,0 +1,65 @@ +use std::path::PathBuf; + +use assert_cmd::Command; + +use super::bonsol_build; +use crate::tests::bonsol_cmd; + +fn bonsol_estimate() -> Command { + let mut cmd = bonsol_cmd(); + let keypair = cmd + .get_current_dir() + .unwrap() + .join("cli") + .join("src") + .join("tests") + .join("test_data") + .join("test_id.json"); + cmd.args(&[ + "--keypair", + keypair.to_str().unwrap(), + "--rpc-url", + "http://localhost:8899", + ]) + .arg("estimate"); + cmd +} + +fn build_test_image(image_path: &PathBuf) { + let mut cmd = bonsol_build(); + cmd.args(&[ + "-z", + image_path + .to_str() + .expect("failed to convert image path to str"), + ]); + cmd.assert().success(); +} + +#[test] +fn estimate_simple() { + let mut bonsol_estimate = bonsol_estimate(); + let image_path = bonsol_estimate + .get_current_dir() + .unwrap() + .join("images") + .join("simple"); + + build_test_image(&image_path); + let input_file = bonsol_estimate + .get_current_dir() + .unwrap() + .join("testing-examples") + .join("example-input-file.json"); + + bonsol_estimate.args(&[ + "--manifest-path", + image_path.join("manifest.json").to_str().unwrap(), + "--input-file", + input_file.to_str().unwrap(), + ]); + bonsol_estimate.assert().success().stdout( + predicates::str::is_match(r##"User cycles: 3380\nTotal cycles: 65536\nSegments: 1"##) + .unwrap(), + ); +} diff --git a/cli/src/tests/test_data/test_id.json b/cli/src/tests/test_data/test_id.json new file mode 100644 index 0000000..299600c --- /dev/null +++ b/cli/src/tests/test_data/test_id.json @@ -0,0 +1 @@ +[23,63,45,154,238,247,208,106,253,84,40,156,83,148,184,176,156,65,81,38,111,102,129,239,63,225,80,151,2,247,225,80,177,81,166,141,115,49,177,27,143,115,92,164,45,31,137,203,27,112,123,75,141,151,218,1,117,144,233,58,129,29,112,119] \ No newline at end of file diff --git a/testing-examples/example-input-file.json b/testing-examples/example-input-file.json new file mode 100644 index 0000000..16e2397 --- /dev/null +++ b/testing-examples/example-input-file.json @@ -0,0 +1,12 @@ +{ + "inputs": [ + { + "inputType": "PublicData", + "data": "{\"attestation\":\"test\"}" + }, + { + "inputType": "Private", + "data": "https://echoserver.dev/server?response=N4IgFgpghgJhBOBnEAuA2mkBjA9gOwBcJCBaAgTwAcIQAaEIgDwIHpKAbKASzxAF0+9AEY4Y5VKArVUDCMzogYUAlBlFEBEAF96G5QFdkKAEwAGU1qA" + } + ] +}