use qp_wormhole_circuit::{
inputs::{CircuitInputs, PrivateCircuitInputs},
nullifier::Nullifier,
sensitive::Secret,
};
use qp_wormhole_inputs::PublicCircuitInputs;
use qp_zk_circuits_common::{
utils::{digest_to_bytes, BytesDigest},
zk_merkle::SIBLINGS_PER_LEVEL,
};
use std::{
mem::size_of,
path::Path,
ptr,
sync::atomic::{compiler_fence, Ordering},
};
pub const NATIVE_ASSET_ID: u32 = 0;
pub const SCALE_DOWN_FACTOR: u128 = 10_000_000_000;
pub const VOLUME_FEE_BPS: u32 = 4;
pub type Result<T> = std::result::Result<T, WormholeLibError>;
#[derive(Debug, Clone)]
pub struct WormholeLibError {
pub message: String,
}
impl std::fmt::Display for WormholeLibError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for WormholeLibError {}
impl From<String> for WormholeLibError {
fn from(message: String) -> Self {
Self { message }
}
}
fn zeroize_bytes(bytes: &mut [u8]) {
for byte in bytes {
unsafe { ptr::write_volatile(byte, 0) };
}
compiler_fence(Ordering::SeqCst);
}
fn zeroize_bytes_digest(digest: &mut BytesDigest) {
let ptr = ptr::addr_of_mut!(*digest).cast::<u8>();
for offset in 0..size_of::<BytesDigest>() {
unsafe { ptr.add(offset).write_volatile(0) };
}
compiler_fence(Ordering::SeqCst);
}
struct ZeroizingDigest(BytesDigest);
impl Drop for ZeroizingDigest {
fn drop(&mut self) {
zeroize_bytes_digest(&mut self.0);
}
}
struct ZeroizingCircuitInputs(CircuitInputs);
impl Drop for ZeroizingCircuitInputs {
fn drop(&mut self) {
self.0.private.secret = Secret::from(
BytesDigest::try_from([0u8; 32]).expect("all-zero digest is always valid"),
);
}
}
#[derive(Debug, Clone)]
pub struct ProofGenerationInput {
pub secret: [u8; 32],
pub transfer_count: u64,
pub wormhole_address: [u8; 32],
pub input_amount: u32,
pub block_hash: [u8; 32],
pub block_number: u32,
pub parent_hash: [u8; 32],
pub state_root: [u8; 32],
pub extrinsics_root: [u8; 32],
pub digest: Vec<u8>,
pub zk_tree_root: [u8; 32],
pub zk_merkle_siblings: Vec<[[u8; 32]; SIBLINGS_PER_LEVEL]>,
pub zk_merkle_positions: Vec<u8>,
pub exit_account_1: [u8; 32],
pub exit_account_2: [u8; 32],
pub output_amount_1: u32,
pub output_amount_2: u32,
pub volume_fee_bps: u32,
pub asset_id: u32,
}
#[derive(Debug, Clone)]
pub struct ProofGenerationOutput {
pub proof_bytes: Vec<u8>,
#[allow(dead_code)]
pub nullifier: [u8; 32],
}
pub fn compute_wormhole_address(secret: &[u8; 32]) -> Result<[u8; 32]> {
let secret_digest: BytesDigest = (*secret)
.try_into()
.map_err(|e| WormholeLibError::from(format!("Invalid secret: {:?}", e)))?;
let unspendable =
qp_wormhole_circuit::unspendable_account::UnspendableAccount::from_secret(secret_digest);
Ok(*digest_to_bytes(unspendable.account_id))
}
#[allow(dead_code)]
pub fn compute_nullifier(secret: &[u8; 32], transfer_count: u64) -> Result<[u8; 32]> {
let secret_digest: BytesDigest = (*secret)
.try_into()
.map_err(|e| WormholeLibError::from(format!("Invalid secret: {:?}", e)))?;
let nullifier = Nullifier::from_preimage(secret_digest, transfer_count);
Ok(*digest_to_bytes(nullifier.hash))
}
pub fn quantize_amount(amount: u128) -> Result<u32> {
let quantized = amount / SCALE_DOWN_FACTOR;
if quantized > u32::MAX as u128 {
return Err(WormholeLibError::from(format!(
"Quantized amount {} exceeds u32::MAX",
quantized
)));
}
Ok(quantized as u32)
}
pub fn compute_output_amount(input_amount: u32, fee_bps: u32) -> u32 {
((input_amount as u64) * (10000 - fee_bps as u64) / 10000) as u32
}
pub fn generate_proof(
input: &mut ProofGenerationInput,
prover_bin_path: &Path,
common_bin_path: &Path,
) -> Result<ProofGenerationOutput> {
let _ = (prover_bin_path, common_bin_path);
let result = generate_proof_inner(input);
zeroize_bytes(&mut input.secret);
result
}
fn generate_proof_inner(input: &ProofGenerationInput) -> Result<ProofGenerationOutput> {
let parent_hash = input
.parent_hash
.as_slice()
.try_into()
.map_err(|e| WormholeLibError::from(format!("Invalid parent hash: {:?}", e)))?;
let state_root = input
.state_root
.as_slice()
.try_into()
.map_err(|e| WormholeLibError::from(format!("Invalid state root: {:?}", e)))?;
let extrinsics_root = input
.extrinsics_root
.as_slice()
.try_into()
.map_err(|e| WormholeLibError::from(format!("Invalid extrinsics root: {:?}", e)))?;
let exit_account_1 = input
.exit_account_1
.as_slice()
.try_into()
.map_err(|e| WormholeLibError::from(format!("Invalid exit account 1: {:?}", e)))?;
let exit_account_2 = input
.exit_account_2
.as_slice()
.try_into()
.map_err(|e| WormholeLibError::from(format!("Invalid exit account 2: {:?}", e)))?;
let block_hash = input
.block_hash
.as_slice()
.try_into()
.map_err(|e| WormholeLibError::from(format!("Invalid block hash: {:?}", e)))?;
let secret_digest = ZeroizingDigest(
input
.secret
.try_into()
.map_err(|e| WormholeLibError::from(format!("Invalid secret: {:?}", e)))?,
);
let nullifier = Nullifier::from_preimage(secret_digest.0, input.transfer_count);
let nullifier_bytes = digest_to_bytes(nullifier.hash);
let unspendable =
qp_wormhole_circuit::unspendable_account::UnspendableAccount::from_secret(secret_digest.0);
let unspendable_bytes = digest_to_bytes(unspendable.account_id);
if *unspendable_bytes != input.wormhole_address {
return Err(WormholeLibError::from(
"Wormhole address doesn't match the computed unspendable account from secret"
.to_string(),
));
}
const DIGEST_LOGS_SIZE: usize = 110;
let mut digest_padded = [0u8; DIGEST_LOGS_SIZE];
let copy_len = input.digest.len().min(DIGEST_LOGS_SIZE);
digest_padded[..copy_len].copy_from_slice(&input.digest[..copy_len]);
let circuit_inputs = ZeroizingCircuitInputs(CircuitInputs {
public: PublicCircuitInputs {
asset_id: input.asset_id,
output_amount_1: input.output_amount_1,
output_amount_2: input.output_amount_2,
volume_fee_bps: input.volume_fee_bps,
nullifier: nullifier_bytes,
exit_account_1,
exit_account_2,
block_hash,
block_number: input.block_number,
},
private: PrivateCircuitInputs {
secret: Secret::from(secret_digest.0),
transfer_count: input.transfer_count,
unspendable_account: unspendable_bytes,
parent_hash,
state_root,
extrinsics_root,
digest: digest_padded,
input_amount: input.input_amount,
zk_tree_root: input.zk_tree_root,
zk_merkle_siblings: input.zk_merkle_siblings.clone(),
zk_merkle_positions: input.zk_merkle_positions.clone(),
},
});
drop(secret_digest);
zeroize_bytes(&mut digest_padded);
let prover = qp_wormhole_prover::build_fresh();
let prover_with_inputs = prover
.commit(&circuit_inputs.0)
.map_err(|e| WormholeLibError::from(format!("Failed to commit inputs: {}", e)))?;
let proof = prover_with_inputs
.prove()
.map_err(|e| WormholeLibError::from(format!("Proof generation failed: {}", e)))?;
Ok(ProofGenerationOutput { proof_bytes: proof.to_bytes(), nullifier: *nullifier_bytes })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_quantize_amount() {
let result = quantize_amount(1_000_000_000_000).unwrap();
assert_eq!(result, 100);
let result = quantize_amount(10_000_000_000).unwrap();
assert_eq!(result, 1);
}
#[test]
fn test_compute_output_amount() {
let result = compute_output_amount(100, 10);
assert_eq!(result, 99);
let result = compute_output_amount(1000, 10);
assert_eq!(result, 999);
}
#[test]
fn test_compute_wormhole_address() {
let secret = [42u8; 32];
let address = compute_wormhole_address(&secret).unwrap();
assert_eq!(address.len(), 32);
let address2 = compute_wormhole_address(&secret).unwrap();
assert_eq!(address, address2);
}
fn decode_32(hex_str: &str) -> [u8; 32] {
let bytes = hex::decode(hex_str).expect("valid hex fixture");
bytes.try_into().expect("fixture is 32 bytes")
}
#[test]
fn secret_is_zeroized_after_successful_wormhole_proof_generation() {
let secret = decode_32("4c8587bd422e01d961acdc75e7d66f6761b7af7c9b1864a492f369c9d6724f05");
let transfer_count = 4u64;
let wormhole_address = compute_wormhole_address(&secret).expect("secret derives address");
let mut input = ProofGenerationInput {
secret,
transfer_count,
wormhole_address,
input_amount: 100,
block_hash: [0u8; 32],
block_number: 0,
parent_hash: [0u8; 32],
state_root: decode_32(
"ae6e4ff0dca1ef5ede9dccc84365cecfab4e431c6f3086216bc3b819cdf0a893",
),
extrinsics_root: [0u8; 32],
digest: vec![
8, 6, 112, 111, 119, 95, 128, 233, 182, 183, 107, 158, 1, 115, 19, 219, 126, 253,
86, 30, 208, 176, 70, 21, 45, 180, 229, 9, 62, 91, 4, 6, 53, 245, 52, 48, 38, 123,
225, 5, 112, 111, 119, 95, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 18, 79, 226,
],
zk_tree_root: [0u8; 32],
zk_merkle_siblings: vec![],
zk_merkle_positions: vec![],
exit_account_1: [0u8; 32],
exit_account_2: [0u8; 32],
output_amount_1: 0,
output_amount_2: 0,
volume_fee_bps: VOLUME_FEE_BPS,
asset_id: NATIVE_ASSET_ID,
};
let output = generate_proof(
&mut input,
Path::new("ignored-prover.bin"),
Path::new("ignored-common.bin"),
)
.expect("real wormhole proof generation succeeds");
assert!(!output.proof_bytes.is_empty(), "the real prover produced a proof");
assert_eq!(
input.secret, [0u8; 32],
"generate_proof must zeroize the caller-owned secret before returning"
);
}
#[test]
fn secret_is_zeroized_when_proof_generation_fails_early() {
let secret = decode_32("4c8587bd422e01d961acdc75e7d66f6761b7af7c9b1864a492f369c9d6724f05");
let mut input = ProofGenerationInput {
secret,
transfer_count: 0,
wormhole_address: [0xAAu8; 32],
input_amount: 100,
block_hash: [0u8; 32],
block_number: 0,
parent_hash: [0u8; 32],
state_root: [0u8; 32],
extrinsics_root: [0u8; 32],
digest: vec![],
zk_tree_root: [0u8; 32],
zk_merkle_siblings: vec![],
zk_merkle_positions: vec![],
exit_account_1: [0u8; 32],
exit_account_2: [0u8; 32],
output_amount_1: 0,
output_amount_2: 0,
volume_fee_bps: VOLUME_FEE_BPS,
asset_id: NATIVE_ASSET_ID,
};
let err = generate_proof(
&mut input,
Path::new("ignored-prover.bin"),
Path::new("ignored-common.bin"),
)
.expect_err("mismatched wormhole address must be rejected");
assert!(
err.message.contains("doesn't match"),
"expected address-mismatch error, got: {}",
err.message
);
assert_eq!(
input.secret, [0u8; 32],
"generate_proof must zeroize the caller-owned secret on error paths too"
);
}
}