diff --git a/crates/node/src/node/block_importer.rs b/crates/node/src/node/block_importer.rs index 0394068..7663a08 100644 --- a/crates/node/src/node/block_importer.rs +++ b/crates/node/src/node/block_importer.rs @@ -169,20 +169,30 @@ impl Node { // A valid proof means the block producer correctly accumulated all // tx signature entries; this is belt-and-suspenders verification on top // of the existing individual sig checks below. + // Commitment-only payloads (no proof_bytes) are accepted as-is; full + // STARK verification happens when a ProofAmendment is gossiped. if let Some(proof_bytes) = &block.header.sig_aggregate_proof { match shell_stark_prover::proof::SigBatchProof::from_json(proof_bytes.as_ref()) { Ok(sig_proof) => { - if let Err(e) = verify_sig_batch(&sig_proof) { - return Err(NodeError::Startup(format!( - "block {} STARK aggregate proof verification failed: {e}", - block.number() - ))); + if sig_proof.has_proof() { + if let Err(e) = verify_sig_batch(&sig_proof) { + return Err(NodeError::Startup(format!( + "block {} STARK aggregate proof verification failed: {e}", + block.number() + ))); + } + debug!( + block = block.number(), + n_sigs = sig_proof.n_sigs, + "C3: STARK aggregate proof verified" + ); + } else { + debug!( + block = block.number(), + n_sigs = sig_proof.n_sigs, + "C3: commitment-only sig_aggregate_proof accepted; full proof pending ProofAmendment" + ); } - debug!( - block = block.number(), - n_sigs = sig_proof.n_sigs, - "C3: STARK aggregate proof verified" - ); } Err(e) => { return Err(NodeError::Startup(format!( @@ -426,11 +436,7 @@ impl Node { &result.system_contract_effects, )?; } else { - commit_pqvm_state( - &result, - evm.state_db_mut().world_state_mut(), - &self.chain_store, - )?; + commit_pqvm_state(&result, evm.state_db_mut())?; } total_effective_fees = total_effective_fees.saturating_add( U256::from(result.gas_used).saturating_mul(U256::from(price)), diff --git a/crates/node/src/node/block_producer.rs b/crates/node/src/node/block_producer.rs index 94adee9..04e2a5e 100644 --- a/crates/node/src/node/block_producer.rs +++ b/crates/node/src/node/block_producer.rs @@ -128,16 +128,15 @@ impl Node { )?; } else { // Normal PQVM tx: commit the revm state changeset. - commit_pqvm_state( - &result, - evm.state_db_mut().world_state_mut(), - &self.chain_store, - )?; + // Snapshot the address registry before the first commit clears it, + // so the persistent WorldState can resolve PQ addresses too. + let registry = evm.state_db().address_registry_snapshot(); + commit_pqvm_state(&result, evm.state_db_mut())?; // Commit to the node's persistent WorldState. { let mut ws = self.world_state.write(); - commit_pqvm_state(&result, &mut ws, &self.chain_store)?; + commit_pqvm_state_raw(&result, &mut *ws, &self.chain_store, ®istry)?; } } total_effective_fees = total_effective_fees.saturating_add( @@ -300,14 +299,27 @@ impl Node { proposer_seal: None, }; - // C3: If STARK aggregation is enabled, collect sig batch entries now. - // G4: ProofTask pushed to backlog AFTER signing so we have the real block hash. + // C3: If STARK aggregation is enabled, collect sig batch entries and + // compute the 32-byte commitment synchronously so it can be embedded + // in the header (and thus covered by the block hash / proposer seal). + // The full STARK proof is generated asynchronously; nodes that receive + // a commitment-only header will skip full STARK verification until a + // ProofAmendment arrives. let stark_entries: Option> = if self.stark_aggregation { Some(stark_sources::entries_from_txs(&included_txs)) } else { None }; + // Embed the batch-root commitment in the header before signing. + if let Some(entries) = stark_entries.as_ref() { + if !entries.is_empty() { + let batch_root = compute_batch_root(entries); + let commitment = SigBatchProof::commitment_only(batch_root, entries.len()); + block.header.sig_aggregate_proof = commitment.to_json().ok().map(Into::into); + } + } + // Sign the block with the proposer's key. consensus.sign_block(&mut block, signer)?; diff --git a/crates/node/src/node/mod.rs b/crates/node/src/node/mod.rs index a20e3c6..5e2ef15 100644 --- a/crates/node/src/node/mod.rs +++ b/crates/node/src/node/mod.rs @@ -35,7 +35,9 @@ pub(crate) use shell_crypto::{ }; pub(crate) use shell_mempool::TxPool; pub(crate) use shell_network::{NetworkMessage, NetworkService}; -pub(crate) use shell_pqvm::{commit_pqvm_state, validate_tx_for_import, ShellPqvm, ShellStateDb}; +pub(crate) use shell_pqvm::{ + commit_pqvm_state, commit_pqvm_state_raw, validate_tx_for_import, ShellPqvm, ShellStateDb, +}; pub(crate) use shell_primitives::{Address, Bytes, ShellHash, U256}; pub(crate) use shell_rpc::DevRpcControl; pub(crate) use shell_storage::{ @@ -56,7 +58,8 @@ pub(crate) use challenge_lifecycle::{ pub(crate) use readiness::{ProductionReadiness, ProductionReadinessState}; pub(crate) use shell_stark_prover::{ - prover::{verify_sig_batch, SigBatchEntry}, + proof::SigBatchProof, + prover::{compute_batch_root, verify_sig_batch, SigBatchEntry}, AggregationConfig, AggregationScheduler, AggregationTrigger, ProofAmendment, ProofBacklog, ProofTask, SettledL1Input, DEFAULT_MAX_L1_RANGE_SOURCES, MIN_L1_STARK_TXS, }; @@ -1533,7 +1536,7 @@ mod tests { start_block: Some(6), proof: shell_stark_prover::proof::SigBatchProof { version: shell_stark_prover::proof::SIG_BATCH_PROOF_VERSION, - batch_root_bytes: [0x22; 16], + batch_root_bytes: [0x22; 32], n_sigs: if layer == 1 { MIN_L1_STARK_TXS } else { 2 }, proof_bytes: vec![0x33; compressed_size as usize], }, @@ -1600,7 +1603,7 @@ mod tests { .and_then(|end_plus_one| end_plus_one.checked_sub(source_hashes.len() as u64)), proof: shell_stark_prover::proof::SigBatchProof { version: shell_stark_prover::proof::SIG_BATCH_PROOF_VERSION, - batch_root_bytes: [0x22; 16], + batch_root_bytes: [0x22; 32], n_sigs: if layer == 1 { MIN_L1_STARK_TXS } else { @@ -1820,7 +1823,7 @@ mod tests { .insert((1, l1_src.block_hash)); // Build an L2 amendment with correct n_sigs and aggregate root. - let root = u128::from_le_bytes(l1_src.proof.batch_root_bytes); + let root = u128::from_le_bytes(l1_src.proof.batch_root_bytes[0..16].try_into().unwrap()); let agg_root = compute_aggregate_root(&[root]); let l2 = ProofAmendment { version: shell_stark_prover::amendment::PROOF_AMENDMENT_VERSION, @@ -1829,7 +1832,11 @@ mod tests { start_block: Some(1), proof: shell_stark_prover::proof::SigBatchProof { version: shell_stark_prover::proof::SIG_BATCH_PROOF_VERSION, - batch_root_bytes: agg_root.to_le_bytes(), + batch_root_bytes: { + let mut b = [0u8; 32]; + b[0..16].copy_from_slice(&agg_root.to_le_bytes()); + b + }, n_sigs: 1, proof_bytes: vec![0x33; 128], }, @@ -1868,7 +1875,7 @@ mod tests { .lock() .insert((1, l1_src.block_hash)); - let root = u128::from_le_bytes(l1_src.proof.batch_root_bytes); + let root = u128::from_le_bytes(l1_src.proof.batch_root_bytes[0..16].try_into().unwrap()); let agg_root = compute_aggregate_root(&[root]); let l2 = ProofAmendment { version: shell_stark_prover::amendment::PROOF_AMENDMENT_VERSION, @@ -1877,7 +1884,11 @@ mod tests { start_block: Some(1), proof: shell_stark_prover::proof::SigBatchProof { version: shell_stark_prover::proof::SIG_BATCH_PROOF_VERSION, - batch_root_bytes: agg_root.to_le_bytes(), + batch_root_bytes: { + let mut b = [0u8; 32]; + b[0..16].copy_from_slice(&agg_root.to_le_bytes()); + b + }, n_sigs: 1, // H-1: These garbage bytes cannot be decoded as a RecursiveProof. proof_bytes: vec![0x33; 128], @@ -2288,7 +2299,7 @@ mod tests { start_block: Some(0), proof: shell_stark_prover::proof::SigBatchProof { version: shell_stark_prover::proof::SIG_BATCH_PROOF_VERSION, - batch_root_bytes: [0x22; 16], + batch_root_bytes: [0x22; 32], n_sigs: MIN_L1_STARK_TXS, proof_bytes: vec![0x33; 128], }, @@ -2531,7 +2542,7 @@ mod tests { start_block: Some(0), proof: shell_stark_prover::proof::SigBatchProof { version: shell_stark_prover::proof::SIG_BATCH_PROOF_VERSION, - batch_root_bytes: [0u8; 16], + batch_root_bytes: [0u8; 32], n_sigs: 999, // wrong — actual canonical entry count is 0 proof_bytes: vec![0x33; 128], }, diff --git a/crates/node/src/node/system_rewards.rs b/crates/node/src/node/system_rewards.rs index b9d4b06..813eee5 100644 --- a/crates/node/src/node/system_rewards.rs +++ b/crates/node/src/node/system_rewards.rs @@ -336,9 +336,10 @@ impl Node { ))); } - // Extract the L1 batch root as a u128 for aggregate computation. + // Extract the L1 batch root lo-half as a u128 for L2 aggregate computation. + // batch_root_bytes is [lo:16 ‖ hi:16]; the L2 recursive circuit operates on u128. let root_bytes = source_amendment.proof.batch_root_bytes; - let root = u128::from_le_bytes(root_bytes); + let root = u128::from_le_bytes(root_bytes[0..16].try_into().unwrap()); l1_roots.push(root); } @@ -355,7 +356,9 @@ impl Node { // Check 2: aggregate root must match compute_aggregate_root(l1_roots). let expected_agg_root = shell_stark_prover::recursive_air::compute_aggregate_root(&l1_roots); - let declared_agg_root = u128::from_le_bytes(amendment.proof.batch_root_bytes); + // batch_root_bytes is [lo:16 ‖ hi:16]; L2 aggregate root lives in the lo half. + let declared_agg_root = + u128::from_le_bytes(amendment.proof.batch_root_bytes[0..16].try_into().unwrap()); if expected_agg_root != declared_agg_root { self.metrics.stark_settlements_rejected.inc(); return Err(NodeError::Startup(format!( @@ -510,7 +513,9 @@ impl Node { let start_block = amendment .range_start_block() .unwrap_or(amendment.block_number); - let batch_root = u128::from_le_bytes(amendment.proof.batch_root_bytes); + // batch_root_bytes is [lo:16 ‖ hi:16]; SettledL1Input uses the lo half (u128). + let batch_root = + u128::from_le_bytes(amendment.proof.batch_root_bytes[0..16].try_into().unwrap()); let input = SettledL1Input { start_block, end_block: amendment.block_number, diff --git a/crates/pqvm/src/executor.rs b/crates/pqvm/src/executor.rs index 1441c10..4324b86 100644 --- a/crates/pqvm/src/executor.rs +++ b/crates/pqvm/src/executor.rs @@ -4,7 +4,6 @@ //! provides a high-level API for executing individual transactions and //! full blocks. -use alloy_primitives::Address as EvmAddress; use alloy_primitives::{Bytes as AlBytes, B256, U256}; use revm::context::result::ExecutionResult; use revm::context::{BlockEnv, CfgEnv, Context, Evm, TxEnv}; @@ -49,10 +48,6 @@ pub struct TxExecutionResult { pub receipt: TransactionReceipt, /// State changes produced by this transaction (for committing). pub state_changes: EvmState, - /// Maps 20-byte revm bridge address → full 32-byte PQ Shell address for accounts - /// whose upper 12 bytes are non-zero. Required by `commit_pqvm_state` to - /// write state to the correct canonical key in world_state. - pub pq_addr_map: std::collections::HashMap, /// The sender's nonce after this transaction (= tx.nonce + 1). Used by /// `commit_pqvm_state` to ensure the nonce is always advanced correctly even /// when revm's `disable_nonce_check = true` suppresses the normal increment. @@ -158,9 +153,9 @@ impl ShellPqvm { let sender_shell_addr = signed_tx.from; let sender_nonce_after = tx.nonce.saturating_add(1); - // Register the sender's full 32-byte PQ address so ShellStateDb::basic() - // can find it when revm queries by the 20-byte EVM address. - self.state_db.hint_pq_address(signed_tx.from); + // Register the sender's full 32-byte address so ShellStateDb can find + // it when revm queries by the 20-byte truncated form. + self.state_db.register_pq_address(signed_tx.from); // Build revm TxEnv let kind = match &tx.to { @@ -227,10 +222,6 @@ impl ShellPqvm { let exec_result = result_and_state.result; let state = result_and_state.state; - // Capture PQ address hints for commit_pqvm_state so it writes to the - // canonical Shell address rather than the zero-padded EVM address. - let pq_addr_map = self.state_db.pq_hints.clone(); - // Build receipt let gas_used = exec_result.gas().spent(); let new_cumulative = cumulative_gas_used.saturating_add(gas_used); @@ -283,7 +274,6 @@ impl ShellPqvm { Ok(TxExecutionResult { receipt, state_changes: state, - pq_addr_map, sender_shell_addr, sender_nonce_after, gas_used, @@ -389,7 +379,6 @@ impl ShellPqvm { return Ok(TxExecutionResult { receipt, state_changes: EvmState::default(), - pq_addr_map: Default::default(), sender_shell_addr: ShellAddress::default(), sender_nonce_after: 0, gas_used: 0, @@ -482,15 +471,12 @@ impl ShellPqvm { } } successful_values_sum = successful_values_sum.saturating_add(inner.value); - let cs_arc = std::sync::Arc::clone(self.state_db.chain_store().store()); - let cs_view = ChainStore::new(cs_arc); // Build a minimal result for commit_pqvm_state; no PQ addresses in AA // inner calls (they use EVM-canonical addresses), no nonce advance here // as outer tx handles it. let inner_result = TxExecutionResult { receipt: empty_receipt(), state_changes: state, - pq_addr_map: Default::default(), sender_shell_addr: ShellAddress::default(), sender_nonce_after: 0, gas_used: 0, @@ -498,7 +484,7 @@ impl ShellPqvm { is_system_tx: false, system_contract_effects: SystemContractEffects::default(), }; - commit_pqvm_state(&inner_result, self.state_db.world_state_mut(), &cs_view)?; + commit_pqvm_state(&inner_result, &mut self.state_db)?; } ExecutionResult::Revert { output, .. } => { atomic_failure = true; @@ -625,7 +611,6 @@ impl ShellPqvm { Ok(TxExecutionResult { receipt, state_changes: EvmState::default(), - pq_addr_map: Default::default(), sender_shell_addr: ShellAddress::default(), sender_nonce_after: 0, gas_used: total_gas_used, @@ -754,7 +739,6 @@ impl ShellPqvm { Ok(TxExecutionResult { receipt, state_changes: EvmState::default(), - pq_addr_map: Default::default(), sender_shell_addr: ShellAddress::default(), sender_nonce_after: 0, gas_used, @@ -791,7 +775,6 @@ impl ShellPqvm { Ok(TxExecutionResult { receipt, state_changes: EvmState::default(), - pq_addr_map: Default::default(), sender_shell_addr: ShellAddress::default(), sender_nonce_after: 0, gas_used, @@ -818,31 +801,17 @@ fn empty_receipt() -> TransactionReceipt { } } -/// Apply EVM state changes to a WorldState and ChainStore. -/// -/// Iterates the revm `EvmState` (address → account) and for each touched -/// account, updates balance, nonce, contract code, and storage slots. -/// -/// Call this after `ShellPqvm::execute_tx()` to persist the computed state -/// diff. For multi-transaction blocks, call after **each** transaction so -/// subsequent transactions see prior state updates. +/// Core commit logic shared by `commit_pqvm_state` and `commit_pqvm_state_raw`. /// -/// Uses `result.pq_addr_map` to write PQ-derived accounts to the correct -/// 32-byte canonical key, and `result.sender_nonce_after` to ensure the -/// sender's nonce advances even when revm's `disable_nonce_check = true`. -pub fn commit_pqvm_state( +/// `resolve` maps a 20-byte EVM address to the full 32-byte Shell address. +fn do_commit_state( result: &TxExecutionResult, world_state: &mut WorldState, chain_store: &ChainStore, + resolve: &impl Fn(&alloy_primitives::Address) -> ShellAddress, ) -> Result<(), ExecutorError> { - let state = &result.state_changes; - let pq_addr_map = &result.pq_addr_map; - - for (addr, acct) in state { - let shell_addr = pq_addr_map - .get(addr) - .copied() - .unwrap_or_else(|| ShellAddress::from(*addr)); + for (addr, acct) in &result.state_changes { + let shell_addr = resolve(addr); let info = &acct.info; let mut account = world_state @@ -859,7 +828,7 @@ pub fn commit_pqvm_state( account.nonce = info.nonce; account.balance = info.balance; - // Store deployed contract bytecode + // Store deployed contract bytecode. if let Some(code) = &info.code { let code_bytes = code.bytes_slice(); if !code_bytes.is_empty() && info.code_hash != KECCAK_EMPTY { @@ -871,7 +840,7 @@ pub fn commit_pqvm_state( world_state.set_account(&shell_addr, &account)?; - // Apply storage slot changes + // Apply storage slot changes. for (slot, value) in &acct.storage { let key = ShellHash::from(B256::from(*slot)); let val = ShellHash::from(B256::from(value.present_value)); @@ -879,26 +848,85 @@ pub fn commit_pqvm_state( } } - // Force-advance the sender's nonce to tx.nonce + 1. When revm runs with + // Force-advance the sender's nonce to tx.nonce + 1. When revm runs with // `disable_nonce_check = true` the nonce in EvmState is not incremented, // so we do it explicitly here for any non-system tx (sender_nonce_after > 0). if result.sender_nonce_after > 0 { - let sender = &result.sender_shell_addr; - let mut account = world_state.get_account(sender)?.unwrap_or_else(|| Account { - pq_pubkey_hash: ShellHash::default(), - nonce: 0, - balance: U256::ZERO, - validation_code_hash: None, - code_hash: None, - storage_root: ShellHash::ZERO, - }); + let sender = result.sender_shell_addr; + let mut account = world_state + .get_account(&sender)? + .unwrap_or_else(|| Account { + pq_pubkey_hash: ShellHash::default(), + nonce: 0, + balance: U256::ZERO, + validation_code_hash: None, + code_hash: None, + storage_root: ShellHash::ZERO, + }); account.nonce = account.nonce.max(result.sender_nonce_after); - world_state.set_account(sender, &account)?; + world_state.set_account(&sender, &account)?; } Ok(()) } +/// Apply EVM state changes to a WorldState and ChainStore. +/// +/// Iterates the revm `EvmState` (address → account) and for each touched +/// account, updates balance, nonce, contract code, and storage slots. +/// +/// Call this after `ShellPqvm::execute_tx()` to persist the computed state +/// diff. For multi-transaction blocks, call after **each** transaction so +/// subsequent transactions see prior state updates. +/// +/// Uses `result.sender_nonce_after` to ensure the sender's nonce advances +/// even when revm's `disable_nonce_check = true`. +/// +/// Uses `state_db.address_registry` to write PQ-derived accounts to their +/// correct 32-byte canonical key rather than the zero-padded 20-byte form. +/// Clears the registry after committing. +pub fn commit_pqvm_state( + result: &TxExecutionResult, + state_db: &mut ShellStateDb, +) -> Result<(), ExecutorError> { + // Clone the registry before the split borrow (typically 0–1 entries). + let registry = state_db.address_registry.clone(); + let resolve = |addr: &alloy_primitives::Address| { + registry + .get(addr) + .copied() + .unwrap_or_else(|| ShellAddress::from(*addr)) + }; + let (world_state, chain_store) = state_db.world_state_and_chain_store(); + do_commit_state(result, world_state, chain_store, &resolve)?; + state_db.clear_address_registry(); + Ok(()) +} + +/// Apply EVM state changes directly to a `WorldState` and `ChainStore`, +/// using an explicit address registry for PQ address resolution. +/// +/// This variant is used when the caller holds a separate `WorldState` +/// (e.g. the node's persistent world state) that must mirror execution +/// results from the EVM's in-process `ShellStateDb`. +/// +/// Obtain the registry with `state_db.address_registry_snapshot()` before +/// calling `commit_pqvm_state` (which clears it). +pub fn commit_pqvm_state_raw( + result: &TxExecutionResult, + world_state: &mut WorldState, + chain_store: &ChainStore, + registry: &std::collections::HashMap, +) -> Result<(), ExecutorError> { + let resolve = |addr: &alloy_primitives::Address| { + registry + .get(addr) + .copied() + .unwrap_or_else(|| ShellAddress::from(*addr)) + }; + do_commit_state(result, world_state, chain_store, &resolve) +} + #[cfg(test)] mod tests { use super::*; @@ -1453,11 +1481,9 @@ mod tests { // ── Helpers for advanced EVM tests ──────────────────────── fn commit_state(evm: &mut ShellPqvm, state: &EvmState) { - let (ws, cs) = evm.state_db_mut().world_state_and_chain_store(); let fake_result = TxExecutionResult { receipt: empty_receipt(), state_changes: state.clone(), - pq_addr_map: Default::default(), sender_shell_addr: ShellAddress::default(), sender_nonce_after: 0, gas_used: 0, @@ -1465,7 +1491,7 @@ mod tests { is_system_tx: false, system_contract_effects: SystemContractEffects::default(), }; - commit_pqvm_state(&fake_result, ws, cs).unwrap(); + commit_pqvm_state(&fake_result, evm.state_db_mut()).unwrap(); } fn deploy_contract( diff --git a/crates/pqvm/src/lib.rs b/crates/pqvm/src/lib.rs index 01a6db3..693d767 100644 --- a/crates/pqvm/src/lib.rs +++ b/crates/pqvm/src/lib.rs @@ -24,7 +24,9 @@ mod tx_validation; pub use aa_validation::{ validate_aa_tx, AaValidationError, AaValidationOutcome, VALIDATION_GAS_CAP, }; -pub use executor::{commit_pqvm_state, ExecutorError, ShellPqvm, TxExecutionResult}; +pub use executor::{ + commit_pqvm_state, commit_pqvm_state_raw, ExecutorError, ShellPqvm, TxExecutionResult, +}; pub use parallel::{ ConflictMetric, ConflictReason, ExecutionWave, ParallelExecutionPlan, ParallelPqvmConfig, ParallelScheduler, TxConflict, TxConflictGraph, diff --git a/crates/pqvm/src/state_db.rs b/crates/pqvm/src/state_db.rs index 26dc398..5de8720 100644 --- a/crates/pqvm/src/state_db.rs +++ b/crates/pqvm/src/state_db.rs @@ -3,6 +3,18 @@ //! [`ShellStateDb`] wraps a [`WorldState`] and [`ChainStore`] to satisfy //! revm's [`Database`] trait, translating between shell-chain's account //! model and the EVM account model. +//! +//! # Address model at the revm boundary +//! +//! revm's [`Database`] trait uses 20-byte [`alloy_primitives::Address`] +//! values throughout. Shell-Chain uses 32-byte BLAKE3 addresses +//! (`ShellAddress`). This file is the boundary where the translation happens. +//! +//! For EVM-compatible accounts (upper 12 bytes are all zero), the 20-byte +//! form is losslessly recovered by zero-padding. For PQ-derived accounts +//! (upper 12 bytes non-zero, produced by `PQADDR`), the 20-byte form is a +//! lossy truncation; full 32-byte-native execution requires a future revm +//! fork that passes `ShellAddress` through the EVM call stack. use alloy_primitives::{Address as EvmAddress, B256, U256}; use revm::database_interface::{DBErrorMarker, Database}; @@ -31,13 +43,24 @@ impl DBErrorMarker for StateDbError {} /// /// # Type Parameter /// - `S`: The key-value store backend (e.g. `MemoryDb` or `RocksDbStore`) +/// +/// # Address translation (revm compatibility bridge) +/// +/// revm's [`Database`] trait uses 20-byte [`alloy_primitives::Address`]. +/// Shell-Chain uses 32-byte BLAKE3 addresses (`ShellAddress`). For accounts +/// whose address has non-zero upper 12 bytes (PQ-derived via `PQADDR`), the +/// `address_registry` provides the full 32-byte address so that lookups +/// succeed when revm queries by the 20-byte truncated form. +/// +/// Full 32-byte-native execution throughout the EVM call stack requires a +/// future revm fork; this registry is the compatibility shim until then. pub struct ShellStateDb { world_state: WorldState, chain_store: ChainStore, - /// Maps 20-byte EVM address → full 32-byte Shell address for PQ-derived - /// accounts. Populated by the executor before each tx so that `basic()` - /// can locate accounts whose upper 12 bytes are non-zero. - pub(crate) pq_hints: HashMap, + /// Maps 20-byte revm address → full 32-byte Shell address for PQ-derived + /// accounts (upper 12 bytes non-zero). Populated by the executor before + /// each tx; cleared after commit. + pub(crate) address_registry: HashMap, } impl ShellStateDb { @@ -49,25 +72,46 @@ impl ShellStateDb { Self { world_state, chain_store, - pq_hints: HashMap::new(), + address_registry: HashMap::new(), } } /// Register the full 32-byte Shell address for a PQ-derived account so - /// that `basic()` can find it when the EVM queries by 20-byte suffix. - /// Call this before executing any transaction whose `from` is PQ-derived - /// (i.e. has non-zero upper 12 bytes). - pub fn hint_pq_address(&mut self, addr: ShellAddress) { + /// that `basic()` and `storage()` can find it when revm queries by the + /// 20-byte truncated form. Call this before executing any transaction + /// whose `from` has non-zero upper 12 bytes. + pub fn register_pq_address(&mut self, addr: ShellAddress) { let evm_addr: EvmAddress = addr.into(); let zero_padded = ShellAddress::from(evm_addr); if zero_padded != addr { - self.pq_hints.insert(evm_addr, addr); + self.address_registry.insert(evm_addr, addr); } } - /// Clear all PQ address hints registered for the previous transaction. - pub fn clear_pq_hints(&mut self) { - self.pq_hints.clear(); + /// Clear the address registry after a transaction has been committed. + pub fn clear_address_registry(&mut self) { + self.address_registry.clear(); + } + + /// Return a snapshot of the address registry (cloned). + /// + /// Use this to obtain the registry before `commit_pqvm_state` clears it, + /// so it can be passed to `commit_pqvm_state_raw` for a second commit + /// target (e.g. the node's persistent WorldState). + pub fn address_registry_snapshot( + &self, + ) -> std::collections::HashMap { + self.address_registry.clone() + } + + /// Resolve a 20-byte EVM address to a full 32-byte Shell address. + /// Checks the registry first; falls back to zero-padding. + #[inline] + pub(crate) fn resolve_address(&self, addr: &EvmAddress) -> ShellAddress { + self.address_registry + .get(addr) + .copied() + .unwrap_or_else(|| ShellAddress::from(*addr)) } /// Returns a reference to the underlying WorldState. @@ -119,19 +163,11 @@ impl Database for ShellStateDb { &mut self, address: alloy_primitives::Address, ) -> Result, Self::Error> { - let shell_addr = ShellAddress::from(address); - if let Some(account) = self.world_state.get_account(&shell_addr)? { - return Ok(Some(Self::to_account_info(&account))); + let shell_addr = self.resolve_address(&address); + match self.world_state.get_account(&shell_addr)? { + Some(account) => Ok(Some(Self::to_account_info(&account))), + None => Ok(None), } - // Fallback: check PQ hints — the EVM uses the 20-byte suffix of a - // 32-byte PQ-derived address, so the zero-padded lookup above misses - // accounts stored at the full PQ address. - if let Some(&pq_addr) = self.pq_hints.get(&address) { - if let Some(account) = self.world_state.get_account(&pq_addr)? { - return Ok(Some(Self::to_account_info(&account))); - } - } - Ok(None) } fn code_by_hash(&mut self, code_hash: B256) -> Result { @@ -149,11 +185,7 @@ impl Database for ShellStateDb { address: alloy_primitives::Address, index: U256, ) -> Result { - let shell_addr = if let Some(&pq) = self.pq_hints.get(&address) { - pq - } else { - ShellAddress::from(address) - }; + let shell_addr = self.resolve_address(&address); let key = ShellHash::from(B256::from(index)); let value_hash = self.world_state.get_storage(&shell_addr, &key)?; Ok(U256::from_be_bytes(*value_hash.as_bytes())) diff --git a/crates/pqvm/tests/common/mod.rs b/crates/pqvm/tests/common/mod.rs index 43d52e5..620f5e8 100644 --- a/crates/pqvm/tests/common/mod.rs +++ b/crates/pqvm/tests/common/mod.rs @@ -99,8 +99,7 @@ pub fn apply_tx( .expect("execute_tx failed"); if !result.is_system_tx { - commit_pqvm_state(&result, evm.state_db_mut().world_state_mut(), chain_store) - .expect("commit_pqvm_state failed"); + commit_pqvm_state(&result, evm.state_db_mut()).expect("commit_pqvm_state failed"); } result diff --git a/crates/pqvm/tests/integration.rs b/crates/pqvm/tests/integration.rs index 5f7d9e4..ef29abb 100644 --- a/crates/pqvm/tests/integration.rs +++ b/crates/pqvm/tests/integration.rs @@ -262,9 +262,8 @@ fn e2e_hybrid_pubkey_second_tx_from_registry() { assert_eq!(r1.receipt.status, 1); // Commit the nonce change from execution using commit_pqvm_state so PQ - // addresses are correctly resolved via the pq_addr_map. - shell_pqvm::commit_pqvm_state(&r1, evm.state_db_mut().world_state_mut(), &cs) - .expect("commit r1 failed"); + // addresses are correctly resolved via the address_registry. + shell_pqvm::commit_pqvm_state(&r1, evm.state_db_mut()).expect("commit r1 failed"); // Second tx: NO pubkey attached — should read from registry let tx2 = Transaction { diff --git a/crates/stark-prover/Cargo.toml b/crates/stark-prover/Cargo.toml index 6621027..188f365 100644 --- a/crates/stark-prover/Cargo.toml +++ b/crates/stark-prover/Cargo.toml @@ -8,6 +8,7 @@ description = "STARK-based aggregate proof for PQ (Dilithium3) signature batches [dependencies] winterfell.workspace = true +blake3 = { workspace = true } shell-crypto = { path = "../crypto" } shell-core = { path = "../core" } shell-primitives = { path = "../primitives" } diff --git a/crates/stark-prover/src/air.rs b/crates/stark-prover/src/air.rs index bcde3e2..9b7e122 100644 --- a/crates/stark-prover/src/air.rs +++ b/crates/stark-prover/src/air.rs @@ -2,6 +2,21 @@ //! commitment circuit. //! //! See [`crate`] module docs for the full circuit description. +//! +//! # Circuit summary +//! +//! Each signature is mapped to a 256-bit BLAKE3 leaf: +//! `leaf_i = BLAKE3(msg_hash_i ‖ pk_hash_i)` +//! +//! The leaf is split into two f128 field elements: +//! `leaf_lo_i = u128::from_le_bytes(leaf_i[0..16])` +//! `leaf_hi_i = u128::from_le_bytes(leaf_i[16..32])` +//! +//! Two parallel degree-3 accumulators produce the 256-bit batch root: +//! `acc_lo[t+1] = acc_lo[t]^3 + leaf_lo[t]` +//! `acc_hi[t+1] = acc_hi[t]^3 + leaf_hi[t]` +//! +//! The batch root is `acc_lo_final ‖ acc_hi_final` (32 LE bytes). use winterfell::{ math::{fields::f128::BaseElement, FieldElement, ToElements}, @@ -13,32 +28,44 @@ use winterfell::{ /// Public inputs for the signature batch commitment proof. /// -/// - `batch_root`: the final accumulator value after hashing all entries. +/// - `batch_root_lo` / `batch_root_hi`: the two halves of the 256-bit root. /// - `n_sigs`: number of signatures included in the batch. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SigBatchPublicInputs { - /// Final accumulator value — committed to in the block header. - pub batch_root: BaseElement, + /// Low 128 bits of the final batch root (acc_lo after all entries). + pub batch_root_lo: BaseElement, + /// High 128 bits of the final batch root (acc_hi after all entries). + pub batch_root_hi: BaseElement, /// Number of signatures in the batch (padded trace length may be larger). pub n_sigs: usize, } impl ToElements for SigBatchPublicInputs { fn to_elements(&self) -> Vec { - vec![self.batch_root, BaseElement::new(self.n_sigs as u128)] + vec![ + self.batch_root_lo, + self.batch_root_hi, + BaseElement::new(self.n_sigs as u128), + ] } } // ── AIR ────────────────────────────────────────────────────────────────────── /// Trace column indices. -pub const COL_ACC: usize = 0; -pub const COL_ENTRY: usize = 1; +pub const COL_ACC_LO: usize = 0; +pub const COL_ACC_HI: usize = 1; +pub const COL_LEAF_LO: usize = 2; +pub const COL_LEAF_HI: usize = 3; /// Number of trace columns. -pub const TRACE_WIDTH: usize = 2; +pub const TRACE_WIDTH: usize = 4; -/// AIR for the hash-chain accumulator circuit. +/// AIR for the dual hash-chain accumulator circuit. +/// +/// Two parallel degree-3 transitions: +/// - `acc_lo[t+1] = acc_lo[t]^3 + leaf_lo[t]` +/// - `acc_hi[t+1] = acc_hi[t]^3 + leaf_hi[t]` pub struct SigBatchAir { context: AirContext, pub_inputs: SigBatchPublicInputs, @@ -55,12 +82,16 @@ impl Air for SigBatchAir { "SigBatchAir requires exactly {TRACE_WIDTH} trace columns" ); - // Single transition constraint of degree 3: - // acc[t+1] = acc[t]^3 + entry[t] - let degrees = vec![TransitionConstraintDegree::new(3)]; + // Two parallel transition constraints of degree 3: + // acc_lo[t+1] = acc_lo[t]^3 + leaf_lo[t] + // acc_hi[t+1] = acc_hi[t]^3 + leaf_hi[t] + let degrees = vec![ + TransitionConstraintDegree::new(3), + TransitionConstraintDegree::new(3), + ]; - // Two boundary assertions: acc at step 0 and at the last step. - let num_assertions = 2; + // Four boundary assertions: both accumulators at step 0 and last step. + let num_assertions = 4; SigBatchAir { context: AirContext::new(trace_info, degrees, num_assertions, options), @@ -74,21 +105,28 @@ impl Air for SigBatchAir { _periodic_values: &[E], result: &mut [E], ) { - let cur_acc = frame.current()[COL_ACC]; - let cur_entry = frame.current()[COL_ENTRY]; - let next_acc = frame.next()[COL_ACC]; - - // acc[t+1] - (acc[t]^3 + entry[t]) = 0 - result[0] = next_acc - (cur_acc.exp(3u32.into()) + cur_entry); + let cur_acc_lo = frame.current()[COL_ACC_LO]; + let cur_acc_hi = frame.current()[COL_ACC_HI]; + let cur_leaf_lo = frame.current()[COL_LEAF_LO]; + let cur_leaf_hi = frame.current()[COL_LEAF_HI]; + let next_acc_lo = frame.next()[COL_ACC_LO]; + let next_acc_hi = frame.next()[COL_ACC_HI]; + + // acc_lo[t+1] - (acc_lo[t]^3 + leaf_lo[t]) = 0 + result[0] = next_acc_lo - (cur_acc_lo.exp(3u32.into()) + cur_leaf_lo); + // acc_hi[t+1] - (acc_hi[t]^3 + leaf_hi[t]) = 0 + result[1] = next_acc_hi - (cur_acc_hi.exp(3u32.into()) + cur_leaf_hi); } fn get_assertions(&self) -> Vec> { let last_step = self.trace_length() - 1; vec![ - // Accumulator must start at zero. - Assertion::single(COL_ACC, 0, BaseElement::ZERO), - // Accumulator must end at the claimed batch_root. - Assertion::single(COL_ACC, last_step, self.pub_inputs.batch_root), + // Both accumulators must start at zero. + Assertion::single(COL_ACC_LO, 0, BaseElement::ZERO), + Assertion::single(COL_ACC_HI, 0, BaseElement::ZERO), + // Both accumulators must end at the claimed batch root halves. + Assertion::single(COL_ACC_LO, last_step, self.pub_inputs.batch_root_lo), + Assertion::single(COL_ACC_HI, last_step, self.pub_inputs.batch_root_hi), ] } diff --git a/crates/stark-prover/src/proof.rs b/crates/stark-prover/src/proof.rs index f4c2455..bd05d83 100644 --- a/crates/stark-prover/src/proof.rs +++ b/crates/stark-prover/src/proof.rs @@ -3,29 +3,34 @@ //! //! [`SigBatchProof`] bundles the Winterfell [`Proof`] bytes with the //! `batch_root` and `n_sigs` needed to verify it. This is what gets stored -//! in `BlockHeader::sig_aggregate_proof`. +//! in `BlockHeader::sig_aggregate_proof` as a full proof (when available). +//! +//! For the compact commitment stored in the block header at production time, +//! only `batch_root_bytes` (32 bytes) and `n_sigs` are required; the full +//! `proof_bytes` are populated later and gossiped as a `ProofAmendment`. use serde::{Deserialize, Serialize}; use winterfell::Proof; /// Current serialization version tag. -pub const SIG_BATCH_PROOF_VERSION: u8 = 1; +pub const SIG_BATCH_PROOF_VERSION: u8 = 2; /// Serializable aggregate proof for a block's signature batch. /// /// Contains everything a verifier needs: -/// - `batch_root`: the 16-byte (128-bit field element) final accumulator. +/// - `batch_root`: the 32-byte (256-bit) Merkle-accumulator root. /// - `n_sigs`: number of signatures included. -/// - `proof_bytes`: Winterfell [`Proof`] serialized via its own codec. +/// - `proof_bytes`: Winterfell [`Proof`] serialized via its own codec +/// (empty when only the compact commitment is stored in the header). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SigBatchProof { /// Protocol version (always [`SIG_BATCH_PROOF_VERSION`]). pub version: u8, - /// Final accumulator value as 16 little-endian bytes. - pub batch_root_bytes: [u8; 16], + /// Final accumulator root as 32 little-endian bytes (256-bit). + pub batch_root_bytes: [u8; 32], /// Number of signatures covered by this proof. pub n_sigs: usize, - /// Raw Winterfell proof bytes. + /// Raw Winterfell proof bytes (empty for commitment-only payloads). pub proof_bytes: Vec, } @@ -40,8 +45,26 @@ impl SigBatchProof { serde_json::from_slice(bytes) } + /// Build a commitment-only payload (no STARK proof bytes). + /// + /// Use this to populate `BlockHeader::sig_aggregate_proof` immediately + /// during block production, before async STARK proving completes. + pub fn commitment_only(batch_root_bytes: [u8; 32], n_sigs: usize) -> Self { + Self { + version: SIG_BATCH_PROOF_VERSION, + batch_root_bytes, + n_sigs, + proof_bytes: Vec::new(), + } + } + + /// Returns true if this carries a full STARK proof (not just a commitment). + pub fn has_proof(&self) -> bool { + !self.proof_bytes.is_empty() + } + /// Wrap a raw Winterfell proof. - pub fn from_proof(proof: Proof, batch_root_bytes: [u8; 16], n_sigs: usize) -> Self { + pub fn from_proof(proof: Proof, batch_root_bytes: [u8; 32], n_sigs: usize) -> Self { let proof_bytes = proof.to_bytes(); Self { version: SIG_BATCH_PROOF_VERSION, diff --git a/crates/stark-prover/src/prover.rs b/crates/stark-prover/src/prover.rs index a53ea77..119e879 100644 --- a/crates/stark-prover/src/prover.rs +++ b/crates/stark-prover/src/prover.rs @@ -4,6 +4,7 @@ //! //! - [`prove_sig_batch`]: build trace from entries, generate STARK proof. //! - [`verify_sig_batch`]: verify a [`SigBatchProof`] against claimed public inputs. +//! - [`compute_batch_root`]: compute the 32-byte Merkle-accumulator root without proving. use winterfell::verify; use winterfell::{ @@ -16,7 +17,10 @@ use winterfell::{ }; use crate::{ - air::{SigBatchAir, SigBatchPublicInputs, COL_ACC, COL_ENTRY, TRACE_WIDTH}, + air::{ + SigBatchAir, SigBatchPublicInputs, COL_ACC_HI, COL_ACC_LO, COL_LEAF_HI, COL_LEAF_LO, + TRACE_WIDTH, + }, proof::SigBatchProof, }; @@ -24,25 +28,34 @@ use crate::{ /// One entry in the signature batch — derived from a verified signature. /// -/// The entry value is computed by XOR-folding the first 16 bytes of -/// `msg_hash` and `pk_hash`, then interpreting the result as a little-endian -/// `u128` field element. +/// The entry leaf is `BLAKE3(msg_hash ‖ pk_hash)` — a 256-bit value with +/// full collision resistance per WP §STARK. The leaf is split into two +/// 128-bit f128 field elements (`lo`, `hi`) for the dual-accumulator STARK. #[derive(Debug, Clone)] pub struct SigBatchEntry { - /// First 32 bytes of the message hash (e.g. SHA3-256 of message bytes). + /// First 32 bytes of the message hash (BLAKE3 of the transaction signing bytes). pub msg_hash: [u8; 32], - /// First 32 bytes of the public key hash (e.g. SHA3-256 of serialised pubkey). + /// First 32 bytes of the public key hash (BLAKE3 of the serialized pubkey). pub pk_hash: [u8; 32], } impl SigBatchEntry { - /// Derive the field element entry value for this signature. - pub fn to_field_element(&self) -> BaseElement { - let mut bytes = [0u8; 16]; - for (i, b) in bytes.iter_mut().enumerate() { - *b = self.msg_hash[i] ^ self.pk_hash[i]; - } - BaseElement::new(u128::from_le_bytes(bytes)) + /// Compute the 256-bit BLAKE3 leaf: `BLAKE3(msg_hash ‖ pk_hash)`. + pub fn to_leaf_bytes(&self) -> [u8; 32] { + let mut input = [0u8; 64]; + input[..32].copy_from_slice(&self.msg_hash); + input[32..].copy_from_slice(&self.pk_hash); + *blake3::hash(&input).as_bytes() + } + + /// Split the 32-byte BLAKE3 leaf into two f128 field elements (lo, hi). + /// + /// `lo = u128::from_le_bytes(leaf[0..16])`, `hi = u128::from_le_bytes(leaf[16..32])`. + pub fn to_field_elements(&self) -> (BaseElement, BaseElement) { + let leaf = self.to_leaf_bytes(); + let lo = u128::from_le_bytes(leaf[0..16].try_into().unwrap()); + let hi = u128::from_le_bytes(leaf[16..32].try_into().unwrap()); + (BaseElement::new(lo), BaseElement::new(hi)) } } @@ -66,65 +79,79 @@ pub fn default_proof_options() -> ProofOptions { // ── Trace builder ───────────────────────────────────────────────────────────── -/// Build the execution trace for the hash-chain accumulator circuit. +/// Build the 4-column execution trace for the dual hash-chain accumulator circuit. +/// +/// Returns `(trace, batch_root_lo, batch_root_hi)`. /// -/// Returns `(trace, batch_root)`. -pub fn build_trace(entries: &[SigBatchEntry]) -> (TraceTable, BaseElement) { +/// Columns: `[acc_lo, acc_hi, leaf_lo, leaf_hi]` +/// Transitions: `acc_lo[t+1] = acc_lo[t]^3 + leaf_lo[t]` +/// `acc_hi[t+1] = acc_hi[t]^3 + leaf_hi[t]` +pub fn build_trace( + entries: &[SigBatchEntry], +) -> (TraceTable, BaseElement, BaseElement) { assert!(!entries.is_empty(), "batch must have at least one entry"); - // Minimum trace length is 8 rows (Winterfell requirement); round up to - // next power of two. - // - // IMPORTANT: we always add +1 before rounding so there is **at least one - // padding row**. The boundary assertion checks `acc[trace_len - 1] == - // batch_root`, which is only true if the last row is a stable padding row - // where `acc` has already been updated by all real entries. If - // `trace_len == n_entries` exactly (no padding), the last row would hold - // the intermediate accumulator before the final entry is applied — causing - // `InconsistentOodConstraintEvaluations` during verification. + // Minimum trace length is 8 rows (Winterfell requirement); always add +1 + // to ensure at least one stable padding row at the end. let trace_len = ((entries.len() + 1).max(8)).next_power_of_two(); - // Pre-compute all accumulator values and entry values. - let mut acc = BaseElement::ZERO; - let mut accs: Vec = Vec::with_capacity(trace_len); - let mut entry_vals: Vec = Vec::with_capacity(trace_len); + // Pre-compute all accumulator and leaf values. + let mut acc_lo = BaseElement::ZERO; + let mut acc_hi = BaseElement::ZERO; + let mut accs_lo: Vec = Vec::with_capacity(trace_len); + let mut accs_hi: Vec = Vec::with_capacity(trace_len); + let mut leafs_lo: Vec = Vec::with_capacity(trace_len); + let mut leafs_hi: Vec = Vec::with_capacity(trace_len); for entry in entries.iter() { - let fe = entry.to_field_element(); - accs.push(acc); - entry_vals.push(fe); - acc = acc.exp(3u32.into()) + fe; + let (lo, hi) = entry.to_field_elements(); + accs_lo.push(acc_lo); + accs_hi.push(acc_hi); + leafs_lo.push(lo); + leafs_hi.push(hi); + acc_lo = acc_lo.exp(3u32.into()) + lo; + acc_hi = acc_hi.exp(3u32.into()) + hi; } - // Padding rows: keep acc stable by choosing entry = acc - acc^3. - // This satisfies the transition: acc^3 + (acc - acc^3) = acc. ✓ + // Padding rows: keep both accumulators stable. + // acc_lo^3 + pad_lo = acc_lo => pad_lo = acc_lo - acc_lo^3 for _ in entries.len()..trace_len { - let padding_entry = acc - acc.exp(3u32.into()); - accs.push(acc); - entry_vals.push(padding_entry); + let pad_lo = acc_lo - acc_lo.exp(3u32.into()); + let pad_hi = acc_hi - acc_hi.exp(3u32.into()); + accs_lo.push(acc_lo); + accs_hi.push(acc_hi); + leafs_lo.push(pad_lo); + leafs_hi.push(pad_hi); } - let batch_root = acc; + let batch_root_lo = acc_lo; + let batch_root_hi = acc_hi; // Fill the Winterfell TraceTable. - let accs_clone = accs.clone(); - let evs_clone = entry_vals.clone(); + let al = accs_lo.clone(); + let ah = accs_hi.clone(); + let ll = leafs_lo.clone(); + let lh = leafs_hi.clone(); let mut trace = TraceTable::new(TRACE_WIDTH, trace_len); trace.fill( |state| { - state[COL_ACC] = accs_clone[0]; - state[COL_ENTRY] = evs_clone[0]; + state[COL_ACC_LO] = al[0]; + state[COL_ACC_HI] = ah[0]; + state[COL_LEAF_LO] = ll[0]; + state[COL_LEAF_HI] = lh[0]; }, |step, state| { let next = step + 1; if next < trace_len { - state[COL_ACC] = accs[next]; - state[COL_ENTRY] = entry_vals[next]; + state[COL_ACC_LO] = accs_lo[next]; + state[COL_ACC_HI] = accs_hi[next]; + state[COL_LEAF_LO] = leafs_lo[next]; + state[COL_LEAF_HI] = leafs_hi[next]; } }, ); - (trace, batch_root) + (trace, batch_root_lo, batch_root_hi) } // ── Winterfell Prover impl ──────────────────────────────────────────────────── @@ -205,32 +232,38 @@ impl Prover for SigBatchProverImpl { // ── Public API ──────────────────────────────────────────────────────────────── -/// Recompute the batch root for a slice of entries without building the full -/// Winterfell execution trace. +/// Compute the 32-byte batch root for a slice of entries without building +/// the full Winterfell execution trace. /// -/// Uses the same degree-3 accumulator as [`build_trace`]: -/// `acc[t+1] = acc[t]^3 + entry[t]`, starting from `acc = 0`. +/// Each entry produces a 256-bit BLAKE3 leaf `BLAKE3(msg_hash ‖ pk_hash)`. +/// The leaf is split into (lo, hi) f128 halves, which are accumulated via: +/// `acc_lo[t+1] = acc_lo[t]^3 + leaf_lo[t]` +/// `acc_hi[t+1] = acc_hi[t]^3 + leaf_hi[t]` /// -/// Returns 16 little-endian bytes of the final field element (identical to -/// [`SigBatchProof::batch_root_bytes`]). For an empty slice the result is -/// all-zero bytes (BaseElement::ZERO). +/// Returns the 32-byte root `acc_lo_final ‖ acc_hi_final` (16 LE bytes each). +/// For an empty slice the result is 32 zero bytes. /// /// Callers can compare the returned bytes against `proof.batch_root_bytes` to /// verify that a proof covers exactly the canonical entries they expect. -pub fn compute_batch_root(entries: &[SigBatchEntry]) -> [u8; 16] { - let mut acc = BaseElement::ZERO; +pub fn compute_batch_root(entries: &[SigBatchEntry]) -> [u8; 32] { + let mut acc_lo = BaseElement::ZERO; + let mut acc_hi = BaseElement::ZERO; for entry in entries { - acc = acc.exp(3u32.into()) + entry.to_field_element(); + let (lo, hi) = entry.to_field_elements(); + acc_lo = acc_lo.exp(3u32.into()) + lo; + acc_hi = acc_hi.exp(3u32.into()) + hi; } - let mut bytes = [0u8; 16]; - bytes.copy_from_slice(&acc.as_int().to_le_bytes()); + let mut bytes = [0u8; 32]; + bytes[0..16].copy_from_slice(&acc_lo.as_int().to_le_bytes()); + bytes[16..32].copy_from_slice(&acc_hi.as_int().to_le_bytes()); bytes } +/// Generate a STARK proof for a slice of signature batch entries. /// -/// The caller is responsible for verifying all Dilithium3 signatures natively -/// before calling this function. The STARK proves only that the hash-chain -/// accumulator was correctly computed over the entries. +/// The caller is responsible for verifying all PQ signatures natively +/// before calling this function. The STARK proves only that the dual +/// hash-chain accumulator was correctly computed over the BLAKE3 entries. /// /// # Errors /// Returns `Err(String)` if the Winterfell prover fails. @@ -240,11 +273,13 @@ pub fn prove_sig_batch(entries: &[SigBatchEntry]) -> Result Result Result<(), String> { + if !sig_proof.has_proof() { + // Commitment-only payloads carry no verifiable STARK proof bytes. + return Err( + "sig_aggregate_proof is commitment-only; full STARK proof not yet settled".to_string(), + ); + } let inner = sig_proof.inner_proof()?; - let batch_root_u128 = u128::from_le_bytes(sig_proof.batch_root_bytes); - let batch_root = BaseElement::new(batch_root_u128); + let (batch_root_lo, batch_root_hi) = bytes_to_root(&sig_proof.batch_root_bytes); let pub_inputs = SigBatchPublicInputs { - batch_root, + batch_root_lo, + batch_root_hi, n_sigs: sig_proof.n_sigs, }; let acceptable = AcceptableOptions::OptionSet(vec![default_proof_options()]); @@ -272,6 +314,21 @@ pub fn verify_sig_batch(sig_proof: &SigBatchProof) -> Result<(), String> { .map_err(|e| format!("verify_sig_batch failed: {:?}", e)) } +// ── Internal helpers ────────────────────────────────────────────────────────── + +fn root_to_bytes(lo: BaseElement, hi: BaseElement) -> [u8; 32] { + let mut bytes = [0u8; 32]; + bytes[0..16].copy_from_slice(&lo.as_int().to_le_bytes()); + bytes[16..32].copy_from_slice(&hi.as_int().to_le_bytes()); + bytes +} + +fn bytes_to_root(bytes: &[u8; 32]) -> (BaseElement, BaseElement) { + let lo = u128::from_le_bytes(bytes[0..16].try_into().unwrap()); + let hi = u128::from_le_bytes(bytes[16..32].try_into().unwrap()); + (BaseElement::new(lo), BaseElement::new(hi)) +} + // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -290,7 +347,9 @@ mod tests { let entries = vec![make_entry(1)]; let proof = prove_sig_batch(&entries).expect("prove failed"); assert_eq!(proof.n_sigs, 1); + assert_eq!(proof.batch_root_bytes.len(), 32); assert!(proof.size_bytes() > 0, "proof should have bytes"); + assert!(proof.has_proof()); verify_sig_batch(&proof).expect("verify failed"); } @@ -333,4 +392,36 @@ mod tests { fn empty_batch_returns_error() { assert!(prove_sig_batch(&[]).is_err()); } + + #[test] + fn compute_batch_root_matches_prove() { + let entries: Vec<_> = (1u8..=4).map(make_entry).collect(); + let proof = prove_sig_batch(&entries).expect("prove failed"); + let root = compute_batch_root(&entries); + assert_eq!(root, proof.batch_root_bytes); + } + + #[test] + fn commitment_only_is_not_verifiable() { + let entries = vec![make_entry(1)]; + let root = compute_batch_root(&entries); + let commitment = SigBatchProof::commitment_only(root, 1); + assert!(!commitment.has_proof()); + let result = verify_sig_batch(&commitment); + assert!(result.is_err()); + } + + #[test] + fn leaf_bytes_differ_from_inputs() { + // BLAKE3(msg ‖ pk) must differ from just XOR-folding the inputs. + let e = make_entry(0xAB); + let leaf = e.to_leaf_bytes(); + // The leaf should not be trivially zero or match a naive XOR of inputs. + assert_ne!(leaf, [0u8; 32]); + let mut xor_fold = [0u8; 32]; + for (i, b) in xor_fold.iter_mut().enumerate() { + *b = e.msg_hash[i] ^ e.pk_hash[i]; + } + assert_ne!(leaf, xor_fold, "BLAKE3 leaf must differ from raw XOR fold"); + } }