use std::{
future,
sync::{
atomic::{AtomicUsize, Ordering},
Arc,
},
task::{Context, Poll},
};
use tower::{Service, ServiceExt};
use zakura_chain::{
amount::Amount,
block::{Block, Height},
parameters::{Network, NetworkUpgrade},
primitives::Groth16Proof,
sapling::{Nullifier, Output, PerSpendAnchor, ShieldedData, Spend, TransferData},
serialization::{AtLeastOne, ZcashDeserializeInto},
transaction::{arbitrary::transaction_to_fake_v5, HashType, Transaction},
transparent,
};
use crate::BoxError;
use super::{sapling_prover, Authorized, Bundle, CacheKey, Cached, CachedItem, Item, ZatBalance};
const TEST_CACHE_VERIFIER_LABEL: &str = "groth16_sapling_test";
#[test]
fn sapling_prover_is_reused() {
assert!(std::ptr::eq(sapling_prover(), sapling_prover()));
}
fn mined_sapling_transactions() -> Vec<(NetworkUpgrade, Transaction)> {
let mut transactions = Vec::new();
for (height, bytes) in zakura_test::vectors::MAINNET_BLOCKS.iter() {
let block: Block = bytes
.zcash_deserialize_into()
.expect("hard-coded test vector must deserialize");
let nu = NetworkUpgrade::current(&Network::Mainnet, Height(*height));
for tx in &block.transactions {
if !tx.inputs().is_empty() {
continue;
}
if item(tx, nu).is_some() {
transactions.push((nu, tx.as_ref().clone()));
}
}
}
assert!(
!transactions.is_empty(),
"mainnet test blocks must contain a transparent-input-free Sapling transaction"
);
transactions
}
fn sapling_transactions() -> Vec<Transaction> {
mined_sapling_transactions()
.into_iter()
.map(|(_, tx)| tx)
.collect()
}
fn sapling_transaction() -> Transaction {
sapling_transactions()
.into_iter()
.next()
.expect("there is at least one Sapling transaction")
}
fn mined_v4_sapling_transaction_with_spends() -> (NetworkUpgrade, Transaction) {
mined_sapling_transactions()
.into_iter()
.find(|(_, tx)| {
matches!(tx, Transaction::V4 { .. }) && tx.sapling_spends_per_anchor().next().is_some()
})
.expect("mainnet test blocks must contain a V4 Sapling transaction with spends")
}
fn sapling_transaction_with_spends() -> Transaction {
mined_v4_sapling_transaction_with_spends().1
}
fn item(tx: &Transaction, nu: NetworkUpgrade) -> Option<Item> {
let all_previous_outputs: Arc<Vec<transparent::Output>> = Arc::new(Vec::new());
let sighasher = tx.sighasher(nu, all_previous_outputs).ok()?;
let bundle = sighasher.sapling_bundle()?;
Some(Item::new(
bundle,
sighasher.sighash(HashType::ALL, None),
tx.unmined_id(),
))
}
fn cache_key(tx: &Transaction, nu: NetworkUpgrade) -> CacheKey {
item(tx, nu)
.expect("the transaction was selected for having a Sapling bundle")
.cache_key()
.expect("every Sapling item carries a cache key")
}
fn mutated_transaction(
tx: &Transaction,
mutate: impl FnOnce(&mut ShieldedData<PerSpendAnchor>),
) -> Transaction {
let mut mutated = tx.clone();
let Transaction::V4 {
sapling_shielded_data: Some(shielded_data),
..
} = &mut mutated
else {
panic!("this fixture is a V4 transaction with Sapling shielded data")
};
mutate(shielded_data);
mutated
}
fn mutated_spend(tx: &Transaction, mutate: impl FnOnce(&mut Spend<PerSpendAnchor>)) -> Transaction {
mutated_transaction(tx, |shielded_data| {
let TransferData::SpendsAndMaybeOutputs { spends, .. } = &mut shielded_data.transfers
else {
panic!("the fixture was selected for having spends")
};
let mut spends_vec = spends.as_slice().to_vec();
mutate(&mut spends_vec[0]);
*spends =
AtLeastOne::from_vec(spends_vec).expect("replacing a field keeps at least one spend");
})
}
fn bundle_fingerprint(bundle: &Bundle<Authorized, ZatBalance>) -> Vec<Vec<u8>> {
let mut parts = vec![
i64::from(*bundle.value_balance()).to_le_bytes().to_vec(),
<[u8; 64]>::from(bundle.authorization().binding_sig).to_vec(),
];
for spend in bundle.shielded_spends() {
parts.push(spend.anchor().to_bytes().to_vec());
parts.push(spend.nullifier().0.to_vec());
parts.push(spend.zkproof().to_vec());
parts.push(<[u8; 64]>::from(*spend.spend_auth_sig()).to_vec());
}
for output in bundle.shielded_outputs() {
parts.push(output.cmu().to_bytes().to_vec());
parts.push(output.ephemeral_key().0.to_vec());
parts.push(output.zkproof().to_vec());
}
parts
}
fn mutated_output(tx: &Transaction, mutate: impl FnOnce(&mut Output)) -> Transaction {
mutated_transaction(tx, |shielded_data| {
let TransferData::SpendsAndMaybeOutputs { maybe_outputs, .. } =
&mut shielded_data.transfers
else {
panic!("the fixture was selected for having spends")
};
mutate(
maybe_outputs
.first_mut()
.expect("the fixture was selected for having outputs"),
);
})
}
fn as_fake_v5(tx: &Transaction) -> Transaction {
let network = Network::Mainnet;
let nu5_height = NetworkUpgrade::Nu5
.activation_height(&network)
.expect("NU5 has an activation height on Mainnet");
let v5 = transaction_to_fake_v5(tx, &network, nu5_height);
assert!(
matches!(v5, Transaction::V5 { .. }),
"the fixture must have been converted to V5"
);
v5
}
#[test]
fn cache_key_is_deterministic() {
let tx = sapling_transaction();
assert_eq!(
cache_key(&tx, NetworkUpgrade::Nu5),
cache_key(&tx, NetworkUpgrade::Nu5),
"the same transaction, bundle and sighash must always produce the same key"
);
}
#[test]
fn cache_key_distinguishes_different_transactions() {
let transactions = sapling_transactions();
assert!(
transactions.len() > 1,
"this test needs at least two Sapling transactions in the test vectors"
);
let keys: Vec<_> = transactions
.iter()
.map(|tx| cache_key(tx, NetworkUpgrade::Nu5))
.collect();
let unique: std::collections::HashSet<_> = keys.iter().collect();
assert_eq!(
unique.len(),
keys.len(),
"distinct Sapling transactions must not share a cache key"
);
}
#[test]
fn cache_key_commits_to_the_sighash() {
let tx = sapling_transaction();
let original = item(&tx, NetworkUpgrade::Nu5).expect("the transaction has a bundle");
let mut tweaked_sighash = original.sighash;
tweaked_sighash.0[0] ^= 1;
let tweaked = Item::new(original.bundle.clone(), tweaked_sighash, tx.unmined_id());
assert_ne!(
original.cache_key(),
tweaked.cache_key(),
"the sighash is an input to verification, so it must be an input to the key"
);
}
#[test]
fn v4_cache_key_changes_with_every_piece_of_authorizing_data() {
let tx = sapling_transaction_with_spends();
let original = cache_key(&tx, NetworkUpgrade::Nu5);
let mutations = authorizing_data_mutations(&tx).into_iter().chain([
(
mutated_value_balance(&tx),
"the value balance, which enters the binding verification key",
),
(
mutated_nullifier(&tx),
"a spend nullifier, which is a public input to its proof",
),
]);
for (mutated, what) in mutations {
assert_ne!(
original,
cache_key(&mutated, NetworkUpgrade::Nu5),
"{what} is verified, so it must be an input to the key"
);
}
}
#[test]
fn v5_cache_key_changes_with_authorizing_data_its_txid_ignores() {
let v4 = sapling_transaction_with_spends();
let v5 = as_fake_v5(&v4);
let original = cache_key(&v5, NetworkUpgrade::Nu5);
for (mutated, what) in authorizing_data_mutations(&v4) {
let mutated = as_fake_v5(&mutated);
assert_eq!(
v5.hash(),
mutated.hash(),
"changing {what} must leave the V5 txid alone, or this test proves nothing"
);
assert_ne!(
original,
cache_key(&mutated, NetworkUpgrade::Nu5),
"{what} is verified, so it must be an input to the key"
);
}
for (mutated, what) in [
(
mutated_value_balance(&v4),
"the value balance, which enters the binding verification key",
),
(
mutated_nullifier(&v4),
"a spend nullifier, which is a public input to its proof",
),
] {
assert_ne!(
original,
cache_key(&as_fake_v5(&mutated), NetworkUpgrade::Nu5),
"{what} is verified, so it must be an input to the key"
);
}
}
fn authorizing_data_mutations(tx: &Transaction) -> Vec<(Transaction, &'static str)> {
vec![
(
mutated_spend(tx, |spend| spend.zkproof = Groth16Proof([0xFF; 192])),
"a spend proof",
),
(
mutated_spend(tx, |spend| spend.spend_auth_sig = [0xFF; 64].into()),
"a spend authorization signature",
),
(
mutated_output(tx, |output| output.zkproof = Groth16Proof([0xFF; 192])),
"an output proof",
),
(
mutated_transaction(tx, |shielded_data| {
shielded_data.binding_sig = [0xFF; 64].into()
}),
"the binding signature",
),
]
}
fn mutated_nullifier(tx: &Transaction) -> Transaction {
mutated_spend(tx, |spend| spend.nullifier = Nullifier([0xFF; 32].into()))
}
fn mutated_value_balance(tx: &Transaction) -> Transaction {
mutated_transaction(tx, |shielded_data| {
shielded_data.value_balance = (shielded_data.value_balance
+ Amount::try_from(1).expect("one is a valid amount"))
.expect("the fixture's value balance is not at the maximum");
})
}
#[test]
fn cache_key_distinguishes_v4_and_v5_carrying_the_same_bundle() {
let v4 = sapling_transactions()
.into_iter()
.find(|tx| matches!(tx, Transaction::V4 { .. }))
.expect("mainnet test blocks must contain a V4 Sapling transaction");
let v5 = as_fake_v5(&v4);
let v4_item = item(&v4, NetworkUpgrade::Nu5).expect("the V4 transaction has a bundle");
let v5_item = item(&v5, NetworkUpgrade::Nu5).expect("the V5 transaction has a bundle");
assert_eq!(
bundle_fingerprint(&v4_item.bundle),
bundle_fingerprint(&v5_item.bundle),
"the conversion must carry the Sapling bundle across unchanged, or this test proves \
nothing"
);
assert_ne!(
v4_item.cache_key(),
v5_item.cache_key(),
"the same bundle in two transaction versions must not share a cache key"
);
}
#[derive(Clone)]
struct CountingVerifier {
calls: Arc<AtomicUsize>,
accepts: bool,
}
impl CountingVerifier {
fn new(accepts: bool) -> Self {
Self {
calls: Arc::new(AtomicUsize::new(0)),
accepts,
}
}
fn calls(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
}
impl Service<Item> for CountingVerifier {
type Response = ();
type Error = BoxError;
type Future = future::Ready<Result<(), BoxError>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, _item: Item) -> Self::Future {
self.calls.fetch_add(1, Ordering::SeqCst);
future::ready(if self.accepts {
Ok(())
} else {
Err(BoxError::from("rejected"))
})
}
}
#[tokio::test]
async fn cache_skips_the_inner_service_for_an_already_verified_bundle() {
let tx = sapling_transaction();
let inner = CountingVerifier::new(true);
let mut verifier = Cached::new(inner.clone(), 8, TEST_CACHE_VERIFIER_LABEL);
for _ in 0..3 {
verifier
.ready()
.await
.expect("the cache must become ready")
.call(item(&tx, NetworkUpgrade::Nu5).expect("the transaction has a bundle"))
.await
.expect("a valid item must verify");
}
assert_eq!(
inner.calls(),
1,
"only the first verification of a bundle may reach the inner service"
);
}
#[tokio::test]
async fn cache_does_not_reuse_a_result_across_bundles() {
let transactions = sapling_transactions();
let inner = CountingVerifier::new(true);
let mut verifier = Cached::new(inner.clone(), 8, TEST_CACHE_VERIFIER_LABEL);
for tx in transactions.iter().take(2) {
verifier
.ready()
.await
.expect("the cache must become ready")
.call(item(tx, NetworkUpgrade::Nu5).expect("the transaction has a bundle"))
.await
.expect("the inner service accepts everything in this test");
}
assert_eq!(
inner.calls(),
2,
"bundles with different keys must each be verified"
);
}
#[tokio::test]
async fn clearing_the_cache_forces_reverification() {
let tx = sapling_transaction();
let inner = CountingVerifier::new(true);
let mut verifier = Cached::new(inner.clone(), 8, TEST_CACHE_VERIFIER_LABEL);
for _ in 0..2 {
verifier
.ready()
.await
.expect("the cache must become ready")
.call(item(&tx, NetworkUpgrade::Nu5).expect("the transaction has a bundle"))
.await
.expect("a valid item must verify");
}
assert_eq!(inner.calls(), 1, "the second verification must be a hit");
verifier.clear();
verifier
.ready()
.await
.expect("the cache must become ready")
.call(item(&tx, NetworkUpgrade::Nu5).expect("the transaction has a bundle"))
.await
.expect("a valid item must verify");
assert_eq!(
inner.calls(),
2,
"a cleared cache must send the bundle back to the inner service"
);
}
#[tokio::test]
async fn cache_does_not_remember_failures() {
let tx = sapling_transaction();
let inner = CountingVerifier::new(false);
let mut verifier = Cached::new(inner.clone(), 8, TEST_CACHE_VERIFIER_LABEL);
for _ in 0..3 {
verifier
.ready()
.await
.expect("the cache must become ready")
.call(item(&tx, NetworkUpgrade::Nu5).expect("the transaction has a bundle"))
.await
.expect_err("the inner service rejects everything in this test");
}
assert_eq!(
inner.calls(),
3,
"a failed verification must not be remembered"
);
}
#[derive(Clone)]
struct UnreadyVerifier {
poll_readies: Arc<AtomicUsize>,
}
impl UnreadyVerifier {
fn new() -> Self {
Self {
poll_readies: Arc::new(AtomicUsize::new(0)),
}
}
fn poll_readies(&self) -> usize {
self.poll_readies.load(Ordering::SeqCst)
}
}
impl Service<Item> for UnreadyVerifier {
type Response = ();
type Error = BoxError;
type Future = future::Ready<Result<(), BoxError>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.poll_readies.fetch_add(1, Ordering::SeqCst);
Poll::Ready(Err(BoxError::from("batch worker finished unexpectedly")))
}
fn call(&mut self, _item: Item) -> Self::Future {
unreachable!("a service whose poll_ready failed must not be called")
}
}
#[tokio::test]
async fn cache_hit_survives_an_inner_service_that_never_becomes_ready() {
let tx = sapling_transaction();
let healthy = CountingVerifier::new(true);
let mut verifier = Cached::new(healthy.clone(), 8, TEST_CACHE_VERIFIER_LABEL);
verifier
.ready()
.await
.expect("the cache must become ready")
.call(item(&tx, NetworkUpgrade::Nu5).expect("the transaction has a bundle"))
.await
.expect("the first verification must succeed");
assert_eq!(healthy.calls(), 1, "the first verification must be a miss");
let dead = UnreadyVerifier::new();
let mut verifier = verifier.with_inner(dead.clone());
verifier
.ready()
.await
.expect("the cache must be ready even when the inner service is not")
.call(item(&tx, NetworkUpgrade::Nu5).expect("the transaction has a bundle"))
.await
.expect("a cache hit must be answered from the cache, not from the dead inner service");
assert_eq!(
dead.poll_readies(),
0,
"a hit must not poll the inner service for readiness at all"
);
}
#[tokio::test]
async fn cache_miss_propagates_an_inner_readiness_failure() {
let tx = sapling_transaction();
let dead = UnreadyVerifier::new();
let mut verifier = Cached::new(dead.clone(), 8, TEST_CACHE_VERIFIER_LABEL);
verifier
.ready()
.await
.expect("the cache itself is always ready")
.call(item(&tx, NetworkUpgrade::Nu5).expect("the transaction has a bundle"))
.await
.expect_err("a miss must surface the inner service's readiness failure");
assert!(
dead.poll_readies() > 0,
"a miss must acquire inner readiness"
);
let counting = CountingVerifier::new(true);
let mut verifier = verifier.with_inner(counting.clone());
verifier
.ready()
.await
.expect("the cache must become ready")
.call(item(&tx, NetworkUpgrade::Nu5).expect("the transaction has a bundle"))
.await
.expect("the retry must succeed");
assert_eq!(
counting.calls(),
1,
"the bundle must still be verified, so the readiness failure was not recorded as an Ok"
);
}
fn uncached_verification_behind_a_fresh_cache() -> Cached<super::BatchFallbackService> {
Cached::new(
super::batch_fallback_verifier(),
8,
TEST_CACHE_VERIFIER_LABEL,
)
}
#[tokio::test(flavor = "multi_thread")]
async fn a_bundle_verified_under_one_upgrade_is_not_reused_under_another() {
use crate::error::TransactionError;
let _init_guard = zakura_test::init();
let (mined_upgrade, tx) = mined_v4_sapling_transaction_with_spends();
let other_upgrade = if mined_upgrade == NetworkUpgrade::Nu5 {
NetworkUpgrade::Canopy
} else {
NetworkUpgrade::Nu5
};
let mined_item = item(&tx, mined_upgrade).expect("the transaction has a bundle");
let other_item = item(&tx, other_upgrade).expect("the transaction has a bundle");
assert_eq!(
bundle_fingerprint(&mined_item.bundle),
bundle_fingerprint(&other_item.bundle),
"the branch id must not change the parsed bundle, or this test proves nothing"
);
assert_ne!(
mined_item.cache_key(),
other_item.cache_key(),
"the two sighashes must produce different keys"
);
let verifier = uncached_verification_behind_a_fresh_cache();
verifier
.clone()
.oneshot(mined_item)
.await
.expect("a real mainnet Sapling bundle must verify under the upgrade that mined it");
let error = verifier
.clone()
.oneshot(other_item)
.await
.expect_err("the same bundle must not verify against another upgrade's sighash");
let error = error
.downcast::<TransactionError>()
.expect("the verifier reports a typed transaction error");
assert!(
matches!(*error, TransactionError::SaplingVerificationFailed),
"expected SaplingVerificationFailed, got: {error:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn cached_verifier_still_rejects_a_corrupted_proof_after_verifying_a_valid_one() {
use crate::error::TransactionError;
let _init_guard = zakura_test::init();
let (nu, valid) = mined_v4_sapling_transaction_with_spends();
let corrupted = mutated_spend(&valid, |spend| {
let (first, rest) = spend.zkproof.0.split_at_mut(48);
first.swap_with_slice(&mut rest[96..144]);
});
let valid_item = item(&valid, nu).expect("the transaction has a bundle");
let corrupted_item = item(&corrupted, nu).expect("the transaction has a bundle");
assert_ne!(
valid_item.cache_key(),
corrupted_item.cache_key(),
"the corrupted proof must key differently, or the cache would return the remembered Ok"
);
let verifier = uncached_verification_behind_a_fresh_cache();
verifier
.clone()
.oneshot(valid_item)
.await
.expect("a real mainnet Sapling bundle must verify");
let error =
verifier.clone().oneshot(corrupted_item).await.expect_err(
"a corrupted Sapling proof must be rejected even after a valid one verified",
);
let error = error
.downcast::<TransactionError>()
.expect("the verifier reports a typed transaction error");
assert!(
matches!(*error, TransactionError::SaplingVerificationFailed),
"expected SaplingVerificationFailed, got: {error:?}"
);
}