add(consensus): Check consensus branch ids in tx verifier (#9063)

* Add a consensus branch id check to tx verifier

* Allow updating tx network upgrades

* Fix unit tests for txs

* Remove `println`

* Move test-only tx methods out of the default impl

* Add a test for checking consensus branch ids

* Simplify some tests

* Docs formatting

* Update zebra-consensus/src/transaction/check.rs

Co-authored-by: Conrado Gouvea <conrado@zfnd.org>

* Add `effectiveVersion` to txs

* Refactor the consensus branch ID check

* Update zebra-consensus/src/error.rs

Co-authored-by: Alfredo Garcia <oxarbitrage@gmail.com>

* Refactor the consensus branch ID check

* Remove `effective_version`

* Refactor tests for consensus branch ID check

---------

Co-authored-by: Conrado Gouvea <conrado@zfnd.org>
Co-authored-by: Alfredo Garcia <oxarbitrage@gmail.com>
This commit is contained in:
Marek 2024-12-05 16:06:17 +01:00 committed by GitHub
parent a3bb1e2e05
commit bd122b6f7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 554 additions and 338 deletions

View File

@ -324,7 +324,17 @@ impl Transaction {
} }
} }
/// Return the version of this transaction. /// Returns the version of this transaction.
///
/// Note that the returned version is equal to `effectiveVersion`, described in [§ 7.1
/// Transaction Encoding and Consensus]:
///
/// > `effectiveVersion` [...] is equal to `min(2, version)` when `fOverwintered = 0` and to
/// > `version` otherwise.
///
/// Zebra handles the `fOverwintered` flag via the [`Self::is_overwintered`] method.
///
/// [§ 7.1 Transaction Encoding and Consensus]: <https://zips.z.cash/protocol/protocol.pdf#txnencoding>
pub fn version(&self) -> u32 { pub fn version(&self) -> u32 {
match self { match self {
Transaction::V1 { .. } => 1, Transaction::V1 { .. } => 1,
@ -429,32 +439,6 @@ impl Transaction {
} }
} }
/// Modify the expiry height of this transaction.
///
/// # Panics
///
/// - if called on a v1 or v2 transaction
#[cfg(any(test, feature = "proptest-impl"))]
pub fn expiry_height_mut(&mut self) -> &mut block::Height {
match self {
Transaction::V1 { .. } | Transaction::V2 { .. } => {
panic!("v1 and v2 transactions are not supported")
}
Transaction::V3 {
ref mut expiry_height,
..
}
| Transaction::V4 {
ref mut expiry_height,
..
}
| Transaction::V5 {
ref mut expiry_height,
..
} => expiry_height,
}
}
/// Get this transaction's network upgrade field, if any. /// Get this transaction's network upgrade field, if any.
/// This field is serialized as `nConsensusBranchId` ([7.1]). /// This field is serialized as `nConsensusBranchId` ([7.1]).
/// ///
@ -484,18 +468,6 @@ impl Transaction {
} }
} }
/// Modify the transparent inputs of this transaction, regardless of version.
#[cfg(any(test, feature = "proptest-impl"))]
pub fn inputs_mut(&mut self) -> &mut Vec<transparent::Input> {
match self {
Transaction::V1 { ref mut inputs, .. } => inputs,
Transaction::V2 { ref mut inputs, .. } => inputs,
Transaction::V3 { ref mut inputs, .. } => inputs,
Transaction::V4 { ref mut inputs, .. } => inputs,
Transaction::V5 { ref mut inputs, .. } => inputs,
}
}
/// Access the [`transparent::OutPoint`]s spent by this transaction's [`transparent::Input`]s. /// Access the [`transparent::OutPoint`]s spent by this transaction's [`transparent::Input`]s.
pub fn spent_outpoints(&self) -> impl Iterator<Item = transparent::OutPoint> + '_ { pub fn spent_outpoints(&self) -> impl Iterator<Item = transparent::OutPoint> + '_ {
self.inputs() self.inputs()
@ -514,28 +486,6 @@ impl Transaction {
} }
} }
/// Modify the transparent outputs of this transaction, regardless of version.
#[cfg(any(test, feature = "proptest-impl"))]
pub fn outputs_mut(&mut self) -> &mut Vec<transparent::Output> {
match self {
Transaction::V1 {
ref mut outputs, ..
} => outputs,
Transaction::V2 {
ref mut outputs, ..
} => outputs,
Transaction::V3 {
ref mut outputs, ..
} => outputs,
Transaction::V4 {
ref mut outputs, ..
} => outputs,
Transaction::V5 {
ref mut outputs, ..
} => outputs,
}
}
/// Returns `true` if this transaction has valid inputs for a coinbase /// Returns `true` if this transaction has valid inputs for a coinbase
/// transaction, that is, has a single input and it is a coinbase input /// transaction, that is, has a single input and it is a coinbase input
/// (null prevout). /// (null prevout).
@ -943,27 +893,6 @@ impl Transaction {
} }
} }
/// Modify the [`orchard::ShieldedData`] in this transaction,
/// regardless of version.
#[cfg(any(test, feature = "proptest-impl"))]
pub fn orchard_shielded_data_mut(&mut self) -> Option<&mut orchard::ShieldedData> {
match self {
Transaction::V5 {
orchard_shielded_data: Some(orchard_shielded_data),
..
} => Some(orchard_shielded_data),
Transaction::V1 { .. }
| Transaction::V2 { .. }
| Transaction::V3 { .. }
| Transaction::V4 { .. }
| Transaction::V5 {
orchard_shielded_data: None,
..
} => None,
}
}
/// Iterate over the [`orchard::Action`]s in this transaction, if there are any, /// Iterate over the [`orchard::Action`]s in this transaction, if there are any,
/// regardless of version. /// regardless of version.
pub fn orchard_actions(&self) -> impl Iterator<Item = &orchard::Action> { pub fn orchard_actions(&self) -> impl Iterator<Item = &orchard::Action> {
@ -1035,14 +964,6 @@ impl Transaction {
.map_err(ValueBalanceError::Transparent) .map_err(ValueBalanceError::Transparent)
} }
/// Modify the transparent output values of this transaction, regardless of version.
#[cfg(any(test, feature = "proptest-impl"))]
pub fn output_values_mut(&mut self) -> impl Iterator<Item = &mut Amount<NonNegative>> {
self.outputs_mut()
.iter_mut()
.map(|output| &mut output.value)
}
/// Returns the `vpub_old` fields from `JoinSplit`s in this transaction, /// Returns the `vpub_old` fields from `JoinSplit`s in this transaction,
/// regardless of version, in the order they appear in the transaction. /// regardless of version, in the order they appear in the transaction.
/// ///
@ -1090,55 +1011,6 @@ impl Transaction {
} }
} }
/// Modify the `vpub_old` fields from `JoinSplit`s in this transaction,
/// regardless of version, in the order they appear in the transaction.
///
/// See `output_values_to_sprout` for details.
#[cfg(any(test, feature = "proptest-impl"))]
pub fn output_values_to_sprout_mut(
&mut self,
) -> Box<dyn Iterator<Item = &mut Amount<NonNegative>> + '_> {
match self {
// JoinSplits with Bctv14 Proofs
Transaction::V2 {
joinsplit_data: Some(joinsplit_data),
..
}
| Transaction::V3 {
joinsplit_data: Some(joinsplit_data),
..
} => Box::new(
joinsplit_data
.joinsplits_mut()
.map(|joinsplit| &mut joinsplit.vpub_old),
),
// JoinSplits with Groth16 Proofs
Transaction::V4 {
joinsplit_data: Some(joinsplit_data),
..
} => Box::new(
joinsplit_data
.joinsplits_mut()
.map(|joinsplit| &mut joinsplit.vpub_old),
),
// No JoinSplits
Transaction::V1 { .. }
| Transaction::V2 {
joinsplit_data: None,
..
}
| Transaction::V3 {
joinsplit_data: None,
..
}
| Transaction::V4 {
joinsplit_data: None,
..
}
| Transaction::V5 { .. } => Box::new(std::iter::empty()),
}
}
/// Returns the `vpub_new` fields from `JoinSplit`s in this transaction, /// Returns the `vpub_new` fields from `JoinSplit`s in this transaction,
/// regardless of version, in the order they appear in the transaction. /// regardless of version, in the order they appear in the transaction.
/// ///
@ -1186,55 +1058,6 @@ impl Transaction {
} }
} }
/// Modify the `vpub_new` fields from `JoinSplit`s in this transaction,
/// regardless of version, in the order they appear in the transaction.
///
/// See `input_values_from_sprout` for details.
#[cfg(any(test, feature = "proptest-impl"))]
pub fn input_values_from_sprout_mut(
&mut self,
) -> Box<dyn Iterator<Item = &mut Amount<NonNegative>> + '_> {
match self {
// JoinSplits with Bctv14 Proofs
Transaction::V2 {
joinsplit_data: Some(joinsplit_data),
..
}
| Transaction::V3 {
joinsplit_data: Some(joinsplit_data),
..
} => Box::new(
joinsplit_data
.joinsplits_mut()
.map(|joinsplit| &mut joinsplit.vpub_new),
),
// JoinSplits with Groth Proofs
Transaction::V4 {
joinsplit_data: Some(joinsplit_data),
..
} => Box::new(
joinsplit_data
.joinsplits_mut()
.map(|joinsplit| &mut joinsplit.vpub_new),
),
// No JoinSplits
Transaction::V1 { .. }
| Transaction::V2 {
joinsplit_data: None,
..
}
| Transaction::V3 {
joinsplit_data: None,
..
}
| Transaction::V4 {
joinsplit_data: None,
..
}
| Transaction::V5 { .. } => Box::new(std::iter::empty()),
}
}
/// Return a list of sprout value balances, /// Return a list of sprout value balances,
/// the changes in the transaction value pool due to each sprout `JoinSplit`. /// the changes in the transaction value pool due to each sprout `JoinSplit`.
/// ///
@ -1331,35 +1154,6 @@ impl Transaction {
ValueBalance::from_sapling_amount(sapling_value_balance) ValueBalance::from_sapling_amount(sapling_value_balance)
} }
/// Modify the `value_balance` field from the `sapling::ShieldedData` in this transaction,
/// regardless of version.
///
/// See `sapling_value_balance` for details.
#[cfg(any(test, feature = "proptest-impl"))]
pub fn sapling_value_balance_mut(&mut self) -> Option<&mut Amount<NegativeAllowed>> {
match self {
Transaction::V4 {
sapling_shielded_data: Some(sapling_shielded_data),
..
} => Some(&mut sapling_shielded_data.value_balance),
Transaction::V5 {
sapling_shielded_data: Some(sapling_shielded_data),
..
} => Some(&mut sapling_shielded_data.value_balance),
Transaction::V1 { .. }
| Transaction::V2 { .. }
| Transaction::V3 { .. }
| Transaction::V4 {
sapling_shielded_data: None,
..
}
| Transaction::V5 {
sapling_shielded_data: None,
..
} => None,
}
}
/// Return the orchard value balance, the change in the transaction value /// Return the orchard value balance, the change in the transaction value
/// pool due to [`orchard::Action`]s. /// pool due to [`orchard::Action`]s.
/// ///
@ -1380,16 +1174,6 @@ impl Transaction {
ValueBalance::from_orchard_amount(orchard_value_balance) ValueBalance::from_orchard_amount(orchard_value_balance)
} }
/// Modify the `value_balance` field from the `orchard::ShieldedData` in this transaction,
/// regardless of version.
///
/// See `orchard_value_balance` for details.
#[cfg(any(test, feature = "proptest-impl"))]
pub fn orchard_value_balance_mut(&mut self) -> Option<&mut Amount<NegativeAllowed>> {
self.orchard_shielded_data_mut()
.map(|shielded_data| &mut shielded_data.value_balance)
}
/// Returns the value balances for this transaction using the provided transparent outputs. /// Returns the value balances for this transaction using the provided transparent outputs.
pub(crate) fn value_balance_from_outputs( pub(crate) fn value_balance_from_outputs(
&self, &self,
@ -1428,3 +1212,246 @@ impl Transaction {
self.value_balance_from_outputs(&outputs_from_utxos(utxos.clone())) self.value_balance_from_outputs(&outputs_from_utxos(utxos.clone()))
} }
} }
#[cfg(any(test, feature = "proptest-impl"))]
impl Transaction {
/// Updates the [`NetworkUpgrade`] for this transaction.
///
/// ## Notes
///
/// - Updating the network upgrade for V1, V2, V3 and V4 transactions is not possible.
pub fn update_network_upgrade(&mut self, nu: NetworkUpgrade) -> Result<(), &str> {
match self {
Transaction::V1 { .. }
| Transaction::V2 { .. }
| Transaction::V3 { .. }
| Transaction::V4 { .. } => Err(
"Updating the network upgrade for V1, V2, V3 and V4 transactions is not possible.",
),
Transaction::V5 {
ref mut network_upgrade,
..
} => {
*network_upgrade = nu;
Ok(())
}
}
}
/// Modify the expiry height of this transaction.
///
/// # Panics
///
/// - if called on a v1 or v2 transaction
pub fn expiry_height_mut(&mut self) -> &mut block::Height {
match self {
Transaction::V1 { .. } | Transaction::V2 { .. } => {
panic!("v1 and v2 transactions are not supported")
}
Transaction::V3 {
ref mut expiry_height,
..
}
| Transaction::V4 {
ref mut expiry_height,
..
}
| Transaction::V5 {
ref mut expiry_height,
..
} => expiry_height,
}
}
/// Modify the transparent inputs of this transaction, regardless of version.
pub fn inputs_mut(&mut self) -> &mut Vec<transparent::Input> {
match self {
Transaction::V1 { ref mut inputs, .. } => inputs,
Transaction::V2 { ref mut inputs, .. } => inputs,
Transaction::V3 { ref mut inputs, .. } => inputs,
Transaction::V4 { ref mut inputs, .. } => inputs,
Transaction::V5 { ref mut inputs, .. } => inputs,
}
}
/// Modify the `value_balance` field from the `orchard::ShieldedData` in this transaction,
/// regardless of version.
///
/// See `orchard_value_balance` for details.
pub fn orchard_value_balance_mut(&mut self) -> Option<&mut Amount<NegativeAllowed>> {
self.orchard_shielded_data_mut()
.map(|shielded_data| &mut shielded_data.value_balance)
}
/// Modify the `value_balance` field from the `sapling::ShieldedData` in this transaction,
/// regardless of version.
///
/// See `sapling_value_balance` for details.
pub fn sapling_value_balance_mut(&mut self) -> Option<&mut Amount<NegativeAllowed>> {
match self {
Transaction::V4 {
sapling_shielded_data: Some(sapling_shielded_data),
..
} => Some(&mut sapling_shielded_data.value_balance),
Transaction::V5 {
sapling_shielded_data: Some(sapling_shielded_data),
..
} => Some(&mut sapling_shielded_data.value_balance),
Transaction::V1 { .. }
| Transaction::V2 { .. }
| Transaction::V3 { .. }
| Transaction::V4 {
sapling_shielded_data: None,
..
}
| Transaction::V5 {
sapling_shielded_data: None,
..
} => None,
}
}
/// Modify the `vpub_new` fields from `JoinSplit`s in this transaction,
/// regardless of version, in the order they appear in the transaction.
///
/// See `input_values_from_sprout` for details.
pub fn input_values_from_sprout_mut(
&mut self,
) -> Box<dyn Iterator<Item = &mut Amount<NonNegative>> + '_> {
match self {
// JoinSplits with Bctv14 Proofs
Transaction::V2 {
joinsplit_data: Some(joinsplit_data),
..
}
| Transaction::V3 {
joinsplit_data: Some(joinsplit_data),
..
} => Box::new(
joinsplit_data
.joinsplits_mut()
.map(|joinsplit| &mut joinsplit.vpub_new),
),
// JoinSplits with Groth Proofs
Transaction::V4 {
joinsplit_data: Some(joinsplit_data),
..
} => Box::new(
joinsplit_data
.joinsplits_mut()
.map(|joinsplit| &mut joinsplit.vpub_new),
),
// No JoinSplits
Transaction::V1 { .. }
| Transaction::V2 {
joinsplit_data: None,
..
}
| Transaction::V3 {
joinsplit_data: None,
..
}
| Transaction::V4 {
joinsplit_data: None,
..
}
| Transaction::V5 { .. } => Box::new(std::iter::empty()),
}
}
/// Modify the `vpub_old` fields from `JoinSplit`s in this transaction,
/// regardless of version, in the order they appear in the transaction.
///
/// See `output_values_to_sprout` for details.
pub fn output_values_to_sprout_mut(
&mut self,
) -> Box<dyn Iterator<Item = &mut Amount<NonNegative>> + '_> {
match self {
// JoinSplits with Bctv14 Proofs
Transaction::V2 {
joinsplit_data: Some(joinsplit_data),
..
}
| Transaction::V3 {
joinsplit_data: Some(joinsplit_data),
..
} => Box::new(
joinsplit_data
.joinsplits_mut()
.map(|joinsplit| &mut joinsplit.vpub_old),
),
// JoinSplits with Groth16 Proofs
Transaction::V4 {
joinsplit_data: Some(joinsplit_data),
..
} => Box::new(
joinsplit_data
.joinsplits_mut()
.map(|joinsplit| &mut joinsplit.vpub_old),
),
// No JoinSplits
Transaction::V1 { .. }
| Transaction::V2 {
joinsplit_data: None,
..
}
| Transaction::V3 {
joinsplit_data: None,
..
}
| Transaction::V4 {
joinsplit_data: None,
..
}
| Transaction::V5 { .. } => Box::new(std::iter::empty()),
}
}
/// Modify the transparent output values of this transaction, regardless of version.
pub fn output_values_mut(&mut self) -> impl Iterator<Item = &mut Amount<NonNegative>> {
self.outputs_mut()
.iter_mut()
.map(|output| &mut output.value)
}
/// Modify the [`orchard::ShieldedData`] in this transaction,
/// regardless of version.
pub fn orchard_shielded_data_mut(&mut self) -> Option<&mut orchard::ShieldedData> {
match self {
Transaction::V5 {
orchard_shielded_data: Some(orchard_shielded_data),
..
} => Some(orchard_shielded_data),
Transaction::V1 { .. }
| Transaction::V2 { .. }
| Transaction::V3 { .. }
| Transaction::V4 { .. }
| Transaction::V5 {
orchard_shielded_data: None,
..
} => None,
}
}
/// Modify the transparent outputs of this transaction, regardless of version.
pub fn outputs_mut(&mut self) -> &mut Vec<transparent::Output> {
match self {
Transaction::V1 {
ref mut outputs, ..
} => outputs,
Transaction::V2 {
ref mut outputs, ..
} => outputs,
Transaction::V3 {
ref mut outputs, ..
} => outputs,
Transaction::V4 {
ref mut outputs, ..
} => outputs,
Transaction::V5 {
ref mut outputs, ..
} => outputs,
}
}
}

View File

@ -241,6 +241,12 @@ pub enum TransactionError {
)] )]
#[cfg_attr(any(test, feature = "proptest-impl"), proptest(skip))] #[cfg_attr(any(test, feature = "proptest-impl"), proptest(skip))]
Zip317(#[from] zebra_chain::transaction::zip317::Error), Zip317(#[from] zebra_chain::transaction::zip317::Error),
#[error("transaction uses an incorrect consensus branch id")]
WrongConsensusBranchId,
#[error("wrong tx format: tx version is ≥ 5, but `nConsensusBranchId` is missing")]
MissingConsensusBranchId,
} }
impl From<ValidateContextError> for TransactionError { impl From<ValidateContextError> for TransactionError {

View File

@ -380,6 +380,7 @@ where
// Do quick checks first // Do quick checks first
check::has_inputs_and_outputs(&tx)?; check::has_inputs_and_outputs(&tx)?;
check::has_enough_orchard_flags(&tx)?; check::has_enough_orchard_flags(&tx)?;
check::consensus_branch_id(&tx, req.height(), &network)?;
// Validate the coinbase input consensus rules // Validate the coinbase input consensus rules
if req.is_mempool() && tx.is_coinbase() { if req.is_mempool() && tx.is_coinbase() {

View File

@ -495,3 +495,44 @@ pub fn tx_transparent_coinbase_spends_maturity(
Ok(()) Ok(())
} }
/// Checks the `nConsensusBranchId` field.
///
/// # Consensus
///
/// ## [7.1.2 Transaction Consensus Rules]
///
/// > [**NU5** onward] If `effectiveVersion` ≥ 5, the `nConsensusBranchId` field **MUST** match the
/// > consensus branch ID used for SIGHASH transaction hashes, as specified in [ZIP-244].
///
/// ### Notes
///
/// - When deserializing transactions, Zebra converts the `nConsensusBranchId` into
/// [`NetworkUpgrade`].
///
/// - The values returned by [`Transaction::version`] match `effectiveVersion` so we use them in
/// place of `effectiveVersion`. More details in [`Transaction::version`].
///
/// [ZIP-244]: <https://zips.z.cash/zip-0244>
/// [7.1.2 Transaction Consensus Rules]: <https://zips.z.cash/protocol/protocol.pdf#txnconsensus>
pub fn consensus_branch_id(
tx: &Transaction,
height: Height,
network: &Network,
) -> Result<(), TransactionError> {
let current_nu = NetworkUpgrade::current(network, height);
if current_nu < NetworkUpgrade::Nu5 || tx.version() < 5 {
return Ok(());
}
let Some(tx_nu) = tx.network_upgrade() else {
return Err(TransactionError::MissingConsensusBranchId);
};
if tx_nu != current_nu {
return Err(TransactionError::WrongConsensusBranchId);
}
Ok(())
}

View File

@ -6,6 +6,7 @@ use std::{collections::HashMap, sync::Arc};
use chrono::{DateTime, TimeZone, Utc}; use chrono::{DateTime, TimeZone, Utc};
use color_eyre::eyre::Report; use color_eyre::eyre::Report;
use futures::{FutureExt, TryFutureExt};
use halo2::pasta::{group::ff::PrimeField, pallas}; use halo2::pasta::{group::ff::PrimeField, pallas};
use tower::{buffer::Buffer, service_fn, ServiceExt}; use tower::{buffer::Buffer, service_fn, ServiceExt};
@ -1002,58 +1003,47 @@ async fn v5_transaction_is_rejected_before_nu5_activation() {
} }
#[test] #[test]
fn v5_transaction_is_accepted_after_nu5_activation_mainnet() { fn v5_transaction_is_accepted_after_nu5_activation() {
v5_transaction_is_accepted_after_nu5_activation_for_network(Network::Mainnet)
}
#[test]
fn v5_transaction_is_accepted_after_nu5_activation_testnet() {
v5_transaction_is_accepted_after_nu5_activation_for_network(Network::new_default_testnet())
}
fn v5_transaction_is_accepted_after_nu5_activation_for_network(network: Network) {
let _init_guard = zebra_test::init(); let _init_guard = zebra_test::init();
zebra_test::MULTI_THREADED_RUNTIME.block_on(async {
let nu5 = NetworkUpgrade::Nu5;
let nu5_activation_height = nu5
.activation_height(&network)
.expect("NU5 activation height is specified");
let blocks = network.block_iter();
let state_service = service_fn(|_| async { unreachable!("Service should not be called") }); for network in Network::iter() {
let verifier = Verifier::new_for_tests(&network, state_service); zebra_test::MULTI_THREADED_RUNTIME.block_on(async {
let nu5_activation_height = NetworkUpgrade::Nu5
.activation_height(&network)
.expect("NU5 activation height is specified");
let mut transaction = fake_v5_transactions_for_network(&network, blocks) let state = service_fn(|_| async { unreachable!("Service should not be called") });
.next_back()
.expect("At least one fake V5 transaction in the test vectors");
if transaction
.expiry_height()
.expect("V5 must have expiry_height")
< nu5_activation_height
{
let expiry_height = transaction.expiry_height_mut();
*expiry_height = nu5_activation_height;
}
let expected_hash = transaction.unmined_id(); let mut tx = fake_v5_transactions_for_network(&network, network.block_iter())
let expiry_height = transaction .next_back()
.expiry_height() .expect("At least one fake V5 transaction in the test vectors");
.expect("V5 must have expiry_height");
let result = verifier if tx.expiry_height().expect("V5 must have expiry_height") < nu5_activation_height {
.oneshot(Request::Block { *tx.expiry_height_mut() = nu5_activation_height;
transaction: Arc::new(transaction), tx.update_network_upgrade(NetworkUpgrade::Nu5)
known_utxos: Arc::new(HashMap::new()), .expect("updating the network upgrade for a V5 tx should succeed");
height: expiry_height, }
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!( let expected_hash = tx.unmined_id();
result.expect("unexpected error response").tx_id(), let expiry_height = tx.expiry_height().expect("V5 must have expiry_height");
expected_hash
); let verification_result = Verifier::new_for_tests(&network, state)
}) .oneshot(Request::Block {
transaction: Arc::new(tx),
known_utxos: Arc::new(HashMap::new()),
height: expiry_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
verification_result
.expect("successful verification")
.tx_id(),
expected_hash
);
});
}
} }
/// Test if V4 transaction with transparent funds is accepted. /// Test if V4 transaction with transparent funds is accepted.
@ -1872,7 +1862,13 @@ async fn v5_coinbase_transaction_expiry_height() {
*new_transaction.expiry_height_mut() = new_expiry_height; *new_transaction.expiry_height_mut() = new_expiry_height;
let result = verifier // Setting the new expiry height as the block height will activate NU6, so we need to set NU6
// for the tx as well.
new_transaction
.update_network_upgrade(NetworkUpgrade::Nu6)
.expect("updating the network upgrade for a V5 tx should succeed");
let verification_result = verifier
.clone() .clone()
.oneshot(Request::Block { .oneshot(Request::Block {
transaction: Arc::new(new_transaction.clone()), transaction: Arc::new(new_transaction.clone()),
@ -1883,7 +1879,9 @@ async fn v5_coinbase_transaction_expiry_height() {
.await; .await;
assert_eq!( assert_eq!(
result.expect("unexpected error response").tx_id(), verification_result
.expect("successful verification")
.tx_id(),
new_transaction.unmined_id() new_transaction.unmined_id()
); );
} }
@ -1941,22 +1939,18 @@ async fn v5_transaction_with_too_low_expiry_height() {
); );
} }
/// Tests if a non-coinbase V5 transaction with an expiry height exceeding the /// Tests if a non-coinbase V5 transaction with an expiry height exceeding the maximum is rejected.
/// maximum is rejected.
#[tokio::test] #[tokio::test]
async fn v5_transaction_with_exceeding_expiry_height() { async fn v5_transaction_with_exceeding_expiry_height() {
let state_service = let state = service_fn(|_| async { unreachable!("State service should not be called") });
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = Verifier::new_for_tests(&Network::Mainnet, state_service);
let block_height = block::Height::MAX; let height_max = block::Height::MAX;
let fund_height = (block_height - 1).expect("fake source fund block height is too small");
let (input, output, known_utxos) = mock_transparent_transfer( let (input, output, known_utxos) = mock_transparent_transfer(
fund_height, height_max.previous().expect("valid height"),
true, true,
0, 0,
Amount::try_from(1).expect("invalid value"), Amount::try_from(1).expect("valid amount"),
); );
// This expiry height exceeds the maximum defined by the specification. // This expiry height exceeds the maximum defined by the specification.
@ -1970,25 +1964,27 @@ async fn v5_transaction_with_exceeding_expiry_height() {
expiry_height, expiry_height,
sapling_shielded_data: None, sapling_shielded_data: None,
orchard_shielded_data: None, orchard_shielded_data: None,
network_upgrade: NetworkUpgrade::Nu5, network_upgrade: NetworkUpgrade::Nu6,
}; };
let result = verifier let transaction_hash = transaction.hash();
let verification_result = Verifier::new_for_tests(&Network::Mainnet, state)
.oneshot(Request::Block { .oneshot(Request::Block {
transaction: Arc::new(transaction.clone()), transaction: Arc::new(transaction),
known_utxos: Arc::new(known_utxos), known_utxos: Arc::new(known_utxos),
height: block_height, height: height_max,
time: DateTime::<Utc>::MAX_UTC, time: DateTime::<Utc>::MAX_UTC,
}) })
.await; .await;
assert_eq!( assert_eq!(
result, verification_result,
Err(TransactionError::MaximumExpiryHeight { Err(TransactionError::MaximumExpiryHeight {
expiry_height, expiry_height,
is_coinbase: false, is_coinbase: false,
block_height, block_height: height_max,
transaction_hash: transaction.hash(), transaction_hash,
}) })
); );
} }
@ -2105,59 +2101,49 @@ async fn v5_transaction_with_transparent_transfer_is_rejected_by_the_script() {
/// Test if V5 transaction with an internal double spend of transparent funds is rejected. /// Test if V5 transaction with an internal double spend of transparent funds is rejected.
#[tokio::test] #[tokio::test]
async fn v5_transaction_with_conflicting_transparent_spend_is_rejected() { async fn v5_transaction_with_conflicting_transparent_spend_is_rejected() {
let network = Network::Mainnet; for network in Network::iter() {
let network_upgrade = NetworkUpgrade::Nu5; let canopy_activation_height = NetworkUpgrade::Canopy
.activation_height(&network)
.expect("Canopy activation height is specified");
let canopy_activation_height = NetworkUpgrade::Canopy let height = (canopy_activation_height + 10).expect("valid height");
.activation_height(&network)
.expect("Canopy activation height is specified");
let transaction_block_height = // Create a fake transparent transfer that should succeed
(canopy_activation_height + 10).expect("transaction block height is too large"); let (input, output, known_utxos) = mock_transparent_transfer(
height.previous().expect("valid height"),
true,
0,
Amount::try_from(1).expect("valid amount"),
);
let fake_source_fund_height = let transaction = Transaction::V5 {
(transaction_block_height - 1).expect("fake source fund block height is too small"); inputs: vec![input.clone(), input.clone()],
outputs: vec![output],
lock_time: LockTime::Height(block::Height(0)),
expiry_height: height.next().expect("valid height"),
sapling_shielded_data: None,
orchard_shielded_data: None,
network_upgrade: NetworkUpgrade::Canopy,
};
// Create a fake transparent transfer that should succeed let state = service_fn(|_| async { unreachable!("State service should not be called") });
let (input, output, known_utxos) = mock_transparent_transfer(
fake_source_fund_height,
true,
0,
Amount::try_from(1).expect("invalid value"),
);
// Create a V4 transaction let verification_result = Verifier::new_for_tests(&network, state)
let transaction = Transaction::V5 { .oneshot(Request::Block {
inputs: vec![input.clone(), input.clone()], transaction: Arc::new(transaction),
outputs: vec![output], known_utxos: Arc::new(known_utxos),
lock_time: LockTime::Height(block::Height(0)), height,
expiry_height: (transaction_block_height + 1).expect("expiry height is too large"), time: DateTime::<Utc>::MAX_UTC,
sapling_shielded_data: None, })
orchard_shielded_data: None, .await;
network_upgrade,
};
let state_service = assert_eq!(
service_fn(|_| async { unreachable!("State service should not be called") }); verification_result,
let verifier = Verifier::new_for_tests(&network, state_service); Err(TransactionError::DuplicateTransparentSpend(
input.outpoint().expect("Input should have an outpoint")
let result = verifier ))
.oneshot(Request::Block { );
transaction: Arc::new(transaction), }
known_utxos: Arc::new(known_utxos),
height: transaction_block_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
let expected_outpoint = input.outpoint().expect("Input should have an outpoint");
assert_eq!(
result,
Err(TransactionError::DuplicateTransparentSpend(
expected_outpoint
))
);
} }
/// Test if signed V4 transaction with a dummy [`sprout::JoinSplit`] is accepted. /// Test if signed V4 transaction with a dummy [`sprout::JoinSplit`] is accepted.
@ -2577,6 +2563,161 @@ fn v5_with_duplicate_orchard_action() {
}); });
} }
/// Checks that the tx verifier handles consensus branch ids in V5 txs correctly.
#[tokio::test]
async fn v5_consensus_branch_ids() {
let mut state = MockService::build().for_unit_tests();
let (input, output, known_utxos) = mock_transparent_transfer(
Height(1),
true,
0,
Amount::try_from(10001).expect("valid amount"),
);
let known_utxos = Arc::new(known_utxos);
// NU5 is the first network upgrade that supports V5 txs.
let mut network_upgrade = NetworkUpgrade::Nu5;
let mut tx = Transaction::V5 {
inputs: vec![input],
outputs: vec![output],
lock_time: LockTime::unlocked(),
expiry_height: Height::MAX_EXPIRY_HEIGHT,
sapling_shielded_data: None,
orchard_shielded_data: None,
network_upgrade,
};
let outpoint = match tx.inputs()[0] {
transparent::Input::PrevOut { outpoint, .. } => outpoint,
transparent::Input::Coinbase { .. } => panic!("requires a non-coinbase transaction"),
};
for network in Network::iter() {
let verifier = Buffer::new(Verifier::new_for_tests(&network, state.clone()), 10);
while let Some(next_nu) = network_upgrade.next_upgrade() {
// Check an outdated network upgrade.
let height = next_nu.activation_height(&network).expect("height");
let block_req = verifier
.clone()
.oneshot(Request::Block {
transaction: Arc::new(tx.clone()),
known_utxos: known_utxos.clone(),
// The consensus branch ID of the tx is outdated for this height.
height,
time: DateTime::<Utc>::MAX_UTC,
})
.map_err(|err| *err.downcast().expect("`TransactionError` type"));
let mempool_req = verifier
.clone()
.oneshot(Request::Mempool {
transaction: tx.clone().into(),
// The consensus branch ID of the tx is outdated for this height.
height,
})
.map_err(|err| *err.downcast().expect("`TransactionError` type"));
let (block_rsp, mempool_rsp) = futures::join!(block_req, mempool_req);
assert_eq!(block_rsp, Err(TransactionError::WrongConsensusBranchId));
assert_eq!(mempool_rsp, Err(TransactionError::WrongConsensusBranchId));
// Check the currently supported network upgrade.
let height = network_upgrade.activation_height(&network).expect("height");
let block_req = verifier
.clone()
.oneshot(Request::Block {
transaction: Arc::new(tx.clone()),
known_utxos: known_utxos.clone(),
// The consensus branch ID of the tx is supported by this height.
height,
time: DateTime::<Utc>::MAX_UTC,
})
.map_ok(|rsp| rsp.tx_id())
.map_err(|e| format!("{e}"));
let mempool_req = verifier
.clone()
.oneshot(Request::Mempool {
transaction: tx.clone().into(),
// The consensus branch ID of the tx is supported by this height.
height,
})
.map_ok(|rsp| rsp.tx_id())
.map_err(|e| format!("{e}"));
let state_req = async {
state
.expect_request(zebra_state::Request::UnspentBestChainUtxo(outpoint))
.map(|r| {
r.respond(zebra_state::Response::UnspentBestChainUtxo(
known_utxos.get(&outpoint).map(|utxo| utxo.utxo.clone()),
))
})
.await;
state
.expect_request_that(|req| {
matches!(
req,
zebra_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.map(|r| {
r.respond(zebra_state::Response::ValidBestChainTipNullifiersAndAnchors)
})
.await;
};
let (block_rsp, mempool_rsp, _) = futures::join!(block_req, mempool_req, state_req);
let txid = tx.unmined_id();
assert_eq!(block_rsp, Ok(txid));
assert_eq!(mempool_rsp, Ok(txid));
// Check a network upgrade that Zebra doesn't support yet.
tx.update_network_upgrade(next_nu)
.expect("V5 txs support updating NUs");
let height = network_upgrade.activation_height(&network).expect("height");
let block_req = verifier
.clone()
.oneshot(Request::Block {
transaction: Arc::new(tx.clone()),
known_utxos: known_utxos.clone(),
// The consensus branch ID of the tx is not supported by this height.
height,
time: DateTime::<Utc>::MAX_UTC,
})
.map_err(|err| *err.downcast().expect("`TransactionError` type"));
let mempool_req = verifier
.clone()
.oneshot(Request::Mempool {
transaction: tx.clone().into(),
// The consensus branch ID of the tx is not supported by this height.
height,
})
.map_err(|err| *err.downcast().expect("`TransactionError` type"));
let (block_rsp, mempool_rsp) = futures::join!(block_req, mempool_req);
assert_eq!(block_rsp, Err(TransactionError::WrongConsensusBranchId));
assert_eq!(mempool_rsp, Err(TransactionError::WrongConsensusBranchId));
// Shift the network upgrade for the next loop iteration.
network_upgrade = next_nu;
}
}
}
// Utility functions // Utility functions
/// Create a mock transparent transfer to be included in a transaction. /// Create a mock transparent transfer to be included in a transaction.