diff --git a/zebra-chain/src/transparent.rs b/zebra-chain/src/transparent.rs index 8392c5c7..891e384b 100644 --- a/zebra-chain/src/transparent.rs +++ b/zebra-chain/src/transparent.rs @@ -158,6 +158,18 @@ impl Input { } } + /// Sets the input's sequence number. + /// + /// Only for use in tests. + #[cfg(any(test, feature = "proptest-impl"))] + pub fn set_sequence(&mut self, new_sequence: u32) { + match self { + Input::PrevOut { sequence, .. } | Input::Coinbase { sequence, .. } => { + *sequence = new_sequence + } + } + } + /// If this is a `PrevOut` input, returns this input's outpoint. /// Otherwise, returns `None`. pub fn outpoint(&self) -> Option { diff --git a/zebra-consensus/src/transaction/tests.rs b/zebra-consensus/src/transaction/tests.rs index 8024deea..839223ca 100644 --- a/zebra-consensus/src/transaction/tests.rs +++ b/zebra-consensus/src/transaction/tests.rs @@ -31,6 +31,9 @@ use super::{check, Request, Verifier}; use crate::{error::TransactionError, script}; use color_eyre::eyre::Report; +#[cfg(test)] +mod prop; + #[test] fn v5_fake_transactions() -> Result<(), Report> { zebra_test::init(); @@ -358,7 +361,7 @@ async fn v4_transaction_with_transparent_transfer_is_accepted() { (transaction_block_height - 1).expect("fake source fund block height is too small"); // Create a fake transparent transfer that should succeed - let (input, output, known_utxos) = mock_transparent_transfer(fake_source_fund_height, true); + let (input, output, known_utxos) = mock_transparent_transfer(fake_source_fund_height, true, 0); // Create a V4 transaction let transaction = Transaction::V4 { @@ -458,7 +461,7 @@ async fn v4_transaction_with_transparent_transfer_is_rejected_by_the_script() { (transaction_block_height - 1).expect("fake source fund block height is too small"); // Create a fake transparent transfer that should not succeed - let (input, output, known_utxos) = mock_transparent_transfer(fake_source_fund_height, false); + let (input, output, known_utxos) = mock_transparent_transfer(fake_source_fund_height, false, 0); // Create a V4 transaction let transaction = Transaction::V4 { @@ -509,7 +512,7 @@ async fn v4_transaction_with_conflicting_transparent_spend_is_rejected() { (transaction_block_height - 1).expect("fake source fund block height is too small"); // Create a fake transparent transfer that should succeed - let (input, output, known_utxos) = mock_transparent_transfer(fake_source_fund_height, true); + let (input, output, known_utxos) = mock_transparent_transfer(fake_source_fund_height, true, 0); // Create a V4 transaction let transaction = Transaction::V4 { @@ -700,7 +703,7 @@ async fn v5_transaction_with_transparent_transfer_is_accepted() { (transaction_block_height - 1).expect("fake source fund block height is too small"); // Create a fake transparent transfer that should succeed - let (input, output, known_utxos) = mock_transparent_transfer(fake_source_fund_height, true); + let (input, output, known_utxos) = mock_transparent_transfer(fake_source_fund_height, true, 0); // Create a V5 transaction let transaction = Transaction::V5 { @@ -805,7 +808,7 @@ async fn v5_transaction_with_transparent_transfer_is_rejected_by_the_script() { (transaction_block_height - 1).expect("fake source fund block height is too small"); // Create a fake transparent transfer that should not succeed - let (input, output, known_utxos) = mock_transparent_transfer(fake_source_fund_height, false); + let (input, output, known_utxos) = mock_transparent_transfer(fake_source_fund_height, false, 0); // Create a V5 transaction let transaction = Transaction::V5 { @@ -858,7 +861,7 @@ async fn v5_transaction_with_conflicting_transparent_spend_is_rejected() { (transaction_block_height - 1).expect("fake source fund block height is too small"); // Create a fake transparent transfer that should succeed - let (input, output, known_utxos) = mock_transparent_transfer(fake_source_fund_height, true); + let (input, output, known_utxos) = mock_transparent_transfer(fake_source_fund_height, true, 0); // Create a V4 transaction let transaction = Transaction::V5 { @@ -1330,6 +1333,7 @@ fn v5_with_duplicate_orchard_action() { fn mock_transparent_transfer( previous_utxo_height: block::Height, script_should_succeed: bool, + outpoint_index: u32, ) -> ( transparent::Input, transparent::Output, @@ -1343,7 +1347,7 @@ fn mock_transparent_transfer( // Mock an unspent transaction output let previous_outpoint = transparent::OutPoint { hash: Hash([1u8; 32]), - index: 0, + index: outpoint_index, }; let lock_script = if script_should_succeed { diff --git a/zebra-consensus/src/transaction/tests/prop.rs b/zebra-consensus/src/transaction/tests/prop.rs new file mode 100644 index 00000000..06a4a446 --- /dev/null +++ b/zebra-consensus/src/transaction/tests/prop.rs @@ -0,0 +1,458 @@ +use std::{collections::HashMap, convert::TryInto, sync::Arc}; + +use chrono::{DateTime, Duration, Utc}; +use proptest::{collection::vec, prelude::*}; +use tower::ServiceExt; + +use zebra_chain::{ + block, + parameters::{Network, NetworkUpgrade}, + serialization::arbitrary::{datetime_full, datetime_u32}, + transaction::{LockTime, Transaction}, + transparent, +}; + +use super::mock_transparent_transfer; +use crate::{error::TransactionError, script, transaction}; + +/// The maximum number of transparent inputs to include in a mock transaction. +const MAX_TRANSPARENT_INPUTS: usize = 10; + +proptest! { + /// Test if a transaction that has a zero value as the lock time is always unlocked. + #[test] + fn zero_lock_time_is_always_unlocked( + (network, block_height) in sapling_onwards_strategy(), + block_time in datetime_full(), + relative_source_fund_heights in vec(0.0..1.0, 1..=MAX_TRANSPARENT_INPUTS), + transaction_version in 4_u8..=5, + ) { + zebra_test::init(); + + let zero_lock_time = LockTime::Height(block::Height(0)); + + let (transaction, known_utxos) = mock_transparent_transaction( + network, + block_height, + relative_source_fund_heights, + transaction_version, + zero_lock_time, + ); + + let transaction_id = transaction.unmined_id(); + + let result = validate(transaction, block_height, block_time, known_utxos, network); + + prop_assert!( + result.is_ok(), + "Unexpected validation error: {}", + result.unwrap_err() + ); + prop_assert_eq!(result.unwrap().tx_id(), transaction_id); + } + + /// Test if having [`u32::MAX`] as the sequence number of all inputs disables the lock time. + #[test] + fn lock_time_is_ignored_because_of_sequence_numbers( + (network, block_height) in sapling_onwards_strategy(), + block_time in datetime_full(), + relative_source_fund_heights in vec(0.0..1.0, 1..=MAX_TRANSPARENT_INPUTS), + transaction_version in 4_u8..=5, + lock_time in any::(), + ) { + zebra_test::init(); + + let (mut transaction, known_utxos) = mock_transparent_transaction( + network, + block_height, + relative_source_fund_heights, + transaction_version, + lock_time, + ); + + for input in transaction.inputs_mut() { + input.set_sequence(u32::MAX); + } + + let transaction_id = transaction.unmined_id(); + + let result = validate(transaction, block_height, block_time, known_utxos, network); + + prop_assert!( + result.is_ok(), + "Unexpected validation error: {}", + result.unwrap_err() + ); + prop_assert_eq!(result.unwrap().tx_id(), transaction_id); + } + + /// Test if a transaction locked at a certain block height is rejected. + #[test] + fn transaction_is_rejected_based_on_lock_height( + (network, block_height) in sapling_onwards_strategy(), + block_time in datetime_full(), + relative_source_fund_heights in vec(0.0..1.0, 1..=MAX_TRANSPARENT_INPUTS), + transaction_version in 4_u8..=5, + relative_unlock_height in 0.0..1.0, + ) { + zebra_test::init(); + + let unlock_height = scale_block_height(block_height, None, relative_unlock_height); + let lock_time = LockTime::Height(unlock_height); + + let (transaction, known_utxos) = mock_transparent_transaction( + network, + block_height, + relative_source_fund_heights, + transaction_version, + lock_time, + ); + + let result = validate(transaction, block_height, block_time, known_utxos, network); + + prop_assert_eq!( + result, + Err(TransactionError::LockedUntilAfterBlockHeight(unlock_height)) + ); + } + + /// Test if a transaction locked at a certain block time is rejected. + #[test] + fn transaction_is_rejected_based_on_lock_time( + (network, block_height) in sapling_onwards_strategy(), + first_datetime in datetime_u32(), + second_datetime in datetime_u32(), + relative_source_fund_heights in vec(0.0..1.0, 1..=MAX_TRANSPARENT_INPUTS), + transaction_version in 4_u8..=5, + ) { + zebra_test::init(); + + let (unlock_time, block_time) = if first_datetime >= second_datetime { + (first_datetime, second_datetime) + } else { + (second_datetime, first_datetime) + }; + + let (transaction, known_utxos) = mock_transparent_transaction( + network, + block_height, + relative_source_fund_heights, + transaction_version, + LockTime::Time(unlock_time), + ); + + let result = validate(transaction, block_height, block_time, known_utxos, network); + + prop_assert_eq!( + result, + Err(TransactionError::LockedUntilAfterBlockTime(unlock_time)) + ); + } + + /// Test if a transaction unlocked at an earlier block time is accepted. + #[test] + fn transaction_with_lock_height_is_accepted( + (network, block_height) in sapling_onwards_strategy(), + block_time in datetime_full(), + relative_source_fund_heights in vec(0.0..1.0, 1..=MAX_TRANSPARENT_INPUTS), + transaction_version in 4_u8..=5, + relative_unlock_height in 0.0..1.0, + ) { + zebra_test::init(); + + // Because `scale_block_height` uses the range `[min, max)`, with `max` being + // non-inclusive, we have to use `block_height + 1` as the upper bound in order to test + // verifying at a block height equal to the lock height. + let exclusive_max_height = block::Height(block_height.0 + 1); + let unlock_height = scale_block_height(None, exclusive_max_height, relative_unlock_height); + let lock_time = LockTime::Height(unlock_height); + + let (transaction, known_utxos) = mock_transparent_transaction( + network, + block_height, + relative_source_fund_heights, + transaction_version, + lock_time, + ); + + let transaction_id = transaction.unmined_id(); + + let result = validate(transaction, block_height, block_time, known_utxos, network); + + prop_assert!( + result.is_ok(), + "Unexpected validation error: {}", + result.unwrap_err() + ); + prop_assert_eq!(result.unwrap().tx_id(), transaction_id); + } + + /// Test if transaction unlocked at a previous block time is accepted. + #[test] + fn transaction_with_lock_time_is_accepted( + (network, block_height) in sapling_onwards_strategy(), + first_datetime in datetime_u32(), + second_datetime in datetime_u32(), + relative_source_fund_heights in vec(0.0..1.0, 1..=MAX_TRANSPARENT_INPUTS), + transaction_version in 4_u8..=5, + ) { + zebra_test::init(); + + let (unlock_time, block_time) = if first_datetime < second_datetime { + (first_datetime, second_datetime) + } else if first_datetime > second_datetime { + (second_datetime, first_datetime) + } else if first_datetime == chrono::MAX_DATETIME { + (first_datetime - Duration::nanoseconds(1), first_datetime) + } else { + (first_datetime, first_datetime + Duration::nanoseconds(1)) + }; + + let (transaction, known_utxos) = mock_transparent_transaction( + network, + block_height, + relative_source_fund_heights, + transaction_version, + LockTime::Time(unlock_time), + ); + + let transaction_id = transaction.unmined_id(); + + let result = validate(transaction, block_height, block_time, known_utxos, network); + + prop_assert!( + result.is_ok(), + "Unexpected validation error: {}", + result.unwrap_err() + ); + prop_assert_eq!(result.unwrap().tx_id(), transaction_id); + } +} + +/// Generate an arbitrary block height after the Sapling activation height on an arbitrary network. +/// +/// A proptest [`Strategy`] that generates random tuples with +/// +/// - a network (mainnet or testnet) +/// - a block height between the Sapling activation height (inclusive) on that network and the +/// maximum block height. +fn sapling_onwards_strategy() -> impl Strategy { + any::().prop_flat_map(|network| { + let start_height_value = NetworkUpgrade::Sapling + .activation_height(network) + .expect("Sapling to have an activation height") + .0; + + let end_height_value = block::Height::MAX.0; + + (start_height_value..=end_height_value) + .prop_map(move |height_value| (network, block::Height(height_value))) + }) +} + +/// Create a mock transaction that only transfers transparent amounts. +/// +/// # Parameters +/// +/// - `network`: the network to use for the transaction (mainnet or testnet) +/// - `block_height`: the block height to be used for the transaction's expiry height as well as +/// the height that the transaction was (hypothetically) included in a block +/// - `relative_source_heights`: a list of values in the range `0.0..1.0`; each item results in the +/// creation of a transparent input and output, where the item itself represents a scaled value +/// to be converted into a block height between zero and `block_height` (see +/// [`scale_block_height`] for details) to serve as the block height that created the input UTXO +/// - `transaction_version`: a value that's either `4` or `5` indicating the transaction version to +/// be generated; this value is sanitized by [`sanitize_transaction_version`], so it may not be +/// able to create a V5 transaction if the `block_height` is before the NU5 activation height +/// - `lock_time`: the transaction lock time to be used (note that all transparent inputs have a +/// sequence number of `0`, so the lock time is enabled by default) +/// +/// # Panics +/// +/// - if `transaction_version` is not `4` or `5` (the only transaction versions that are currently +/// supported by the transaction verifier) +/// - if `relative_source_heights` has more than `u32::MAX` items (see +/// [`mock_transparent_transfers`] for details) +/// - if any item of `relative_source_heights` is not in the range `0.0..1.0` (see +/// [`scale_block_height`] for details) +fn mock_transparent_transaction( + network: Network, + block_height: block::Height, + relative_source_heights: Vec, + transaction_version: u8, + lock_time: LockTime, +) -> ( + Transaction, + HashMap, +) { + let (transaction_version, network_upgrade) = + sanitize_transaction_version(network, transaction_version, block_height); + + // Create fake transparent transfers that should succeed + let (inputs, outputs, known_utxos) = + mock_transparent_transfers(relative_source_heights, block_height); + + // Create the mock transaction + let expiry_height = block_height; + + let transaction = match transaction_version { + 4 => Transaction::V4 { + inputs, + outputs, + lock_time, + expiry_height, + joinsplit_data: None, + sapling_shielded_data: None, + }, + 5 => Transaction::V5 { + inputs, + outputs, + lock_time, + expiry_height, + sapling_shielded_data: None, + orchard_shielded_data: None, + network_upgrade, + }, + invalid_version => unreachable!("invalid transaction version: {}", invalid_version), + }; + + (transaction, known_utxos) +} + +/// Sanitize a transaction version so that it is supported at the specified `block_height` of the +/// `network`. +/// +/// The `transaction_version` might be reduced if it is not supported by the network upgrade active +/// at the `block_height` of the specified `network`. +fn sanitize_transaction_version( + network: Network, + transaction_version: u8, + block_height: block::Height, +) -> (u8, NetworkUpgrade) { + let network_upgrade = NetworkUpgrade::current(network, block_height); + + let max_version = { + use NetworkUpgrade::*; + + match network_upgrade { + Genesis => 1, + BeforeOverwinter => 2, + Overwinter => 3, + Sapling | Blossom | Heartwood | Canopy => 4, + Nu5 => 5, + } + }; + + let sanitized_version = transaction_version.min(max_version); + + (sanitized_version, network_upgrade) +} + +/// Create multiple mock transparent transfers. +/// +/// Creates one mock transparent transfer per item in the `relative_source_heights` vector. Each +/// item represents a relative scale (in the range `0.0..1.0`) representing the scale to obtain a +/// block height between the genesis block and the specified `block_height`. Each block height is +/// then used as the height for the source of the UTXO that will be spent by the transfer. +/// +/// The function returns a list of inputs and outputs to be included in a mock transaction, as well +/// as a [`HashMap`] of source UTXOs to be sent to the transaction verifier. +/// +/// # Panics +/// +/// This will panic if there are more than [`u32::MAX`] items in `relative_source_heights`. Ideally +/// the tests should use a number of items at most [`MAX_TRANSPARENT_INPUTS`]. +fn mock_transparent_transfers( + relative_source_heights: Vec, + block_height: block::Height, +) -> ( + Vec, + Vec, + HashMap, +) { + let transfer_count = relative_source_heights.len(); + let mut inputs = Vec::with_capacity(transfer_count); + let mut outputs = Vec::with_capacity(transfer_count); + let mut known_utxos = HashMap::with_capacity(transfer_count); + + for (index, relative_source_height) in relative_source_heights.into_iter().enumerate() { + let fake_source_fund_height = + scale_block_height(None, block_height, relative_source_height); + + let outpoint_index = index + .try_into() + .expect("too many mock transparent transfers requested"); + + let (input, output, new_utxos) = + mock_transparent_transfer(fake_source_fund_height, true, outpoint_index); + + inputs.push(input); + outputs.push(output); + known_utxos.extend(new_utxos); + } + + (inputs, outputs, known_utxos) +} + +/// Selects a [`block::Height`] between `min_height` and `max_height` using the `scale` factor. +/// +/// The `scale` must be in the range `0.0..1.0`, where `0.0` results in the selection of +/// `min_height` and `1.0` would select the `max_height` if the range was inclusive. The range is +/// exclusive however, so `max_height` is never selected (unless it is equal to `min_height`). +/// +/// # Panics +/// +/// - if `scale` is not in the range `0.0..1.0` +/// - if `min_height` is greater than `max_height` +fn scale_block_height( + min_height: impl Into>, + max_height: impl Into>, + scale: f64, +) -> block::Height { + assert!(scale >= 0.0); + assert!(scale < 1.0); + + let min_height = min_height.into().unwrap_or(block::Height(0)); + let max_height = max_height.into().unwrap_or(block::Height::MAX); + + assert!(min_height <= max_height); + + let min_height_value = f64::from(min_height.0); + let max_height_value = f64::from(max_height.0); + let height_range = max_height_value - min_height_value; + + let new_height_value = (height_range * scale + min_height_value).floor(); + + block::Height(new_height_value as u32) +} + +/// Validate a `transaction` using a [`transaction::Verifier`] and return the result. +/// +/// Configures an asynchronous runtime to run the verifier, sets it up and then uses it verify a +/// `transaction` using the provided parameters. +fn validate( + transaction: Transaction, + height: block::Height, + block_time: DateTime, + known_utxos: HashMap, + network: Network, +) -> Result { + zebra_test::RUNTIME.block_on(async { + // Initialize the verifier + let state_service = + tower::service_fn(|_| async { unreachable!("State service should not be called") }); + let script_verifier = script::Verifier::new(state_service); + let verifier = transaction::Verifier::new(network, script_verifier); + + // Test the transaction verifier + verifier + .clone() + .oneshot(transaction::Request::Block { + transaction: Arc::new(transaction), + known_utxos: Arc::new(known_utxos), + height, + time: block_time, + }) + .await + }) +}