From 6d403993c8ce7883a67039813552db4234bb5502 Mon Sep 17 00:00:00 2001 From: Dev Ojha Date: Wed, 1 Jul 2026 17:54:37 -0600 Subject: [PATCH] perf(transaction): cache empty ZIP-244 bundle hashes --- zebra-chain/benches/transaction.rs | 54 +++++- zebra-chain/src/transaction/zip244.rs | 243 ++++++++++++++++++++++---- 2 files changed, 261 insertions(+), 36 deletions(-) diff --git a/zebra-chain/benches/transaction.rs b/zebra-chain/benches/transaction.rs index 1267546fd32..dc6a62545f9 100644 --- a/zebra-chain/benches/transaction.rs +++ b/zebra-chain/benches/transaction.rs @@ -21,9 +21,9 @@ // Disabled due to warnings in criterion macros #![allow(missing_docs)] -use std::io::Cursor; +use std::{io::Cursor, sync::Arc}; -use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; +use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion}; use zebra_chain::{ block::Block, @@ -40,6 +40,20 @@ fn first_tx_of_version(block: &Block, version: u32) -> Option> { .map(|tx| tx.zcash_serialize_to_vec().expect("valid transaction")) } +/// Extracts the first Orchard-only v5 transaction from a block. +fn first_v5_orchard_only_tx(block: &Block) -> Option> { + block + .transactions + .iter() + .find(|tx| { + tx.version() == 5 + && !tx.has_transparent_inputs_or_outputs() + && !tx.has_sapling_shielded_data() + && tx.has_orchard_shielded_data() + }) + .cloned() +} + fn bench_transaction_deserialize(c: &mut Criterion) { let mut group = c.benchmark_group("Transaction Deserialization"); @@ -114,9 +128,43 @@ fn bench_transaction_deserialize(c: &mut Criterion) { group.finish(); } +fn bench_zip244_digests(c: &mut Criterion) { + let nu5_blocks = [ + zebra_test::vectors::BLOCK_MAINNET_1687107_BYTES.as_slice(), + zebra_test::vectors::BLOCK_MAINNET_1687108_BYTES.as_slice(), + zebra_test::vectors::BLOCK_MAINNET_1687113_BYTES.as_slice(), + zebra_test::vectors::BLOCK_MAINNET_1687118_BYTES.as_slice(), + zebra_test::vectors::BLOCK_MAINNET_1687121_BYTES.as_slice(), + ]; + let tx = nu5_blocks + .into_iter() + .find_map(|block_bytes| { + let block = Block::zcash_deserialize(Cursor::new(block_bytes)).expect("valid block"); + first_v5_orchard_only_tx(&block) + }) + .expect("vectors contain an Orchard-only v5 tx"); + + let mut group = c.benchmark_group("ZIP-244 Digests"); + group.sample_size(1000); + + group.bench_function("auth_digest/orchard_only", |b| { + b.iter(|| { + black_box(&tx) + .auth_digest() + .expect("v5 tx has an auth digest") + }) + }); + + group.bench_function("txid_and_auth_digest/orchard_only", |b| { + b.iter(|| black_box(&tx).txid_and_auth_digest()) + }); + + group.finish(); +} + criterion_group! { name = benches; config = Criterion::default().noise_threshold(0.1).sample_size(50); - targets = bench_transaction_deserialize + targets = bench_transaction_deserialize, bench_zip244_digests } criterion_main!(benches); diff --git a/zebra-chain/src/transaction/zip244.rs b/zebra-chain/src/transaction/zip244.rs index 43dd5c111c3..3030d291861 100644 --- a/zebra-chain/src/transaction/zip244.rs +++ b/zebra-chain/src/transaction/zip244.rs @@ -89,6 +89,59 @@ const ZCASH_ORCHARD_SIGS_HASH_PERSONALIZATION: &[u8; 16] = b"ZTxAuthOrchaHash"; const ZCASH_ORCHARD_V6_SIGS_HASH_PERSONALIZATION: &[u8; 16] = b"ZTxAuthOrchaH_v6"; const ZCASH_IRONWOOD_SIGS_HASH_PERSONALIZATION: &[u8; 16] = b"ZTxAuthIrnwdH_v6"; +const EMPTY_TRANSPARENT_TXID_HASH: &[u8; 32] = &[ + 0xc3, 0x3f, 0x2e, 0x95, 0x70, 0x5f, 0xaa, 0xb3, 0x5f, 0x8d, 0x53, 0x3f, 0xa6, 0x1e, 0x95, 0xc3, + 0xb7, 0xaa, 0xba, 0x07, 0x76, 0xb8, 0x74, 0xa9, 0xf7, 0x4f, 0xc1, 0x27, 0x84, 0x37, 0x6a, 0x59, +]; +const EMPTY_SAPLING_TXID_HASH: &[u8; 32] = &[ + 0x6f, 0x2f, 0xc8, 0xf9, 0x8f, 0xea, 0xfd, 0x94, 0xe7, 0x4a, 0x0d, 0xf4, 0xbe, 0xd7, 0x43, 0x91, + 0xee, 0x0b, 0x5a, 0x69, 0x94, 0x5e, 0x4c, 0xed, 0x8c, 0xa8, 0xa0, 0x95, 0x20, 0x6f, 0x00, 0xae, +]; +const EMPTY_SAPLING_SPENDS_HASH: &[u8; 32] = &[ + 0xd7, 0x9c, 0x8d, 0xcb, 0x8a, 0xf4, 0x6b, 0xd8, 0x0f, 0xe3, 0xbc, 0xdc, 0x0e, 0x5d, 0x0e, 0xd7, + 0x34, 0x4d, 0x5f, 0x48, 0xcb, 0xef, 0xf3, 0xc9, 0x4b, 0x13, 0xd4, 0x0a, 0x23, 0x05, 0xf8, 0x4d, +]; +const EMPTY_SAPLING_OUTPUTS_HASH: &[u8; 32] = &[ + 0x9e, 0x28, 0xee, 0xf8, 0xdf, 0x6f, 0xcc, 0x96, 0x68, 0xef, 0x92, 0xfc, 0xdc, 0x41, 0x2f, 0xc5, + 0xb6, 0xd7, 0x03, 0x7e, 0xf1, 0xf9, 0x63, 0x76, 0x7b, 0xd7, 0xb8, 0x01, 0x12, 0x67, 0xbf, 0x95, +]; +const EMPTY_ORCHARD_V5_TXID_HASH: &[u8; 32] = &[ + 0x9f, 0xbe, 0x4e, 0xd1, 0x3b, 0x0c, 0x08, 0xe6, 0x71, 0xc1, 0x1a, 0x34, 0x07, 0xd8, 0x4e, 0x11, + 0x17, 0xcd, 0x45, 0x02, 0x8a, 0x2e, 0xee, 0x1b, 0x9f, 0xea, 0xe7, 0x8b, 0x48, 0xa6, 0xe2, 0xc1, +]; +const EMPTY_ORCHARD_V6_TXID_HASH: &[u8; 32] = &[ + 0xa3, 0x36, 0x7d, 0x2f, 0xde, 0xa2, 0x91, 0x01, 0x59, 0xfc, 0x50, 0x26, 0xe9, 0xbf, 0x1f, 0xcc, + 0xd3, 0xe2, 0x8c, 0xe5, 0xe6, 0xde, 0x46, 0xbf, 0xb7, 0x15, 0x87, 0x23, 0x0e, 0xea, 0x95, 0x15, +]; +const EMPTY_IRONWOOD_TXID_HASH: &[u8; 32] = &[ + 0xb9, 0xcf, 0xe6, 0x43, 0xce, 0x45, 0xb2, 0x8c, 0x33, 0x19, 0x0f, 0x0d, 0x52, 0x23, 0xe4, 0x75, + 0x97, 0x2f, 0x2a, 0x14, 0x9d, 0xc5, 0x44, 0x04, 0xfd, 0x83, 0x65, 0x52, 0x1f, 0x84, 0x16, 0xc5, +]; +const EMPTY_TRANSPARENT_AUTH_HASH: &[u8; 32] = &[ + 0xe9, 0x88, 0x2b, 0xce, 0x1c, 0xf1, 0x35, 0x69, 0x02, 0xc6, 0xe2, 0x58, 0xc5, 0x67, 0xeb, 0xc0, + 0xd9, 0x92, 0x88, 0x67, 0xde, 0x9a, 0x35, 0x89, 0xbb, 0xbd, 0x31, 0x0e, 0xb6, 0x89, 0x04, 0xe5, +]; +const EMPTY_SAPLING_V5_AUTH_HASH: &[u8; 32] = &[ + 0xd2, 0x25, 0x67, 0x30, 0x66, 0xb0, 0xcd, 0x76, 0xa7, 0x71, 0x51, 0xbf, 0x05, 0x6d, 0x57, 0x77, + 0x92, 0xf3, 0x57, 0x73, 0x91, 0x20, 0x8d, 0x4c, 0xec, 0x25, 0x31, 0x8a, 0x8d, 0x5c, 0xd9, 0x6f, +]; +const EMPTY_SAPLING_V6_AUTH_HASH: &[u8; 32] = &[ + 0x0e, 0xe4, 0xb9, 0x56, 0x0b, 0xae, 0x42, 0x30, 0xa9, 0x9b, 0xfa, 0x52, 0x8e, 0x0a, 0x6a, 0xab, + 0xc7, 0xe2, 0x53, 0x86, 0xf3, 0x66, 0x59, 0x97, 0x67, 0xe7, 0xca, 0x10, 0x7b, 0x8c, 0x17, 0x46, +]; +const EMPTY_ORCHARD_V5_AUTH_HASH: &[u8; 32] = &[ + 0x14, 0xed, 0xaa, 0x1e, 0x66, 0x9a, 0x63, 0xa8, 0x00, 0xbf, 0xe0, 0xb8, 0xfc, 0xd3, 0xd1, 0x0e, + 0x36, 0x81, 0x11, 0x5b, 0xee, 0x03, 0x25, 0x3d, 0xa0, 0x2e, 0x09, 0x80, 0x42, 0xd9, 0xff, 0x90, +]; +const EMPTY_ORCHARD_V6_AUTH_HASH: &[u8; 32] = &[ + 0x79, 0x8e, 0x7f, 0xcd, 0x05, 0xbb, 0x7b, 0x7e, 0x9e, 0x49, 0x42, 0x4b, 0xdd, 0xe7, 0x68, 0xa5, + 0x00, 0xb6, 0x6f, 0xf2, 0x3d, 0x75, 0x9b, 0x87, 0x7d, 0x07, 0x54, 0xe3, 0xff, 0x79, 0x7d, 0x91, +]; +const EMPTY_IRONWOOD_AUTH_HASH: &[u8; 32] = &[ + 0xec, 0x97, 0x68, 0xfd, 0xaa, 0x11, 0xf1, 0x2c, 0xdb, 0x13, 0xf5, 0x66, 0xb5, 0x95, 0x84, 0x3f, + 0x3d, 0x0e, 0x92, 0xb6, 0x70, 0x3e, 0xaf, 0xff, 0x17, 0x2e, 0x21, 0x34, 0x5b, 0x58, 0x61, 0x33, +]; + /// A new BLAKE2b-256 state with the given 16-byte personalization. fn hasher(personal: &[u8; 16]) -> State { Params::new().hash_length(32).personal(personal).to_state() @@ -160,6 +213,13 @@ impl Zip244Version { } } + fn empty_sapling_auth_hash(self) -> &'static [u8; 32] { + match self { + Self::V5 => EMPTY_SAPLING_V5_AUTH_HASH, + Self::V6 => EMPTY_SAPLING_V6_AUTH_HASH, + } + } + fn sapling_auth_includes_anchor(self) -> bool { match self { Self::V5 => false, @@ -244,6 +304,22 @@ impl BundleCommitmentFormat { Self::OrchardV6 | Self::IronwoodV6 => true, } } + + fn empty_txid_hash(self) -> &'static [u8; 32] { + match self { + Self::OrchardV5 => EMPTY_ORCHARD_V5_TXID_HASH, + Self::OrchardV6 => EMPTY_ORCHARD_V6_TXID_HASH, + Self::IronwoodV6 => EMPTY_IRONWOOD_TXID_HASH, + } + } + + fn empty_auth_hash(self) -> &'static [u8; 32] { + match self { + Self::OrchardV5 => EMPTY_ORCHARD_V5_AUTH_HASH, + Self::OrchardV6 => EMPTY_ORCHARD_V6_AUTH_HASH, + Self::IronwoodV6 => EMPTY_IRONWOOD_AUTH_HASH, + } + } } /// The fields of a v5/v6 transaction needed to compute its digests. @@ -443,16 +519,24 @@ fn hash_sapling_txid( sapling: Option<&sapling::ShieldedData>, version: Zip244Version, ) -> Blake2bHash { + let Some(sapling) = sapling else { + return hasher(ZCASH_SAPLING_HASH_PERSONALIZATION).finalize(); + }; + let mut h = hasher(ZCASH_SAPLING_HASH_PERSONALIZATION); - if let Some(sapling) = sapling { - // `ShieldedData` only exists with at least one spend or output, so this - // matches librustzcash's "non-empty bundle" branch. - if sapling.spends().next().is_some() || sapling.outputs().next().is_some() { - h.update(hash_sapling_spends(sapling, version).as_bytes()); - h.update(hash_sapling_outputs(sapling).as_bytes()); - h.update(&sapling.value_balance.zatoshis().to_le_bytes()); - } + // `ShieldedData` only exists with at least one spend or output, so this + // matches librustzcash's "non-empty bundle" branch. + if sapling.spends().next().is_some() { + h.update(hash_sapling_spends(sapling, version).as_bytes()); + } else { + h.update(EMPTY_SAPLING_SPENDS_HASH); + } + if sapling.outputs().next().is_some() { + h.update(hash_sapling_outputs(sapling).as_bytes()); + } else { + h.update(EMPTY_SAPLING_OUTPUTS_HASH); } + h.update(&sapling.value_balance.zatoshis().to_le_bytes()); h.finalize() } @@ -500,13 +584,6 @@ fn hash_bundle_txid( /// https://github.com/zcash/librustzcash/blob/4367ba26ed57624544e2350f055a5df89079474a/zcash_primitives/src/transaction/txid.rs#L426 fn txid_inner(parts: &Zip244Parts) -> Hash { let header = hash_header(parts); - let transparent = hash_transparent_txid(parts.inputs, parts.outputs); - let sapling = hash_sapling_txid(parts.sapling, parts.version); - let orchard = hash_bundle_txid(parts.orchard, parts.version.orchard_format()); - let ironwood = parts - .version - .has_ironwood() - .then(|| hash_bundle_txid(parts.ironwood, BundleCommitmentFormat::IronwoodV6)); let mut personal = [0u8; 16]; personal[..12].copy_from_slice(ZCASH_TX_PERSONALIZATION_PREFIX); @@ -514,11 +591,31 @@ fn txid_inner(parts: &Zip244Parts) -> Hash { let mut h = hasher(&personal); h.update(header.as_bytes()); - h.update(transparent.as_bytes()); - h.update(sapling.as_bytes()); - h.update(orchard.as_bytes()); - if let Some(ironwood) = ironwood { - h.update(ironwood.as_bytes()); + if parts.inputs.is_empty() && parts.outputs.is_empty() { + h.update(EMPTY_TRANSPARENT_TXID_HASH); + } else { + h.update(hash_transparent_txid(parts.inputs, parts.outputs).as_bytes()); + } + if parts.sapling.is_some() { + h.update(hash_sapling_txid(parts.sapling, parts.version).as_bytes()); + } else { + h.update(EMPTY_SAPLING_TXID_HASH); + } + + let orchard_format = parts.version.orchard_format(); + if parts.orchard.is_some() { + h.update(hash_bundle_txid(parts.orchard, orchard_format).as_bytes()); + } else { + h.update(orchard_format.empty_txid_hash()); + } + if parts.version.has_ironwood() { + if parts.ironwood.is_some() { + h.update( + hash_bundle_txid(parts.ironwood, BundleCommitmentFormat::IronwoodV6).as_bytes(), + ); + } else { + h.update(BundleCommitmentFormat::IronwoodV6.empty_txid_hash()); + } } Hash( @@ -617,24 +714,36 @@ fn hash_bundle_auth( /// Reference implementation: /// https://github.com/zcash/librustzcash/blob/4367ba26ed57624544e2350f055a5df89079474a/zcash_primitives/src/transaction/txid.rs#L426-L448 fn auth_digest_inner(parts: &Zip244Parts) -> AuthDigest { - let transparent = hash_transparent_auth(parts.inputs, parts.outputs); - let sapling = hash_sapling_auth(parts.sapling, parts.version); - let orchard = hash_bundle_auth(parts.orchard, parts.version.orchard_format()); - let ironwood = parts - .version - .has_ironwood() - .then(|| hash_bundle_auth(parts.ironwood, BundleCommitmentFormat::IronwoodV6)); - let mut personal = [0u8; 16]; personal[..12].copy_from_slice(ZCASH_AUTH_PERSONALIZATION_PREFIX); personal[12..].copy_from_slice(&consensus_branch_id(parts).to_le_bytes()); let mut h = hasher(&personal); - h.update(transparent.as_bytes()); - h.update(sapling.as_bytes()); - h.update(orchard.as_bytes()); - if let Some(ironwood) = ironwood { - h.update(ironwood.as_bytes()); + if parts.inputs.is_empty() && parts.outputs.is_empty() { + h.update(EMPTY_TRANSPARENT_AUTH_HASH); + } else { + h.update(hash_transparent_auth(parts.inputs, parts.outputs).as_bytes()); + } + if parts.sapling.is_some() { + h.update(hash_sapling_auth(parts.sapling, parts.version).as_bytes()); + } else { + h.update(parts.version.empty_sapling_auth_hash()); + } + + let orchard_format = parts.version.orchard_format(); + if parts.orchard.is_some() { + h.update(hash_bundle_auth(parts.orchard, orchard_format).as_bytes()); + } else { + h.update(orchard_format.empty_auth_hash()); + } + if parts.version.has_ironwood() { + if parts.ironwood.is_some() { + h.update( + hash_bundle_auth(parts.ironwood, BundleCommitmentFormat::IronwoodV6).as_bytes(), + ); + } else { + h.update(BundleCommitmentFormat::IronwoodV6.empty_auth_hash()); + } } AuthDigest( @@ -665,3 +774,71 @@ pub(crate) fn txid_and_auth_digest(tx: &Transaction) -> Option<(Hash, AuthDigest let parts = zip244_parts(tx)?; Some((txid_inner(&parts), auth_digest_inner(&parts))) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_hash_constants_match_personalizations() { + let empty_hashes = [ + ( + ZCASH_TRANSPARENT_HASH_PERSONALIZATION, + EMPTY_TRANSPARENT_TXID_HASH, + ), + (ZCASH_SAPLING_HASH_PERSONALIZATION, EMPTY_SAPLING_TXID_HASH), + ( + ZCASH_SAPLING_SPENDS_HASH_PERSONALIZATION, + EMPTY_SAPLING_SPENDS_HASH, + ), + ( + ZCASH_SAPLING_OUTPUTS_HASH_PERSONALIZATION, + EMPTY_SAPLING_OUTPUTS_HASH, + ), + ( + ZCASH_ORCHARD_HASH_PERSONALIZATION, + EMPTY_ORCHARD_V5_TXID_HASH, + ), + ( + ZCASH_ORCHARD_V6_HASH_PERSONALIZATION, + EMPTY_ORCHARD_V6_TXID_HASH, + ), + ( + ZCASH_IRONWOOD_HASH_PERSONALIZATION, + EMPTY_IRONWOOD_TXID_HASH, + ), + ( + ZCASH_TRANSPARENT_SCRIPTS_HASH_PERSONALIZATION, + EMPTY_TRANSPARENT_AUTH_HASH, + ), + ( + ZCASH_SAPLING_SIGS_HASH_PERSONALIZATION, + EMPTY_SAPLING_V5_AUTH_HASH, + ), + ( + ZCASH_SAPLING_V6_SIGS_HASH_PERSONALIZATION, + EMPTY_SAPLING_V6_AUTH_HASH, + ), + ( + ZCASH_ORCHARD_SIGS_HASH_PERSONALIZATION, + EMPTY_ORCHARD_V5_AUTH_HASH, + ), + ( + ZCASH_ORCHARD_V6_SIGS_HASH_PERSONALIZATION, + EMPTY_ORCHARD_V6_AUTH_HASH, + ), + ( + ZCASH_IRONWOOD_SIGS_HASH_PERSONALIZATION, + EMPTY_IRONWOOD_AUTH_HASH, + ), + ]; + + for (personalization, expected_hash) in empty_hashes { + assert_eq!( + hasher(personalization).finalize().as_bytes(), + expected_hash, + "empty hash must match {personalization:?}", + ); + } + } +}