Add some proptests for lock time validation (#3089)

* Create a strategy for block heights after Sapling

Provides an arbitrary network (mainnet or testnet) and a block height
between the Sapling activation height on that network and the maximum
block height.

* Create a helper function to select block heights

Allows generating block heights inside a range using a scale factor
between 0 and 1.

* Allow specifying the outpoint index for mock UTXOs

Avoid creating multiple transparent transfers in the same transaction
with the same source UTXO, which would lead to a double spend.

* Create helper function to mock multiple transfers

Given relative block height scale factors, create a mock transparent
transfer for each one of them.

Also add a constant that serves as a guideline for the maximum number of
transparent transfers to mock.

* Create helper function to sanitize tx. version

Make sure the arbitrary transaction version is valid for the network
(testnet or mainnet) at the specified block height.

* Create `mock_transparent_transaction` helper func.

Creates a V4 or V5 mock transaction that only includes transparent
inputs and outputs.

* Create helper function for transaction validation

Performs the actual tested action of verifying a transaction. It sets up
the verifier and uses it to obtain the verification result.

* Test if zero lock time means unlocked

Generate arbitrary transactions with zero lock time, and check that they
are accepted by the transaction verifier.

* Allow changing the sequence number of an input

Add a setter method for a `transparent::Input`'s sequence number. This
setter is only available for testing.

* Test if sequence numbers can disable lock time

Create arbitrary transactions and set the sequence numbers of all of its
inputs to `u32::MAX` to see if that disables the lock time and the
transactions are accepted by the verifier.

* Test block height lock times

Make sure that the transaction verifier rejects transactions that are
still locked at a certain block height.

* Test block time lock times

Test that the transaction verifier rejects a transaction that is
validated at a block time that's before the transaction's lock time.

* Test unlocking by block height

Test that transactions unlocked at an earlier block height are accepted
by the transaction verifier.

* Test transactions unlocked by the block time

Test that transactions that were unlocked at a previous block time are
accepted by the transaction verifier.

* Fix an incorrect method comment

Co-authored-by: teor <teor@riseup.net>
This commit is contained in:
Janito Vaqueiro Ferreira Filho 2021-12-19 20:44:12 -03:00 committed by GitHub
parent 6cbd7dce43
commit 410133435e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 481 additions and 7 deletions

View File

@ -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<OutPoint> {

View File

@ -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 {

View File

@ -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::<LockTime>(),
) {
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<Value = (Network, block::Height)> {
any::<Network>().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<f64>,
transaction_version: u8,
lock_time: LockTime,
) -> (
Transaction,
HashMap<transparent::OutPoint, transparent::OrderedUtxo>,
) {
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<f64>,
block_height: block::Height,
) -> (
Vec<transparent::Input>,
Vec<transparent::Output>,
HashMap<transparent::OutPoint, transparent::OrderedUtxo>,
) {
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<Option<block::Height>>,
max_height: impl Into<Option<block::Height>>,
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<Utc>,
known_utxos: HashMap<transparent::OutPoint, transparent::OrderedUtxo>,
network: Network,
) -> Result<transaction::Response, TransactionError> {
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
})
}