diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..204ac893 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +target/ +!target/proving/ +!target/proving/assumevalid-syscalls.executable.json +.proofs +**/*.rlib +**/*.rmeta + +.git +.gitignore + +**/.vscode + +**/node_modules +**/dist +**/build + +*.log +tmp/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index f0082407..2db1adc4 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,5 @@ client.errors.log cairo_project.toml *.bin *.bz2 -*.gz \ No newline at end of file +*.gz +scripts/prove/.last_instance \ No newline at end of file diff --git a/.tool-versions b/.tool-versions index c402b075..cf444ce9 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1,2 @@ -scarb 2.12.0 +scarb 2.12.2 +scarb \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index ac34763c..a5107ba6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,9 +2,6 @@ resolver = "2" members = ["crates/*"] -[patch."https://github.com/m-kus/cairo-vm"] -cairo-vm = { git = "https://github.com/maciejka/cairo-vm", rev = "19d8a07ce9799a8af9db6f8a14a8accaad900214" } - [workspace.dependencies] # Accumulators accumulators = { version = "0.5.1", features = ["blake", "memory", "mmr"]} @@ -37,6 +34,8 @@ serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["arbitrary_precision"] } hex = "0.4" bincode = "1.3" +starknet-ff = "0.3.7" +bytes = "1.0" # BigInt num-bigint = { version = "0.4", features = ["serde"] } @@ -54,3 +53,12 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } mockall = "0.12" wiremock = "0.5" tempfile = "3.10" + +# Stwo-cairo +stwo = { git = "https://github.com/starkware-libs/stwo", rev = "d9176e6e22319370a8501f799829b920c0db2eac", default-features = false } +cairo-air = { git = "https://github.com/maciejka/stwo-cairo", rev = "d2793e9f19f575c13f6bc37c36f9b229797456e1", default-features = false } +stwo-cairo-serialize = { git = "https://github.com/maciejka/stwo-cairo", rev = "d2793e9f19f575c13f6bc37c36f9b229797456e1" } +stwo-prover = { git = "https://github.com/starkware-libs/stwo", rev = "d9176e6e22319370a8501f799829b920c0db2eac", features = [], default-features = false } +stwo_cairo_prover = { git = "https://github.com/maciejka/stwo-cairo", rev = "d2793e9f19f575c13f6bc37c36f9b229797456e1" } +stwo_cairo_utils = { git = "https://github.com/maciejka/stwo-cairo", rev = "d2793e9f19f575c13f6bc37c36f9b229797456e1" } + diff --git a/Makefile b/Makefile index b6ee6a1d..cac25a13 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Git revisions for external dependencies -BOOTLOADER_HINTS_REV ?= 5648cf0a5a2574c2870151cd178ff3ae4b141824 -STWO_REV ?= e5981958234c4b28fa2b4c3368a0290ec3fc57c2 -CAIRO_EXECUTE_REV ?= 7fbbd0112b5a926403c17fa95ad831c1715fd1b1 +BOOTLOADER_HINTS_REV ?= cec3b568447a2ebc7f1cdee8c1001b5af11ba34b +STWO_REV ?= d2793e9f19f575c13f6bc37c36f9b229797456e1 +CAIRO_EXECUTE_REV ?= e209f4557c535ddb4d2c76b2c6b14d004af99467 ################################## CLIENT ################################## client-build: @@ -16,7 +16,7 @@ install-bootloader-hints: cargo install \ --git ssh://git@github.com/starkware-libs/bootloader-hints.git \ --rev $(BOOTLOADER_HINTS_REV) \ - cairo-program-runner + cairo-program-runner stwo_run_and_prove install-stwo: RUSTFLAGS="-C target-cpu=native -C opt-level=3" \ @@ -31,7 +31,8 @@ install-cairo-execute: install-scarb-eject: cargo install --git \ - https://github.com/software-mansion-labs/scarb-eject + https://github.com/m-kus/scarb-eject \ + --rev 9c9b35870ac35af46e62b725219a8ac925281fe5 install-convert-proof-format: RUSTFLAGS="-C target-cpu=native -C opt-level=3" \ @@ -42,14 +43,15 @@ install-convert-proof-format: install-corelib: mkdir -p vendor rm -rf vendor/cairo - git clone --single-branch --branch m-kus/system-builtin \ + git clone --single-branch --branch raito \ https://github.com/m-kus/cairo vendor/cairo - (cd vendor/cairo && git checkout $(CAIRO_EXECUTE_REV)) + + (cd vendor/cairo && git config --add remote.origin.fetch "+refs/pull/*/head:refs/remotes/origin/pr/*" && git fetch origin --prune && git checkout $(CAIRO_EXECUTE_REV)) ln -s "$(CURDIR)/vendor/cairo/corelib" \ packages/assumevalid/corelib -install: install-bootloader-hints install-stwo install-cairo-execute \ - install-convert-proof-format install-scarb-eject install-corelib +install: install-bootloader-hints install-cairo-execute \ + install-scarb-eject install-corelib ################################## ASSUMEVALID ################################## @@ -57,21 +59,28 @@ assumevalid-build: scarb --profile proving build --package assumevalid \ --no-default-features - assumevalid-eject: scarb-eject --package assumevalid \ --output packages/assumevalid/cairo_project.toml assumevalid-build-with-syscalls: + mkdir -p target/proving cd packages/assumevalid && \ cairo-execute \ --build-only \ --output-path \ - ../../target/proving/assumevalid.executable.json \ + ../../target/proving/assumevalid-syscalls.executable.json \ --executable assumevalid::main \ --ignore-warnings \ --allow-syscalls . +assumevalid-execute: + @[ -n "$(ARGS_FILE)" ] || (echo "ERROR: ARGS_FILE is required. Usage: make assumevalid-execute ARGS_FILE=path/to/args.json" >&2; exit 1) + scarb --profile proving execute \ + --no-build \ + --package assumevalid \ + --arguments-file $(ARGS_FILE) \ + --print-resource-usage ################################## PIPELINE ################################## @@ -125,6 +134,20 @@ prove-pow: $(if $(SLOW),--slow) \ $(if $(VERBOSE),--verbose) +# Run the raito-assumevalid CLI Prove subcommand +assumevalid-prove: + cargo run -p raito-assumevalid -- \ + $(if $(LOG_LEVEL),--log-level $(LOG_LEVEL)) \ + $(if $(BRIDGE_URL),--bridge-url $(BRIDGE_URL)) \ + prove \ + $(if $(KEEP_TEMP_FILES),--keep-temp-files) \ + $(if $(OUTPUT_DIR),--output-dir $(OUTPUT_DIR)) \ + $(if $(TOTAL_BLOCKS),--total-blocks $(TOTAL_BLOCKS)) \ + $(if $(STEP_SIZE),--step-size $(STEP_SIZE)) \ + $(if $(LOAD_FROM_GCS),--load-from-gcs) \ + $(if $(SAVE_TO_GCS),--save-to-gcs) \ + $(if $(GCS_BUCKET),--gcs-bucket $(GCS_BUCKET)) + build-recent-proof: @echo ">>> Building recent proof..." . .venv/bin/activate && cd scripts/data && \ diff --git a/Scarb.lock b/Scarb.lock index 57d4f049..be7a6db1 100644 --- a/Scarb.lock +++ b/Scarb.lock @@ -13,7 +13,7 @@ dependencies = [ [[package]] name = "bounded_int" version = "0.1.0" -source = "git+https://github.com/starkware-libs/stwo-cairo?rev=bbe3e469bc636b89c37cb385854447bd46277b3b#bbe3e469bc636b89c37cb385854447bd46277b3b" +source = "git+https://github.com/maciejka/stwo-cairo?rev=d2793e9f19f575c13f6bc37c36f9b229797456e1#d2793e9f19f575c13f6bc37c36f9b229797456e1" [[package]] name = "client" @@ -59,16 +59,18 @@ source = "git+https://github.com/keep-starknet-strange/shinigami.git?rev=1963116 [[package]] name = "stwo_cairo_air" version = "0.1.0" -source = "git+https://github.com/starkware-libs/stwo-cairo?rev=bbe3e469bc636b89c37cb385854447bd46277b3b#bbe3e469bc636b89c37cb385854447bd46277b3b" +source = "git+https://github.com/maciejka/stwo-cairo?rev=d2793e9f19f575c13f6bc37c36f9b229797456e1#d2793e9f19f575c13f6bc37c36f9b229797456e1" dependencies = [ + "bounded_int", "stwo_constraint_framework", "stwo_verifier_core", + "stwo_verifier_utils", ] [[package]] name = "stwo_constraint_framework" version = "0.1.0" -source = "git+https://github.com/starkware-libs/stwo-cairo?rev=bbe3e469bc636b89c37cb385854447bd46277b3b#bbe3e469bc636b89c37cb385854447bd46277b3b" +source = "git+https://github.com/maciejka/stwo-cairo?rev=d2793e9f19f575c13f6bc37c36f9b229797456e1#d2793e9f19f575c13f6bc37c36f9b229797456e1" dependencies = [ "stwo_verifier_core", ] @@ -76,11 +78,17 @@ dependencies = [ [[package]] name = "stwo_verifier_core" version = "0.1.0" -source = "git+https://github.com/starkware-libs/stwo-cairo?rev=bbe3e469bc636b89c37cb385854447bd46277b3b#bbe3e469bc636b89c37cb385854447bd46277b3b" +source = "git+https://github.com/maciejka/stwo-cairo?rev=d2793e9f19f575c13f6bc37c36f9b229797456e1#d2793e9f19f575c13f6bc37c36f9b229797456e1" dependencies = [ "bounded_int", + "stwo_verifier_utils", ] +[[package]] +name = "stwo_verifier_utils" +version = "0.1.0" +source = "git+https://github.com/maciejka/stwo-cairo?rev=d2793e9f19f575c13f6bc37c36f9b229797456e1#d2793e9f19f575c13f6bc37c36f9b229797456e1" + [[package]] name = "utils" version = "0.1.0" diff --git a/Scarb.toml b/Scarb.toml index dc933a64..1aedcd9a 100644 --- a/Scarb.toml +++ b/Scarb.toml @@ -3,14 +3,14 @@ members = ["packages/*"] [workspace.package] description = "Bitcoin ZK client." -cairo-version = "2.12.0" +cairo-version = "2.12.2" version = "0.1.0" readme = "README.md" repository = "https://github.com/keep-starknet-strange/raito" license-file = "LICENSE" [workspace.dependencies] -cairo_test = "2.12.0" +cairo_test = "2.12.2" shinigami_engine = { git = "https://github.com/keep-starknet-strange/shinigami.git", rev = "1963116" } [profile.proving] @@ -21,7 +21,7 @@ enable-gas = false sierra-replace-ids = true # NOTE: this flag set causes build errors in Cairo 2.12.0 (because of use of print right before a panic). # Use only for assumevalid package. -unsafe-panic = true +# unsafe-panic = true [profile.release.cairo] enable-gas = false diff --git a/crates/raito-assumevalid/Cargo.toml b/crates/raito-assumevalid/Cargo.toml new file mode 100644 index 00000000..b9535fd2 --- /dev/null +++ b/crates/raito-assumevalid/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "raito-assumevalid" +version = "0.1.0" +edition = "2021" + +[lib] +name = "raito_assumevalid" +path = "src/lib.rs" + +[[bin]] +name = "raito-assumevalid" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +tokio = { workspace = true } +reqwest = { workspace = true } +clap = { workspace = true } +hex = { workspace = true } +tracing-subscriber = { workspace = true } +bytes = { workspace = true } +regex = "1.0" +starknet-ff = { workspace = true } +num-bigint = { workspace = true } +chrono = { version = "0.4", features = ["clock"] } + +# Raito dependencies +raito-cairo-serialize = { path = "../raito-cairo-serialize"} +stwo-cairo-serialize = { workspace = true } +raito-spv-verify = { path = "../raito-spv-verify" } +raito-spv-mmr = { path = "../raito-spv-mmr" } + +# Bitcoin +bitcoin = { workspace = true } + +cairo-air = { workspace = true } +stwo = { workspace = true} +stwo_cairo_prover = { workspace = true } +stwo_cairo_utils = { workspace = true } + +# Compression +flate2 = "1.0" +percent-encoding = "2" +google-cloud-storage = "1.1.0" +gcp_auth = "0.11" diff --git a/crates/raito-assumevalid/README.md b/crates/raito-assumevalid/README.md new file mode 100644 index 00000000..e8266b77 --- /dev/null +++ b/crates/raito-assumevalid/README.md @@ -0,0 +1,115 @@ +# raito-assumevalid + +A Rust crate for generating assumevalid arguments for Cairo programs. This crate provides both a library interface and a command-line tool for fetching chain state and block headers from a raito-bridge-node and generating Cairo-compatible arguments. + +## Features + +- **Library Interface**: Use `raito-assumevalid` as a dependency in your Rust projects +- **CLI Tool**: Command-line interface for generating and managing assumevalid arguments +- **Bridge Node Integration**: Fetches data from raito-bridge-node via HTTP API +- **Cairo Serialization**: Converts data to Cairo-compatible format using raito-cairo-args +- **Flexible Configuration**: Configurable bridge node URL + +## Installation + +Add to your `Cargo.toml`: + +```toml +[dependencies] +raito-assumevalid = { path = "../raito-assumevalid" } +``` + +## Library Usage + +```rust +use raito_assumevalid::{ProveClient, ProveConfig, AssumeValidParams, generate_assumevalid_args, save_cairo_args_to_file}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Create client configuration + let config = ProveConfig { + bridge_node_url: "https://api.raito.wtf/".to_string(), + }; + + // Create client + let client = ProveClient::new(config); + + // Define parameters + let params = AssumeValidParams { + start_height: 100, + block_count: 10, + chain_height: None, // Use latest + chain_state_proof: None, + }; + + // Generate assumevalid args + let cairo_args = generate_assumevalid_args(&client, params).await?; + + println!("Generated {} Cairo arguments", cairo_args.len()); + + // Save to file + save_cairo_args_to_file(&cairo_args, "args.json").await?; + + Ok(()) +} +``` + +## CLI Usage + +### Generate assumevalid arguments + +```bash +# Generate args for blocks 100-109 +raito-assumevalid generate --start-height 100 --block-count 10 + +# Specify output file +raito-assumevalid generate --start-height 100 --block-count 10 --output my_args.json + +# Use custom bridge node +raito-assumevalid --bridge-url http://localhost:8080 generate --start-height 100 --block-count 10 +``` + +### Query bridge node + +```bash +# Get current head +raito-assumevalid head + +# Get chain state for specific height +raito-assumevalid chain-state 100 +``` + +## Configuration + +### Environment Variables + +- `RAITO_BRIDGE_URL`: Default bridge node URL + +### Command Line Options + +- `--bridge-url`: Bridge node RPC URL (default: https://api.raito.wtf/) +- `--log-level`: Log level (trace, debug, info, warn, error) + +## API Reference + +### Core Types + +- `ProveConfig`: Configuration for the client +- `ProveClient`: HTTP client for bridge node communication +- `AssumeValidParams`: Parameters for argument generation + +### Key Functions + +- `generate_assumevalid_args()`: Generate assumevalid arguments (returns `Vec`) +- `save_cairo_args_to_file()`: Save Cairo arguments to JSON file + +## Dependencies + +- `raito-bridge-node`: For fetching chain state and block headers +- `raito-cairo-args`: For Cairo-compatible serialization +- `raito-spv-verify`: For chain state types +- `raito-spv-mmr`: For MMR roots types + +## License + +This project is part of the Raito ecosystem. See the main project LICENSE for details. diff --git a/crates/raito-assumevalid/src/adapters.rs b/crates/raito-assumevalid/src/adapters.rs new file mode 100644 index 00000000..72cfc6ea --- /dev/null +++ b/crates/raito-assumevalid/src/adapters.rs @@ -0,0 +1,114 @@ +use bitcoin::block::Header; + +use stwo::core::vcs::blake2_merkle::Blake2sMerkleHasher; +use stwo_cairo_serialize::CairoSerialize; + +use raito_cairo_serialize::{DigestString, U256String, U256StringLittleEndian}; +use raito_spv_mmr::sparse_roots::SparseRoots; +use raito_spv_verify::ChainState; + +use cairo_air::CairoProof; +use num_bigint::BigUint; + +/// View for assumevalid Args struct that matches Cairo's structure +#[derive(CairoSerialize)] +struct AssumeValidArgsView { + chain_state: ChainStateView, + blocks: Vec, + block_mmr: SparseRootsView, + chain_state_proof: Option>, +} + +/// View matching Cairo `ChainState` layout +#[derive(CairoSerialize)] +struct ChainStateView { + block_height: u32, + total_work: U256String, + best_block_hash: DigestString, + current_target: U256String, + epoch_start_time: u32, + prev_timestamps: Vec, +} + +#[derive(CairoSerialize)] +pub struct SparseRootsView { + pub roots: Vec, +} + +/// View for a single block matching Cairo's Block structure +#[derive(CairoSerialize)] +struct BlockView { + header: HeaderView, + data: Option, +} + +/// Reuse HeaderView from header module +#[derive(CairoSerialize)] +struct HeaderView { + pub version: u32, + pub time: u32, + pub bits: u32, + pub nonce: u32, +} + +/// Main adapter function for assumevalid Args +pub fn to_runner_args_hex( + chain_state: ChainState, + headers: &[Header], + block_mmr: &SparseRoots, + chain_state_proof: Option>, +) -> Vec { + // Convert headers to BlockView (merkle_root is already in the header) + let blocks: Vec = headers + .iter() + .map(|header| BlockView { + header: HeaderView { + version: header.version.to_consensus() as u32, + time: header.time, + bits: header.bits.to_consensus(), + nonce: header.nonce, + }, + data: Some(DigestString(header.merkle_root.to_string())), + }) + .collect(); + + let chain_state_view = ChainStateView { + block_height: chain_state.block_height, + total_work: U256String(chain_state.total_work.to_string()), + best_block_hash: DigestString(chain_state.best_block_hash.to_string()), + current_target: U256String(chain_state.current_target.to_string()), + epoch_start_time: chain_state.epoch_start_time, + prev_timestamps: chain_state.prev_timestamps.clone(), + }; + + let block_mmr_view = SparseRootsView { + roots: block_mmr + .roots + .iter() + .map(|root_hex| U256StringLittleEndian(hex_u256_to_decimal_string(root_hex))) + .collect(), + }; + + let args_view = AssumeValidArgsView { + chain_state: chain_state_view, + blocks, + block_mmr: block_mmr_view, + chain_state_proof, + }; + + let mut felts = Vec::new(); + args_view.serialize(&mut felts); + + felts + .into_iter() + .map(|felt| format!("0x{felt:x}")) + .collect() +} + +fn hex_u256_to_decimal_string(hex_input: &str) -> String { + let trimmed = hex_input.trim(); + let hex_without_prefix = trimmed.strip_prefix("0x").unwrap_or(trimmed); + let big_uint = BigUint::parse_bytes(hex_without_prefix.as_bytes(), 16) + .expect("Invalid hex string for u256 root"); + big_uint.to_str_radix(10) +} diff --git a/crates/raito-assumevalid/src/gcs.rs b/crates/raito-assumevalid/src/gcs.rs new file mode 100644 index 00000000..76de90d7 --- /dev/null +++ b/crates/raito-assumevalid/src/gcs.rs @@ -0,0 +1,175 @@ +use anyhow::Result; +use bytes::Bytes; +use cairo_air::CairoProof; +use flate2::write::GzEncoder; +use flate2::Compression; +use google_cloud_storage::client::Storage; +use google_cloud_storage::model_ext::ReadRange; +use raito_spv_verify::ChainState; +use serde::{Deserialize, Serialize}; +use std::io::Write; +use stwo::core::vcs::blake2_merkle::Blake2sMerkleHasher; +use tracing::{debug, info}; + +/// Create a Google Cloud Storage client +async fn create_gcs_client() -> Result { + Ok(Storage::builder().build().await?) +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct RecentProvenHeight { + pub block_height: u32, +} + +/// Download the latest proven height JSON from a GCS bucket object `recent_proven_height` +pub async fn download_recent_proof_height_from(bucket_name: &str) -> Result { + info!( + "Downloading latest proof height from GCS bucket: {} (object: recent_proven_height)", + bucket_name + ); + + let client = create_gcs_client().await?; + let bucket_path = format!("projects/_/buckets/{}", bucket_name); + + let mut reader = client + .read_object(&bucket_path, "recent_proven_height") + .set_read_range(ReadRange::offset(0)) + .send() + .await?; + + let mut contents = Vec::new(); + while let Some(chunk) = reader.next().await.transpose()? { + contents.extend_from_slice(&chunk); + } + + let body = String::from_utf8(contents)?; + let response: RecentProvenHeight = serde_json::from_str(&body)?; + let height = response.block_height; + debug!( + "Successfully downloaded latest GCS proof height: {}", + height + ); + Ok(height) +} + +#[derive(Serialize, Deserialize)] +pub struct RecentProof { + pub timestamp: String, + pub chainstate: ChainState, + pub proof: CairoProof, +} + +/// Download complete `recent_proof` using only reqwest, bypassing the GCS crate +/// Google Cloud Storage client library requires a content-length header when reading objects +pub async fn download_recent_proof_via_reqwest(bucket_name: &str) -> Result { + debug!( + "Downloading proof data from GCS bucket: {} (object: recent_proof)", + bucket_name + ); + + // Build the media URL + let url = format!( + "https://storage.googleapis.com/storage/v1/b/{}/o/recent_proof?alt=media", + bucket_name + ); + + let client = reqwest::Client::new(); + // Fetch access token via ADC (uses GOOGLE_APPLICATION_CREDENTIALS if set) + let scopes = &["https://www.googleapis.com/auth/devstorage.read_only"]; + let manager = gcp_auth::AuthenticationManager::new().await?; + let token = manager.get_token(scopes).await?; + + let resp = client.get(url).bearer_auth(token.as_str()).send().await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("GCS request failed: {} - {}", status, body); + } + + let body = resp.text().await?; + + debug!("Downloaded {} bytes of GCS proof data", body.len()); + + Ok(serde_json::from_str(&body)?) +} + +/// Download complete `recent_proof` object from a GCS bucket, does not work with gzipped objects +pub async fn download_recent_proof(bucket_name: &str) -> Result { + debug!( + "Downloading proof data from GCS bucket: {} (object: recent_proof)", + bucket_name + ); + + let client = create_gcs_client().await?; + let bucket_path = format!("projects/_/buckets/{}", bucket_name); + + let mut reader = client + .read_object(&bucket_path, "recent_proof") + .set_read_range(ReadRange::offset(0)) + .send() + .await?; + + let mut contents = Vec::new(); + while let Some(chunk) = reader.next().await.transpose()? { + contents.extend_from_slice(&chunk); + } + + let body = String::from_utf8(contents)?; + + debug!("Downloaded {} bytes of GCS proof data", body.len()); + + Ok(serde_json::from_str(&body)?) +} + +/// Upload recent proof to Google Cloud Storage in gzipped JSON format +pub async fn upload_recent_proof(recent_proof: &RecentProof, bucket_name: &str) -> Result<()> { + debug!( + "Uploading recent proof to Google Cloud Storage bucket: {}", + bucket_name + ); + + let client = create_gcs_client().await?; + let bucket_path = format!("projects/_/buckets/{}", bucket_name); + + // Serialize the proof to JSON + let json_data = serde_json::to_string_pretty(recent_proof)?; + + // Compress the JSON data using gzip + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(json_data.as_bytes())?; + let compressed_data = encoder.finish()?; + + debug!("Compressed data size: {} bytes", compressed_data.len()); + + // Upload the compressed data as recent_proof with proper content type and encoding + let _ = client + .write_object(&bucket_path, "recent_proof", Bytes::from(compressed_data)) + .set_content_type("application/json") + .set_content_encoding("gzip") + .send_buffered() + .await?; + + debug!("Successfully uploaded compressed proof to GCS as recent_proof"); + + // Upload recent_proven_height file with block height information + let proven_height_data = RecentProvenHeight { + block_height: recent_proof.chainstate.block_height, + }; + + let height_json = serde_json::to_string_pretty(&proven_height_data)?; + + let _ = client + .write_object( + &bucket_path, + "recent_proven_height", + Bytes::from(height_json), + ) + .set_content_type("application/json") + .send_unbuffered() + .await?; + + debug!("Successfully uploaded block_height to GCS as recent_proven_height"); + + Ok(()) +} diff --git a/crates/raito-assumevalid/src/generate_args.rs b/crates/raito-assumevalid/src/generate_args.rs new file mode 100644 index 00000000..336fdba4 --- /dev/null +++ b/crates/raito-assumevalid/src/generate_args.rs @@ -0,0 +1,170 @@ +use crate::adapters::to_runner_args_hex; +use anyhow::{anyhow, Result}; +use bitcoin::block::Header as BlockHeader; +use cairo_air::utils::{deserialize_proof_from_file, ProofFormat}; +use raito_spv_mmr::sparse_roots::SparseRoots; +use raito_spv_verify::ChainState; +use std::path::PathBuf; +use tracing::debug; + +use stwo::core::vcs::blake2_merkle::Blake2sMerkleHasher; + +/// Configuration for the raito-assumevalid client +#[derive(Debug, Clone)] +pub struct ProveConfig { + /// Bridge node RPC URL + pub bridge_node_url: String, +} + +impl Default for ProveConfig { + fn default() -> Self { + Self { + bridge_node_url: "https://api.raito.wtf/".to_string(), + } + } +} + +/// Client for interacting with raito-bridge-node +pub struct ProveClient { + config: ProveConfig, + client: reqwest::Client, +} + +impl ProveClient { + /// Create a new ProveClient with the given configuration + pub fn new(config: ProveConfig) -> Self { + let client = reqwest::Client::new(); + Self { config, client } + } + + /// Fetch chain state for a given block height + pub async fn get_chain_state(&self, block_height: u32) -> Result { + let url = format!( + "{}/chain-state/{}", + self.config.bridge_node_url, block_height + ); + let response = self.make_request(&url).await?; + Ok(response.json().await?) + } + + /// Fetch block headers for a given range + pub async fn get_block_headers(&self, offset: u32, size: u32) -> Result> { + let url = format!( + "{}/headers?offset={}&size={}", + self.config.bridge_node_url, offset, size + ); + let response = self.make_request(&url).await?; + Ok(response.json().await?) + } + + /// Fetch MMR roots for a given chain height + pub async fn get_mmr_roots(&self, chain_height: u32) -> Result { + let url = format!( + "{}/roots?chain_height={}", + self.config.bridge_node_url, chain_height + ); + let response = self.make_request(&url).await?; + Ok(response.json().await?) + } + + /// Get the current head (latest block height) + pub async fn get_head(&self) -> Result { + let url = format!("{}/head", self.config.bridge_node_url); + let response = self.make_request(&url).await?; + Ok(response.json().await?) + } + /// Make an HTTP request + async fn make_request(&self, url: &str) -> Result { + debug!("Making request to {}", url); + let response = self + .client + .get(url) + .header("Accept-Encoding", "gzip") + .send() + .await?; + + if !response.status().is_success() { + return Err(anyhow!("HTTP error: {}", response.status())); + } + + Ok(response) + } +} + +/// Parameters for generating assumevalid args +#[derive(Debug, Clone)] +pub struct AssumeValidParams { + /// Starting block height + pub start_height: u32, + /// Number of blocks to include + pub block_count: u32, + /// Optional chain state proof path + pub chain_state_proof_path: Option, +} + +/// Generate assumevalid args for the given parameters +pub async fn generate_assumevalid_args( + client: &ProveClient, + params: AssumeValidParams, +) -> Result> { + debug!( + "Generating assumevalid args for height {} with {} blocks", + params.start_height, params.block_count + ); + + // Fetch chain state for the starting height + let chain_state = client.get_chain_state(params.start_height).await?; + debug!("Fetched chain state for height {}", params.start_height); + + // Fetch block headers for the range: starting AFTER the current chain_state height + let block_headers = client + .get_block_headers(params.start_height + 1, params.block_count) + .await?; + debug!("Fetched {} block headers", block_headers.len()); + + // Fetch MMR roots + let block_mmr = client.get_mmr_roots(params.start_height).await?; + debug!( + "Fetched MMR roots for chain height {:?}", + params.start_height + ); + + let chain_state_proof = if let Some(path) = ¶ms.chain_state_proof_path { + Some(deserialize_proof_from_file::( + path, + ProofFormat::CairoSerde, + )?) + } else { + None + }; + + // Generate Cairo-compatible arguments + let cairo_args = to_runner_args_hex(chain_state, &block_headers, &block_mmr, chain_state_proof); + + debug!("Generated {} Cairo arguments", cairo_args.len()); + + Ok(cairo_args) +} + +/// Generate and save assumevalid args to a file +pub async fn generate_and_save_args( + client: &ProveClient, + params: AssumeValidParams, + file_path: &str, +) -> Result<()> { + let cairo_args = generate_assumevalid_args(client, params).await?; + save_cairo_args_to_file(&cairo_args, file_path).await?; + Ok(()) +} + +/// Save Cairo arguments to a file +pub async fn save_cairo_args_to_file(cairo_args: &[String], file_path: &str) -> Result<()> { + let json = serde_json::to_string_pretty(cairo_args)?; + tokio::fs::write(file_path, json).await?; + debug!( + "Saved {} Cairo arguments to {}", + cairo_args.len(), + file_path + ); + Ok(()) +} diff --git a/crates/raito-assumevalid/src/lib.rs b/crates/raito-assumevalid/src/lib.rs new file mode 100644 index 00000000..49099a08 --- /dev/null +++ b/crates/raito-assumevalid/src/lib.rs @@ -0,0 +1,12 @@ +//! Raito Prove - Generate assumevalid arguments and prove Cairo programs +//! +//! This library provides functionality to: +//! 1. Generate assumevalid arguments from bridge node data +//! 2. Prove assumevalid arguments using Cairo programs and STARK proofs + +pub mod adapters; +pub mod gcs; +pub mod generate_args; +pub mod prove; + +pub use prove::{prove, ProveParams}; diff --git a/crates/raito-assumevalid/src/main.rs b/crates/raito-assumevalid/src/main.rs new file mode 100644 index 00000000..0cdd9b47 --- /dev/null +++ b/crates/raito-assumevalid/src/main.rs @@ -0,0 +1,139 @@ +use anyhow::Result; +use clap::{Parser, Subcommand}; +use raito_assumevalid::prove::{prove, ProveParams}; +use std::path::PathBuf; +use tracing_subscriber::{self, EnvFilter}; + +/// Raito AssumeValid - Generate assumevalid arguments and prove Cairo programs +#[derive(Parser)] +#[command(name = "raito-assumevalid")] +#[command(about = "Generate assumevalid arguments and prove Cairo programs")] +#[command(version)] +struct Cli { + /// Bridge node RPC URL + #[arg(long, default_value = "https://staging.raito.wtf")] + bridge_url: String, + + /// Log level + #[arg(long, default_value = "info")] + log_level: String, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Prove multiple batches iteratively (similar to prove_pow in Python) + Prove { + /// Use cloud storage to detect latest proof height instead of local directory scanning + #[arg(long)] + load_from_gcs: bool, + + #[arg(long)] + save_to_gcs: bool, + + #[arg(long, default_value = "raito-proofs")] + gcs_bucket: String, + + /// Total number of blocks to process + #[arg(long, default_value = "1")] + total_blocks: u32, + + /// Step size for each batch + #[arg(long, default_value = "1")] + step_size: u32, + + /// Output directory for all proofs + #[arg(long, default_value = ".proofs")] + output_dir: PathBuf, + + /// Path to the Cairo executable JSON file + #[arg( + long, + default_value = "target/proving/assumevalid-syscalls.executable.json" + )] + executable: PathBuf, + + /// Path to the bootloader JSON file + #[arg(long, default_value = "bootloaders/simple_bootloader_compiled.json")] + bootloader: PathBuf, + + /// Path to the prover parameters JSON file + #[arg(long, default_value = "packages/assumevalid/prover_params.json")] + prover_params: PathBuf, + + /// Don't delete temporary files after completion + #[arg(long, default_value = "false")] + keep_temp_files: bool, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + // Initialize logging - validate and normalize the log level + let base_level = match cli.log_level.as_str() { + "trace" | "debug" | "info" | "warn" | "error" => cli.log_level.as_str(), + _ => "info", + }; + + // Build an EnvFilter with per-target overrides to silence noisy dependencies. + // Always merge our suppressions even if RUST_LOG is set. + let mut env_filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(base_level)); + + for directive in [ + "gcp_auth::custom_service_account=off", + "gcp_auth::authentication_manager=off", + "reqwest::connect=off", + "hyper_util::client::legacy::connect::http=off", + "hyper::client::connect::dns=off", + "rustls::client=off", + // Reduce chattiness of h2/hyper/reqwest to info+ regardless of base debug level + "h2=info", + "hyper=info", + "hyper_util=info", + "reqwest=info", + ] { + if let Ok(dir) = directive.parse() { + env_filter = env_filter.add_directive(dir); + } + } + + tracing_subscriber::fmt().with_env_filter(env_filter).init(); + + match cli.command { + Commands::Prove { + load_from_gcs, + save_to_gcs, + gcs_bucket, + total_blocks, + step_size, + output_dir, + executable, + bootloader, + prover_params, + keep_temp_files, + } => { + let params = ProveParams { + load_from_gcs, + save_to_gcs, + gcs_bucket, + bridge_url: cli.bridge_url, + total_blocks, + step_size, + output_dir, + executable, + bootloader, + prover_params, + keep_temp_files, + }; + + prove(params).await?; + } + } + + Ok(()) +} diff --git a/crates/raito-assumevalid/src/prove.rs b/crates/raito-assumevalid/src/prove.rs new file mode 100644 index 00000000..325b80a4 --- /dev/null +++ b/crates/raito-assumevalid/src/prove.rs @@ -0,0 +1,413 @@ +use anyhow::{anyhow, Result}; +use cairo_air::utils::{deserialize_proof_from_file, serialize_proof_to_file, ProofFormat}; +use regex::Regex; + +use serde_json::json; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Instant; +use stwo::core::vcs::blake2_merkle::Blake2sMerkleHasher; +use tracing::{debug, error, info, warn}; + +use crate::gcs::{download_recent_proof_via_reqwest, upload_recent_proof, RecentProof}; +use crate::generate_args::{generate_and_save_args, AssumeValidParams, ProveClient, ProveConfig}; + +/// Generate program-input.json for bootloader execution +pub async fn generate_program_input( + executable_path: &Path, + arguments_file: &Path, + input_file: &Path, +) -> Result<()> { + // Convert to absolute paths + let executable_path = executable_path.canonicalize()?; + let args_file = arguments_file.canonicalize()?; + + // Create the program input structure + let program_input = json!({ + "single_page": true, + "tasks": [ + { + "type": "Cairo1Executable", + "path": executable_path.to_string_lossy(), + "program_hash_function": "blake", + "user_args_file": args_file.to_string_lossy(), + } + ], + }); + + // Write to output file + let json = serde_json::to_string_pretty(&program_input)?; + tokio::fs::write(input_file, json).await?; + + debug!("Generated program-input.json at {}", input_file.display()); + Ok(()) +} + +/// Parse memory usage from /usr/bin/time output +fn parse_memory_usage(stderr: &str) -> Option { + for line in stderr.lines() { + if line.contains("Maximum resident set size (kbytes):") { + if let Some(kb_str) = line.split(':').nth(1) { + if let Ok(kb) = kb_str.trim().parse::() { + return Some(kb); + } + } + } + } + None +} + +/// Main function to prove batch - orchestrates the full pipeline +pub async fn run_and_prove( + arguments_file: &Path, + output_dir: &Path, + executable: &Path, + bootloader: &Path, + prover_params: &Path, + keep_temp_files: bool, +) -> Result { + info!("Starting assumevalid proving process"); + debug!("Arguments file: {}", arguments_file.display()); + debug!("Output directory: {}", output_dir.display()); + + // Create output directory + tokio::fs::create_dir_all(output_dir).await?; + + // Resolve output dir to absolute and set up file paths + let out_dir = output_dir + .canonicalize() + .unwrap_or_else(|_| output_dir.to_path_buf()); + let program_input_file = out_dir.join("program-input.json"); + let proof_file = out_dir.join("proof.json"); + + // Prepare program input for the bootloader + debug!("Generating program-input.json"); + generate_program_input(executable, arguments_file, &program_input_file).await?; + + // Inline stwo_run_and_prove: build and run CLI + let start_time = Instant::now(); + let program_abs = bootloader.canonicalize()?; + let input_abs = program_input_file.canonicalize()?; + let params_abs = prover_params.canonicalize()?; + let proofs_dir_abs = out_dir.canonicalize()?; + + let mut cmd = Command::new("/usr/bin/time"); + cmd.args([ + "-v", + "stwo_run_and_prove", + "--program", + program_abs.to_str().unwrap(), + "--program_input", + input_abs.to_str().unwrap(), + "--prover_params_json", + params_abs.to_str().unwrap(), + "--proofs_dir", + proofs_dir_abs.to_str().unwrap(), + "--proof-format", + "cairo-serde", + "--n_proof_attempts", + "1", + "--verify", + ]); + debug!("Running command: {:?}", cmd); + + let output = cmd.output()?; + let elapsed = start_time.elapsed(); + let max_memory = parse_memory_usage(&String::from_utf8_lossy(&output.stderr)); + + if output.status.success() { + if let Some(mem) = max_memory { + info!( + "stwo_run_and_prove succeeded in {:.2}s, max RSS: {:.1} MB", + elapsed.as_secs_f64(), + mem as f64 / 1024.0 + ); + } else { + info!( + "stwo_run_and_prove succeeded in {:.2}s", + elapsed.as_secs_f64() + ); + } + + // Find and rename the generated proof file to proof.json + if let Ok(entries) = fs::read_dir(&out_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { + // Look for files that start with "proof_" and end with "_success" or similar patterns + if file_name.starts_with("proof_") + && (file_name.ends_with("_success") || file_name.contains("_success")) + { + if let Err(e) = fs::rename(&path, &proof_file) { + warn!( + "Failed to rename proof file from {} to {}: {}", + path.display(), + proof_file.display(), + e + ); + } else { + debug!( + "Renamed proof file from {} to {}", + file_name, + proof_file.file_name().unwrap().to_string_lossy() + ); + } + break; + } + } + } + } + } + } else { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + error!( + "stwo_run_and_prove failed with return code {:?}", + output.status.code() + ); + error!("STDOUT: {}", stdout); + error!("STDERR: {}", stderr); + return Err(anyhow!("stwo_run_and_prove failed: {}", stderr)); + } + + // Clean up temporary files if requested + if !keep_temp_files { + debug!("Cleaning up temporary files"); + let temp_files = vec![program_input_file, arguments_file.to_path_buf()]; + + for temp_file in temp_files { + if temp_file.exists() { + // Use std::fs::remove_file for synchronous context + if let Err(e) = std::fs::remove_file(&temp_file) { + warn!( + "Failed to remove temporary file {}: {}", + temp_file.display(), + e + ); + } + } + } + } + + info!("Proof saved to: {}", proof_file.display()); + + Ok(proof_file) +} + +/// Parameters for proving multiple batches iteratively +#[derive(Debug, Clone)] +pub struct ProveParams { + pub load_from_gcs: bool, + pub save_to_gcs: bool, + /// URL to fetch the latest proof height from (used with --use-gcs) + pub gcs_bucket: String, + pub bridge_url: String, + /// Total number of blocks to process + pub total_blocks: u32, + /// Step size for each batch + pub step_size: u32, + /// Output directory for all proofs + pub output_dir: PathBuf, + /// Path to the Cairo executable JSON file + pub executable: PathBuf, + /// Path to the bootloader JSON file + pub bootloader: PathBuf, + /// Path to the prover parameters JSON file + pub prover_params: PathBuf, + /// Whether to keep temporary files after completion + pub keep_temp_files: bool, +} + +/// Find the previous proof file for a given start height +pub fn find_proof_file(start_height: u32, output_dir: &Path) -> Option { + if start_height == 0 { + return None; + } + + if let Ok(entries) = fs::read_dir(output_dir) { + for entry in entries.flatten() { + if entry.path().is_dir() { + if let Some(dir_name) = entry.file_name().to_str() { + if dir_name.ends_with(&format!("_to_{}", start_height)) { + let proof_file = entry.path().join("proof.json"); + if proof_file.exists() { + return Some(proof_file); + } + } + } + } + } + } + None +} + +/// Auto-detect the starting height by finding the highest ending height from existing proof directories +pub fn auto_detect_start_height(proof_dir: &Path) -> u32 { + let mut max_height = 0; + let pattern = Regex::new(r"batch_(\d+)_to_(\d+)").unwrap(); + + if !proof_dir.exists() { + return max_height; + } + + if let Ok(entries) = fs::read_dir(proof_dir) { + for entry in entries.flatten() { + if entry.path().is_dir() { + if let Some(dir_name) = entry.file_name().to_str() { + if let Some(captures) = pattern.captures(dir_name) { + if let (Ok(_start), Ok(end)) = + (captures[1].parse::(), captures[2].parse::()) + { + let proof_file = entry.path().join("proof.json"); + if proof_file.exists() { + if end > max_height { + max_height = end; + } + } + } + } + } + } + } + } + max_height +} + +pub async fn create_batch_dir( + start_height: u32, + step_size: u32, + output_dir: &Path, +) -> Result { + // Create dedicated directory for this proof batch + let batch_name = format!("batch_{}_to_{}", start_height, start_height + step_size); + let batch_dir = output_dir.join(&batch_name); + tokio::fs::create_dir_all(&batch_dir).await?; + Ok(batch_dir) +} + +/// Main function to prove multiple batches iteratively +pub async fn prove(params: ProveParams) -> Result<()> { + if params.load_from_gcs { + let recent_proof = download_recent_proof_via_reqwest(params.gcs_bucket.as_str()).await?; + let batch_dir = + create_batch_dir(0, recent_proof.chainstate.block_height, ¶ms.output_dir).await?; + + // Save the recent proof to the output directory + let proof_file = batch_dir.join("proof.json"); + serialize_proof_to_file::( + &recent_proof.proof, + &proof_file, + ProofFormat::CairoSerde, + )?; + + debug!( + "Using recent proof up to height: {}", + recent_proof.chainstate.block_height + ); + } + + let start_height = auto_detect_start_height(¶ms.output_dir); + + info!( + "Starting iterative proving process: start_height={}, total_blocks={}, step_size={}", + start_height, params.total_blocks, params.step_size + ); + debug!("Output directory: {}", params.output_dir.display()); + + // Create output directory + tokio::fs::create_dir_all(¶ms.output_dir).await?; + + let end_height = start_height + params.total_blocks; + let mut current_height = start_height; + let mut last_proof_path: Option = None; + let mut last_height = start_height; + + // Process batches sequentially + while current_height < end_height { + let current_step = std::cmp::min(params.step_size, end_height - current_height); + if current_step <= 0 { + break; + } + + // Process a single batch + let job_info = format!("Job(height='{}', blocks={})", current_height, current_step); + info!("{} proving...", job_info); + + let batch_dir = create_batch_dir(current_height, current_step, ¶ms.output_dir).await?; + + // Look for previous proof + let chain_state_proof_path = find_proof_file(current_height, ¶ms.output_dir); + + // Generate arguments for this batch + debug!("{} generating args...", job_info); + let args_start_time = Instant::now(); + + let config = ProveConfig { + bridge_node_url: params.bridge_url.clone(), + }; + let client = ProveClient::new(config); + + let assumevalid_params = AssumeValidParams { + start_height: current_height, + block_count: current_step, + chain_state_proof_path, + }; + + let args_file = batch_dir.join("arguments.json"); + generate_and_save_args(&client, assumevalid_params, &args_file.to_string_lossy()).await?; + let _args_elapsed = args_start_time.elapsed(); + + // Prove the batch using inlined parameters + let batch_result = run_and_prove( + &args_file, + &batch_dir, + ¶ms.executable, + ¶ms.bootloader, + ¶ms.prover_params, + params.keep_temp_files, + ) + .await; + + match batch_result { + Ok(proof_path) => { + info!("{} done", job_info); + + // Store the last proof path and height for later upload + last_proof_path = Some(proof_path); + last_height = current_height + current_step; + current_height += current_step; + } + Err(e) => { + error!("Batch at height {} failed: {}", current_height, e); + info!("Stopping further processing due to batch failure"); + return Err(e); + } + } + } + + // Upload only the last proof to GCS + if params.save_to_gcs { + if let Some(proof_path) = last_proof_path { + info!("Uploading final proof to GCS for height {}", last_height); + let client = ProveClient::new(ProveConfig { + bridge_node_url: params.bridge_url.clone(), + }); + + let timestamp = format!("{}", chrono::Utc::now()); + let chainstate = client.get_chain_state(last_height).await?; + let proof = deserialize_proof_from_file(&proof_path, ProofFormat::CairoSerde)?; + + let recent_proof = RecentProof { + timestamp, + chainstate, + proof, + }; + upload_recent_proof(&recent_proof, ¶ms.gcs_bucket).await?; + info!("Successfully uploaded final proof to GCS"); + } + } + + Ok(()) +} diff --git a/crates/raito-bridge-node/Cargo.toml b/crates/raito-bridge-node/Cargo.toml index ddc196b6..637a08dd 100644 --- a/crates/raito-bridge-node/Cargo.toml +++ b/crates/raito-bridge-node/Cargo.toml @@ -22,7 +22,6 @@ tower-http = { version = "0.5", features = ["trace", "cors", "compression-gzip"] bitcoin.workspace = true # Storage sqlx.workspace = true -bincode.workspace = true # CLI clap.workspace = true dotenv.workspace = true diff --git a/crates/raito-bridge-node/src/indexer.rs b/crates/raito-bridge-node/src/indexer.rs index 373d3045..a8109d65 100644 --- a/crates/raito-bridge-node/src/indexer.rs +++ b/crates/raito-bridge-node/src/indexer.rs @@ -9,7 +9,7 @@ use tracing::{error, info}; use raito_bitcoin_client::BitcoinClient; -use crate::{chain_state::ChainStateManager, store::AppStore}; +use crate::store::AppStore; /// Bitcoin block indexer that builds MMR accumulator and generates sparse roots pub struct Indexer { @@ -60,18 +60,13 @@ impl Indexer { let mut next_block_height = mmr.get_block_count().await?; info!("Current MMR blocks count: {}", next_block_height); - let mut chain_state_mgr = - ChainStateManager::restore(store.clone(), next_block_height).await?; - info!("Chain state manager initialized"); - loop { tokio::select! { res = bitcoin_client.wait_block_header(next_block_height, self.config.indexing_lag) => { match res { Ok((block_header, block_hash)) => { store.begin().await?; - mmr.add_block_header(&block_header).await.map_err(|e| anyhow::anyhow!("Failed to add block header to MMR: {}", e))?; - chain_state_mgr.update(next_block_height, &block_header).await.map_err(|e| anyhow::anyhow!("Failed to update chain state: {}", e))?; + mmr.add_block_header(next_block_height, &block_header).await?; store.commit().await?; info!("Block #{} {} processed", next_block_height, block_hash); next_block_height += 1; diff --git a/crates/raito-bridge-node/src/main.rs b/crates/raito-bridge-node/src/main.rs index eda8705e..8a24d661 100644 --- a/crates/raito-bridge-node/src/main.rs +++ b/crates/raito-bridge-node/src/main.rs @@ -13,7 +13,6 @@ use crate::{ shutdown::Shutdown, }; -mod chain_state; mod indexer; mod rpc; mod shutdown; @@ -33,6 +32,7 @@ struct Cli { #[arg(long, env = "USERPWD")] bitcoin_rpc_userpwd: Option, /// Path to the database storing the app state + /// Path to the database storing the app state #[arg(long, default_value = "./.mmr_data/mmr.db")] db_path: PathBuf, /// MMR ID diff --git a/crates/raito-bridge-node/src/rpc.rs b/crates/raito-bridge-node/src/rpc.rs index 922cec1b..f6015f97 100644 --- a/crates/raito-bridge-node/src/rpc.rs +++ b/crates/raito-bridge-node/src/rpc.rs @@ -21,9 +21,9 @@ use raito_spv_mmr::{ block_mmr::{BlockInclusionProof, BlockMMR}, sparse_roots::SparseRoots, }; -use raito_spv_verify::{ChainState, TransactionInclusionProof}; +use raito_spv_verify::TransactionInclusionProof; -use crate::{chain_state::ChainStateStore, store::AppStore}; +use crate::store::AppStore; /// Query parameters for block inclusion proof generation and roots retrieval #[derive(Debug, Deserialize)] @@ -62,25 +62,21 @@ pub struct RpcServer { #[derive(Debug, Clone)] pub struct AppState { mmr: Arc, - store: Arc, bitcoin_client: Arc, } impl AppState { - pub fn new(config: RpcConfig) -> Result { + pub async fn new(config: RpcConfig) -> Result { let mmr_id = Some(config.mmr_id.clone()); - let store = Arc::new(AppStore::multiple_concurrent_readers( - &config.mmr_db_path, - mmr_id.clone(), - )); + let store = + AppStore::multiple_concurrent_readers(&config.mmr_db_path, mmr_id.clone()).await?; let hasher = StarkBlakeHasher::default(); - let mmr = BlockMMR::new(store.clone(), Arc::new(hasher), mmr_id); + let mmr = BlockMMR::new(Arc::new(store), Arc::new(hasher), mmr_id); let bitcoin_client = BitcoinClient::new(config.rpc_url.clone(), config.rpc_userpwd.clone())?; Ok(Self { mmr: Arc::new(mmr), bitcoin_client: Arc::new(bitcoin_client), - store: store.clone(), }) } } @@ -97,6 +93,7 @@ impl RpcServer { info!("Starting RPC server on {}", self.config.rpc_host); let app_state = AppState::new(self.config.clone()) + .await .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; let app = Router::new() @@ -106,7 +103,6 @@ impl RpcServer { .route("/headers", get(get_block_headers)) .route("/transaction-proof/:tx_id", get(get_transaction_proof)) .route("/block-header/:block_height", get(get_block_header)) - .route("/chain-state/:block_height", get(get_chain_state)) .with_state(app_state) .layer(CompressionLayer::new()) .layer(CorsLayer::permissive()) @@ -214,7 +210,7 @@ pub async fn get_block_header( Path(block_height): Path, ) -> Result, StatusCode> { let block_header = state - .store + .mmr .get_block_headers(block_height, 1) .await .map_err(|e| { @@ -224,8 +220,9 @@ pub async fn get_block_header( ); StatusCode::INTERNAL_SERVER_ERROR })? - .pop() - .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + .get(0) + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)? + .clone(); Ok(Json(block_header)) } @@ -245,7 +242,7 @@ pub async fn get_block_headers( let offset = query.offset.unwrap_or(0); let size = query.size.unwrap_or(10); let block_headers = state - .store + .mmr .get_block_headers(offset, size) .await .map_err(|e| { @@ -285,17 +282,13 @@ pub async fn get_transaction_proof( })?; let block_hash = block_header.block_hash(); - let block_height = state - .store - .get_block_height(&block_hash) - .await - .map_err(|e| { - error!( - "Failed to get block height for block hash {}: {}", - block_hash, e - ); - StatusCode::INTERNAL_SERVER_ERROR - })?; + let block_height = state.mmr.get_block_height(&block_hash).await.map_err(|e| { + error!( + "Failed to get block height for block hash {}: {}", + block_hash, e + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; let transaction = state .bitcoin_client @@ -314,26 +307,3 @@ pub async fn get_transaction_proof( }; Ok(Json(transaction_proof.into())) } - -/// Get the chain state for a specific block height -/// -/// # Returns -/// * `Json` - The chain state in JSON format -/// * `StatusCode::INTERNAL_SERVER_ERROR` - If fetching the chain state fails -pub async fn get_chain_state( - State(state): State, - Path(block_height): Path, -) -> Result, StatusCode> { - let chain_state = state - .store - .get_chain_state(block_height) - .await - .map_err(|e| { - error!( - "Failed to get chain state for height {}: {}", - block_height, e - ); - StatusCode::INTERNAL_SERVER_ERROR - })?; - Ok(Json(chain_state)) -} diff --git a/crates/raito-bridge-node/src/store.rs b/crates/raito-bridge-node/src/store.rs index a11a85b5..1ff21c3b 100644 --- a/crates/raito-bridge-node/src/store.rs +++ b/crates/raito-bridge-node/src/store.rs @@ -7,7 +7,7 @@ use async_trait::async_trait; use bitcoin::block::Header as BlockHeader; use bitcoin::consensus::{Decodable, Encodable}; use bitcoin::BlockHash; -use raito_spv_verify::ChainState; +use raito_spv_mmr::block_mmr::BlockMMRStore; use sqlx::sqlite::{ SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous, SqliteTransactionManager, @@ -15,8 +15,6 @@ use sqlx::sqlite::{ use sqlx::{Row, TransactionManager}; use tokio::fs; -use crate::chain_state::ChainStateStore; - /// SQLite busy timeout in milliseconds const SQLITE_BUSY_TIMEOUT: &str = "5000"; @@ -60,16 +58,20 @@ impl AppStore { } /// Create a store for multiple concurrent readers - pub fn multiple_concurrent_readers>(path: P, id: Option) -> Self { + pub async fn multiple_concurrent_readers>( + path: P, + id: Option, + ) -> Result { let options = SqliteConnectOptions::new() .filename(path.as_ref()) .read_only(true); let pool = SqlitePoolOptions::new() .max_connections(SQLITE_MAX_CONCURRENT_READERS) - .connect_lazy_with(options); + .connect_with(options) + .await?; - Self(SQLiteStore::with_pool(pool, id)) + Ok(Self(SQLiteStore::with_pool(pool, id))) } /// Initialize the store by creating the tables if missing @@ -87,15 +89,6 @@ impl AppStore { ) .execute(conn.deref_mut()) .await?; - // Create a table for chain states - sqlx::query( - r#"CREATE TABLE IF NOT EXISTS chain_states ( - height INTEGER PRIMARY KEY, - state BLOB NOT NULL - );"#, - ) - .execute(conn.deref_mut()) - .await?; // Add index on block hash column sqlx::query( r#"CREATE INDEX IF NOT EXISTS idx_block_headers_hash ON block_headers (hash);"#, @@ -125,7 +118,7 @@ impl AppStore { } #[async_trait] -impl ChainStateStore for AppStore { +impl BlockMMRStore for AppStore { /// Add a new block header to the store async fn add_block_header( &self, @@ -176,31 +169,6 @@ impl ChainStateStore for AppStore { .await?; row.map(|row| row.get("height")).ok_or(StoreError::GetError) } - - async fn get_chain_state(&self, height: u32) -> Result { - let mut conn = self.0.acquire_connection().await?; - let row = sqlx::query("SELECT state FROM chain_states WHERE height = ?") - .bind(height) - .fetch_optional(conn.deref_mut()) - .await?; - let data: Vec = row.ok_or(StoreError::GetError)?.get("state"); - bincode::deserialize::(&data).map_err(|e| StoreError::Custom(Box::new(e))) - } - - async fn add_chain_state( - &self, - height: u32, - chain_state: &ChainState, - ) -> Result<(), StoreError> { - let mut conn = self.0.acquire_connection().await?; - let data = bincode::serialize(chain_state).map_err(|e| StoreError::Custom(Box::new(e)))?; - sqlx::query("INSERT INTO chain_states (height, state) VALUES (?, ?)") - .bind(height) - .bind(data) - .execute(conn.deref_mut()) - .await?; - Ok(()) - } } #[async_trait] diff --git a/crates/raito-cairo-serialize/Cargo.toml b/crates/raito-cairo-serialize/Cargo.toml new file mode 100644 index 00000000..be43e089 --- /dev/null +++ b/crates/raito-cairo-serialize/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "raito-cairo-serialize" +version = "0.1.0" +edition = "2021" +description = "Serialize Rust structures into Cairo/Scarb runner-compatible argument felts" +license = "MIT OR Apache-2.0" + +[dependencies] +anyhow = { workspace = true } +hex = { workspace = true } +num-traits = { workspace = true } +num-bigint = { workspace = true } + +stwo = { workspace = true } +stwo-cairo-serialize = { workspace = true } + +starknet-ff = "0.3.7" + diff --git a/crates/raito-cairo-serialize/src/lib.rs b/crates/raito-cairo-serialize/src/lib.rs new file mode 100644 index 00000000..dbe2551b --- /dev/null +++ b/crates/raito-cairo-serialize/src/lib.rs @@ -0,0 +1,249 @@ +//! Serialize Rust structures into Cairo/Scarb runner-compatible argument felts +//! +//! This library provides a custom encoder that converts Rust data structures +//! into the specific format expected by Cairo programs and Scarb runner arguments. + +#[cfg(test)] +use anyhow::Result; +use num_bigint::BigUint; +use num_traits::Num; +use starknet_ff::FieldElement; +use stwo_cairo_serialize::CairoSerialize; + +// Wrapper types for specialized Cairo serialization of string data +pub struct U256String(pub String); +pub struct ByteArrayString(pub String); +pub struct DigestString(pub String); +pub struct U256StringLittleEndian(pub String); + +impl CairoSerialize for U256String { + fn serialize(&self, output: &mut Vec) { + // Accept decimal string only, produce 32-byte big-endian + let s = self.0.trim(); + assert!( + !s.starts_with("0x") && !s.starts_with("0X"), + "Hex not supported for U256String; use decimal", + ); + let n = BigUint::from_str_radix(s, 10).expect("Invalid decimal string for U256"); + let bytes = n.to_bytes_be(); + assert!(bytes.len() <= 32, "U256 value exceeds 256 bits"); + let mut be = [0u8; 32]; + be[32 - bytes.len()..].copy_from_slice(&bytes); + + // lo = least-significant 16 bytes, hi = most-significant 16 bytes + let (hi16, lo16) = be.split_at(16); + + let mut lo_bytes = [0u8; 32]; + lo_bytes[16..].copy_from_slice(lo16); + let mut hi_bytes = [0u8; 32]; + hi_bytes[16..].copy_from_slice(hi16); + + output.push(FieldElement::from_bytes_be(&lo_bytes).unwrap()); + output.push(FieldElement::from_bytes_be(&hi_bytes).unwrap()); + } +} + +impl CairoSerialize for U256StringLittleEndian { + fn serialize(&self, output: &mut Vec) { + // Accept decimal string only, produce 32-byte big-endian + let s = self.0.trim(); + assert!( + !s.starts_with("0x") && !s.starts_with("0X"), + "Hex not supported for U256StringHiLo; use decimal", + ); + let n = BigUint::from_str_radix(s, 10).expect("Invalid decimal string for U256"); + let bytes = n.to_bytes_be(); + assert!(bytes.len() <= 32, "U256 value exceeds 256 bits"); + let mut be = [0u8; 32]; + be[32 - bytes.len()..].copy_from_slice(&bytes); + + // hi = most-significant 16 bytes, lo = least-significant 16 bytes + let (lo16, hi16) = be.split_at(16); + + let mut hi_bytes = [0u8; 32]; + hi_bytes[16..].copy_from_slice(hi16); + let mut lo_bytes = [0u8; 32]; + lo_bytes[16..].copy_from_slice(lo16); + + // Note: emit HI first, then LO to match Cairo MMR Serde (high, low) + output.push(FieldElement::from_bytes_be(&lo_bytes).unwrap()); + output.push(FieldElement::from_bytes_be(&hi_bytes).unwrap()); + } +} + +impl CairoSerialize for ByteArrayString { + // Split into 31-byte chunks and save the remainder + fn serialize(&self, output: &mut Vec) { + let s = self.0.as_str(); + let hex_str = if s.starts_with("0x") || s.starts_with("0X") { + s.to_string() + } else { + format!("0x{}", hex::encode(s.as_bytes())) + }; + + // Remove 0x prefix + let hex_data = hex_str.strip_prefix("0x").unwrap_or(&hex_str); + let bytes = hex::decode(hex_data).expect("Invalid hex string"); + + // Calculate chunks and remainder + let chunk_size = 31; // 31 bytes per chunk (248 bits, fits in felt252) + let num_chunks = bytes.len() / chunk_size; + let remainder_len = bytes.len() % chunk_size; + + // Serialize: num_chunks, chunks..., remainder, rem_len + output.push(FieldElement::from(num_chunks as u128)); + + // Serialize chunks + for chunk in bytes.chunks(chunk_size) { + if chunk.len() == chunk_size { + let mut chunk_bytes = [0u8; 32]; + chunk_bytes[1..=chunk_size].copy_from_slice(chunk); + output.push(FieldElement::from_bytes_be(&chunk_bytes).unwrap()); + } + } + + // Serialize remainder + if remainder_len > 0 { + let remainder = &bytes[bytes.len() - remainder_len..]; + let mut rem_bytes = [0u8; 32]; + let start = 32 - remainder_len; + rem_bytes[start..].copy_from_slice(remainder); + output.push(FieldElement::from_bytes_be(&rem_bytes).unwrap()); + } else { + output.push(FieldElement::from(0u8)); + } + + output.push(FieldElement::from(remainder_len as u128)); + } +} + +impl CairoSerialize for DigestString { + // Reversed hex string into 4-byte words then into BE u32 + fn serialize(&self, output: &mut Vec) { + let s = self.0.as_str(); + let hex_str = s + .strip_prefix("0x") + .or_else(|| s.strip_prefix("0X")) + .unwrap_or(s); + + // Convert 64-char hex to 8 u32 words (reversed for little-endian) + let bytes = hex::decode(hex_str).expect("Invalid hex string"); + assert!(bytes.len() == 32, "expected 32-byte digest"); + let mut rev = bytes; + rev.reverse(); + for chunk in rev.chunks(4) { + let mut word_bytes = [0u8; 4]; + word_bytes[..chunk.len()].copy_from_slice(chunk); + let word = u32::from_be_bytes(word_bytes) as u128; + output.push(FieldElement::from(word)); + } + } +} + +// Backwards-compatibility: preserve `serializer::...` path +pub mod serializer { + pub use super::{ByteArrayString, DigestString, U256String, U256StringLittleEndian}; +} + +#[cfg(test)] +mod tests { + use super::*; + use starknet_ff::FieldElement; + use stwo_cairo_serialize::CairoSerialize; + + fn to_hex(value: &T) -> Result> { + let mut felts = Vec::new(); + value.serialize(&mut felts); + Ok(felts.into_iter().map(|felt| fe_to_min_hex(&felt)).collect()) + } + + fn fe_to_min_hex(fe: &FieldElement) -> String { + let bytes = fe.to_bytes_be(); + let mut i = 0; + while i < bytes.len() && bytes[i] == 0 { + i += 1; + } + if i == bytes.len() { + return "0x0".to_string(); + } + let mut s = String::from("0x"); + s.push_str(&format!("{:x}", bytes[i])); + for b in &bytes[i + 1..] { + s.push_str(&format!("{:02x}", b)); + } + s + } + + // Homogeneous wrapper for heterogeneous types implementing CairoSerialize + enum Kind { + U256, + Digest, + ByteArray, + } + + #[test] + fn test_all_cases() -> Result<()> { + let all_cases: &[(Kind, &str, &[&str])] = &[ + ( + Kind::U256, + "340282366920938463463374607431768211455", + &["0xffffffffffffffffffffffffffffffff", "0x0"] as &[&str], + ), + ( + Kind::U256, + "23232323340282366920938463463374607431768211455", + &["0x1b81a14a66f78cd9da6f237fffffffff", "0x411c5fe"] as &[&str], + ), + ( + Kind::Digest, + "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", + &[ + "0x6fe28c0a", + "0xb6f1b372", + "0xc1a6a246", + "0xae63f74f", + "0x931e8365", + "0xe15a089c", + "0x68d61900", + "0x0", + ] as &[&str], + ), + ( + Kind::Digest, + "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098", + &[ + "0x982051fd", + "0x1e4ba744", + "0xbbbe680e", + "0x1fee1467", + "0x7ba1a3c3", + "0x540bf7b1", + "0xcdb606e8", + "0x57233e0e", + ] as &[&str], + ), + ( + Kind::ByteArray, + "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20212223", + &[ + "0x1", + "0x102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "0x20212223", + "0x4", + ] as &[&str], + ), + ]; + + for (kind, input, expected) in all_cases { + let actual_strings = match kind { + Kind::U256 => to_hex(&U256String((*input).to_string()))?, + Kind::Digest => to_hex(&DigestString((*input).to_string()))?, + Kind::ByteArray => to_hex(&ByteArrayString((*input).to_string()))?, + }; + let actual: Vec<&str> = actual_strings.iter().map(|s| s.as_str()).collect(); + assert_eq!(actual.as_slice(), *expected); + } + + Ok(()) + } +} diff --git a/crates/raito-spv-client/Cargo.toml b/crates/raito-spv-client/Cargo.toml index 2c27de6d..9b80782b 100644 --- a/crates/raito-spv-client/Cargo.toml +++ b/crates/raito-spv-client/Cargo.toml @@ -31,11 +31,9 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } # Stwo-cairo -stwo-prover = { git = "https://github.com/starkware-libs/stwo", rev = "0e90b31", features = [ - "parallel", -], default-features = false } -cairo-air = { git = "https://github.com/maciejka/stwo-cairo", rev = "e5981958234c4b28fa2b4c3368a0290ec3fc57c2", features = ["std"] } -starknet-ff = "0.3.7" +stwo = { workspace = true, features = ["parallel", "prover"] } +cairo-air = { workspace = true } +starknet-ff = { workspace = true } # Serialization hex = "0.4.3" diff --git a/crates/raito-spv-mmr/src/block_mmr.rs b/crates/raito-spv-mmr/src/block_mmr.rs index 4fdd122d..b3710fc9 100644 --- a/crates/raito-spv-mmr/src/block_mmr.rs +++ b/crates/raito-spv-mmr/src/block_mmr.rs @@ -9,9 +9,11 @@ use accumulators::mmr::{ PeaksOptions, Proof, ProofOptions, MMR, }; use accumulators::store::memory::InMemoryStore; -use accumulators::store::Store; +use accumulators::store::{Store, StoreError}; +use async_trait::async_trait; use bitcoin::block::Header as BlockHeader; use bitcoin::hashes::Hash; +use bitcoin::BlockHash; use serde::{Deserialize, Serialize}; use crate::sparse_roots::SparseRoots; @@ -20,9 +22,39 @@ use crate::sparse_roots::SparseRoots; #[derive(Debug)] pub struct BlockMMR { hasher: Arc, + store: Arc, mmr: MMR, } +#[async_trait] +pub trait BlockMMRStore: Store { + async fn add_block_header( + &self, + height: u32, + block_header: &BlockHeader, + ) -> Result<(), StoreError>; + async fn get_block_headers( + &self, + start_height: u32, + num_blocks: u32, + ) -> Result, StoreError>; + async fn get_block_height(&self, block_hash: &BlockHash) -> Result; +} + +#[async_trait] +impl BlockMMRStore for InMemoryStore { + async fn add_block_header(&self, _: u32, _: &BlockHeader) -> Result<(), StoreError> { + tracing::warn!("Adding block header to in-memory store is not supported"); + Ok(()) + } + async fn get_block_headers(&self, _: u32, _: u32) -> Result, StoreError> { + unimplemented!("Getting block headers from in-memory store is not supported"); + } + async fn get_block_height(&self, _: &BlockHash) -> Result { + unimplemented!("Getting block height from in-memory store is not supported"); + } +} + /// Proof data structure for demonstrating inclusion of a block in the MMR #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BlockInclusionProof { @@ -47,9 +79,13 @@ impl Default for BlockMMR { impl BlockMMR { /// Create a new default MMR - pub fn new(store: Arc, hasher: Arc, mmr_id: Option) -> Self { + pub fn new( + store: Arc, + hasher: Arc, + mmr_id: Option, + ) -> Self { let mmr = MMR::new(store.clone(), hasher.clone(), mmr_id); - Self { hasher, mmr } + Self { hasher, mmr, store } } /// Create in-memory MMR from peaks hashes and elements count @@ -67,7 +103,7 @@ impl BlockMMR { leaf_count_to_mmr_size(leaf_count), ) .await?; - Ok(Self { hasher, mmr }) + Ok(Self { hasher, mmr, store }) } /// Add a leaf to the MMR @@ -77,12 +113,49 @@ impl BlockMMR { } /// Add a block header to the MMR - pub async fn add_block_header(&mut self, block_header: &BlockHeader) -> anyhow::Result<()> { + pub async fn add_block_header( + &mut self, + height: u32, + block_header: &BlockHeader, + ) -> anyhow::Result<()> { let leaf = block_header_digest(self.hasher.clone(), block_header)?; self.add(leaf).await?; + self.store + .add_block_header(height, block_header) + .await + .map_err(|e| anyhow::anyhow!("Failed to add block header: {}", e))?; Ok(()) } + /// Get a range of block headers from the MMR + pub async fn get_block_headers( + &self, + start_height: u32, + num_blocks: u32, + ) -> anyhow::Result> { + let res = self + .store + .get_block_headers(start_height, num_blocks) + .await + .map_err(|e| anyhow::anyhow!("Failed to get block headers: {}", e))?; + if res.len() != num_blocks as usize { + return Err(anyhow::anyhow!( + "Failed to get block headers: expected {}, got {}", + num_blocks, + res.len() + )); + } + Ok(res) + } + + /// Get the height of a block by its hash + pub async fn get_block_height(&self, block_hash: &BlockHash) -> anyhow::Result { + self.store + .get_block_height(block_hash) + .await + .map_err(|e| anyhow::anyhow!("Failed to get block height: {}", e)) + } + /// Get the number of blocks in the MMR (number of leaves) pub async fn get_block_count(&self) -> anyhow::Result { self.mmr @@ -390,8 +463,8 @@ mod tests { ) .unwrap(); // Add 10 blocks - for _ in 0..10 { - mmr.add_block_header(&block_header).await.unwrap(); + for i in 0..10 { + mmr.add_block_header(i as u32, &block_header).await.unwrap(); } // Generate a proof for the fifth block let proof = mmr.generate_proof(5, None).await.unwrap(); diff --git a/crates/raito-spv-mmr/src/sparse_roots.rs b/crates/raito-spv-mmr/src/sparse_roots.rs index 6b6477a9..0a53cd69 100644 --- a/crates/raito-spv-mmr/src/sparse_roots.rs +++ b/crates/raito-spv-mmr/src/sparse_roots.rs @@ -1,22 +1,25 @@ //! Sparse roots representation for MMR peaks compatible with Cairo implementation. use accumulators::mmr::elements_count_to_leaf_count; -use num_bigint::BigInt; +use num_bigint::{BigInt, BigUint}; use num_traits::Num; -use serde::{Serialize, Serializer}; +use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize, Serializer}; use serde_json; use std::str::FromStr; /// Sparse roots is MMR peaks for all heights, where missing ones are filled with zeros /// This representation is different from the "compact" one, which contains only non-zero peaks /// but with total number of elements. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct SparseRoots { /// Block height #[serde(skip)] pub block_height: u32, /// MMR peaks for all heights, where missing ones are filled with zeros - #[serde(serialize_with = "serialize_u256_array")] + #[serde( + serialize_with = "serialize_u256_array", + deserialize_with = "deserialize_u256_array" + )] pub roots: Vec, } @@ -88,6 +91,61 @@ where seq.end() } +/// Custom deserialization for Vec where items are Cairo u256 objects {hi, lo} +pub fn deserialize_u256_array<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + use serde::Deserialize as _; + + let values = Vec::::deserialize(deserializer)?; + let mut result: Vec = Vec::with_capacity(values.len()); + + for value in values { + match value { + serde_json::Value::Object(mut map) => { + let hi_val = map + .remove("hi") + .ok_or_else(|| D::Error::custom("missing 'hi' in u256 object"))?; + let lo_val = map + .remove("lo") + .ok_or_else(|| D::Error::custom("missing 'lo' in u256 object"))?; + + let hi_hex = json_value_to_padded_hex::(hi_val)?; + let lo_hex = json_value_to_padded_hex::(lo_val)?; + result.push(format!("0x{}{}", hi_hex, lo_hex)); + } + other => { + return Err(D::Error::custom(format!( + "unsupported u256 representation: {}", + other + ))) + } + } + } + + Ok(result) +} + +fn json_value_to_padded_hex(value: serde_json::Value) -> Result +where + E: DeError, +{ + match value { + serde_json::Value::Number(n) => { + // Convert decimal string to 32-byte (64 hex chars) lowercased hex + let dec_str = n.to_string(); + let big = BigUint::from_str(&dec_str) + .map_err(|e| E::custom(format!("invalid decimal number: {}", e)))?; + Ok(format!("{:032x}", big)) + } + other => Err(E::custom(format!( + "unexpected type for u256 limb: {}", + other + ))), + } +} + /// Convert a hex string to a JSON number /// What we are doing here is making sure we get `{"key": 123123}` instead of `{"key": "123123"}` fn num_str_to_json_number(num_str: &str) -> Result diff --git a/crates/raito-spv-verify-wasm/Cargo.toml b/crates/raito-spv-verify-wasm/Cargo.toml index 579837d1..336c7863 100644 --- a/crates/raito-spv-verify-wasm/Cargo.toml +++ b/crates/raito-spv-verify-wasm/Cargo.toml @@ -18,11 +18,12 @@ raito-spv-mmr = { path = "../raito-spv-mmr" } bitcoin = { workspace = true } # Stwo-cairo -cairo-air = { git = "https://github.com/maciejka/stwo-cairo", rev = "e5981958234c4b28fa2b4c3368a0290ec3fc57c2", default-features = false, features = ["std"] } +cairo-air = { workspace = true } +stwo = { workspace = true } # WASM bindings -wasm-bindgen = "0.2" -wasm-bindgen-futures = "0.4" +wasm-bindgen = "=0.2.100" +wasm-bindgen-futures = "=0.4.50" # Serialization for WASM serde = { workspace = true } @@ -38,4 +39,3 @@ console_error_panic_hook = "0.1" getrandom = { version = "0.3", features = ["wasm_js"] } -stwo-prover = { git = "https://github.com/starkware-libs/stwo", rev = "0e90b31", features = [], default-features = false } diff --git a/crates/raito-spv-verify-wasm/src/lib.rs b/crates/raito-spv-verify-wasm/src/lib.rs index eccbff78..e5543f36 100644 --- a/crates/raito-spv-verify-wasm/src/lib.rs +++ b/crates/raito-spv-verify-wasm/src/lib.rs @@ -1,6 +1,11 @@ //! WASM bindings for raito SPV verification //! This crate provides WebAssembly bindings for SPV proof verification +#![no_std] +extern crate alloc; + +use alloc::format; +use alloc::string::String; use bitcoin::block::Header; use raito_spv_mmr::block_mmr::BlockInclusionProof; use raito_spv_verify::ChainState; @@ -60,7 +65,7 @@ pub fn verify_transaction( transaction, transaction_proof, block_header, - block_height, + block_height: _, } = serde_json::from_str(transaction_proof_data) .map_err(|e| JsValue::from_str(&format!("Failed to parse transaction proof: {}", e)))?; @@ -134,9 +139,9 @@ pub fn create_custom_config( task_output_size: u32, ) -> JsValue { let config = VerifierConfig { - min_work: min_work.to_string(), - bootloader_hash: bootloader_hash.to_string(), - task_program_hash: task_program_hash.to_string(), + min_work: String::from(min_work), + bootloader_hash: String::from(bootloader_hash), + task_program_hash: String::from(task_program_hash), task_output_size, }; serde_wasm_bindgen::to_value(&config).unwrap_or(JsValue::NULL) @@ -173,5 +178,5 @@ pub fn init() { /// Get the version of the WASM module #[wasm_bindgen] pub fn get_version() -> String { - env!("CARGO_PKG_VERSION").to_string() + String::from(env!("CARGO_PKG_VERSION")) } diff --git a/crates/raito-spv-verify/Cargo.toml b/crates/raito-spv-verify/Cargo.toml index aafc2c75..791e7a7b 100644 --- a/crates/raito-spv-verify/Cargo.toml +++ b/crates/raito-spv-verify/Cargo.toml @@ -19,15 +19,14 @@ tracing = "0.1" hex = "0.4.3" serde = { workspace = true } num-bigint = "0.4.3" -starknet-ff = "0.3.7" +starknet-ff = { workspace = true } # Stwo-cairo -cairo-air = { git = "https://github.com/maciejka/stwo-cairo", rev = "e5981958234c4b28fa2b4c3368a0290ec3fc57c2", default-features = false, features = ["std"] } -[target.'cfg(not(target_family = "wasm"))'.dependencies] -stwo-prover = { git = "https://github.com/starkware-libs/stwo", rev = "0e90b31", features = [ - "parallel", -], default-features = false } +# [target.'cfg(not(target_family = "wasm"))'.dependencies] +# stwo = { workspace = true, features = ["parallel", "prover"] } +# cairo-air = { workspace = true, features = ["std"]} -[target.'cfg(target_family = "wasm")'.dependencies] -stwo-prover = { git = "https://github.com/starkware-libs/stwo", rev = "0e90b31", features = [], default-features = false } +# [target.'cfg(target_family = "wasm")'.dependencies] +stwo = { workspace = true } +cairo-air = { workspace = true, default-features = false } diff --git a/crates/raito-spv-verify/src/proof.rs b/crates/raito-spv-verify/src/proof.rs index c5f3dcfe..99813def 100644 --- a/crates/raito-spv-verify/src/proof.rs +++ b/crates/raito-spv-verify/src/proof.rs @@ -8,8 +8,8 @@ use cairo_air::CairoProof; use raito_spv_mmr::block_mmr::BlockInclusionProof; use serde::{Deserialize, Serialize}; use starknet_ff::FieldElement; -use stwo_prover::core::vcs::blake2_hash::Blake2sHasher; -use stwo_prover::core::vcs::blake2_merkle::Blake2sMerkleHasher; +use stwo::core::vcs::blake2_hash::Blake2sHasher; +use stwo::core::vcs::blake2_merkle::Blake2sMerkleHasher; /// Bitcoin transaction inclusion data in a specific block #[derive(Serialize, Deserialize)] diff --git a/crates/raito-spv-verify/src/verify.rs b/crates/raito-spv-verify/src/verify.rs index 54cc0c6d..d13e96d0 100644 --- a/crates/raito-spv-verify/src/verify.rs +++ b/crates/raito-spv-verify/src/verify.rs @@ -6,7 +6,7 @@ use cairo_air::utils::{get_verification_output, VerificationOutput}; use cairo_air::{CairoProof, PreProcessedTraceVariant}; use raito_spv_mmr::block_mmr::{BlockInclusionProof, BlockMMR}; use serde::{Deserialize, Serialize}; -use stwo_prover::core::vcs::blake2_merkle::Blake2sMerkleHasher; +use stwo::core::vcs::blake2_merkle::{Blake2sMerkleChannel, Blake2sMerkleHasher}; use tracing::info; use crate::proof::{BootloaderOutput, ChainState, TaskResult}; @@ -145,7 +145,7 @@ pub async fn verify_block_header( /// Returns the block MMR root extracted from the proof on success. pub fn verify_chain_state( chain_state: &ChainState, - chain_state_proof: CairoProof, + chain_state_proof: CairoProof, config: &VerifierConfig, ) -> anyhow::Result { info!("Extracting verification output..."); @@ -231,9 +231,9 @@ pub fn verify_chain_state( } info!("Verifying Cairo proof..."); - cairo_air::verifier::verify_cairo::( + cairo_air::verifier::verify_cairo::( chain_state_proof, - PreProcessedTraceVariant::CanonicalWithoutPedersenAndPoseidon, + PreProcessedTraceVariant::Canonical, )?; Ok(block_mmr_hash) diff --git a/packages/assumevalid/Scarb.toml b/packages/assumevalid/Scarb.toml index 7b7d5499..19667ff3 100644 --- a/packages/assumevalid/Scarb.toml +++ b/packages/assumevalid/Scarb.toml @@ -10,7 +10,7 @@ allow-syscalls = true cairo_execute = "2.11.4" consensus = { path = "../consensus" } utils = { path = "../utils" } -stwo_cairo_air = { git = "https://github.com/starkware-libs/stwo-cairo", rev = "bbe3e469bc636b89c37cb385854447bd46277b3b", features = ["minimal_verifier", "qm31_opcode"] } +stwo_cairo_air = { git = "https://github.com/maciejka/stwo-cairo", rev = "d2793e9f19f575c13f6bc37c36f9b229797456e1", features = ["qm31_opcode"] } [features] default = ["syscalls"] diff --git a/packages/assumevalid/prover_params.json b/packages/assumevalid/prover_params.json index be0ec05d..ffa5bb42 100644 --- a/packages/assumevalid/prover_params.json +++ b/packages/assumevalid/prover_params.json @@ -8,5 +8,5 @@ "n_queries": 70 } }, - "preprocessed_trace": "canonical_without_pedersen_and_poseidon" + "preprocessed_trace": "canonical" } \ No newline at end of file diff --git a/packages/assumevalid/src/lib.cairo b/packages/assumevalid/src/lib.cairo index 64a95a82..4aa54923 100644 --- a/packages/assumevalid/src/lib.cairo +++ b/packages/assumevalid/src/lib.cairo @@ -6,7 +6,6 @@ use stwo_cairo_air::{CairoProof, VerificationOutput, get_verification_output, ve use utils::blake2s_hasher::{Blake2sDigestFromU256, Blake2sDigestIntoU256}; use utils::mmr::{MMR, MMRTrait}; - #[derive(Drop, Serde)] struct Args { /// Current (initial) chain state. @@ -109,10 +108,7 @@ fn get_prev_result(proof: CairoProof) -> Result { let VerificationOutput { program_hash, output } = get_verification_output(proof: @proof); // Verify the proof - match verify_cairo(proof) { - Ok(_) => {}, - Err(e) => panic!("Invalid proof: {:?}", e), - } + verify_cairo(proof); // Deserialize the bootloader output let mut serialized_bootloader_output = output.span(); diff --git a/raito-spv-verify-sdk/package.json b/raito-spv-verify-sdk/package.json index 68f034dc..9b12069c 100644 --- a/raito-spv-verify-sdk/package.json +++ b/raito-spv-verify-sdk/package.json @@ -30,10 +30,11 @@ "./wasm/web": "./dist/bundler/index.js" }, "scripts": { - "build": "npm run build:wasm && npm run build:ts && npm run types:copy", + "build": "npm run build:wasm && npm run postbuild:wasm && npm run build:ts && npm run types:copy", "build:wasm": "npm run build:wasm:node && npm run build:wasm:bundler", "build:wasm:node": "RUSTFLAGS='--cfg getrandom_backend=\"wasm_js\"' wasm-pack build ../crates/raito-spv-verify-wasm --release --target nodejs --out-dir dist/node --out-name index && mkdir -p dist/node && cp -r ../crates/raito-spv-verify-wasm/dist/node/* dist/node/", "build:wasm:bundler": "RUSTFLAGS='--cfg getrandom_backend=\"wasm_js\"' wasm-pack build ../crates/raito-spv-verify-wasm --release --target bundler --out-dir dist/bundler --out-name index && mkdir -p dist/bundler && cp -r ../crates/raito-spv-verify-wasm/dist/bundler/* dist/bundler/", + "postbuild:wasm": "node scripts/strip-bad-wasm-dts.js", "build:ts": "tsc -p tsconfig.json", "types:copy": "mkdir -p dist/types && cp dist/bundler/index.d.ts dist/types/wasm.d.ts", "format": "prettier --write .", diff --git a/raito-spv-verify-sdk/scripts/strip-bad-wasm-dts.js b/raito-spv-verify-sdk/scripts/strip-bad-wasm-dts.js new file mode 100644 index 00000000..cbe142ec --- /dev/null +++ b/raito-spv-verify-sdk/scripts/strip-bad-wasm-dts.js @@ -0,0 +1,14 @@ +import fs from 'fs'; + +const paths = [ + 'dist/bundler/index_bg.wasm.d.ts', + 'dist/node/index_bg.wasm.d.ts', +]; +for (const p of paths) { + let s = fs.readFileSync(p, 'utf8'); + s = s + .split('\n') + .filter(l => !l.includes('LIBBZ2_RS_SYS_v0.1.x_')) + .join('\n'); + fs.writeFileSync(p, s); +} diff --git a/scripts/data/prove_pow.py b/scripts/data/prove_pow.py index 9cc474fc..b95c956a 100755 --- a/scripts/data/prove_pow.py +++ b/scripts/data/prove_pow.py @@ -268,7 +268,7 @@ def run_prover(job_info, executable, proof, arguments): save_prover_log(batch_dir, "PROVE", stdout, stderr, returncode, elapsed, max_memory) - if returncode == 0: + if returncode == 0 and False: temp_files = [ program_input_file, # pub_json, diff --git a/scripts/prove/Dockerfile b/scripts/prove/Dockerfile new file mode 100644 index 00000000..2493c877 --- /dev/null +++ b/scripts/prove/Dockerfile @@ -0,0 +1,45 @@ +FROM rust:1.78-bookworm AS builder +WORKDIR /app + +# Copy the entire workspace to ensure Cargo can resolve the workspace members +COPY . . + +# Verify that the executable JSON file was copied +RUN ls -la target/proving/assumevalid-syscalls.executable.json + +# Install specific nightly toolchain for bootloader-hints +RUN rustup toolchain install nightly-2025-07-14 + +# Install bootloader hints before building with SSH authentication +# This requires building with: docker build --ssh default ... +# Use specific nightly channel for bootloader-hints compilation +RUN --mount=type=ssh \ + mkdir -p -m 0700 ~/.ssh && \ + ssh-keyscan github.com >> ~/.ssh/known_hosts && \ + RUSTUP_TOOLCHAIN=nightly-2025-07-14 make install-bootloader-hints + +# Build only the raito-assumevalid binary in release mode +RUN cargo build --release -p raito-assumevalid + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates time \ + && rm -rf /var/lib/apt/lists/* + +# Copy the compiled binary +COPY --from=builder /app/target/release/raito-assumevalid /bin/raito-assumevalid + +# Copy required prover binaries from builder stage (installed by make install-bootloader-hints) +COPY --from=builder /usr/local/cargo/bin/stwo_run_and_prove /bin/stwo_run_and_prove +COPY --from=builder /usr/local/cargo/bin/cairo_program_runner /bin/cairo_program_runner + +# Copy required runtime assets (Cairo executable, bootloader, prover params) +# into stable locations inside the image so the binary can reference them. +COPY --from=builder /app/target/proving/assumevalid-syscalls.executable.json /opt/raito/assumevalid-syscalls.executable.json +COPY --from=builder /app/bootloaders/simple_bootloader_compiled.json /opt/raito/bootloader.json +COPY --from=builder /app/packages/assumevalid/prover_params.json /opt/raito/prover_params.json + +RUN ls -la /opt/raito + +# Default entrypoint; pass args via container args/command +ENTRYPOINT ["/bin/raito-assumevalid"] + diff --git a/scripts/prove/build_image.sh b/scripts/prove/build_image.sh new file mode 100755 index 00000000..841b1dc2 --- /dev/null +++ b/scripts/prove/build_image.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Fix locale warnings from gcloud/perl +export LC_ALL=C.UTF-8 + +# Build and push the raito-assumevalid container image to Artifact Registry. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/config.sh" + +if [[ -z "$PROJECT_ID" ]]; then + echo "PROJECT_ID is required. Set it in scripts/prove/config.sh or export PROJECT_ID." >&2 + exit 1 +fi + +IMAGE_URI="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/${IMAGE}:${TAG}" + +# Discover repo root for proper Docker build context +if REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null); then + : +else + REPO_ROOT=$(realpath "$SCRIPT_DIR/../..") +fi + +echo "Using build context: $REPO_ROOT" +echo "Target image: $IMAGE_URI" + +echo "Enabling Artifact Registry API (idempotent)" +gcloud services enable artifactregistry.googleapis.com --project "$PROJECT_ID" || true + +echo "Creating Artifact Registry repo '$REPO' in $REGION (idempotent)" +gcloud artifacts repositories create "$REPO" \ + --repository-format=docker \ + --location="$REGION" \ + --project "$PROJECT_ID" || true + +echo "Configuring Docker auth for Artifact Registry" +gcloud auth configure-docker "$REGION-docker.pkg.dev" --quiet + +echo "Building Docker image with multi-stage Dockerfile (using BuildKit with SSH)" +export DOCKER_BUILDKIT=1 +docker build --ssh default -f "$REPO_ROOT/scripts/prove/Dockerfile" -t "$IMAGE_URI" "$REPO_ROOT" + +echo "Pushing image to Artifact Registry" +docker push "$IMAGE_URI" + +echo "Done. Pushed: $IMAGE_URI" + diff --git a/scripts/prove/cleanup.sh b/scripts/prove/cleanup.sh new file mode 100755 index 00000000..66946a13 --- /dev/null +++ b/scripts/prove/cleanup.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Fix locale warnings from gcloud/perl +export LC_ALL=C.UTF-8 + +# Delete the Spot instance to stop billing. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/config.sh" + +INSTANCE_NAME="" + +LAST_FILE="$SCRIPT_DIR/.last_instance" +if [[ -z "$INSTANCE_NAME" && -f "$LAST_FILE" ]]; then + INSTANCE_NAME="$(cat "$LAST_FILE")" +fi + +if [[ -z "$INSTANCE_NAME" ]]; then + echo "No instance specified and $LAST_FILE not found. Set INSTANCE_NAME in config.sh or create $LAST_FILE." >&2 + exit 1 +fi + +gcloud compute instances delete "$INSTANCE_NAME" --zone="$ZONE" --quiet +echo "Deleted instance: $INSTANCE_NAME" + diff --git a/scripts/prove/config.sh b/scripts/prove/config.sh new file mode 100755 index 00000000..8b0f5cc3 --- /dev/null +++ b/scripts/prove/config.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +# Centralized configuration for proving on GCP Spot instances. +# You can override any of these by exporting env vars before calling the scripts, +# or by passing CLI flags where supported. + +# Required +PROJECT_ID=${PROJECT_ID:-"exploration-dev-417917"} + +# Defaults +REGION=${REGION:-us-central1} +ZONE=${ZONE:-us-central1-a} +REPO=${REPO:-raito} +IMAGE=${IMAGE:-raito-assumevalid} +TAG=${TAG:-latest} +MACHINE_TYPE=${MACHINE_TYPE:-n2-highmem-32} # 256GB RAM +BOOT_DISK_TYPE=${BOOT_DISK_TYPE:-pd-balanced} +BOOT_DISK_SIZE_GB=${BOOT_DISK_SIZE_GB:-10} + +# Base image (use COS for fastest boot; includes Docker/containerd by default) +BASE_IMAGE_FAMILY=${BASE_IMAGE_FAMILY:-cos-109-lts} +BASE_IMAGE_PROJECT=${BASE_IMAGE_PROJECT:-cos-cloud} + +# Container execution +# Leave empty to rely on the image ENTRYPOINT; override if needed +CONTAINER_COMMAND=${CONTAINER_COMMAND:-} +# Bash array for args; edit below to set defaults for your run +if [[ -z ${CONTAINER_ARGS_SET:-} ]]; then + # Example defaults mirroring Makefile target rust-prove-pow + # Edit as needed; the first non-flag should be 'prove' + CONTAINER_ARGS=( + --log-level debug + --bridge-url https://staging.raito.wtf + prove + --executable /opt/raito/assumevalid-syscalls.executable.json + --bootloader /opt/raito/bootloader.json + --prover-params /opt/raito/prover_params.json + ) + CONTAINER_ARGS_SET=1 +fi + +# Orchestration behavior +BUILD_FIRST=${BUILD_FIRST:-1} +STREAM_LOGS=${STREAM_LOGS:-1} +AUTO_DELETE=${AUTO_DELETE:-1} + +# Instance naming +INSTANCE_PREFIX=${INSTANCE_PREFIX:-av-job} +# Leave empty to auto-generate; otherwise use this fixed name +INSTANCE_NAME=${INSTANCE_NAME:-} + +# Instance labels (comma-separated k=v pairs). Used for stable log filtering. +INSTANCE_LABELS=${INSTANCE_LABELS:-job=assumevalid,component=prove} + diff --git a/scripts/prove/logs.sh b/scripts/prove/logs.sh new file mode 100755 index 00000000..95458244 --- /dev/null +++ b/scripts/prove/logs.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Fix locale warnings from gcloud/perl +export LC_ALL=C.UTF-8 + +# Stream serial console logs for the Spot instance (Container-Optimized OS forwards container logs). + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/config.sh" + +INSTANCE_NAME="" + +LAST_FILE="$SCRIPT_DIR/.last_instance" +if [[ -z "$INSTANCE_NAME" && -f "$LAST_FILE" ]]; then + INSTANCE_NAME="$(cat "$LAST_FILE")" +fi + +if [[ -z "$INSTANCE_NAME" ]]; then + echo "No instance specified and $LAST_FILE not found. Set INSTANCE_NAME in config.sh or create $LAST_FILE." >&2 + exit 1 +fi + +START=0 +PORT=1 +while true; do + OUTPUT=$(gcloud compute instances get-serial-port-output "$INSTANCE_NAME" \ + --zone="$ZONE" \ + --port="$PORT" \ + --start="$START" 2>&1 || true) + + # Print only startup and raito lines, normalized and cleaned + echo "$OUTPUT" \ + | sed -e 's/\r//g' \ + | perl -pe 's/\e\[[\d;]*[A-Za-z]//g; s/\f//g' \ + | sed -E 's/^\[[[:space:]]*[0-9]+\.[0-9]+\][[:space:]]*cloud-init\[[0-9]+\]:[[:space:]]*//g' \ + | grep -E '^(\[startup\]|\[raito\])' \ + | awk '!seen[$0]++' || true + + # Break early if we detect the startup script completion marker + if echo "$OUTPUT" | grep -q "\[startup\] Container exited with code"; then + break + fi + + # Extract next start hint from gcloud message, e.g., "Specify --start=442 ..." + NEXT=$(printf '%s\n' "$OUTPUT" | grep -o 'Specify --start=[0-9]\+' | awk -F= '{print $2}' | tail -n1) + if [[ -n "$NEXT" ]]; then + START="$NEXT" + fi + + # Break when instance is no longer RUNNING + STATUS=$(gcloud compute instances describe "$INSTANCE_NAME" --zone="$ZONE" --format='value(status)' 2>/dev/null || true) + if [[ "$STATUS" != "RUNNING" ]]; then + break + fi + + sleep 2 +done + diff --git a/scripts/prove/prove.sh b/scripts/prove/prove.sh new file mode 100755 index 00000000..9e1a3946 --- /dev/null +++ b/scripts/prove/prove.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Fix locale warnings from gcloud/perl +export LC_ALL=C.UTF-8 + +# Orchestrate: launch one-shot Spot VM for proving. +# Usage: prove_once.sh [--step-size N] [--total-blocks N] [--other-container-args] + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/config.sh" + +IMAGE_URI="" +INSTANCE_NAME="${INSTANCE_NAME:-}" + +# Append command line arguments to the default CONTAINER_ARGS from config +while [[ $# -gt 0 ]]; do + CONTAINER_ARGS+=("$1") + shift +done + +# Resolve image URI from config +if [[ -z "$PROJECT_ID" ]]; then + echo "PROJECT_ID is required. Set it in scripts/prove/config.sh or export PROJECT_ID." >&2 + exit 1 +fi + +if [[ -z "$INSTANCE_NAME" ]]; then + INSTANCE_NAME="${INSTANCE_PREFIX}-$(date +%s)" +fi + +if [[ -z "$IMAGE_URI" ]]; then + IMAGE_URI="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/${IMAGE}:${TAG}" +fi + +if [[ ${#CONTAINER_ARGS[@]} -eq 0 ]]; then + CONTAINER_ARGS=(prove) +fi + +echo "Launching Spot VM '$INSTANCE_NAME' in $ZONE using $MACHINE_TYPE" +echo "Container image: $IMAGE_URI" +echo "Container args: ${CONTAINER_ARGS[*]}" + +# Build a startup script that installs Docker, authenticates to Artifact Registry using the +# instance service account, pulls the image, runs it once, and then powers off the VM. + +# Render CONTAINER_ARGS as a Bash array literal to preserve argument boundaries +CONTAINER_ARGS_BASH_LITERAL="(" +for _arg in "${CONTAINER_ARGS[@]}"; do + printf -v _q '%q' "$_arg" + CONTAINER_ARGS_BASH_LITERAL+=" $_q" +done +CONTAINER_ARGS_BASH_LITERAL+=" )" + +STARTUP_SCRIPT_FILE=$(mktemp) +{ + echo '#!/usr/bin/env bash' + echo 'set -euo pipefail' + printf 'REGION=%q\n' "$REGION" + printf 'IMAGE_URI=%q\n' "$IMAGE_URI" + printf 'CONTAINER_COMMAND=%q\n' "$CONTAINER_COMMAND" + printf 'declare -a CONTAINER_ARGS=%s\n' "$CONTAINER_ARGS_BASH_LITERAL" + echo '' + cat "$SCRIPT_DIR/startup.sh" +} > "$STARTUP_SCRIPT_FILE" + +gcloud compute instances create "$INSTANCE_NAME" \ + --project="$PROJECT_ID" \ + --zone="$ZONE" \ + --provisioning-model=SPOT \ + --instance-termination-action=DELETE \ + --machine-type="$MACHINE_TYPE" \ + --maintenance-policy=TERMINATE \ + --boot-disk-type="$BOOT_DISK_TYPE" --boot-disk-size="${BOOT_DISK_SIZE_GB}GB" \ + --boot-disk-auto-delete \ + --scopes=https://www.googleapis.com/auth/cloud-platform \ + --metadata=google-logging-enabled=true \ + --labels="$INSTANCE_LABELS" \ + --image-family=cos-109-lts \ + --image-project=cos-cloud \ + --metadata-from-file user-data="$STARTUP_SCRIPT_FILE" + +echo "Instance created: $INSTANCE_NAME" +echo "$INSTANCE_NAME" > "$SCRIPT_DIR/.last_instance" + +# Stream logs if enabled +if [[ ${STREAM_LOGS} -eq 1 ]]; then + "$SCRIPT_DIR/logs.sh" || true +fi + +echo "Done. Instance: $INSTANCE_NAME" diff --git a/scripts/prove/startup.sh b/scripts/prove/startup.sh new file mode 100644 index 00000000..3ff5a6c0 --- /dev/null +++ b/scripts/prove/startup.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Mirror all output to the serial console for easy log streaming +exec > >(tee -a /dev/console) 2>&1 + +echo "[startup] Beginning setup on $(hostname)" +echo "[startup] Using COS built-in Docker runtime" + +# Expect the following variables to be defined before sourcing/concatenation: +# REGION, IMAGE_URI, CONTAINER_COMMAND, CONTAINER_ARGS[] + +REGISTRY="${REGION}-docker.pkg.dev" + +# Use a writable Docker config directory on COS (root FS is read-only) +export DOCKER_CONFIG=/mnt/stateful_partition/docker-config +echo "[startup] Using Docker config dir: ${DOCKER_CONFIG}" +mkdir -p "${DOCKER_CONFIG}" || true + +echo "[startup] Fetching access token from metadata server" +TOKEN=$(curl -s -H "Metadata-Flavor: Google" \ + "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token" \ + | sed -n 's/.*"access_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + +if [[ -z "${TOKEN}" ]]; then + echo "[startup] ERROR: empty access token from metadata server" | tee /dev/console + exit 1 +fi + +echo "[startup] Acquired access token" + +echo "[startup] Logging in to Artifact Registry: ${REGISTRY}" +set +e +{ echo "${TOKEN}" | timeout 30s docker login -u oauth2accesstoken --password-stdin "${REGISTRY}"; } 2>&1 \ + | while IFS= read -r line; do echo "[startup] docker login: $line"; done +LOGIN_RC=${PIPESTATUS[0]} +set -e +if [[ ${LOGIN_RC} -ne 0 ]]; then + echo "[startup] Docker login failed with code ${LOGIN_RC}" | tee /dev/console + exit ${LOGIN_RC} +fi + +echo "[startup] Pulling image: ${IMAGE_URI}" +set +e +{ timeout 15m docker pull "${IMAGE_URI}"; } 2>&1 \ + | while IFS= read -r line; do echo "[startup] docker pull: $line"; done +PULL_RC=${PIPESTATUS[0]} +set -e +if [[ ${PULL_RC} -ne 0 ]]; then + echo "[startup] Docker pull failed with code ${PULL_RC}" | tee /dev/console + exit ${PULL_RC} +fi + +echo "[startup] Running container" +set +e +# Prefix container lines with [raito] so the log streamer can filter +if [[ -n "${CONTAINER_COMMAND}" ]]; then + docker run --rm --name raito --network host "${IMAGE_URI}" "${CONTAINER_COMMAND}" "${CONTAINER_ARGS[@]}" \ + 2>&1 | sed -e 's/^/[raito] /' +else + docker run --rm --name raito --network host "${IMAGE_URI}" "${CONTAINER_ARGS[@]}" \ + 2>&1 | sed -e 's/^/[raito] /' +fi +EXIT_CODE=${PIPESTATUS[0]} +set -e + +echo "[startup] Container exited with code ${EXIT_CODE}. Shutting down VM." +/sbin/shutdown -h now || true +exit ${EXIT_CODE} + + diff --git a/scripts/prove/status.sh b/scripts/prove/status.sh new file mode 100755 index 00000000..581eaf6d --- /dev/null +++ b/scripts/prove/status.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Fix locale warnings from gcloud/perl +export LC_ALL=C.UTF-8 + +# Check the status of the Spot instance. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/config.sh" + +INSTANCE_NAME="" + +LAST_FILE="$SCRIPT_DIR/.last_instance" +if [[ -z "$INSTANCE_NAME" && -f "$LAST_FILE" ]]; then + INSTANCE_NAME="$(cat "$LAST_FILE")" +fi + +if [[ -z "$INSTANCE_NAME" ]]; then + echo "No instance specified and $LAST_FILE not found. Set INSTANCE_NAME in config.sh or create $LAST_FILE." >&2 + exit 1 +fi + +STATUS=$(gcloud compute instances describe "$INSTANCE_NAME" --zone="$ZONE" --format='value(status)' 2>/dev/null || echo "NOT_FOUND") +echo "Instance: $INSTANCE_NAME" +echo "Status: $STATUS" +