diff --git a/zebra/Cargo.lock b/zebra/Cargo.lock index e9e6a273..75459dd3 100644 --- a/zebra/Cargo.lock +++ b/zebra/Cargo.lock @@ -7440,8 +7440,12 @@ dependencies = [ "proptest-derive", "rand 0.8.6", "rayon", + "ripemd 0.1.3", "sapling-crypto", + "secp256k1", "serde", + "sha2 0.10.9", + "siphasher", "spandoc", "thiserror 2.0.18", "tokio", diff --git a/zebra/Cargo.toml b/zebra/Cargo.toml index 315367de..0434cf95 100644 --- a/zebra/Cargo.toml +++ b/zebra/Cargo.toml @@ -134,6 +134,7 @@ serde_with = "3.12" serde_yml = "0.0" sha2 = "0.10" sinsemilla = "0.1" +siphasher = "1.0" schemars = "1" spandoc = "0.2" static_assertions = "1.1" diff --git a/zebra/zebra-consensus/Cargo.toml b/zebra/zebra-consensus/Cargo.toml index 22e23009..990e4bbc 100644 --- a/zebra/zebra-consensus/Cargo.toml +++ b/zebra/zebra-consensus/Cargo.toml @@ -40,6 +40,7 @@ halo2 = { package = "halo2_proofs", version = "0.3" } jubjub = { workspace = true } rand = { workspace = true } rayon = { workspace = true } +siphasher = { workspace = true } mset.workspace = true chrono = { workspace = true, features = ["clock", "std"] } @@ -84,6 +85,9 @@ proptest-derive = { workspace = true, optional = true } [dev-dependencies] color-eyre = { workspace = true } +ripemd = { workspace = true } +secp256k1 = { workspace = true } +sha2 = { workspace = true } hex = { workspace = true } num-integer = { workspace = true } @@ -113,5 +117,9 @@ harness = false name = "sapling" harness = false +[[bench]] +name = "script" +harness = false + [lints] workspace = true diff --git a/zebra/zebra-consensus/benches/script.rs b/zebra/zebra-consensus/benches/script.rs new file mode 100644 index 00000000..f9c0108d --- /dev/null +++ b/zebra/zebra-consensus/benches/script.rs @@ -0,0 +1,271 @@ +//! Benchmarks of transparent script verification and the script cache. +//! +//! The workload is a 1001-input P2SH consolidation with a real ECDSA +//! signature on every input (P2SH-wrapped pay-to-public-key), so a cache +//! miss pays the interpreter, the ZIP-244 sighash, and the signature +//! verification for every input, and a hit skips all three. + +// Disabled due to warnings in criterion macros +#![allow(missing_docs)] + +use std::{collections::HashMap, hint::black_box, sync::Arc}; + +use chrono::{DateTime, Utc}; +use criterion::{criterion_group, criterion_main, BatchSize, Criterion}; +use tower::{service_fn, ServiceExt}; + +use ripemd::Ripemd160; +use sha2::{Digest, Sha256}; + +use zebra_chain::{ + amount::Amount, + block::Height, + parameters::{Network, NetworkUpgrade}, + transaction::{HashType, LockTime, Transaction}, + transparent, +}; +use zebra_consensus::transaction::{BlockRequest, BlockTxVerifier}; +use zebra_script::CachedFfiTransaction; + +const INPUTS: usize = 1001; +const INPUT_VALUE: i64 = 10_000; + +fn testnet_nu5_height() -> Height { + (NetworkUpgrade::Nu5 + .activation_height(&Network::new_default_testnet()) + .expect("NU5 activation height is specified") + + 10) + .expect("height in range") +} + +/// Builds the consolidation transaction with a real ECDSA signature on every +/// input, its spent outputs in input order, and the `known_utxos` map serving +/// them to the block verifier. +fn consolidation( + output_value: i64, +) -> ( + Arc, + Vec, + Arc>, +) { + let block_height = testnet_nu5_height(); + let fund_height = (block_height - 1).expect("height in range"); + + let secp = secp256k1::Secp256k1::signing_only(); + let secret_key = secp256k1::SecretKey::from_slice(&[0x42; 32]).expect("valid secret key"); + let public_key = secret_key.public_key(&secp); + + // Redeem script: <33-byte pubkey> OP_CHECKSIG + let mut redeem = vec![0x21]; + redeem.extend_from_slice(&public_key.serialize()); + redeem.push(0xac); + + // Lock script: OP_HASH160 OP_EQUAL + let redeem_hash = Ripemd160::digest(Sha256::digest(&redeem)); + let mut p2sh_lock_bytes = vec![0xa9, 0x14]; + p2sh_lock_bytes.extend_from_slice(&redeem_hash); + p2sh_lock_bytes.push(0x87); + let lock_script = transparent::Script::new(&p2sh_lock_bytes); + + let spent_output = transparent::Output { + value: Amount::try_from(INPUT_VALUE).expect("valid amount"), + lock_script, + }; + + let source_hash = zebra_chain::transaction::Hash([7u8; 32]); + let mut known_utxos = HashMap::new(); + let unsigned_inputs: Vec = (0..INPUTS) + .map(|index| { + let outpoint = transparent::OutPoint { + hash: source_hash, + // Bounded by INPUTS, so the cast cannot truncate. + index: index as u32, + }; + known_utxos.insert( + outpoint, + transparent::OrderedUtxo::new(spent_output.clone(), fund_height, index), + ); + transparent::Input::PrevOut { + outpoint, + unlock_script: transparent::Script::new(&[]), + sequence: 0, + } + }) + .collect(); + + let output = transparent::Output { + value: Amount::try_from(output_value).expect("valid amount"), + lock_script: transparent::Script::new(&[0]), + }; + + let unsigned = Transaction::V5 { + inputs: unsigned_inputs.clone(), + outputs: vec![output], + lock_time: LockTime::unlocked(), + expiry_height: (block_height + 1).expect("height in range"), + sapling_shielded_data: None, + orchard_shielded_data: None, + network_upgrade: NetworkUpgrade::Nu5, + }; + + let spent_outputs = vec![spent_output; INPUTS]; + + // The ZIP-244 signature digest excludes the unlock scripts, so the unsigned + // transaction produces the same sighashes as the signed one. + let sighasher = unsigned + .sighasher(NetworkUpgrade::Nu5, Arc::new(spent_outputs.clone())) + .expect("supported transaction version"); + + let inputs = unsigned_inputs + .into_iter() + .enumerate() + .map(|(index, input)| { + let sighash = sighasher.sighash(HashType::ALL, Some((index, redeem.clone()))); + let message = secp256k1::Message::from_digest(sighash.into()); + let mut sig_bytes = secp + .sign_ecdsa(&message, &secret_key) + .serialize_der() + .to_vec(); + // The SIGHASH_ALL type byte. + sig_bytes.push(1); + + // Unlock script: . Both pushes are under 76 bytes, + // so the push opcode is the bare length byte. + let mut unlock = Vec::with_capacity(sig_bytes.len() + redeem.len() + 2); + unlock.push(u8::try_from(sig_bytes.len()).expect("a DER signature fits one push byte")); + unlock.extend_from_slice(&sig_bytes); + unlock.push(u8::try_from(redeem.len()).expect("the redeem script fits one push byte")); + unlock.extend_from_slice(&redeem); + + let transparent::Input::PrevOut { + outpoint, sequence, .. + } = input + else { + unreachable!("all inputs are PrevOut") + }; + transparent::Input::PrevOut { + outpoint, + unlock_script: transparent::Script::new(&unlock), + sequence, + } + }) + .collect(); + + let Transaction::V5 { + outputs, + lock_time, + expiry_height, + sapling_shielded_data, + orchard_shielded_data, + network_upgrade, + .. + } = unsigned + else { + unreachable!("the transaction is V5 by construction") + }; + let transaction = Arc::new(Transaction::V5 { + inputs, + outputs, + lock_time, + expiry_height, + sapling_shielded_data, + orchard_shielded_data, + network_upgrade, + }); + + (transaction, spent_outputs, Arc::new(known_utxos)) +} + +fn block_request( + transaction: &Arc, + known_utxos: &Arc>, +) -> BlockRequest { + BlockRequest { + transaction_hash: transaction.hash(), + transaction: transaction.clone(), + known_utxos: known_utxos.clone(), + height: testnet_nu5_height(), + time: DateTime::::MAX_UTC, + } +} + +/// The per-input script verification a cache hit skips. +fn script_verification(c: &mut Criterion) { + let (transaction, spent_outputs, _) = consolidation(5_000); + let cached = CachedFfiTransaction::new( + transaction.clone(), + Arc::new(spent_outputs), + NetworkUpgrade::Nu5, + ) + .expect("supported transaction version"); + + c.bench_function("verify_1001_input_scripts", |b| { + b.iter(|| { + for input_index in 0..INPUTS { + black_box(&cached) + .is_valid(input_index) + .expect("script is valid"); + } + }) + }); +} + +/// Full block-path transaction verification, miss vs hit. +/// +/// Every miss iteration verifies a distinct transaction (unique output value, +/// so a unique cache key); the hit series repeats one transaction after its +/// first verification populated the cache. +fn block_verification(c: &mut Criterion) { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let network = Network::new_default_testnet(); + let state = || service_fn(|_| async { unreachable!("all UTXOs come from known_utxos") }); + + let mut group = c.benchmark_group("block_verification_1001_inputs"); + group.sample_size(20); + + let mut next_value = 5_000; + group.bench_function("cache_miss", |b| { + b.iter_batched( + || { + next_value += 1; + let (transaction, _, known_utxos) = consolidation(next_value); + ( + BlockTxVerifier::new(&network, state()), + block_request(&transaction, &known_utxos), + ) + }, + |(verifier, request)| { + rt.block_on(verifier.oneshot(request)) + .expect("transaction verifies") + }, + BatchSize::SmallInput, + ) + }); + + let (transaction, _, known_utxos) = consolidation(4_000); + rt.block_on( + BlockTxVerifier::new(&network, state()).oneshot(block_request(&transaction, &known_utxos)), + ) + .expect("the populating verification succeeds"); + + group.bench_function("cache_hit", |b| { + b.iter_batched( + || { + ( + BlockTxVerifier::new(&network, state()), + block_request(&transaction, &known_utxos), + ) + }, + |(verifier, request)| { + rt.block_on(verifier.oneshot(request)) + .expect("transaction verifies") + }, + BatchSize::SmallInput, + ) + }); + + group.finish(); +} + +criterion_group!(benches, script_verification, block_verification); +criterion_main!(benches); diff --git a/zebra/zebra-consensus/src/transaction.rs b/zebra/zebra-consensus/src/transaction.rs index d3fb26f9..44db44ac 100644 --- a/zebra/zebra-consensus/src/transaction.rs +++ b/zebra/zebra-consensus/src/transaction.rs @@ -32,7 +32,7 @@ use zebra_chain::{ primitives::Groth16Proof, serialization::DateTime32, transaction::{ - self, HashType, SigHash, Transaction, UnminedTx, UnminedTxId, VerifiedUnminedTx, + self, HashType, SigHash, Transaction, UnminedTx, UnminedTxId, VerifiedUnminedTx, WtxId, }, transparent, }; @@ -44,6 +44,7 @@ use zebra_state as zs; use crate::{error::TransactionError, primitives, script, BoxError}; pub mod check; +mod script_cache; #[cfg(test)] mod tests; @@ -296,6 +297,7 @@ where auth_digest, }), }; + let cache_key = derived_cache_key(&tx); let height = req.height; let time = req.time; let known_utxos = req.known_utxos.clone(); @@ -372,6 +374,7 @@ where let async_checks = dispatch_version_verification( tx.as_ref(), nu, + cache_key, script_verifier, cached_ffi_transaction.clone() )?; @@ -605,10 +608,21 @@ where tracing::trace!(?tx_id, "got state UTXOs"); + // A verification against mempool-paired prevout data must never + // populate a cache entry the block path could hit (the pairing has + // a bug history, zebra#10346), so transactions spending unmined + // outputs bypass the cache. + let cache_key = if spent_mempool_outpoints.is_empty() { + derived_cache_key(&tx) + } else { + None + }; + // Select version-specific async verification pipeline let mut async_checks = dispatch_version_verification( tx.as_ref(), nu, + cache_key, script_verifier, cached_ffi_transaction.clone() )?; @@ -972,6 +986,20 @@ fn check_maturity_height( ) } +/// Returns the script cache key for `tx`, or `None` if `tx` is never cached. +/// +/// The key is derived from the transaction itself, never from a +/// caller-supplied id: a wrong id here would answer verification of one +/// transaction with another's cached result. Pre-v5 transactions have no id +/// committing to the consensus branch id and are never cached; see the +/// `script_cache` docs. +fn derived_cache_key(tx: &Transaction) -> Option { + tx.auth_digest().map(|auth_digest| WtxId { + id: tx.hash(), + auth_digest, + }) +} + /// Dispatches version-specific async verification checks for `tx`. /// /// `nu` is the network upgrade active at the transaction's verification height, @@ -982,6 +1010,7 @@ fn check_maturity_height( fn dispatch_version_verification( tx: &Transaction, nu: NetworkUpgrade, + cache_key: Option, script_verifier: script::Verifier, cached_ffi_transaction: Arc, ) -> Result { @@ -998,10 +1027,10 @@ fn dispatch_version_verification( joinsplit_data, ), Transaction::V5 { .. } => { - verify_v5_transaction(tx, nu, script_verifier, cached_ffi_transaction) + verify_v5_transaction(tx, nu, cache_key, script_verifier, cached_ffi_transaction) } Transaction::V6 { .. } => { - verify_v6_transaction(tx, nu, script_verifier, cached_ffi_transaction) + verify_v6_transaction(tx, nu, cache_key, script_verifier, cached_ffi_transaction) } } } @@ -1022,6 +1051,9 @@ fn dispatch_version_verification( /// - the `script_verifier` to use for verifying the transparent transfers /// - the prepared `cached_ffi_transaction` used by the script verifier /// - the Sprout `joinsplit_data` shielded data in the transaction +/// +/// V4 transactions never use the script cache: their legacy ids don't commit +/// to the consensus branch id, so no cache key exists for them. #[allow(clippy::unwrap_in_result)] fn verify_v4_transaction( tx: &Transaction, @@ -1039,7 +1071,7 @@ fn verify_v4_transaction( .sighash(HashType::ALL, None); Ok( - verify_transparent_inputs_and_outputs(tx, script_verifier, cached_ffi_transaction)? + make_transparent_input_and_output_checks(tx, None, script_verifier, cached_ffi_transaction) .and(verify_sprout_shielded_data(joinsplit_data, &sighash)?) .and(verify_sapling_bundle(sapling_bundle, &sighash)), ) @@ -1105,12 +1137,14 @@ fn verify_v4_transaction_network_upgrade( /// /// - the `tx` transaction to verify /// - the `nu` network upgrade active at the transaction's verification height +/// - the `cache_key` naming `tx` in the script cache, when it is cacheable /// - the `script_verifier` to use for verifying the transparent transfers /// - the prepared `cached_ffi_transaction` used by the script verifier #[allow(clippy::unwrap_in_result)] fn verify_v5_transaction( tx: &Transaction, nu: NetworkUpgrade, + cache_key: Option, script_verifier: script::Verifier, cached_ffi_transaction: Arc, ) -> Result { @@ -1123,11 +1157,14 @@ fn verify_v5_transaction( .sighasher() .sighash(HashType::ALL, None); - Ok( - verify_transparent_inputs_and_outputs(tx, script_verifier, cached_ffi_transaction)? - .and(verify_sapling_bundle(sapling_bundle, &sighash)) - .and(verify_orchard_bundle(orchard_bundle, &sighash, nu)), + Ok(make_transparent_input_and_output_checks( + tx, + cache_key, + script_verifier, + cached_ffi_transaction, ) + .and(verify_sapling_bundle(sapling_bundle, &sighash)) + .and(verify_orchard_bundle(orchard_bundle, &sighash, nu))) } /// Verifies if a V5 `transaction` is supported by `network_upgrade`. @@ -1180,6 +1217,7 @@ fn verify_v5_transaction_network_upgrade( fn verify_v6_transaction( tx: &Transaction, nu: NetworkUpgrade, + cache_key: Option, script_verifier: script::Verifier, cached_ffi_transaction: Arc, ) -> Result { @@ -1195,12 +1233,15 @@ fn verify_v6_transaction( // The Ironwood bundle reuses the Orchard Action proof system and the NU6.3 circuit key, so // it is verified the same way as the v6 Orchard bundle (against the NU6.3 key). - Ok( - verify_transparent_inputs_and_outputs(tx, script_verifier, cached_ffi_transaction)? - .and(verify_sapling_bundle(sapling_bundle, &sighash)) - .and(verify_orchard_v6_bundle(orchard_bundle, &sighash)) - .and(verify_orchard_v6_bundle(ironwood_bundle, &sighash)), + Ok(make_transparent_input_and_output_checks( + tx, + cache_key, + script_verifier, + cached_ffi_transaction, ) + .and(verify_sapling_bundle(sapling_bundle, &sighash)) + .and(verify_orchard_v6_bundle(orchard_bundle, &sighash)) + .and(verify_orchard_v6_bundle(ironwood_bundle, &sighash))) } /// Verifies that a V6 `transaction` is supported by `network_upgrade`. @@ -1234,35 +1275,52 @@ fn verify_v6_transaction_network_upgrade( } } -/// Verifies if a transaction's transparent inputs are valid using the provided -/// `script_verifier` and `cached_ffi_transaction`. +/// Builds the deferred checks that verify `tx`'s transparent input scripts +/// against their spent outputs. Nothing is verified until the returned checks +/// are awaited. /// -/// Returns the asynchronous script verification checks for transparent inputs in `tx`. -fn verify_transparent_inputs_and_outputs( +/// With a `cache_key`, a previously recorded verification makes the checks a +/// no-op, and a new successful verification is recorded; see the +/// [`script_cache`] docs for why the key is sound. +fn make_transparent_input_and_output_checks( tx: &Transaction, + cache_key: Option, script_verifier: script::Verifier, cached_ffi_transaction: Arc, -) -> Result { - if tx.is_coinbase() { - // The script verifier only verifies PrevOut inputs and their corresponding UTXOs. - // Coinbase transactions don't have any PrevOut inputs. - Ok(AsyncChecks::new()) - } else { - // feed all of the inputs to the script verifier - let inputs = tx.inputs(); - - let script_checks = (0..inputs.len()) - .map(move |input_index| { - let request = script::Request { - cached_ffi_transaction: cached_ffi_transaction.clone(), - input_index, - }; +) -> AsyncChecks { + // The script verifier only checks PrevOut inputs against their UTXOs; + // coinbase and shielded-only transactions have none, so caching them + // would spend a slot on a hit that saves nothing. + if tx.is_coinbase() || tx.inputs().is_empty() { + return AsyncChecks::new(); + } - script_verifier.oneshot(request) + let input_count = tx.inputs().len(); + let script_checks = move || { + (0..input_count).map(move |input_index| { + script_verifier.oneshot(script::Request { + cached_ffi_transaction: cached_ffi_transaction.clone(), + input_index, }) - .collect(); - - Ok(script_checks) + }) + }; + + match cache_key { + // Hit: these scripts verified before; skip only these checks. + Some(key) if script_cache::verified_scripts().contains(&key) => AsyncChecks::new(), + // Miss: verify, then remember the success. + Some(key) => { + let checks = futures::future::try_join_all(script_checks()); + let mut checks_then_insert = AsyncChecks::new(); + checks_then_insert.push(async move { + checks.await?; + script_cache::verified_scripts().insert(key); + Ok(()) + }); + checks_then_insert + } + // Uncacheable (pre-v5, or spending unmined mempool outputs). + None => script_checks().collect(), } } diff --git a/zebra/zebra-consensus/src/transaction/script_cache.rs b/zebra/zebra-consensus/src/transaction/script_cache.rs new file mode 100644 index 00000000..94a0e10d --- /dev/null +++ b/zebra/zebra-consensus/src/transaction/script_cache.rs @@ -0,0 +1,292 @@ +//! Memoization of successful transparent script verification. +//! +//! Zebra verifies a transaction's transparent input scripts at mempool +//! admission, then again when the transaction arrives in a block; zcashd +//! skips the repeat through its script and signature caches. This module +//! remembers which transactions' input scripts verified, so the repeat +//! becomes a lookup. A hit skips only the per-input script checks; +//! everything else still runs for every block the transaction appears in. +//! +//! The key is the transaction's [`WtxId`], which commits to everything +//! script verification reads: the ZIP-244 txid covers the effecting data, +//! including the consensus branch id and the outpoints naming the immutable +//! prevout scripts and values, and the authorizing-data digest covers the +//! scriptSigs, so a same-txid twin with different signatures +//! (CVE-2026-34377) never hits. Pre-v5 transactions have no id committing +//! to the branch id and are never cached. Only successful script +//! verifications are inserted (an entry whose transaction later fails a +//! proof or contextual check is harmless), so a missing or evicted entry +//! only ever costs a re-verification. +//! +//! Replacement is random: a full cache evicts the slot chosen by a keyed +//! siphash of an insert counter, uniform over the slots and independent of +//! the inserted key, so an adversary who cannot guess the seed cannot +//! target entries, and no access pattern degrades the cache. + +use std::{ + collections::HashSet, + sync::{Mutex, MutexGuard}, +}; + +use once_cell::sync::Lazy; +use rand::Rng; +use siphasher::sip::SipHasher13; + +use zebra_chain::transaction::WtxId; + +/// The maximum number of remembered transactions: several blocks of history +/// plus a full mempool's worth of churn, a few MiB when full. +const SCRIPT_CACHE_CAPACITY: usize = 30_000; + +/// The process-wide cache, shared by the mempool and block verifiers so a +/// mempool admission can answer the block verification that follows it. +static VERIFIED_SCRIPTS: Lazy = + Lazy::new(|| VerifiedScripts::new(SCRIPT_CACHE_CAPACITY, rand::thread_rng().gen())); + +/// Returns the process-wide transparent script verification cache. +pub(super) fn verified_scripts() -> &'static VerifiedScripts { + &VERIFIED_SCRIPTS +} + +/// The set of remembered script verifications, bounded by random replacement. +pub(super) struct VerifiedScripts { + /// Chooses the victim slot in [`VerifiedScripts::insert`]; keyed so an + /// adversary cannot predict which entry an insert evicts. + siphasher: SipHasher13, + capacity: usize, + inner: Mutex, + /// Test-only: hits per key, so a test can observe that a verification was + /// answered from the cache rather than re-run. + #[cfg(test)] + hits_by_key: Mutex>, +} + +/// The collections behind the mutex. `keys` and `slots` always hold the same +/// set of ids: every mutation updates both. +struct Inner { + /// Answers [`VerifiedScripts::contains`]. + keys: HashSet, + /// One slot per cached id; a full cache replaces a siphash-chosen slot. + slots: Vec, + /// Counts inserts; hashed to choose the victim slot, so the choice is + /// uniform over the slots rather than fixed per key. + inserts: u64, +} + +impl VerifiedScripts { + /// Creates an empty cache holding at most `capacity` keys, with `seed` + /// keying victim selection. Panics if `capacity` is zero. + pub(super) fn new(capacity: usize, seed: [u8; 16]) -> Self { + assert!(capacity > 0, "cache capacity must be greater than zero"); + + Self { + siphasher: SipHasher13::new_with_key(&seed), + capacity, + inner: Mutex::new(Inner { + keys: HashSet::with_capacity(capacity), + slots: Vec::with_capacity(capacity), + inserts: 0, + }), + #[cfg(test)] + hits_by_key: Mutex::new(std::collections::HashMap::new()), + } + } + + fn lock(&self) -> MutexGuard<'_, Inner> { + self.inner + .lock() + .expect("no code can panic while holding the script cache lock") + } + + /// Returns whether `key` was recorded by [`VerifiedScripts::insert`]. + pub(super) fn contains(&self, key: &WtxId) -> bool { + let hit = self.lock().keys.contains(key); + + #[cfg(test)] + if hit { + *self + .hits_by_key + .lock() + .expect("no code can panic while holding the hit counter lock") + .entry(*key) + .or_default() += 1; + } + + // Metrics are reported after the lock is released: every non-coinbase + // v5+ transaction the node verifies takes this lock. + metrics::counter!( + "zebra.consensus.transaction.script_cache.lookups", + "outcome" => if hit { "hit" } else { "miss" }, + ) + .increment(1); + + hit + } + + /// Records that `key`'s transaction passed script verification. Only + /// successful verifications may be recorded. + pub(super) fn insert(&self, key: WtxId) { + let (inserted, evicted, size) = { + let mut inner = self.lock(); + debug_assert!(inner.holds_invariants()); + + if !inner.keys.insert(key) { + // Concurrent verifications of one transaction can both miss; + // the second insert must not occupy a second slot. + (false, false, inner.keys.len()) + } else { + inner.inserts += 1; + if inner.slots.len() < self.capacity { + inner.slots.push(key); + (true, false, inner.keys.len()) + } else { + let victim_index = self.victim_index(inner.inserts); + let victim = std::mem::replace(&mut inner.slots[victim_index], key); + inner.keys.remove(&victim); + (true, true, inner.keys.len()) + } + } + }; + + if inserted { + metrics::counter!("zebra.consensus.transaction.script_cache.inserts").increment(1); + if evicted { + metrics::counter!("zebra.consensus.transaction.script_cache.evictions") + .increment(1); + } + // The cast is lossless: `size` is at most `SCRIPT_CACHE_CAPACITY`, + // far below f64's exact-integer range. + metrics::gauge!("zebra.consensus.transaction.script_cache.entries").set(size as f64); + } + } + + /// The slot the `insert_count`th insert replaces when the cache is full. + /// + /// Hashing the counter rather than the inserted key keeps the choice + /// uniform over the slots: a per-key victim would make the cache + /// direct-mapped, letting two hot colliding keys evict each other forever. + fn victim_index(&self, insert_count: u64) -> usize { + // Casts are lossless: `capacity` is a usize, and the modulus keeps + // the result below it. + (self.siphasher.hash(&insert_count.to_le_bytes()) % self.capacity as u64) as usize + } + + /// Test-only: how many times `key` was answered from the cache. + #[cfg(test)] + pub(super) fn hits_for(&self, key: &WtxId) -> u64 { + self.hits_by_key + .lock() + .expect("no code can panic while holding the hit counter lock") + .get(key) + .copied() + .unwrap_or_default() + } + + /// Test-only: forgets one key, so a test can force re-verification of its + /// own transaction without touching other tests' entries in the global. + #[cfg(test)] + pub(super) fn remove(&self, key: &WtxId) { + let mut inner = self.lock(); + inner.keys.remove(key); + inner.slots.retain(|slot| slot != key); + } +} + +impl Inner { + /// O(len) set comparison; call only from `debug_assert!`. + fn holds_invariants(&self) -> bool { + self.keys.len() == self.slots.len() + && self.keys == self.slots.iter().copied().collect::>() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use zebra_chain::transaction::{AuthDigest, Hash}; + + const SEED: [u8; 16] = [7; 16]; + + fn wtx_id(n: u8) -> WtxId { + WtxId { + id: Hash([n; 32]), + auth_digest: AuthDigest([n; 32]), + } + } + + #[test] + fn insert_then_contains() { + let cache = VerifiedScripts::new(4, SEED); + + assert!(!cache.contains(&wtx_id(1))); + cache.insert(wtx_id(1)); + assert!(cache.contains(&wtx_id(1))); + } + + #[test] + fn duplicate_insert_is_a_no_op() { + let cache = VerifiedScripts::new(2, SEED); + + cache.insert(wtx_id(1)); + cache.insert(wtx_id(1)); + + let inner = cache.lock(); + assert_eq!(inner.keys.len(), 1); + assert_eq!(inner.slots.len(), 1); + } + + #[test] + fn bounded_at_capacity() { + let cache = VerifiedScripts::new(4, SEED); + + for n in 0..100 { + cache.insert(wtx_id(n)); + let inner = cache.lock(); + assert!(inner.holds_invariants()); + assert!(inner.keys.len() <= 4); + } + + assert_eq!(cache.lock().keys.len(), 4); + } + + #[test] + fn replacement_is_deterministic_with_a_fixed_seed() { + let cache = VerifiedScripts::new(4, SEED); + + for n in 1..=4 { + cache.insert(wtx_id(n)); + } + + // The next new insert is the fifth, so it evicts victim_index(5). + let new_key = wtx_id(5); + let victim = cache.lock().slots[cache.victim_index(5)]; + cache.insert(new_key); + + assert!(!cache.contains(&victim), "the predicted victim is evicted"); + assert!(cache.contains(&new_key)); + for n in 1..=4 { + let key = wtx_id(n); + assert_eq!(cache.contains(&key), key != victim); + } + } + + #[test] + fn remove_forgets_one_key_and_keeps_the_collections_in_lockstep() { + let cache = VerifiedScripts::new(4, SEED); + + cache.insert(wtx_id(1)); + cache.insert(wtx_id(2)); + cache.remove(&wtx_id(1)); + + assert!(!cache.contains(&wtx_id(1))); + assert!(cache.contains(&wtx_id(2))); + assert!(cache.lock().holds_invariants()); + } + + #[test] + #[should_panic(expected = "capacity must be greater than zero")] + fn zero_capacity_panics() { + VerifiedScripts::new(0, SEED); + } +} diff --git a/zebra/zebra-consensus/src/transaction/tests.rs b/zebra/zebra-consensus/src/transaction/tests.rs index e4529446..0b81b7b8 100644 --- a/zebra/zebra-consensus/src/transaction/tests.rs +++ b/zebra/zebra-consensus/src/transaction/tests.rs @@ -31,7 +31,8 @@ use zebra_chain::{ insert_fake_orchard_shielded_data, test_transactions, transactions_from_blocks, v5_transactions, }, - zip317, Hash, HashType, JoinSplitData, LockTime, Transaction, + zip317, Hash, HashType, JoinSplitData, LockTime, Transaction, UnminedTx, UnminedTxId, + WtxId, }, transparent::{self, CoinbaseSpendRestriction}, }; @@ -40,7 +41,7 @@ use zebra_node_services::mempool; use zebra_state::ValidateContextError; use zebra_test::mock_service::MockService; -use crate::{error::TransactionError, transaction::POLL_MEMPOOL_DELAY}; +use crate::{error::TransactionError, transaction::POLL_MEMPOOL_DELAY, BoxError}; use super::{check, BlockRequest, BlockTxVerifier, MempoolRequest, MempoolTxVerifier}; @@ -1011,6 +1012,7 @@ async fn block_verification_does_not_use_mempool_verified_state() { 0, Amount::try_from(10001).expect("invalid value"), ); + let (input, known_utxos) = uniquely_sourced(0xA7, input, known_utxos); // Create a non-coinbase V4 tx with the last valid expiry height. let tx = Transaction::V5 { @@ -1109,10 +1111,11 @@ async fn block_verification_does_not_use_mempool_verified_state() { }; // The mempool has already verified this transaction, and it is now submitted twice as a block - // request. Each request runs full block verification independently, which for this transaction - // means fetching the spent UTXO from the state service, so two AwaitUtxo responses are queued - // below. Reuse of the mempool's result — or of the first block request's result — is prevented - // structurally: BlockTxVerifier holds no mempool handle and caches nothing across requests. + // request. Each request fetches the spent UTXO from the state service fresh (spent-UTXO + // lookups are never cached, and BlockTxVerifier holds no mempool handle), so two AwaitUtxo + // responses are queued below. Only the second request's script checks may be answered by the + // script cache, as asserted on the hit counter after each request; the mempool attempt + // recorded nothing because it spent a mempool output, which bypasses the cache. let utxo_clone = utxo.clone(); tokio::spawn(async move { state @@ -1131,17 +1134,29 @@ async fn block_verification_does_not_use_mempool_verified_state() { // Briefly yield and sleep so the spawned task can first expect the requests. tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let cache_key = wtx_id_of(&tx); + let crate::transaction::BlockResponse { .. } = block_verifier .clone() .oneshot(make_request()) .await .expect("should succeed after calling state service"); + assert_eq!( + super::script_cache::verified_scripts().hits_for(&cache_key), + 0, + "the mempool attempt recorded nothing, so the first block request verifies for real" + ); let crate::transaction::BlockResponse { .. } = block_verifier .clone() .oneshot(make_request()) .await .expect("should succeed after calling state service"); + assert_eq!( + super::script_cache::verified_scripts().hits_for(&cache_key), + 1, + "the second block request reuses the first's script verification" + ); tokio::time::sleep(POLL_MEMPOOL_DELAY * 2).await; // polled before AwaitOutput request and after a mempool transaction with transparent outputs @@ -3661,6 +3676,718 @@ fn mock_transparent_transfer( (input, output, known_utxos) } +// Transparent script verification cache tests. +// +// The cache is process-global and keyed by transaction id, so every cache test +// rewrites its outpoint hash with `uniquely_sourced` to make its transaction +// bytes unique: a byte-identical transaction in another test would share the +// key and race the hit counts. + +/// Rewrites `input`'s outpoint hash (and its `known_utxos` entry) to +/// `Hash([tag; 32])`. `tag` must be unique per test; +/// `mock_transparent_transfer` itself always uses `[1; 32]`. +fn uniquely_sourced( + tag: u8, + input: transparent::Input, + known_utxos: HashMap, +) -> ( + transparent::Input, + HashMap, +) { + let transparent::Input::PrevOut { + outpoint, + unlock_script, + sequence, + } = input + else { + panic!("mock input is a PrevOut"); + }; + let unique_outpoint = transparent::OutPoint { + hash: Hash([tag; 32]), + index: outpoint.index, + }; + let utxo = known_utxos + .into_values() + .next() + .expect("the mock always returns one UTXO"); + + ( + transparent::Input::PrevOut { + outpoint: unique_outpoint, + unlock_script, + sequence, + }, + HashMap::from([(unique_outpoint, utxo)]), + ) +} + +/// The script cache key of a v5+ transaction. +fn wtx_id_of(tx: &Transaction) -> WtxId { + match tx.unmined_id() { + UnminedTxId::Witnessed(id) => id, + UnminedTxId::Legacy(_) => panic!("cache tests use v5+ transactions"), + } +} + +/// A v5 txid excludes authorizing data, so a valid transaction and a +/// corrupted twin can share a txid while differing in scriptSigs. The cache +/// keys on the witnessed id, so the twin must be re-verified and rejected +/// (CVE-2026-34377, GHSA-3vmh-33xr-9cqh). +#[tokio::test] +async fn v5_script_cache_rejects_an_authorizing_data_twin() { + let network = Network::new_default_testnet(); + let network_upgrade = NetworkUpgrade::Nu5; + + let block_height = (network_upgrade + .activation_height(&network) + .expect("NU5 activation height is specified") + + 10) + .expect("transaction block height is too large"); + let fund_height = (block_height - 1).expect("fake source fund block height is too small"); + + let (input, output, known_utxos) = mock_transparent_transfer( + fund_height, + true, + 0, + Amount::try_from(10_101).expect("invalid value"), + ); + let (input, known_utxos) = uniquely_sourced(0xA1, input, known_utxos); + + let transaction = Transaction::V5 { + inputs: vec![input], + outputs: vec![output], + lock_time: LockTime::Height(block::Height(0)), + expiry_height: (block_height + 1).expect("expiry height is too large"), + sapling_shielded_data: None, + orchard_shielded_data: None, + network_upgrade, + }; + + // The twin corrupts only authorizing data: its scriptSig no longer + // satisfies the P2SH lock script, while all effecting data stays identical. + let mut twin = transaction.clone(); + let Transaction::V5 { inputs, .. } = &mut twin else { + unreachable!("twin is V5"); + }; + let transparent::Input::PrevOut { unlock_script, .. } = &mut inputs[0] else { + panic!("test input is a PrevOut"); + }; + *unlock_script = transparent::Script::new(&[0]); + + assert_eq!(transaction.hash(), twin.hash()); + assert_ne!(transaction.auth_digest(), twin.auth_digest()); + + // Verify the valid transaction, which records its scripts in the cache. + let state_service = + service_fn(|_| async { unreachable!("State service should not be called") }); + let verifier = BlockTxVerifier::new(&network, state_service); + + let result = verifier + .oneshot(BlockRequest { + transaction_hash: transaction.hash(), + transaction: Arc::new(transaction), + known_utxos: Arc::new(known_utxos.clone()), + height: block_height, + time: DateTime::::MAX_UTC, + }) + .await; + assert!( + result.is_ok(), + "the valid transaction must verify: {result:?}" + ); + + let state_service = + service_fn(|_| async { unreachable!("State service should not be called") }); + let verifier = BlockTxVerifier::new(&network, state_service); + + let result = verifier + .oneshot(BlockRequest { + transaction_hash: twin.hash(), + transaction: Arc::new(twin), + known_utxos: Arc::new(known_utxos), + height: block_height, + time: DateTime::::MAX_UTC, + }) + .await; + assert!( + result.is_err(), + "the authorizing-data twin must fail script verification, not hit the cache" + ); +} + +/// A failed script verification is never recorded: re-verifying the same +/// failing transaction must fail again. +#[tokio::test] +async fn failed_script_verification_is_not_recorded() { + let network = Network::new_default_testnet(); + let network_upgrade = NetworkUpgrade::Nu5; + + let block_height = (network_upgrade + .activation_height(&network) + .expect("NU5 activation height is specified") + + 10) + .expect("transaction block height is too large"); + let fund_height = (block_height - 1).expect("fake source fund block height is too small"); + + let (input, output, known_utxos) = mock_transparent_transfer( + fund_height, + false, + 0, + Amount::try_from(10_102).expect("invalid value"), + ); + let (input, known_utxos) = uniquely_sourced(0xA2, input, known_utxos); + + let transaction = Arc::new(Transaction::V5 { + inputs: vec![input], + outputs: vec![output], + lock_time: LockTime::Height(block::Height(0)), + expiry_height: (block_height + 1).expect("expiry height is too large"), + sapling_shielded_data: None, + orchard_shielded_data: None, + network_upgrade, + }); + let known_utxos = Arc::new(known_utxos); + + for attempt in 1..=2 { + let state_service = + service_fn(|_| async { unreachable!("State service should not be called") }); + let verifier = BlockTxVerifier::new(&network, state_service); + + let result = verifier + .oneshot(BlockRequest { + transaction_hash: transaction.hash(), + transaction: transaction.clone(), + known_utxos: known_utxos.clone(), + height: block_height, + time: DateTime::::MAX_UTC, + }) + .await; + assert!( + result.is_err(), + "attempt {attempt}: the failing script must be rejected every time" + ); + } +} + +/// v4 transactions are never cached: their legacy ids don't commit to the +/// consensus branch id. Re-verifying the same v4 transaction against a +/// rejecting prevout must fail; a (wrongly) recorded first success would +/// answer it. +#[tokio::test] +async fn v4_transactions_are_never_cached() { + let network = Network::Mainnet; + + let block_height = (NetworkUpgrade::Canopy + .activation_height(&network) + .expect("Canopy activation height is specified") + + 10) + .expect("transaction block height is too large"); + let fund_height = (block_height - 1).expect("fake source fund block height is too small"); + + let amount = Amount::try_from(10_103).expect("invalid value"); + let (input, output, known_utxos) = mock_transparent_transfer(fund_height, true, 0, amount); + let (input, known_utxos) = uniquely_sourced(0xA5, input, known_utxos); + + // The same outpoint paired with a rejecting prevout script. The + // transaction bytes are unchanged; only the mocked UTXO differs. + let (rejecting_input, _, rejecting_utxos) = + mock_transparent_transfer(fund_height, false, 0, amount); + let (_, rejecting_utxos) = uniquely_sourced(0xA5, rejecting_input, rejecting_utxos); + + let transaction = Arc::new(Transaction::V4 { + inputs: vec![input], + outputs: vec![output], + lock_time: LockTime::Height(block::Height(0)), + expiry_height: (block_height + 1).expect("expiry height is too large"), + joinsplit_data: None, + sapling_shielded_data: None, + }); + + let verify_with = |known_utxos: HashMap<_, _>| { + let transaction = transaction.clone(); + let network = network.clone(); + async move { + let state_service = + service_fn(|_| async { unreachable!("State service should not be called") }); + let verifier = BlockTxVerifier::new(&network, state_service); + verifier + .oneshot(BlockRequest { + transaction_hash: transaction.hash(), + transaction: transaction.clone(), + known_utxos: Arc::new(known_utxos), + height: block_height, + time: DateTime::::MAX_UTC, + }) + .await + } + }; + + let result = verify_with(known_utxos).await; + assert!( + result.is_ok(), + "the valid transaction must verify: {result:?}" + ); + + let result = verify_with(rejecting_utxos).await; + assert!( + result.is_err(), + "the second verification must run for real and reject, not reuse the v4 success" + ); +} + +/// A repeat verification is answered from the cache, and removing the entry +/// forces a real re-verification. Observed through the test-only per-key hit +/// counter; removal is key-scoped, so parallel tests keep their own entries. +#[tokio::test] +async fn cached_verification_skips_script_checks_until_removed() { + let network = Network::new_default_testnet(); + let network_upgrade = NetworkUpgrade::Nu5; + + let block_height = (network_upgrade + .activation_height(&network) + .expect("NU5 activation height is specified") + + 10) + .expect("transaction block height is too large"); + let fund_height = (block_height - 1).expect("fake source fund block height is too small"); + + let (input, output, known_utxos) = mock_transparent_transfer( + fund_height, + true, + 0, + Amount::try_from(10_104).expect("invalid value"), + ); + let (input, known_utxos) = uniquely_sourced(0xA3, input, known_utxos); + + let transaction = Arc::new(Transaction::V5 { + inputs: vec![input], + outputs: vec![output], + lock_time: LockTime::Height(block::Height(0)), + expiry_height: (block_height + 1).expect("expiry height is too large"), + sapling_shielded_data: None, + orchard_shielded_data: None, + network_upgrade, + }); + + let cache_key = wtx_id_of(&transaction); + let known_utxos = Arc::new(known_utxos); + + let verify = || async { + let state_service = + service_fn(|_| async { unreachable!("State service should not be called") }); + let verifier = BlockTxVerifier::new(&network, state_service); + verifier + .oneshot(BlockRequest { + transaction_hash: transaction.hash(), + transaction: transaction.clone(), + known_utxos: known_utxos.clone(), + height: block_height, + time: DateTime::::MAX_UTC, + }) + .await + }; + + let result = verify().await; + assert!( + result.is_ok(), + "the valid transaction must verify: {result:?}" + ); + assert_eq!( + super::script_cache::verified_scripts().hits_for(&cache_key), + 0, + "the first verification must run for real" + ); + + let result = verify().await; + assert!(result.is_ok(), "the repeat must succeed: {result:?}"); + assert_eq!( + super::script_cache::verified_scripts().hits_for(&cache_key), + 1, + "the repeat verification must be answered from the cache" + ); + + super::script_cache::verified_scripts().remove(&cache_key); + + let result = verify().await; + assert!( + result.is_ok(), + "the re-verification after removal must succeed: {result:?}" + ); + assert_eq!( + super::script_cache::verified_scripts().hits_for(&cache_key), + 1, + "after removal, verification must run for real instead of hitting" + ); +} + +/// A pool-consolidation-shaped transaction (1001 standard P2SH inputs) is +/// admitted through the production mempool verifier, then block-verified with +/// every spent UTXO served by the state service, as on a real node. The block +/// phase must reuse the admission's cache entry. +#[tokio::test] +async fn mempool_admission_is_reused_by_block_verification() { + const FAT_TX_INPUTS: usize = 1001; + + timeout(test_timeout(), async { + let network = Network::new_default_testnet(); + let network_upgrade = NetworkUpgrade::Nu5; + + let block_height = (network_upgrade + .activation_height(&network) + .expect("NU5 activation height is specified") + + 10) + .expect("transaction block height is too large"); + let fund_height = (block_height - 1).expect("fund height is too small"); + + // The same standard P2SH pattern as `mock_transparent_transfer`: an + // OP_TRUE redeem script spend, standard for the mempool gate and valid + // for the interpreter, with no signatures so the test stays fast. + const OP_TRUE: u8 = 0x51; + let unlock_script = transparent::Script::new(&[0x01, OP_TRUE]); + let mut p2sh_lock_bytes = vec![0xa9, 0x14]; + p2sh_lock_bytes.extend_from_slice(&[ + 0xda, 0x17, 0x45, 0xe9, 0xb5, 0x49, 0xbd, 0x0b, 0xfa, 0x1a, 0x56, 0x99, 0x71, 0xc7, + 0x7e, 0xba, 0x30, 0xcd, 0x5a, 0x4b, + ]); + p2sh_lock_bytes.push(0x87); + let lock_script = transparent::Script::new(&p2sh_lock_bytes); + + // A source hash unique to this test, for the same reason the smaller + // cache tests use `uniquely_sourced`: the cache is process-global, and + // another test building the same transaction bytes would share this + // transaction's `WtxId` and pollute its hit counts. + let source_hash = Hash([0xA8; 32]); + let spent_output = transparent::Output { + value: Amount::try_from(10_000).expect("valid amount"), + lock_script, + }; + + let inputs: Vec = (0..FAT_TX_INPUTS) + .map(|index| transparent::Input::PrevOut { + outpoint: transparent::OutPoint { + hash: source_hash, + // Bounded by FAT_TX_INPUTS, so the cast cannot truncate. + index: index as u32, + }, + unlock_script: unlock_script.clone(), + sequence: 0, + }) + .collect(); + + // One consolidated output; the large remainder is the miner fee, which + // comfortably clears the ZIP-317 conventional fee for this size. + let output = transparent::Output { + value: Amount::try_from(5_000).expect("valid amount"), + lock_script: transparent::Script::new(&[0]), + }; + + let transaction = Transaction::V5 { + inputs, + outputs: vec![output], + lock_time: LockTime::unlocked(), + expiry_height: (block_height + 1).expect("expiry height is too large"), + sapling_shielded_data: None, + orchard_shielded_data: None, + network_upgrade, + }; + let unmined_transaction: UnminedTx = transaction.clone().into(); + let transaction = Arc::new(transaction); + + // Mempool admission, with every spent UTXO answered from the mocked + // best chain. + let mempool_state = { + let spent_output = spent_output.clone(); + service_fn(move |request: zebra_state::Request| { + let spent_output = spent_output.clone(); + async move { + match request { + zebra_state::Request::UnspentBestChainUtxo(_) => { + Ok::<_, BoxError>(zebra_state::Response::UnspentBestChainUtxo(Some( + transparent::Utxo::new(spent_output, fund_height, false), + ))) + } + zebra_state::Request::CheckBestChainTipNullifiersAndAnchors(_) => { + Ok(zebra_state::Response::ValidBestChainTipNullifiersAndAnchors) + } + other => unreachable!("unexpected mempool state request: {other:?}"), + } + } + }) + }; + + let mempool_verifier = MempoolTxVerifier::new_for_tests(&network, mempool_state); + let mempool_response = mempool_verifier + .oneshot(MempoolRequest { + transaction: unmined_transaction, + height: block_height, + }) + .await + .expect("mempool admission of the consolidation transaction succeeds"); + assert!( + mempool_response.spent_mempool_outpoints.is_empty(), + "all spends come from the mocked best chain" + ); + + // The mined block arrives; every spent UTXO is served via `AwaitUtxo`. + let fetched = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let block_state = + { + let spent_output = spent_output.clone(); + let fetched = fetched.clone(); + service_fn(move |request: zebra_state::Request| { + let spent_output = spent_output.clone(); + let fetched = fetched.clone(); + async move { + match request { + zebra_state::Request::AwaitUtxo(_) => { + fetched.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok::<_, BoxError>(zebra_state::Response::Utxo( + transparent::Utxo::new(spent_output, fund_height, false), + )) + } + other => unreachable!("unexpected block state request: {other:?}"), + } + } + }) + }; + + let block_verifier = BlockTxVerifier::new(&network, block_state); + let block_response = block_verifier + .oneshot(BlockRequest { + transaction_hash: transaction.hash(), + transaction: transaction.clone(), + known_utxos: Arc::new(HashMap::new()), + height: block_height, + time: DateTime::::MAX_UTC, + }) + .await + .expect("block verification of the mempool-admitted transaction succeeds"); + + assert_eq!( + fetched.load(std::sync::atomic::Ordering::SeqCst), + FAT_TX_INPUTS, + "every spent UTXO must be fetched from the state exactly once" + ); + + // The cast is lossless: FAT_TX_INPUTS is 1001. + let expected_fee = (10_000 * FAT_TX_INPUTS as i64 - 5_000) + .try_into() + .expect("valid fee"); + assert_eq!( + block_response.miner_fee, + Some(expected_fee), + "the block verifier must compute the consolidation fee from the fetched UTXOs" + ); + + assert_eq!( + super::script_cache::verified_scripts().hits_for(&wtx_id_of(&transaction)), + 1, + "block verification must reuse the mempool admission's script verification" + ); + }) + .await + .expect("the test must complete within the test timeout"); +} + +/// A mempool script failure is never recorded, so it cannot poison block +/// verification. The transaction passes every earlier mempool gate +/// (standardness, fees, lock time) and fails only inside the async script +/// checks, so an insert-before-success mutation would record it. +#[tokio::test] +async fn mempool_script_failure_cannot_poison_block_verification() { + let network = Network::new_default_testnet(); + let network_upgrade = NetworkUpgrade::Nu5; + + let block_height = (network_upgrade + .activation_height(&network) + .expect("NU5 activation height is specified") + + 10) + .expect("transaction block height is too large"); + let fund_height = (block_height - 1).expect("fake source fund block height is too small"); + + // A standard P2SH spend whose pushed redeem script does not hash to the + // lock script's commitment: push-only and low-sigop, so it passes the + // mempool standardness gate, and fails only in the script interpreter. + let (input, output, known_utxos) = mock_transparent_transfer( + fund_height, + true, + 0, + Amount::try_from(2_000_105).expect("invalid value"), + ); + let (input, known_utxos) = uniquely_sourced(0xA4, input, known_utxos); + let transparent::Input::PrevOut { + outpoint, sequence, .. + } = input + else { + panic!("mock input is a PrevOut"); + }; + let input = transparent::Input::PrevOut { + outpoint, + // A push of OP_2: standard, zero sigops, wrong redeem script hash. + unlock_script: transparent::Script::new(&[0x01, 0x52]), + sequence, + }; + + let transaction = Arc::new(Transaction::V5 { + inputs: vec![input], + outputs: vec![output], + lock_time: LockTime::unlocked(), + expiry_height: (block_height + 1).expect("expiry height is too large"), + sapling_shielded_data: None, + orchard_shielded_data: None, + network_upgrade, + }); + + let cache_key = wtx_id_of(&transaction); + let spent_output = known_utxos + .values() + .next() + .expect("one mocked UTXO") + .utxo + .output + .clone(); + + // Mempool admission must fail in the script checks and record nothing. + let mempool_state = service_fn(move |request: zebra_state::Request| { + let spent_output = spent_output.clone(); + async move { + match request { + zebra_state::Request::UnspentBestChainUtxo(_) => { + Ok::<_, BoxError>(zebra_state::Response::UnspentBestChainUtxo(Some( + transparent::Utxo::new(spent_output, fund_height, false), + ))) + } + zebra_state::Request::CheckBestChainTipNullifiersAndAnchors(_) => { + Ok(zebra_state::Response::ValidBestChainTipNullifiersAndAnchors) + } + other => unreachable!("unexpected mempool state request: {other:?}"), + } + } + }); + let mempool_verifier = MempoolTxVerifier::new_for_tests(&network, mempool_state); + let result = mempool_verifier + .oneshot(MempoolRequest { + transaction: UnminedTx::from(transaction.as_ref().clone()), + height: block_height, + }) + .await; + assert!( + result.is_err(), + "the wrong redeem script must fail mempool script verification" + ); + + // Block verification of the same transaction, against the same spent + // output data, must re-verify and reject rather than hit a poisoned entry. + let state_service = + service_fn(|_| async { unreachable!("State service should not be called") }); + let block_verifier = BlockTxVerifier::new(&network, state_service); + let result = block_verifier + .oneshot(BlockRequest { + transaction_hash: transaction.hash(), + transaction: transaction.clone(), + known_utxos: Arc::new(known_utxos), + height: block_height, + time: DateTime::::MAX_UTC, + }) + .await; + assert!( + result.is_err(), + "block verification must re-run and reject the failing script" + ); + assert_eq!( + super::script_cache::verified_scripts().hits_for(&cache_key), + 0, + "no phase of a failing transaction's verification may be answered from the cache" + ); +} + +/// A v5 transaction commits to its consensus branch id, so a cached entry can +/// never answer a verification under another upgrade: the branch id check +/// rejects the transaction before the cache is consulted. +#[tokio::test] +async fn v5_branch_id_prevents_cross_upgrade_reuse() { + let network = Network::new_default_testnet(); + + let nu5_height = (NetworkUpgrade::Nu5 + .activation_height(&network) + .expect("NU5 activation height is specified") + + 10) + .expect("transaction block height is too large"); + let nu6_height = (NetworkUpgrade::Nu6 + .activation_height(&network) + .expect("NU6 activation height is specified") + + 10) + .expect("transaction block height is too large"); + let fund_height = (nu5_height - 1).expect("fake source fund block height is too small"); + + let (input, output, known_utxos) = mock_transparent_transfer( + fund_height, + true, + 0, + Amount::try_from(10_106).expect("invalid value"), + ); + let (input, known_utxos) = uniquely_sourced(0xA6, input, known_utxos); + + let transaction = Arc::new(Transaction::V5 { + inputs: vec![input], + outputs: vec![output], + lock_time: LockTime::unlocked(), + expiry_height: (nu6_height + 1).expect("expiry height is too large"), + sapling_shielded_data: None, + orchard_shielded_data: None, + network_upgrade: NetworkUpgrade::Nu5, + }); + + let cache_key = wtx_id_of(&transaction); + let known_utxos = Arc::new(known_utxos); + + let verify_at = |height| { + let transaction = transaction.clone(); + let known_utxos = known_utxos.clone(); + let network = network.clone(); + async move { + let state_service = + service_fn(|_| async { unreachable!("State service should not be called") }); + let verifier = BlockTxVerifier::new(&network, state_service); + verifier + .oneshot(BlockRequest { + transaction_hash: transaction.hash(), + transaction: transaction.clone(), + known_utxos, + height, + time: DateTime::::MAX_UTC, + }) + .await + } + }; + + let result = verify_at(nu5_height).await; + assert!( + result.is_ok(), + "the transaction must verify at the NU5 height: {result:?}" + ); + + // At an NU6 height the committed NU5 branch id is rejected up front. + let result = verify_at(nu6_height).await; + assert!( + result.is_err(), + "the NU5-branch transaction must be rejected at an NU6 height" + ); + assert_eq!( + super::script_cache::verified_scripts().hits_for(&cache_key), + 0, + "the rejection must happen before the cache is consulted" + ); + + // The entry from the NU5 verification is real: a repeat under NU5 hits. + let result = verify_at(nu5_height).await; + assert!(result.is_ok(), "the NU5 repeat must succeed: {result:?}"); + assert_eq!( + super::script_cache::verified_scripts().hits_for(&cache_key), + 1, + "a repeat under the upgrade that earned the entry is answered from the cache" + ); +} + /// Create a mock coinbase input with a transparent output. /// /// Create a [`transparent::Input::Coinbase`] at `coinbase_height`.