From 65ea52e5f81b9101d1740ae39158285fd455a419 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Mon, 23 Mar 2026 11:40:24 +0800 Subject: [PATCH 1/4] use prevalidate proof in validate_unsigned this prevents fake proofs from getting into the tx pool --- pallets/wormhole/src/lib.rs | 121 +++++++++++++++++------------------- 1 file changed, 56 insertions(+), 65 deletions(-) diff --git a/pallets/wormhole/src/lib.rs b/pallets/wormhole/src/lib.rs index c4e43fa7..f4837213 100644 --- a/pallets/wormhole/src/lib.rs +++ b/pallets/wormhole/src/lib.rs @@ -48,7 +48,10 @@ pub mod pallet { }, }; use frame_system::pallet_prelude::*; - use qp_wormhole_verifier::{parse_aggregated_public_inputs, ProofWithPublicInputs, C, D, F}; + use qp_wormhole_verifier::{ + parse_aggregated_public_inputs, AggregatedPublicCircuitInputs, ProofWithPublicInputs, C, D, + F, + }; use sp_runtime::{ traits::{MaybeDisplay, Saturating, Zero}, transaction_validity::{ @@ -226,72 +229,22 @@ pub mod pallet { ) -> DispatchResult { ensure_none(origin)?; - let verifier = crate::get_aggregated_verifier() - .map_err(|_| Error::::AggregatedVerifierNotAvailable)?; - - let proof = ProofWithPublicInputs::::from_bytes( - proof_bytes, - &verifier.circuit_data.common, - ) - .map_err(|e| { - log::error!("Failed to deserialize aggregated proof: {:?}", e); - Error::::AggregatedProofDeserializationFailed - })?; - - // Parse aggregated public inputs - let aggregated_inputs = parse_aggregated_public_inputs(&proof).map_err(|e| { - log::error!("Failed to parse aggregated public inputs: {:?}", e); - Error::::InvalidAggregatedPublicInputs - })?; - - // === Cheap checks first (before expensive ZK verification) === - - // Verify the proof is for native asset only (asset_id = 0) - // Non-native assets are not supported in this version - ensure!(aggregated_inputs.asset_id == 0, Error::::NonNativeAssetNotSupported); - - // Verify the volume fee rate matches our configured rate - ensure!( - aggregated_inputs.volume_fee_bps == T::VolumeFeeRateBps::get(), - Error::::InvalidVolumeFeeRate - ); - - // Convert block number from u32 to BlockNumberFor - let block_number = BlockNumberFor::::from(aggregated_inputs.block_data.block_number); + let (proof, aggregated_inputs) = Self::pre_validate_proof(&proof_bytes) + .map_err(|_| Error::::InvalidAggregatedPublicInputs)?; - // Get the block hash for the specified block number - let block_hash = frame_system::Pallet::::block_hash(block_number); - - // Validate that the block exists by checking if it's not the default hash - // The default hash (all zeros) indicates the block doesn't exist - // If we don't check this a malicious prover can set the block_hash to 0 - // and block_number in the future and this check will pass - let default_hash = T::Hash::default(); - ensure!(block_hash != default_hash, Error::::BlockNotFound); - - // Ensure that the block hash from storage matches the one in public inputs - ensure!( - block_hash.as_ref() == aggregated_inputs.block_data.block_hash.as_ref(), - Error::::InvalidPublicInputs - ); - - // Check and mark nullifiers as used (catches replays and duplicates within proof) + // Mark nullifiers as used (pre_validate_proof only checks existence) let mut nullifier_list = Vec::<[u8; 32]>::new(); for nullifier in &aggregated_inputs.nullifiers { let nullifier_bytes: [u8; 32] = (*nullifier) .as_ref() .try_into() .map_err(|_| Error::::InvalidAggregatedPublicInputs)?; - ensure!( - !UsedNullifiers::::contains_key(nullifier_bytes), - Error::::NullifierAlreadyUsed - ); UsedNullifiers::::insert(nullifier_bytes, true); nullifier_list.push(nullifier_bytes); } - // === Expensive ZK verification === - + let verifier = crate::get_aggregated_verifier() + .map_err(|_| Error::::AggregatedVerifierNotAvailable)?; verifier.verify(proof.clone()).map_err(|e| { log::error!("Aggregated proof verification failed: {:?}", e); Error::::AggregatedVerificationFailed @@ -454,14 +407,9 @@ pub mod pallet { fn validate_unsigned(_source: TransactionSource, call: &Self::Call) -> TransactionValidity { match call { Call::verify_aggregated_proof { proof_bytes } => { - // Basic validation: check proof bytes are not empty - if proof_bytes.is_empty() { - return InvalidTransaction::Custom(2).into(); - } + Self::pre_validate_proof(proof_bytes)?; ValidTransaction::with_tag_prefix("WormholeAggregatedVerify") .and_provides(sp_io::hashing::blake2_256(proof_bytes)) - // Use reduced priority to prevent spam from blocking legitimate - // transactions .priority(TransactionPriority::MAX / 2) .longevity(5) .propagate(true) @@ -472,10 +420,53 @@ pub mod pallet { } } - // Helper functions for recording transfer proofs impl Pallet { - /// Record a transfer proof - /// This should be called by transaction extensions or other runtime components + /// Pre-validate an aggregated proof (all cheap checks, no ZK verification). + /// Called by both validate_unsigned (pool gating) and dispatch (defense-in-depth). + fn pre_validate_proof( + proof_bytes: &[u8], + ) -> Result< + (ProofWithPublicInputs, AggregatedPublicCircuitInputs), + InvalidTransaction, + > { + if proof_bytes.is_empty() { + return Err(InvalidTransaction::Custom(2)); + } + let verifier = crate::get_aggregated_verifier() + .map_err(|_| InvalidTransaction::Custom(3))?; + let proof = ProofWithPublicInputs::::from_bytes( + proof_bytes.to_vec(), + &verifier.circuit_data.common, + ) + .map_err(|_| InvalidTransaction::Custom(4))?; + let inputs = parse_aggregated_public_inputs(&proof) + .map_err(|_| InvalidTransaction::Custom(5))?; + if inputs.asset_id != 0 { + return Err(InvalidTransaction::Custom(6)); + } + if inputs.volume_fee_bps != T::VolumeFeeRateBps::get() { + return Err(InvalidTransaction::Custom(7)); + } + let block_number = BlockNumberFor::::from(inputs.block_data.block_number); + let block_hash = frame_system::Pallet::::block_hash(block_number); + if block_hash == T::Hash::default() { + return Err(InvalidTransaction::Custom(8)); + } + if block_hash.as_ref() != inputs.block_data.block_hash.as_ref() { + return Err(InvalidTransaction::Custom(9)); + } + for nullifier in &inputs.nullifiers { + let bytes: [u8; 32] = (*nullifier) + .as_ref() + .try_into() + .map_err(|_| InvalidTransaction::Custom(10))?; + if UsedNullifiers::::contains_key(bytes) { + return Err(InvalidTransaction::Custom(11)); + } + } + Ok((proof, inputs)) + } + pub fn record_transfer( asset_id: AssetIdOf, from: ::WormholeAccountId, From dee73526935d665685612bc02ebb20562ebe3451 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Mon, 23 Mar 2026 12:50:17 +0800 Subject: [PATCH 2/4] format --- pallets/wormhole/src/lib.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/pallets/wormhole/src/lib.rs b/pallets/wormhole/src/lib.rs index f4837213..7ba10d7d 100644 --- a/pallets/wormhole/src/lib.rs +++ b/pallets/wormhole/src/lib.rs @@ -432,8 +432,8 @@ pub mod pallet { if proof_bytes.is_empty() { return Err(InvalidTransaction::Custom(2)); } - let verifier = crate::get_aggregated_verifier() - .map_err(|_| InvalidTransaction::Custom(3))?; + let verifier = + crate::get_aggregated_verifier().map_err(|_| InvalidTransaction::Custom(3))?; let proof = ProofWithPublicInputs::::from_bytes( proof_bytes.to_vec(), &verifier.circuit_data.common, @@ -456,10 +456,8 @@ pub mod pallet { return Err(InvalidTransaction::Custom(9)); } for nullifier in &inputs.nullifiers { - let bytes: [u8; 32] = (*nullifier) - .as_ref() - .try_into() - .map_err(|_| InvalidTransaction::Custom(10))?; + let bytes: [u8; 32] = + (*nullifier).as_ref().try_into().map_err(|_| InvalidTransaction::Custom(10))?; if UsedNullifiers::::contains_key(bytes) { return Err(InvalidTransaction::Custom(11)); } From 4ad87adff7e89bfdd4b5d4104a9d1c4df966748f Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Mon, 23 Mar 2026 21:25:38 +0800 Subject: [PATCH 3/4] restore errors --- pallets/wormhole/src/lib.rs | 51 +++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 28 deletions(-) diff --git a/pallets/wormhole/src/lib.rs b/pallets/wormhole/src/lib.rs index 7ba10d7d..155e714a 100644 --- a/pallets/wormhole/src/lib.rs +++ b/pallets/wormhole/src/lib.rs @@ -229,8 +229,7 @@ pub mod pallet { ) -> DispatchResult { ensure_none(origin)?; - let (proof, aggregated_inputs) = Self::pre_validate_proof(&proof_bytes) - .map_err(|_| Error::::InvalidAggregatedPublicInputs)?; + let (proof, aggregated_inputs) = Self::pre_validate_proof(&proof_bytes)?; // Mark nullifiers as used (pre_validate_proof only checks existence) let mut nullifier_list = Vec::<[u8; 32]>::new(); @@ -407,7 +406,8 @@ pub mod pallet { fn validate_unsigned(_source: TransactionSource, call: &Self::Call) -> TransactionValidity { match call { Call::verify_aggregated_proof { proof_bytes } => { - Self::pre_validate_proof(proof_bytes)?; + Self::pre_validate_proof(proof_bytes) + .map_err(|_| InvalidTransaction::Call)?; ValidTransaction::with_tag_prefix("WormholeAggregatedVerify") .and_provides(sp_io::hashing::blake2_256(proof_bytes)) .priority(TransactionPriority::MAX / 2) @@ -427,40 +427,35 @@ pub mod pallet { proof_bytes: &[u8], ) -> Result< (ProofWithPublicInputs, AggregatedPublicCircuitInputs), - InvalidTransaction, + Error, > { - if proof_bytes.is_empty() { - return Err(InvalidTransaction::Custom(2)); - } - let verifier = - crate::get_aggregated_verifier().map_err(|_| InvalidTransaction::Custom(3))?; + let verifier = crate::get_aggregated_verifier() + .map_err(|_| Error::::AggregatedVerifierNotAvailable)?; let proof = ProofWithPublicInputs::::from_bytes( proof_bytes.to_vec(), &verifier.circuit_data.common, ) - .map_err(|_| InvalidTransaction::Custom(4))?; + .map_err(|_| Error::::AggregatedProofDeserializationFailed)?; let inputs = parse_aggregated_public_inputs(&proof) - .map_err(|_| InvalidTransaction::Custom(5))?; - if inputs.asset_id != 0 { - return Err(InvalidTransaction::Custom(6)); - } - if inputs.volume_fee_bps != T::VolumeFeeRateBps::get() { - return Err(InvalidTransaction::Custom(7)); - } + .map_err(|_| Error::::InvalidAggregatedPublicInputs)?; + ensure!(inputs.asset_id == 0, Error::::NonNativeAssetNotSupported); + ensure!( + inputs.volume_fee_bps == T::VolumeFeeRateBps::get(), + Error::::InvalidVolumeFeeRate + ); let block_number = BlockNumberFor::::from(inputs.block_data.block_number); let block_hash = frame_system::Pallet::::block_hash(block_number); - if block_hash == T::Hash::default() { - return Err(InvalidTransaction::Custom(8)); - } - if block_hash.as_ref() != inputs.block_data.block_hash.as_ref() { - return Err(InvalidTransaction::Custom(9)); - } + ensure!(block_hash != T::Hash::default(), Error::::BlockNotFound); + ensure!( + block_hash.as_ref() == inputs.block_data.block_hash.as_ref(), + Error::::InvalidPublicInputs + ); for nullifier in &inputs.nullifiers { - let bytes: [u8; 32] = - (*nullifier).as_ref().try_into().map_err(|_| InvalidTransaction::Custom(10))?; - if UsedNullifiers::::contains_key(bytes) { - return Err(InvalidTransaction::Custom(11)); - } + let bytes: [u8; 32] = (*nullifier) + .as_ref() + .try_into() + .map_err(|_| Error::::InvalidAggregatedPublicInputs)?; + ensure!(!UsedNullifiers::::contains_key(bytes), Error::::NullifierAlreadyUsed); } Ok((proof, inputs)) } From 6a3cb7dc79602540dbe44c52fc9ae6c527867eda Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Mon, 23 Mar 2026 21:27:33 +0800 Subject: [PATCH 4/4] format --- pallets/wormhole/src/lib.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/pallets/wormhole/src/lib.rs b/pallets/wormhole/src/lib.rs index 155e714a..13d90b7d 100644 --- a/pallets/wormhole/src/lib.rs +++ b/pallets/wormhole/src/lib.rs @@ -406,8 +406,7 @@ pub mod pallet { fn validate_unsigned(_source: TransactionSource, call: &Self::Call) -> TransactionValidity { match call { Call::verify_aggregated_proof { proof_bytes } => { - Self::pre_validate_proof(proof_bytes) - .map_err(|_| InvalidTransaction::Call)?; + Self::pre_validate_proof(proof_bytes).map_err(|_| InvalidTransaction::Call)?; ValidTransaction::with_tag_prefix("WormholeAggregatedVerify") .and_provides(sp_io::hashing::blake2_256(proof_bytes)) .priority(TransactionPriority::MAX / 2) @@ -425,10 +424,7 @@ pub mod pallet { /// Called by both validate_unsigned (pool gating) and dispatch (defense-in-depth). fn pre_validate_proof( proof_bytes: &[u8], - ) -> Result< - (ProofWithPublicInputs, AggregatedPublicCircuitInputs), - Error, - > { + ) -> Result<(ProofWithPublicInputs, AggregatedPublicCircuitInputs), Error> { let verifier = crate::get_aggregated_verifier() .map_err(|_| Error::::AggregatedVerifierNotAvailable)?; let proof = ProofWithPublicInputs::::from_bytes( @@ -455,7 +451,10 @@ pub mod pallet { .as_ref() .try_into() .map_err(|_| Error::::InvalidAggregatedPublicInputs)?; - ensure!(!UsedNullifiers::::contains_key(bytes), Error::::NullifierAlreadyUsed); + ensure!( + !UsedNullifiers::::contains_key(bytes), + Error::::NullifierAlreadyUsed + ); } Ok((proof, inputs)) }