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, 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); } } /// Generates 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 transaction expiry 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_EXPIRY_HEIGHT.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 verifier = transaction::Verifier::new(network, state_service); // 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 }) }