diff --git a/circuits/operator-proof/guest/Cargo.lock b/circuits/operator-proof/guest/Cargo.lock index f09ea5955..91821b9a1 100644 --- a/circuits/operator-proof/guest/Cargo.lock +++ b/circuits/operator-proof/guest/Cargo.lock @@ -2273,7 +2273,6 @@ dependencies = [ "bitcoin-light-client-circuit", "commit-chain", "header-chain", - "hex", "sha2 0.10.9", "state-chain", "tracing", diff --git a/circuits/operator-proof/guest/Cargo.toml b/circuits/operator-proof/guest/Cargo.toml index a3be8844f..f3ff6ba36 100644 --- a/circuits/operator-proof/guest/Cargo.toml +++ b/circuits/operator-proof/guest/Cargo.toml @@ -26,10 +26,6 @@ alloy-primitives = { version = "1.0.0", features = ["sha3-keccak", "map-foldhash #revm = { git = "https://github.com/ziren-patches/revm", branch = "patch-31.0.2", features = ["serde", "bn"], default-features = false } sha2 = "0.10.9" -[build-dependencies] -hex = "0.4.3" - - [patch.crates-io] # Precompile patches sha2 = { git = "https://github.com/ziren-patches/RustCrypto-hashes", branch = "patch-sha2-0.10.9", package = "sha2" } diff --git a/circuits/operator-proof/guest/build.rs b/circuits/operator-proof/guest/build.rs deleted file mode 100644 index 6263faafc..000000000 --- a/circuits/operator-proof/guest/build.rs +++ /dev/null @@ -1,68 +0,0 @@ -use std::{env, fs, path::PathBuf}; - -const ENV_FIXED_WATCHTOWER_KEYS: &str = "FIXED_WATCHTOWER_XONLY_PUBLIC_KEYS"; - -fn parse_xonly_key(raw: &str) -> Result<[u8; 32], String> { - let hex = raw.trim().strip_prefix("0x").unwrap_or(raw.trim()); - let bytes = hex::decode(hex).map_err(|err| format!("invalid x-only public key hex: {err}"))?; - bytes.try_into().map_err(|bytes: Vec| { - format!("x-only public key must be 32 bytes, got {}", bytes.len()) - }) -} - -fn fixed_watchtower_keys_from_env() -> Vec<[u8; 32]> { - let value = match env::var(ENV_FIXED_WATCHTOWER_KEYS) { - Ok(value) => value, - Err(_) => { - println!( - "cargo:warning={ENV_FIXED_WATCHTOWER_KEYS} is not set; building operator guest with an empty fixed watchtower list" - ); - return Vec::new(); - } - }; - let keys = value - .split(',') - .map(str::trim) - .filter(|key| !key.is_empty()) - .map(parse_xonly_key) - .collect::, _>>() - .unwrap_or_else(|err| panic!("invalid {ENV_FIXED_WATCHTOWER_KEYS}: {err}")); - - if keys.is_empty() { - println!( - "cargo:warning={ENV_FIXED_WATCHTOWER_KEYS} contains no keys; building operator guest with an empty fixed watchtower list" - ); - return Vec::new(); - } - if keys.len() > 256 { - panic!("{ENV_FIXED_WATCHTOWER_KEYS} contains {} keys, max 256", keys.len()); - } - keys -} - -fn main() { - println!("cargo:rerun-if-env-changed={ENV_FIXED_WATCHTOWER_KEYS}"); - - let keys = fixed_watchtower_keys_from_env(); - let mut generated = String::new(); - generated.push_str(&format!("pub const FIXED_WATCHTOWER_COUNT: usize = {};\n", keys.len())); - generated.push_str(&format!( - "pub const FIXED_WATCHTOWER_XONLY_PUBLIC_KEYS: [[u8; 32]; {}] = [\n", - keys.len() - )); - for key in keys { - generated.push_str(" ["); - for (index, byte) in key.iter().enumerate() { - if index > 0 { - generated.push_str(", "); - } - generated.push_str(&format!("0x{byte:02x}")); - } - generated.push_str("],\n"); - } - generated.push_str("];\n"); - - let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set by cargo")); - fs::write(out_dir.join("fixed_watchtowers.rs"), generated) - .expect("failed to write fixed_watchtowers.rs"); -} diff --git a/circuits/operator-proof/guest/src/main.rs b/circuits/operator-proof/guest/src/main.rs index 3563f4894..7431c5a46 100644 --- a/circuits/operator-proof/guest/src/main.rs +++ b/circuits/operator-proof/guest/src/main.rs @@ -8,8 +8,6 @@ use header_chain::{HeaderChainCircuitInput, SPV}; use state_chain::StateChainCircuitInput; use std::str::FromStr; -include!(concat!(env!("OUT_DIR"), "/fixed_watchtowers.rs")); - pub fn main() { // calculate operator public input: https://github.com/ProjectZKM/Ziren/blob/main/crates/sdk/src/utils.rs#L42 let included_watchtowers: U256 = zkm_zkvm::io::read::(); @@ -20,6 +18,8 @@ pub fn main() { let latest_sequencer_commit_txid = operator_latest_sequencer_commit_txn.compute_txid(); // public input // https://github.com/KSlashh/BitVM/blob/v2/goat/src/transactions/watchtower_challenge.rs#L128 + let watchtower_challenge_indices: Vec = zkm_zkvm::io::read(); + let graph_watchtower_xonly_public_keys: Vec<[u8; 32]> = zkm_zkvm::io::read(); let watchtower_challenge_txns: Vec = zkm_zkvm::io::read(); let watchtower_challenge_txn_pubkey: Vec = zkm_zkvm::io::read(); let watchtower_challenge_txn_scripts: Vec = zkm_zkvm::io::read(); @@ -36,11 +36,12 @@ pub fn main() { included_watchtowers, graph_id, operator_genesis_sequencer_commit_txid, + watchtower_challenge_indices, watchtower_challenge_txns, watchtower_challenge_txn_pubkey, watchtower_challenge_txn_scripts, watchtower_challenge_txn_prev_outs, - &FIXED_WATCHTOWER_XONLY_PUBLIC_KEYS, + &graph_watchtower_xonly_public_keys, operator_header_chain, operator_commit_chain, operator_state_chain, diff --git a/circuits/operator-proof/host/build.rs b/circuits/operator-proof/host/build.rs index bd89ff460..dcfa0c539 100644 --- a/circuits/operator-proof/host/build.rs +++ b/circuits/operator-proof/host/build.rs @@ -1,13 +1,5 @@ use zkm_build::build_program; -const ENV_FIXED_WATCHTOWER_KEYS: &str = "FIXED_WATCHTOWER_XONLY_PUBLIC_KEYS"; - fn main() { - println!("cargo:rerun-if-env-changed={ENV_FIXED_WATCHTOWER_KEYS}"); - if std::env::var(ENV_FIXED_WATCHTOWER_KEYS).is_err() { - println!( - "cargo:warning={ENV_FIXED_WATCHTOWER_KEYS} is not set; building operator guest with an empty fixed watchtower list" - ); - } build_program("../guest"); } diff --git a/circuits/operator-proof/host/src/lib.rs b/circuits/operator-proof/host/src/lib.rs index 2f37d1ddb..9c006875b 100644 --- a/circuits/operator-proof/host/src/lib.rs +++ b/circuits/operator-proof/host/src/lib.rs @@ -89,6 +89,34 @@ use sha2::{Digest, Sha256}; use std::sync::OnceLock; static ELF_ID: OnceLock = OnceLock::new(); +/// Parses the full graph key list and keeps each included challenge's original graph index. +fn parse_indexed_watchtower_inputs( + watchtower_challenge_txids: &str, + watchtower_public_keys: &str, +) -> anyhow::Result<(Vec<(u16, Txid, PublicKey)>, Vec<[u8; 32]>)> { + let txids = watchtower_challenge_txids.split(',').collect::>(); + let public_keys = watchtower_public_keys + .split(',') + .map(PublicKey::from_str) + .collect::, _>>()?; + anyhow::ensure!( + txids.len() == public_keys.len(), + "watchtower challenge txids and public keys must have equal lengths" + ); + anyhow::ensure!(!public_keys.is_empty(), "watchtower public key list must not be empty"); + anyhow::ensure!(public_keys.len() <= 256, "watchtower public key list exceeds 256 entries"); + + let graph_keys = public_keys.iter().map(|key| key.x_only_public_key().0.serialize()).collect(); + let included = txids + .iter() + .enumerate() + .filter(|(_, txid)| !txid.trim().is_empty()) + .map(|(index, txid)| Ok((index as u16, Txid::from_str(txid)?, public_keys[index]))) + .collect::>>()?; + + Ok((included, graph_keys)) +} + pub async fn fetch_target_block_and_watchtower_tx( esplora_url: &str, latest_sequencer_commit_txid: &str, @@ -102,19 +130,15 @@ pub async fn fetch_target_block_and_watchtower_tx( bitcoin::Block, BlockHash, Transaction, + Vec, + Vec<[u8; 32]>, Vec, Vec, Vec, Vec, )> { - let watchtower_challenge_txids: Vec<&str> = - watchtower_challenge_txids.split(",").filter(|s| !s.is_empty()).collect(); - let watchtower_public_keys: Vec<&str> = - watchtower_public_keys.split(",").filter(|s| !s.is_empty()).collect(); - anyhow::ensure!( - watchtower_challenge_txids.len() == watchtower_public_keys.len(), - "watchtower challenge txids and public keys must have equal lengths" - ); + let (indexed_watchtower_inputs, graph_watchtower_xonly_public_keys) = + parse_indexed_watchtower_inputs(watchtower_challenge_txids, watchtower_public_keys)?; let btc_client = BTCClient::new(bitcoin_network, Some(&esplora_url)); let latest_sequencer_commit_txid = Txid::from_str(&latest_sequencer_commit_txid)?; @@ -166,6 +190,7 @@ pub async fn fetch_target_block_and_watchtower_tx( // --- watchtower_challenge_txns --- // let mut watchtower_challenge_txns = Vec::new(); + let mut watchtower_challenge_indices = Vec::new(); let mut watchtower_challenge_txn_prev_outs: Vec = Vec::new(); let mut watchtower_challenge_txn_pubkeys = Vec::new(); let mut watchtower_challenge_txn_scripts: Vec = Vec::new(); @@ -179,12 +204,11 @@ pub async fn fetch_target_block_and_watchtower_tx( ), }; - for (id, pk) in watchtower_challenge_txids.iter().zip(watchtower_public_keys.iter()) { - tracing::info!("txid: {}, pk: {}", id, pk); - let txid = id.parse()?; + for (node_index, txid, public_key) in indexed_watchtower_inputs { + tracing::info!("txid: {}, pk: {}", txid, public_key); let txn = match btc_client.get_tx(&txid).await? { Some(tx) => tx, - None => anyhow::bail!("Failed to fetch watchtower challenge txn: {}", id), + None => anyhow::bail!("Failed to fetch watchtower challenge txn: {}", txid), }; // get prev outs // FIXME: update the index @@ -192,8 +216,8 @@ pub async fn fetch_target_block_and_watchtower_tx( watchtower_challenge_txn_prev_outs .push(watchtower_challlenge_init_txn.output[index].clone()); - let public_key = PublicKey::from_str(pk).unwrap(); - watchtower_challenge_txn_pubkeys.push(public_key.clone()); + watchtower_challenge_indices.push(node_index); + watchtower_challenge_txn_pubkeys.push(public_key); watchtower_challenge_txns.push(txn); // https://github.com/GOATNetwork/BitVM/blob/GA/goat/src/transactions/watchtower_challenge.rs#L45 @@ -214,6 +238,8 @@ pub async fn fetch_target_block_and_watchtower_tx( target_block_ss_commit, operator_committed_blockhash, operator_latest_sequencer_commit_txn, + watchtower_challenge_indices, + graph_watchtower_xonly_public_keys, watchtower_challenge_txns, watchtower_challenge_txn_prev_outs, watchtower_challenge_txn_pubkeys, @@ -271,6 +297,8 @@ impl ProofBuilder for OperatorProofBuilder { operator_committed_blockhash, + watchtower_challenge_indices, + graph_watchtower_xonly_public_keys, watchtower_challenge_txns, watchtower_challenge_txn_prev_outs, watchtower_challenge_txn_pubkeys, @@ -413,6 +441,8 @@ impl ProofBuilder for OperatorProofBuilder { stdin.write(&operator_genesis_sequencer_commit_txid.to_byte_array()); stdin.write(&operator_latest_sequencer_commit_txn); + stdin.write(&watchtower_challenge_indices); + stdin.write(&graph_watchtower_xonly_public_keys); stdin.write(&watchtower_challenge_txns); stdin.write(&watchtower_challenge_txn_pubkeys); stdin.write(&watchtower_challenge_txn_scripts); diff --git a/circuits/operator-proof/host/src/main.rs b/circuits/operator-proof/host/src/main.rs index 145bca9f0..9101b09e8 100644 --- a/circuits/operator-proof/host/src/main.rs +++ b/circuits/operator-proof/host/src/main.rs @@ -16,6 +16,8 @@ async fn main() { target_block_ss_commit, operator_committed_blockhash, operator_latest_sequencer_commit_txn, + watchtower_challenge_indices, + graph_watchtower_xonly_public_keys, watchtower_challenge_txns, watchtower_challenge_txn_prev_outs, watchtower_challenge_txn_pubkeys, @@ -51,6 +53,8 @@ async fn main() { operator_latest_sequencer_commit_txn, operator_committed_blockhash, + watchtower_challenge_indices, + graph_watchtower_xonly_public_keys, watchtower_challenge_txns, watchtower_challenge_txn_prev_outs, watchtower_challenge_txn_pubkeys, diff --git a/circuits/proof-builder/src/lib.rs b/circuits/proof-builder/src/lib.rs index ad3081a2a..d90bc5ae7 100644 --- a/circuits/proof-builder/src/lib.rs +++ b/circuits/proof-builder/src/lib.rs @@ -63,6 +63,8 @@ pub enum ProofRequest { operator_committed_blockhash: BlockHash, + watchtower_challenge_indices: Vec, + graph_watchtower_xonly_public_keys: Vec<[u8; 32]>, watchtower_challenge_txns: Vec, watchtower_challenge_txn_prev_outs: Vec, watchtower_challenge_txn_pubkeys: Vec, diff --git a/crates/bitcoin-light-client-circuit/src/lib.rs b/crates/bitcoin-light-client-circuit/src/lib.rs index df14b4464..9cb38b9f7 100644 --- a/crates/bitcoin-light-client-circuit/src/lib.rs +++ b/crates/bitcoin-light-client-circuit/src/lib.rs @@ -174,14 +174,14 @@ pub fn le_bits_to_u256(bits: &[bool]) -> U256 { u } -pub fn verify_fixed_watchtower_pubkey( - fixed_watchtower_xonly_public_keys: &[[u8; 32]], +pub fn verify_graph_watchtower_pubkey( + graph_watchtower_xonly_public_keys: &[[u8; 32]], index: usize, pubkey: &PublicKey, ) -> Result<(), String> { - // The fixed list is order-sensitive: index must match graph watchtower_pubkeys/node_index. - let Some(expected) = fixed_watchtower_xonly_public_keys.get(index) else { - return Err(format!("watchtower index {index} exceeds fixed watchtower list")); + // The graph list is order-sensitive: index must match graph watchtower_pubkeys/node_index. + let Some(expected) = graph_watchtower_xonly_public_keys.get(index) else { + return Err(format!("watchtower index {index} exceeds graph watchtower list")); }; let xonly: XOnlyPublicKey = (*pubkey).into(); let actual = xonly.serialize(); @@ -195,6 +195,37 @@ pub fn verify_fixed_watchtower_pubkey( Ok(()) } +/// Validates challenge indices against the graph-sized public inclusion bitmap. +pub fn validate_watchtower_challenge_indices( + included_watchtowers: &[bool; 256], + graph_watchtower_count: usize, + challenge_indices: &[u16], + challenge_count: usize, +) -> Result<(), String> { + if graph_watchtower_count == 0 || graph_watchtower_count > 256 { + return Err(format!("invalid graph watchtower count {graph_watchtower_count}")); + } + if challenge_indices.len() != challenge_count { + return Err("watchtower challenge index count mismatch".to_string()); + } + + let mut seen = [false; 256]; + for index in challenge_indices { + let index = *index as usize; + if index >= graph_watchtower_count { + return Err(format!("watchtower challenge index {index} out of bounds")); + } + if seen[index] { + return Err(format!("duplicate watchtower challenge index {index}")); + } + seen[index] = true; + } + if included_watchtowers != &seen { + return Err("watchtower challenge indices do not match included bitmap".to_string()); + } + Ok(()) +} + // calculate operator public input: https://github.com/ProjectZKM/Ziren/blob/main/crates/sdk/src/utils.rs#L42 #[allow(clippy::too_many_arguments)] pub fn propose_longest_chain( @@ -202,11 +233,12 @@ pub fn propose_longest_chain( graph_id: [u8; GRAPH_ID_SIZE], // pis operator_genesis_sequencer_commit_txid: [u8; 32], // pis + watchtower_challenge_indices: Vec, watchtower_challenge_txns: Vec, watchtower_challenge_txn_pubkey: Vec, watchtower_challenge_txn_scripts: Vec, watchtower_challenge_txn_prev_outs: Vec, - fixed_watchtower_xonly_public_keys: &[[u8; 32]], + graph_watchtower_xonly_public_keys: &[[u8; 32]], operator_header_chain: HeaderChainCircuitInput, commit_chain: CommitChainCircuitInput, @@ -262,24 +294,34 @@ pub fn propose_longest_chain( // parse included_watchtowers into bits array let included_watchtowers_bits = u256_to_le_bits(included_watchtowers); println!("included watchtowers:{included_watchtowers_bits:?}"); + assert_eq!(watchtower_challenge_txns.len(), watchtower_challenge_txn_pubkey.len()); + assert_eq!(watchtower_challenge_txns.len(), watchtower_challenge_txn_scripts.len()); + assert_eq!(watchtower_challenge_txns.len(), watchtower_challenge_txn_prev_outs.len()); + validate_watchtower_challenge_indices( + &included_watchtowers_bits, + graph_watchtower_xonly_public_keys.len(), + &watchtower_challenge_indices, + watchtower_challenge_txns.len(), + ) + .expect("invalid indexed watchtower challenges"); - let mut valid_included_watchtower_count = 0usize; // For each watchtowers, if the included_watchtowers[i] is true, // verify the watchtower_challenge_txns[i] is valid // verify watchtower_challenge_txns[i].total_work <= operator_header_chain.total_work // verify watchtower_challenge_txns[i].epoch <= operator_latest_sequencer_commit_tx.epoch - for i in 0..watchtower_challenge_txns.len() { + for (challenge_position, node_index) in watchtower_challenge_indices.iter().enumerate() { + let i = *node_index as usize; if included_watchtowers_bits[i] { - let tx = &watchtower_challenge_txns[i]; + let tx = &watchtower_challenge_txns[challenge_position]; println!("Verify watchtower[{i}] tx: {}, {:?}", tx.compute_txid(), tx); - let prev_out = &watchtower_challenge_txn_prev_outs[i]; + let prev_out = &watchtower_challenge_txn_prev_outs[challenge_position]; let prev_index = tx.input[0].previous_output.vout as usize; - let pubkey = &watchtower_challenge_txn_pubkey[i]; + let pubkey = &watchtower_challenge_txn_pubkey[challenge_position]; if let Err(err) = - verify_fixed_watchtower_pubkey(fixed_watchtower_xonly_public_keys, i, pubkey) + verify_graph_watchtower_pubkey(graph_watchtower_xonly_public_keys, i, pubkey) { - println!("Watchtower[{i}] fixed pubkey verification: {err}"); + println!("Watchtower[{i}] graph pubkey verification: {err}"); continue; } @@ -301,7 +343,7 @@ pub fn propose_longest_chain( }; // check tx signature is valid match verify_taproot_leaf_schnorr_signature( - &watchtower_challenge_txn_scripts[i], + &watchtower_challenge_txn_scripts[challenge_position], tx, prev_index, prev_out, @@ -375,11 +417,8 @@ pub fn propose_longest_chain( println!("Watchtower[{i}] consensus block height exceeds operator block height"); continue; } - - valid_included_watchtower_count += 1; } } - assert!(valid_included_watchtower_count > 0, "no included watchtower passed verification"); println!("verify el block"); verify_groth16_proof( @@ -436,7 +475,11 @@ pub fn propose_longest_chain( "operator_genesis_sequencer_commit_txid hex: {:?}", hex::encode(operator_genesis_sequencer_commit_txid) ); - let constant = hash_operator_constant(graph_id, operator_genesis_sequencer_commit_txid); + let constant = hash_operator_constant( + graph_id, + operator_genesis_sequencer_commit_txid, + graph_watchtower_xonly_public_keys, + ); println!("constant hex: {:?}", hex::encode(constant)); println!("btc_best_block_hash hex: {:?}", hex::encode(btc_best_block_hash)); @@ -458,10 +501,16 @@ pub fn propose_longest_chain( pub fn hash_operator_constant( graph_id: [u8; GRAPH_ID_SIZE], operator_genesis_sequencer_commit_txid: [u8; 32], + watchtower_xonly_public_keys: &[[u8; 32]], ) -> [u8; 32] { let mut engine = sha256::HashEngine::default(); + engine.input(b"bitvm2/operator-constant/v2"); engine.input(&graph_id); engine.input(&operator_genesis_sequencer_commit_txid); + engine.input(&(watchtower_xonly_public_keys.len() as u16).to_be_bytes()); + for key in watchtower_xonly_public_keys { + engine.input(key); + } let hash = sha256::Hash::from_engine(engine); *hash.as_byte_array() } @@ -713,6 +762,47 @@ mod tests { ); } + #[test] + fn test_hash_operator_constant_binds_ordered_watchtower_keys() { + use bitcoin::hashes::Hash as _; + + let graph_id = [1u8; GRAPH_ID_SIZE]; + let genesis_txid = [2u8; 32]; + let watchtower_keys = [[3u8; 32], [4u8; 32]]; + let mut input = b"bitvm2/operator-constant/v2".to_vec(); + input.extend_from_slice(&graph_id); + input.extend_from_slice(&genesis_txid); + input.extend_from_slice(&(watchtower_keys.len() as u16).to_be_bytes()); + for key in &watchtower_keys { + input.extend_from_slice(key); + } + let expected = bitcoin::hashes::sha256::Hash::hash(&input); + + assert_eq!( + hash_operator_constant(graph_id, genesis_txid, &watchtower_keys), + *expected.as_byte_array() + ); + assert_ne!( + hash_operator_constant( + graph_id, + genesis_txid, + &[watchtower_keys[1], watchtower_keys[0]], + ), + *expected.as_byte_array() + ); + } + + #[test] + fn watchtower_challenge_indices_preserve_sparse_bitmap_positions() { + let mut included = [false; 256]; + included[1] = true; + included[4] = true; + + validate_watchtower_challenge_indices(&included, 5, &[1, 4], 2).unwrap(); + assert!(validate_watchtower_challenge_indices(&included, 5, &[0, 1], 2).is_err()); + assert!(validate_watchtower_challenge_indices(&included, 5, &[1, 1], 2).is_err()); + } + #[test] fn test_u256_to_le_bits() { use std::str::FromStr; diff --git a/node/Cargo.toml b/node/Cargo.toml index fa4949db5..f805d25a1 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -35,10 +35,6 @@ path = "src/bin/send_bridge_out.rs" name = "update-db" path = "src/bin/db_inject.rs" -[[bin]] -name = "fetch-watchtower-xonly-pubkeys" -path = "src/bin/fetch_watchtower_xonly_pubkeys.rs" - [[bin]] name = "mock-rpc" path = "src/bin/mock_rpc.rs" diff --git a/node/src/bin/fetch_watchtower_xonly_pubkeys.rs b/node/src/bin/fetch_watchtower_xonly_pubkeys.rs deleted file mode 100644 index 3a67094a7..000000000 --- a/node/src/bin/fetch_watchtower_xonly_pubkeys.rs +++ /dev/null @@ -1,108 +0,0 @@ -use alloy::primitives::Address as EvmAddress; -use anyhow::{Context, Result, anyhow}; -use bitvm_noded::env::{ - ENV_GOAT_CHAIN_URL, ENV_GOAT_GATEWAY_CONTRACT_ADDRESS, ENV_GOAT_NETWORK, - get_goat_gateway_contract_from_env, get_goat_network, goat_config_from_env, -}; -use client::goat_chain::GOATClient; -use serde::Serialize; -use sha2::{Digest, Sha256}; -use std::{ - env, fs, - path::{Path, PathBuf}, - time::{SystemTime, UNIX_EPOCH}, -}; - -#[derive(Debug, Serialize)] -struct WatchtowerSnapshot { - goat_chain_url: String, - goat_gateway_contract_address: String, - goat_network: String, - committee_management_address: String, - watchtower_xonly_public_keys: Vec, - watchtower_count: usize, - watchtower_list_hash: String, - watchtower_order_note: String, - generated_at_unix: u64, -} - -fn require_env(name: &str) -> Result { - env::var(name).map_err(|_| anyhow!("{name} must be set")) -} - -fn workspace_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("node crate is under the workspace root") - .to_path_buf() -} - -fn snapshot_path(gateway: &EvmAddress) -> PathBuf { - workspace_root() - .join("target") - .join("watchtower-snapshots") - .join(format!("{}.json", gateway.to_string().to_lowercase())) -} - -fn watchtower_list_hash(watchtower_pubkeys: &[String]) -> String { - let mut hasher = Sha256::new(); - // The hash is intentionally order-sensitive because watchtower index maps to node_index. - for key in watchtower_pubkeys { - hasher.update(key.as_bytes()); - } - format!("0x{}", hex::encode(hasher.finalize())) -} - -#[tokio::main] -async fn main() -> Result<()> { - dotenv::dotenv().ok(); - - let goat_chain_url = require_env(ENV_GOAT_CHAIN_URL)?; - let goat_gateway_contract_address = require_env(ENV_GOAT_GATEWAY_CONTRACT_ADDRESS)?; - let goat_network = env::var(ENV_GOAT_NETWORK).unwrap_or_else(|_| "test".to_string()); - - let gateway = get_goat_gateway_contract_from_env(); - let goat_client = GOATClient::new(goat_config_from_env().await, get_goat_network()); - let committee_management_address = - EvmAddress::from_slice(&goat_client.gateway_get_committee_management().await?); - // Preserve on-chain order. The operator circuit compares this list by index with graph - // watchtower_pubkeys, watchtower_challenge vouts, and included_watchtowers bitmap bits. - let watchtower_pubkeys = goat_client - .committee_mana_get_watchtowers() - .await? - .into_iter() - .map(|key| format!("0x{}", hex::encode(key.serialize()))) - .collect::>(); - - if watchtower_pubkeys.is_empty() { - return Err(anyhow!("committee management returned an empty watchtower list")); - } - - let watchtower_list_hash = watchtower_list_hash(&watchtower_pubkeys); - let snapshot = WatchtowerSnapshot { - goat_chain_url, - goat_gateway_contract_address, - goat_network, - committee_management_address: committee_management_address.to_string(), - watchtower_count: watchtower_pubkeys.len(), - watchtower_xonly_public_keys: watchtower_pubkeys.clone(), - watchtower_list_hash: watchtower_list_hash.clone(), - watchtower_order_note: - "order-sensitive: index must match graph watchtower_pubkeys/node_index".to_string(), - generated_at_unix: SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(), - }; - - let path = snapshot_path(&gateway); - let parent = path.parent().context("snapshot path has parent")?; - fs::create_dir_all(parent)?; - fs::write(&path, serde_json::to_string_pretty(&snapshot)?)?; - - eprintln!("watchtower snapshot: {}", path.display()); - eprintln!("watchtower list hash: {watchtower_list_hash}"); - eprintln!( - "before build, export FIXED_WATCHTOWER_XONLY_PUBLIC_KEYS or eval this command output" - ); - println!("export FIXED_WATCHTOWER_XONLY_PUBLIC_KEYS={}", watchtower_pubkeys.join(",")); - - Ok(()) -} diff --git a/node/src/handle.rs b/node/src/handle.rs index e16f75c9a..1d278c44b 100644 --- a/node/src/handle.rs +++ b/node/src/handle.rs @@ -1203,7 +1203,7 @@ async fn handle_init_graph_verifier( } else { get_babe_gc_asset_paths()?; let vk = crate::vk::get_vk().await.context("load Groth16 verifying key for BABE setup")?; - let static_input = derive_operator_statement(graph_id)?.static_input; + let static_input = derive_operator_static_input()?; let (setup_package, private_state) = tokio::task::spawn_blocking(move || { build_real_setup_package(BABE_N_CC, &vk, static_input) }) @@ -1402,7 +1402,7 @@ async fn handle_cut_circuits_verifier( get_babe_gc_asset_paths()?; let vk = crate::vk::get_vk().await.context("load Groth16 verifying key for BABE opening")?; - let static_input = derive_operator_statement(graph_id)?.static_input; + let static_input = derive_operator_static_input()?; let private_state = verifier_state.private_state.clone(); let selected_indices = selected_circuit_indexes.clone(); let package_for_opening = setup_package.clone(); @@ -1658,7 +1658,7 @@ async fn handle_compact_soldering_proof_operator( .context("expand compact soldering proof payload")?; let vk = crate::vk::get_vk().await.context("load Groth16 verifying key for BABE validation")?; - let static_input = derive_operator_statement(graph_id)?.static_input; + let static_input = derive_operator_static_input()?; let package_for_validation = setup_package.clone(); let opened_for_validation = opened.clone(); let finalized_for_validation = finalized.clone(); @@ -3935,7 +3935,7 @@ async fn handle_assert_sent_verifier( return Ok(()); } let vk = crate::vk::get_vk().await.context("load Groth16 verifying key for BABE challenge")?; - let static_input = derive_operator_statement(graph_id)?.static_input; + let static_input = derive_operator_static_input()?; let challenge_witness = build_real_challenge_assert_witness( &saved_verifier_state.private_state, &saved_verifier_state.setup_package, diff --git a/node/src/utils.rs b/node/src/utils.rs index 5a52ad2e3..8a432807d 100644 --- a/node/src/utils.rs +++ b/node/src/utils.rs @@ -163,6 +163,52 @@ pub mod todo_funcs { 1 } + /// Validates the graph's ordered watchtower selection against the contract registry and bound constant. + pub(super) fn validate_watchtower_selection( + selected: &[XOnlyPublicKey], + registered: &[XOnlyPublicKey], + graph_id: [u8; 16], + genesis_txid: [u8; 32], + constant: [u8; 32], + ) -> Result<()> { + use std::collections::HashSet; + + if selected.len() < min_required_watchtower() { + bail!(SpecialError::InvalidGraph(format!( + "insufficient watchtowers: have {}, required {}", + selected.len(), + min_required_watchtower() + ))); + } + if selected.len() > 256 { + bail!(SpecialError::InvalidGraph(format!( + "too many watchtowers: {}, max 256", + selected.len() + ))); + } + + let mut seen = HashSet::new(); + for key in selected { + if !seen.insert(*key) { + bail!(SpecialError::InvalidGraph( + "duplicate watchtower pubkey in graph".to_string() + )); + } + if !registered.contains(key) { + bail!(SpecialError::InvalidGraph(format!("watchtower {} is not registered", key))); + } + } + + let key_bytes = selected.iter().map(XOnlyPublicKey::serialize).collect::>(); + let expected = hash_operator_constant(graph_id, genesis_txid, &key_bytes); + if constant != expected { + bail!(SpecialError::InvalidGraph( + "operator constant mismatch for graph watchtower list".to_string() + )); + } + Ok(()) + } + pub async fn validate_init_graph( local_db: &LocalDB, btc_client: &BTCClient, @@ -207,31 +253,13 @@ pub mod todo_funcs { SpecialError::InvalidGraph(format!("failed to load watchtowers from chain: {e}")) })?; - // deduplicate watchtower pubkeys: reject graphs that contain duplicate watchtower entries - { - use std::collections::HashSet; - let mut seen = HashSet::new(); - for pk in &graph.parameters.watchtower_pubkeys { - if !seen.insert(*pk) { - bail!(SpecialError::InvalidGraph( - "duplicate watchtower pubkey in graph".to_string() - )); - } - } - } - // allow unregistered watchtowers as long as enough registered ones exist - let required = super::todo_funcs::min_required_watchtower(); - let valid_registered = graph - .parameters - .watchtower_pubkeys - .iter() - .filter(|pk| watchtowers_on_chain.contains(pk)) - .count(); - if valid_registered < required { - bail!(SpecialError::InvalidGraph(format!( - "insufficient registered watchtowers: have {valid_registered}, required {required}" - ))); - } + validate_watchtower_selection( + &graph.parameters.watchtower_pubkeys, + &watchtowers_on_chain, + *graph.parameters.graph_id.as_bytes(), + get_genesis_sequencer_commit_id(), + graph.parameters.pubin_disprove_constant, + )?; // 6) Operator stake sanity: verify operator is registered and has enough locked stake let op_pk_bytes = graph.parameters.operator_pubkey.to_bytes(); @@ -291,6 +319,18 @@ pub mod todo_funcs { bail!(SpecialError::InvalidGraph("unexpected challenge amount".to_string())); } + let watchtowers_on_chain = + goat_client.committee_mana_get_watchtowers().await.map_err(|e| { + SpecialError::InvalidGraph(format!("failed to load watchtowers from chain: {e}")) + })?; + validate_watchtower_selection( + &graph.parameters.watchtower_pubkeys, + &watchtowers_on_chain, + *graph.parameters.graph_id.as_bytes(), + get_genesis_sequencer_commit_id(), + graph.parameters.pubin_disprove_constant, + )?; + // 3) Validate endorsements: unique, from legitimate committee members, and signatures recover to the provided EVM address use std::collections::HashSet; let mut seen_committee: HashSet = HashSet::new(); @@ -2033,12 +2073,27 @@ fn combined_operator_vk_hash(operator_vk_hash: &str, zkm_version: &str) -> Resul Ok(encoded) } -pub fn derive_operator_statement(graph_id: Uuid) -> Result { +fn operator_identity() -> Result<([u8; 32], String, ark_bn254::Fr)> { let vk_hash = get_operator_vk_hash()?; let zkm_version = get_operator_zkm_version()?; - let combined_hash = combined_operator_vk_hash(&format!("0x{}", hex::encode(vk_hash)), &zkm_version)?; + let combined_hash = + combined_operator_vk_hash(&format!("0x{}", hex::encode(vk_hash)), &zkm_version)?; let static_input = load_ark_public_inputs_from_bytes(&combined_hash, &[0u8; 32])[0]; - let constant = hash_operator_constant(*graph_id.as_bytes(), get_genesis_sequencer_commit_id()); + Ok((vk_hash, zkm_version, static_input)) +} + +pub fn derive_operator_static_input() -> Result { + Ok(operator_identity()?.2) +} + +pub fn derive_operator_statement( + graph_id: Uuid, + watchtower_pubkeys: &[XOnlyPublicKey], +) -> Result { + let (vk_hash, zkm_version, static_input) = operator_identity()?; + let key_bytes = watchtower_pubkeys.iter().map(XOnlyPublicKey::serialize).collect::>(); + let constant = + hash_operator_constant(*graph_id.as_bytes(), get_genesis_sequencer_commit_id(), &key_bytes); Ok(OperatorStatement { static_input, vk_hash, zkm_version, constant }) } @@ -2123,7 +2178,11 @@ pub async fn get_operator_proof( return Ok((None, get_operator_proof_wait_secs())); }; - let statement = derive_operator_statement(graph_id)?; + let statement = + derive_operator_statement(graph_id, &bitvm_graph.parameters.watchtower_pubkeys)?; + if statement.constant != bitvm_graph.parameters.pubin_disprove_constant { + bail!("graph operator constant does not match its watchtower list"); + } let proof: ZKMProofWithPublicValues = bincode::deserialize(proof_data.proof.as_slice()) .map_err(|err| anyhow!("failed to deserialize operator proof: {err}"))?; @@ -2133,7 +2192,8 @@ pub async fn get_operator_proof( if operator_vk_hash_raw != statement.vk_hash { bail!("operator proof vk hash does not match configured operator identity"); } - if proof.zkm_version != statement.zkm_version || proof_data.zkm_version != statement.zkm_version { + if proof.zkm_version != statement.zkm_version || proof_data.zkm_version != statement.zkm_version + { bail!("operator proof Ziren version does not match configured operator identity"); } @@ -2810,7 +2870,17 @@ pub async fn build_graph_params( .to_byte_array() }) .collect(); - let pubin_disprove_constant = get_guest_constant_value(instance_id, graph_id)?; + let pubin_disprove_constant = + get_guest_constant_value(instance_id, graph_id, &watchtower_pubkeys)?; + // Local graph construction selects the full on-chain registry; passing the same list twice + // intentionally reuses the helper's size and uniqueness checks. + todo_funcs::validate_watchtower_selection( + &watchtower_pubkeys, + &watchtower_pubkeys, + *graph_id.as_bytes(), + get_genesis_sequencer_commit_id(), + pubin_disprove_constant, + )?; Ok(BitvmGcGraphParameters { instance_parameters, prekickoff_parameters, @@ -4532,8 +4602,13 @@ pub(super) async fn find_instances_by_escrow_hash<'a>( if size > 0 { Ok(Some(instances[0].clone())) } else { Ok(None) } } -pub fn get_guest_constant_value(_instance_id: Uuid, graph_id: Uuid) -> Result<[u8; 32]> { - Ok(hash_operator_constant(graph_id.into_bytes(), get_genesis_sequencer_commit_id())) +pub fn get_guest_constant_value( + _instance_id: Uuid, + graph_id: Uuid, + watchtower_pubkeys: &[XOnlyPublicKey], +) -> Result<[u8; 32]> { + let key_bytes = watchtower_pubkeys.iter().map(XOnlyPublicKey::serialize).collect::>(); + Ok(hash_operator_constant(graph_id.into_bytes(), get_genesis_sequencer_commit_id(), &key_bytes)) } pub(crate) async fn get_bridge_out_global_stats<'a>( storage_processor: &mut StorageProcessor<'a>, diff --git a/proof-builder-rpc/src/task/operator_proof.rs b/proof-builder-rpc/src/task/operator_proof.rs index bf42a9b26..3b9f87e40 100644 --- a/proof-builder-rpc/src/task/operator_proof.rs +++ b/proof-builder-rpc/src/task/operator_proof.rs @@ -52,24 +52,13 @@ pub(crate) fn spawn_operator_proof_task( args.graph_id ); args.watchtower_challenge_init_txid = next_task.watchtower_challenge_init_txid.unwrap().clone(); - let included_challenges: Vec<_> = next_task + args.watchtower_challenge_txids = next_task .watchtower_challenge_txids .iter() - .zip(&next_task.watchtower_public_keys) - .filter_map(|(txid, public_key)| { - txid.as_ref().map(|txid| (txid.as_str(), public_key.as_str())) - }) - .collect(); - args.watchtower_challenge_txids = included_challenges - .iter() - .map(|(txid, _)| *txid) - .collect::>() - .join(","); - args.watchtower_public_keys = included_challenges - .iter() - .map(|(_, public_key)| *public_key) + .map(|txid| txid.as_deref().unwrap_or("")) .collect::>() .join(","); + args.watchtower_public_keys = next_task.watchtower_public_keys.join(","); // LE array to string, e.g. [1, 1, 1, 0] => 7 args.included_watchtowers = le_bits_to_u256(&next_task.included_watchtowers).to_string(); task_index = next_task.task_index; @@ -92,6 +81,8 @@ pub(crate) fn spawn_operator_proof_task( target_block_ss_commit, operator_committed_blockhash, operator_latest_sequencer_commit_txn, + watchtower_challenge_indices, + graph_watchtower_xonly_public_keys, watchtower_challenge_txns, watchtower_challenge_txn_prev_outs, watchtower_challenge_txn_pubkeys, @@ -131,6 +122,8 @@ pub(crate) fn spawn_operator_proof_task( operator_committed_blockhash, + watchtower_challenge_indices, + graph_watchtower_xonly_public_keys, watchtower_challenge_txns, watchtower_challenge_txn_prev_outs, watchtower_challenge_txn_pubkeys,