Reject UTXO double spends (#2511)

* Reject transparent output double-spends

Check that transparent spends use unspent outputs from:
* earlier transaction in the same block,
* earlier blocks in the parent non-finalized chain, or
* the finalized state.

* Fixup UTXOs in proptests

* Add a comment

* Clarify a consensus rule implementation

* Fix an incorrect comment

* Fix an incorrect error message

* Clarify a comment

* Document `unspent_utxos`

* Simplify the UTXO check

Co-authored-by: Janito Vaqueiro Ferreira Filho <janito.vff@gmail.com>

* Further simplify and fix the UTXO check

- split each error case into a separate check
- combine `contains` and `insert`
- add a missing check against the non-finalized unspent UTXOs
- rename arguments and edit error strings for clarity

* Share test methods between check test modules

* Make some chain fields available to tests

* Make error field names consistent with transparent::Input

* WIP: Add tests for UTXO double-spends

- accept output and spend in the same block
- accept output and spend in a later block
- reject output and double-spend all in the same block
- reject output then double-spend in a later block
- reject output, spend, then double-spend all in different blocks

* Use Extend rather than multiple pushes

Co-authored-by: Janito Vaqueiro Ferreira Filho <janito.vff@gmail.com>

* Use Extend for more pushes

* Limit the number of proptest cases, to speed up tests

* Test rejection of UTXOs that were never in the chain

* Test rejection of spends of later transactions in the same block

Co-authored-by: Janito Vaqueiro Ferreira Filho <janito.vff@gmail.com>
This commit is contained in:
teor 2021-07-23 09:40:15 +10:00 committed by GitHub
parent 429ccf7f79
commit e6e03247ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 1082 additions and 58 deletions

View File

@ -3,17 +3,19 @@ use proptest::{
prelude::*,
};
use std::sync::Arc;
use std::{collections::HashSet, convert::TryInto, sync::Arc};
use crate::{
block,
fmt::SummaryDebug,
orchard,
parameters::{
Network,
NetworkUpgrade::{self, *},
GENESIS_PREVIOUS_BLOCK_HASH,
},
serialization,
transparent::Input::*,
work::{difficulty::CompactDifficulty, equihash},
};
@ -326,14 +328,79 @@ impl Block {
current.height.0 += 1;
}
// after the vec strategy generates blocks, update the previous block hashes
// after the vec strategy generates blocks, fixup invalid parts of the blocks
vec.prop_map(|mut vec| {
let mut previous_block_hash = None;
let mut utxos = HashSet::<transparent::OutPoint>::new();
for block in vec.iter_mut() {
// fixup the previous block hash
if let Some(previous_block_hash) = previous_block_hash {
block.header.previous_block_hash = previous_block_hash;
}
previous_block_hash = Some(block.hash());
// fixup the transparent spends
let mut new_transactions = Vec::new();
for transaction in block.transactions.drain(..) {
let mut transaction = (*transaction).clone();
let mut new_inputs = Vec::new();
for mut input in transaction.inputs_mut().drain(..) {
if let PrevOut {
ref mut outpoint, ..
} = input
{
// take a UTXO if available
if utxos.remove(outpoint) {
new_inputs.push(input);
} else if let Some(arbitrary_utxo) = utxos.clone().iter().next() {
*outpoint = *arbitrary_utxo;
utxos.remove(arbitrary_utxo);
new_inputs.push(input);
}
// otherwise, drop the invalid input, it has no UTXOs to spend
} else {
// preserve coinbase inputs
new_inputs.push(input);
}
}
// delete invalid inputs
*transaction.inputs_mut() = new_inputs;
// keep transactions with valid input counts
// coinbase transactions will never fail this check
// this is the input check from `has_inputs_and_outputs`
if !transaction.inputs().is_empty()
|| transaction.joinsplit_count() > 0
|| transaction.sapling_spends_per_anchor().count() > 0
|| (transaction.orchard_actions().count() > 0
&& transaction
.orchard_flags()
.unwrap_or_else(orchard::Flags::empty)
.contains(orchard::Flags::ENABLE_SPENDS))
{
// add the created UTXOs
// these outputs can be spent from the next transaction in this block onwards
// see `new_outputs` for details
let hash = transaction.hash();
for output_index_in_transaction in 0..transaction.outputs().len() {
utxos.insert(transparent::OutPoint {
hash,
index: output_index_in_transaction.try_into().unwrap(),
});
}
// and keep the transaction
new_transactions.push(Arc::new(transaction));
}
}
// delete invalid transactions
block.transactions = new_transactions;
// TODO: fixup the history and authorizing data commitments, if needed
}
SummaryDebug(vec.into_iter().map(Arc::new).collect())
})

View File

@ -223,6 +223,18 @@ 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 outputs of this transaction, regardless of version.
pub fn outputs(&self) -> &[transparent::Output] {
match self {

View File

@ -103,6 +103,37 @@ pub enum Input {
},
}
impl Input {
/// If this is a `PrevOut` input, returns this input's outpoint.
/// Otherwise, returns `None`.
pub fn outpoint(&self) -> Option<OutPoint> {
if let Input::PrevOut { outpoint, .. } = self {
Some(*outpoint)
} else {
None
}
}
/// Set this input's outpoint.
///
/// Should only be called on `PrevOut` inputs.
///
/// # Panics
///
/// If `self` is a coinbase input.
#[cfg(any(test, feature = "proptest-impl"))]
pub fn set_outpoint(&mut self, new_outpoint: OutPoint) {
if let Input::PrevOut {
ref mut outpoint, ..
} = self
{
*outpoint = new_outpoint;
} else {
unreachable!("unexpected variant: Coinbase Inputs do not have OutPoints");
}
}
}
/// A transparent output from a transaction.
///
/// The most fundamental building block of a transaction is a

View File

@ -3,7 +3,9 @@ use std::sync::Arc;
use chrono::{DateTime, Utc};
use thiserror::Error;
use zebra_chain::{block, orchard, sapling, sprout, work::difficulty::CompactDifficulty};
use zebra_chain::{
block, orchard, sapling, sprout, transparent, work::difficulty::CompactDifficulty,
};
/// A wrapper for type erased errors that is itself clonable and implements the
/// Error trait
@ -75,6 +77,24 @@ pub enum ValidateContextError {
expected_difficulty: CompactDifficulty,
},
#[error("transparent double-spend: {outpoint:?} is spent twice in {location:?}")]
#[non_exhaustive]
DuplicateTransparentSpend {
outpoint: transparent::OutPoint,
location: &'static str,
},
#[error("missing transparent output: possible double-spend of {outpoint:?} in {location:?}")]
#[non_exhaustive]
MissingTransparentOutput {
outpoint: transparent::OutPoint,
location: &'static str,
},
#[error("out-of-order transparent spend: {outpoint:?} is created by a later transaction in the same block")]
#[non_exhaustive]
EarlyTransparentSpend { outpoint: transparent::OutPoint },
#[error("sprout double-spend: duplicate nullifier: {nullifier:?}, in finalized state: {in_finalized_state:?}")]
#[non_exhaustive]
DuplicateSproutNullifier {

View File

@ -1,17 +1,19 @@
use std::sync::Arc;
use proptest::{
num::usize::BinarySearch,
prelude::*,
strategy::{NewTree, ValueTree},
test_runner::TestRunner,
};
use std::sync::Arc;
use zebra_chain::{
block::{Block, Height},
fmt::SummaryDebug,
parameters::NetworkUpgrade,
parameters::{Network::*, NetworkUpgrade},
serialization::ZcashDeserializeInto,
LedgerState,
};
use zebra_test::prelude::*;
use crate::tests::Prepare;
@ -146,3 +148,46 @@ pub(crate) fn partial_nu5_chain_strategy(
.prop_map(move |partial_chain| (network, nu_activation, partial_chain))
})
}
/// Return a new `StateService` containing the mainnet genesis block.
/// Also returns the finalized genesis block itself.
pub(super) fn new_state_with_mainnet_genesis() -> (StateService, FinalizedBlock) {
let genesis = zebra_test::vectors::BLOCK_MAINNET_GENESIS_BYTES
.zcash_deserialize_into::<Arc<Block>>()
.expect("block should deserialize");
let mut state = StateService::new(Config::ephemeral(), Mainnet);
assert_eq!(None, state.best_tip());
let genesis = FinalizedBlock::from(genesis);
state
.disk
.commit_finalized_direct(genesis.clone(), "test")
.expect("unexpected invalid genesis block test vector");
assert_eq!(Some((Height(0), genesis.hash)), state.best_tip());
(state, genesis)
}
/// Return a `Transaction::V4` with the coinbase data from `coinbase`.
///
/// Used to convert a coinbase transaction to a version that the non-finalized state will accept.
pub(super) fn transaction_v4_from_coinbase(coinbase: &Transaction) -> Transaction {
assert!(
!coinbase.has_sapling_shielded_data(),
"conversion assumes sapling shielded data is None"
);
Transaction::V4 {
inputs: coinbase.inputs().to_vec(),
outputs: coinbase.outputs().to_vec(),
lock_time: coinbase.lock_time(),
// `Height(0)` means that the expiry height is ignored
expiry_height: coinbase.expiry_height().unwrap_or(Height(0)),
// invalid for coinbase transactions
joinsplit_data: None,
sapling_shielded_data: None,
}
}

View File

@ -19,6 +19,7 @@ use difficulty::{AdjustedDifficulty, POW_MEDIAN_BLOCK_SPAN};
pub(crate) mod difficulty;
pub(crate) mod nullifier;
pub(crate) mod utxo;
#[cfg(test)]
mod tests;

View File

@ -1,4 +1,5 @@
//! Tests for state contextual validation checks.
mod nullifier;
mod utxo;
mod vectors;

View File

@ -1,4 +1,4 @@
//! Randomised property tests for state contextual validation
//! Randomised property tests for nullifier contextual validation
use std::{convert::TryInto, sync::Arc};
@ -9,7 +9,7 @@ use zebra_chain::{
block::{Block, Height},
fmt::TypeNameToDebug,
orchard,
parameters::{Network::*, NetworkUpgrade::Nu5},
parameters::NetworkUpgrade::Nu5,
primitives::Groth16Proof,
sapling::{self, FieldNotPresent, PerSpendAnchor, TransferData::*},
serialization::ZcashDeserializeInto,
@ -18,8 +18,7 @@ use zebra_chain::{
};
use crate::{
config::Config,
service::StateService,
service::arbitrary::{new_state_with_mainnet_genesis, transaction_v4_from_coinbase},
tests::Prepare,
FinalizedBlock,
ValidateContextError::{
@ -848,28 +847,6 @@ proptest! {
}
}
/// Return a new `StateService` containing the mainnet genesis block.
/// Also returns the finalized genesis block itself.
fn new_state_with_mainnet_genesis() -> (StateService, FinalizedBlock) {
let genesis = zebra_test::vectors::BLOCK_MAINNET_GENESIS_BYTES
.zcash_deserialize_into::<Arc<Block>>()
.expect("block should deserialize");
let mut state = StateService::new(Config::ephemeral(), Mainnet);
assert_eq!(None, state.best_tip());
let genesis = FinalizedBlock::from(genesis);
state
.disk
.commit_finalized_direct(genesis.clone(), "test")
.expect("unexpected invalid genesis block test vector");
assert_eq!(Some((Height(0), genesis.hash)), state.best_tip());
(state, genesis)
}
/// Make sure the supplied nullifiers are distinct, modifying them if necessary.
fn make_distinct_nullifiers<'until_modified, NullifierT>(
nullifiers: impl IntoIterator<Item = &'until_modified mut NullifierT>,
@ -1042,24 +1019,3 @@ fn transaction_v5_with_orchard_shielded_data(
orchard_shielded_data,
}
}
/// Return a `Transaction::V4` with the coinbase data from `coinbase`.
///
/// Used to convert a coinbase transaction to a version that the non-finalized state will accept.
fn transaction_v4_from_coinbase(coinbase: &Transaction) -> Transaction {
assert!(
!coinbase.has_sapling_shielded_data(),
"conversion assumes sapling shielded data is None"
);
Transaction::V4 {
inputs: coinbase.inputs().to_vec(),
outputs: coinbase.outputs().to_vec(),
lock_time: coinbase.lock_time(),
// `Height(0)` means that the expiry height is ignored
expiry_height: coinbase.expiry_height().unwrap_or(Height(0)),
// invalid for coinbase transactions
joinsplit_data: None,
sapling_shielded_data: None,
}
}

View File

@ -0,0 +1,764 @@
//! Randomised property tests for UTXO contextual validation
use std::{convert::TryInto, env, sync::Arc};
use proptest::prelude::*;
use zebra_chain::{
block::{Block, Height},
fmt::TypeNameToDebug,
serialization::ZcashDeserializeInto,
transaction::{LockTime, Transaction},
transparent,
};
use crate::{
service::{
arbitrary::{new_state_with_mainnet_genesis, transaction_v4_from_coinbase},
StateService,
},
tests::Prepare,
FinalizedBlock,
ValidateContextError::{
DuplicateTransparentSpend, EarlyTransparentSpend, MissingTransparentOutput,
},
};
// These tests use the `Arbitrary` trait to easily generate complex types,
// then modify those types to cause an error (or to ensure success).
//
// We could use mainnet or testnet blocks in these tests,
// but the differences shouldn't matter,
// because we're only interested in spend validation,
// (and passing various other state checks).
const DEFAULT_UTXO_PROPTEST_CASES: u32 = 16;
proptest! {
#![proptest_config(
proptest::test_runner::Config::with_cases(env::var("PROPTEST_CASES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_UTXO_PROPTEST_CASES))
)]
/// Make sure an arbitrary transparent spend from a previous transaction in this block
/// is accepted by state contextual validation.
///
/// This test makes sure there are no spurious rejections that might hide bugs in the other tests.
/// (And that the test infrastructure generally works.)
///
/// It also covers a potential edge case where later transactions can spend outputs
/// of previous transactions in a block, but earlier transactions can not spend later outputs.
#[test]
fn accept_later_transparent_spend_from_this_block(
output in TypeNameToDebug::<transparent::Output>::arbitrary(),
mut prevout_input in TypeNameToDebug::<transparent::Input>::arbitrary_with(None),
use_finalized_state in any::<bool>(),
) {
zebra_test::init();
let mut block1 = zebra_test::vectors::BLOCK_MAINNET_1_BYTES
.zcash_deserialize_into::<Block>()
.expect("block should deserialize");
// create an output
let output_transaction = transaction_v4_with_transparent_data([], [output.0]);
// create a spend
let expected_outpoint = transparent::OutPoint { hash: output_transaction.hash(), index: 0 };
prevout_input.set_outpoint(expected_outpoint);
let spend_transaction = transaction_v4_with_transparent_data([prevout_input.0], []);
// convert the coinbase transaction to a version that the non-finalized state will accept
block1.transactions[0] = transaction_v4_from_coinbase(&block1.transactions[0]).into();
block1
.transactions
.extend([output_transaction.into(), spend_transaction.into()]);
let (mut state, _genesis) = new_state_with_mainnet_genesis();
let previous_mem = state.mem.clone();
// randomly choose to commit the block to the finalized or non-finalized state
if use_finalized_state {
let block1 = FinalizedBlock::from(Arc::new(block1));
let commit_result = state.disk.commit_finalized_direct(block1.clone(), "test");
// the block was committed
prop_assert_eq!(Some((Height(1), block1.hash)), state.best_tip());
prop_assert!(commit_result.is_ok());
// the non-finalized state didn't change
prop_assert!(state.mem.eq_internal_state(&previous_mem));
// the finalized state added then spent the UTXO
prop_assert!(state.disk.utxo(&expected_outpoint).is_none());
// the non-finalized state does not have the UTXO
prop_assert!(state.mem.any_utxo(&expected_outpoint).is_none());
} else {
let block1 = Arc::new(block1).prepare();
let commit_result =
state.validate_and_commit(block1.clone());
// the block was committed
prop_assert_eq!(commit_result, Ok(()));
prop_assert_eq!(Some((Height(1), block1.hash)), state.best_tip());
// the block data is in the non-finalized state
prop_assert!(!state.mem.eq_internal_state(&previous_mem));
// the non-finalized state has the spent its own UTXO
prop_assert_eq!(state.mem.chain_set.len(), 1);
prop_assert!(!state.mem.chain_set.iter().next().unwrap().unspent_utxos().contains_key(&expected_outpoint));
prop_assert!(state.mem.chain_set.iter().next().unwrap().created_utxos.contains_key(&expected_outpoint));
prop_assert!(state.mem.chain_set.iter().next().unwrap().spent_utxos.contains(&expected_outpoint));
// the finalized state does not have the UTXO
prop_assert!(state.disk.utxo(&expected_outpoint).is_none());
}
}
/// Make sure an arbitrary transparent spend from a previous block
/// is accepted by state contextual validation.
#[test]
fn accept_arbitrary_transparent_spend_from_previous_block(
output in TypeNameToDebug::<transparent::Output>::arbitrary(),
mut prevout_input in TypeNameToDebug::<transparent::Input>::arbitrary_with(None),
use_finalized_state_output in any::<bool>(),
mut use_finalized_state_spend in any::<bool>(),
) {
zebra_test::init();
// if we use the non-finalized state for the first block,
// we have to use it for the second as well
if !use_finalized_state_output {
use_finalized_state_spend = false;
}
let mut block2 = zebra_test::vectors::BLOCK_MAINNET_2_BYTES
.zcash_deserialize_into::<Block>()
.expect("block should deserialize");
let TestState { mut state, block1, .. } = new_state_with_mainnet_transparent_data([], [output.0], use_finalized_state_output);
let previous_mem = state.mem.clone();
let expected_outpoint = transparent::OutPoint { hash: block1.transactions[1].hash(), index: 0 };
prevout_input.set_outpoint(expected_outpoint);
let spend_transaction = transaction_v4_with_transparent_data([prevout_input.0], []);
// convert the coinbase transaction to a version that the non-finalized state will accept
block2.transactions[0] = transaction_v4_from_coinbase(&block2.transactions[0]).into();
block2
.transactions
.push(spend_transaction.into());
if use_finalized_state_spend {
let block2 = FinalizedBlock::from(Arc::new(block2));
let commit_result = state.disk.commit_finalized_direct(block2.clone(), "test");
// the block was committed
prop_assert_eq!(Some((Height(2), block2.hash)), state.best_tip());
prop_assert!(commit_result.is_ok());
// the non-finalized state didn't change
prop_assert!(state.mem.eq_internal_state(&previous_mem));
// the finalized state has spent the UTXO
prop_assert!(state.disk.utxo(&expected_outpoint).is_none());
} else {
let block2 = Arc::new(block2).prepare();
let commit_result =
state.validate_and_commit(block2.clone());
// the block was committed
prop_assert_eq!(commit_result, Ok(()));
prop_assert_eq!(Some((Height(2), block2.hash)), state.best_tip());
// the block data is in the non-finalized state
prop_assert!(!state.mem.eq_internal_state(&previous_mem));
// the UTXO is spent
prop_assert_eq!(state.mem.chain_set.len(), 1);
prop_assert!(!state.mem.chain_set.iter().next().unwrap().unspent_utxos().contains_key(&expected_outpoint));
if use_finalized_state_output {
// the chain has spent the UTXO from the finalized state
prop_assert!(!state.mem.chain_set.iter().next().unwrap().created_utxos.contains_key(&expected_outpoint));
prop_assert!(state.mem.chain_set.iter().next().unwrap().spent_utxos.contains(&expected_outpoint));
// the finalized state has the UTXO, but it will get deleted on commit
prop_assert!(state.disk.utxo(&expected_outpoint).is_some());
} else {
// the chain has spent its own UTXO
prop_assert!(!state.mem.chain_set.iter().next().unwrap().unspent_utxos().contains_key(&expected_outpoint));
prop_assert!(state.mem.chain_set.iter().next().unwrap().created_utxos.contains_key(&expected_outpoint));
prop_assert!(state.mem.chain_set.iter().next().unwrap().spent_utxos.contains(&expected_outpoint));
// the finalized state does not have the UTXO
prop_assert!(state.disk.utxo(&expected_outpoint).is_none());
}
}
}
/// Make sure a duplicate transparent spend, by two inputs in the same transaction,
/// using an output from a previous transaction in this block,
/// is rejected by state contextual validation.
#[test]
fn reject_duplicate_transparent_spend_in_same_transaction_from_same_block(
output in TypeNameToDebug::<transparent::Output>::arbitrary(),
mut prevout_input1 in TypeNameToDebug::<transparent::Input>::arbitrary_with(None),
mut prevout_input2 in TypeNameToDebug::<transparent::Input>::arbitrary_with(None),
) {
zebra_test::init();
let mut block1 = zebra_test::vectors::BLOCK_MAINNET_1_BYTES
.zcash_deserialize_into::<Block>()
.expect("block should deserialize");
let output_transaction = transaction_v4_with_transparent_data([], [output.0]);
let expected_outpoint = transparent::OutPoint { hash: output_transaction.hash(), index: 0 };
prevout_input1.set_outpoint(expected_outpoint);
prevout_input2.set_outpoint(expected_outpoint);
let spend_transaction = transaction_v4_with_transparent_data([prevout_input1.0, prevout_input2.0], []);
// convert the coinbase transaction to a version that the non-finalized state will accept
block1.transactions[0] = transaction_v4_from_coinbase(&block1.transactions[0]).into();
block1
.transactions
.extend([output_transaction.into(), spend_transaction.into()]);
let (mut state, genesis) = new_state_with_mainnet_genesis();
let previous_mem = state.mem.clone();
let block1 = Arc::new(block1).prepare();
let commit_result = state.validate_and_commit(block1);
// the block was rejected
prop_assert_eq!(
commit_result,
Err(DuplicateTransparentSpend {
outpoint: expected_outpoint,
location: "the same block",
}.into())
);
prop_assert_eq!(Some((Height(0), genesis.hash)), state.best_tip());
// the non-finalized state did not change
prop_assert!(state.mem.eq_internal_state(&previous_mem));
// the finalized state does not have the UTXO
prop_assert!(state.disk.utxo(&expected_outpoint).is_none());
}
/// Make sure a duplicate transparent spend, by two inputs in the same transaction,
/// using an output from a previous block in this chain,
/// is rejected by state contextual validation.
#[test]
fn reject_duplicate_transparent_spend_in_same_transaction_from_previous_block(
output in TypeNameToDebug::<transparent::Output>::arbitrary(),
mut prevout_input1 in TypeNameToDebug::<transparent::Input>::arbitrary_with(None),
mut prevout_input2 in TypeNameToDebug::<transparent::Input>::arbitrary_with(None),
use_finalized_state_output in any::<bool>(),
) {
zebra_test::init();
let mut block2 = zebra_test::vectors::BLOCK_MAINNET_2_BYTES
.zcash_deserialize_into::<Block>()
.expect("block should deserialize");
let TestState { mut state, block1, .. } = new_state_with_mainnet_transparent_data([], [output.0], use_finalized_state_output);
let previous_mem = state.mem.clone();
let expected_outpoint = transparent::OutPoint { hash: block1.transactions[1].hash(), index: 0 };
prevout_input1.set_outpoint(expected_outpoint);
prevout_input2.set_outpoint(expected_outpoint);
let spend_transaction = transaction_v4_with_transparent_data([prevout_input1.0, prevout_input2.0], []);
// convert the coinbase transaction to a version that the non-finalized state will accept
block2.transactions[0] = transaction_v4_from_coinbase(&block2.transactions[0]).into();
block2
.transactions
.push(spend_transaction.into());
let block2 = Arc::new(block2).prepare();
let commit_result = state.validate_and_commit(block2);
// the block was rejected
prop_assert_eq!(
commit_result,
Err(DuplicateTransparentSpend {
outpoint: expected_outpoint,
location: "the same block",
}.into())
);
prop_assert_eq!(Some((Height(1), block1.hash())), state.best_tip());
// the non-finalized state did not change
prop_assert!(state.mem.eq_internal_state(&previous_mem));
if use_finalized_state_output {
// the finalized state has the UTXO
prop_assert!(state.disk.utxo(&expected_outpoint).is_some());
// the non-finalized state has no chains (so it can't have the UTXO)
prop_assert!(state.mem.chain_set.iter().next().is_none());
} else {
// the non-finalized state has the UTXO
prop_assert!(state.mem.chain_set.iter().next().unwrap().unspent_utxos().contains_key(&expected_outpoint));
// the finalized state does not have the UTXO
prop_assert!(state.disk.utxo(&expected_outpoint).is_none());
}
}
/// Make sure a duplicate transparent spend,
/// by two inputs in different transactions in the same block,
/// using an output from a previous block in this chain,
/// is rejected by state contextual validation.
#[test]
fn reject_duplicate_transparent_spend_in_same_block_from_previous_block(
output in TypeNameToDebug::<transparent::Output>::arbitrary(),
mut prevout_input1 in TypeNameToDebug::<transparent::Input>::arbitrary_with(None),
mut prevout_input2 in TypeNameToDebug::<transparent::Input>::arbitrary_with(None),
use_finalized_state_output in any::<bool>(),
) {
zebra_test::init();
let mut block2 = zebra_test::vectors::BLOCK_MAINNET_2_BYTES
.zcash_deserialize_into::<Block>()
.expect("block should deserialize");
let TestState { mut state, block1, .. } = new_state_with_mainnet_transparent_data([], [output.0], use_finalized_state_output);
let previous_mem = state.mem.clone();
let expected_outpoint = transparent::OutPoint { hash: block1.transactions[1].hash(), index: 0 };
prevout_input1.set_outpoint(expected_outpoint);
prevout_input2.set_outpoint(expected_outpoint);
let spend_transaction1 = transaction_v4_with_transparent_data([prevout_input1.0], []);
let spend_transaction2 = transaction_v4_with_transparent_data([prevout_input2.0], []);
// convert the coinbase transaction to a version that the non-finalized state will accept
block2.transactions[0] = transaction_v4_from_coinbase(&block2.transactions[0]).into();
block2
.transactions
.extend([spend_transaction1.into(), spend_transaction2.into()]);
let block2 = Arc::new(block2).prepare();
let commit_result = state.validate_and_commit(block2);
// the block was rejected
prop_assert_eq!(
commit_result,
Err(DuplicateTransparentSpend {
outpoint: expected_outpoint,
location: "the same block",
}.into())
);
prop_assert_eq!(Some((Height(1), block1.hash())), state.best_tip());
// the non-finalized state did not change
prop_assert!(state.mem.eq_internal_state(&previous_mem));
if use_finalized_state_output {
// the finalized state has the UTXO
prop_assert!(state.disk.utxo(&expected_outpoint).is_some());
// the non-finalized state has no chains (so it can't have the UTXO)
prop_assert!(state.mem.chain_set.iter().next().is_none());
} else {
// the non-finalized state has the UTXO
prop_assert!(state.mem.chain_set.iter().next().unwrap().unspent_utxos().contains_key(&expected_outpoint));
// the finalized state does not have the UTXO
prop_assert!(state.disk.utxo(&expected_outpoint).is_none());
}
}
/// Make sure a duplicate transparent spend,
/// by two inputs in different blocks in the same chain,
/// using an output from a previous block in this chain,
/// is rejected by state contextual validation.
#[test]
fn reject_duplicate_transparent_spend_in_same_chain_from_previous_block(
output in TypeNameToDebug::<transparent::Output>::arbitrary(),
mut prevout_input1 in TypeNameToDebug::<transparent::Input>::arbitrary_with(None),
mut prevout_input2 in TypeNameToDebug::<transparent::Input>::arbitrary_with(None),
use_finalized_state_output in any::<bool>(),
mut use_finalized_state_spend in any::<bool>(),
) {
zebra_test::init();
// if we use the non-finalized state for the first block,
// we have to use it for the second as well
if !use_finalized_state_output {
use_finalized_state_spend = false;
}
let mut block2 = zebra_test::vectors::BLOCK_MAINNET_2_BYTES
.zcash_deserialize_into::<Block>()
.expect("block should deserialize");
let mut block3 = zebra_test::vectors::BLOCK_MAINNET_3_BYTES
.zcash_deserialize_into::<Block>()
.expect("block should deserialize");
let TestState { mut state, block1, .. } = new_state_with_mainnet_transparent_data([], [output.0], use_finalized_state_output);
let mut previous_mem = state.mem.clone();
let expected_outpoint = transparent::OutPoint { hash: block1.transactions[1].hash(), index: 0 };
prevout_input1.set_outpoint(expected_outpoint);
prevout_input2.set_outpoint(expected_outpoint);
let spend_transaction1 = transaction_v4_with_transparent_data([prevout_input1.0], []);
let spend_transaction2 = transaction_v4_with_transparent_data([prevout_input2.0], []);
// convert the coinbase transactions to a version that the non-finalized state will accept
block2.transactions[0] = transaction_v4_from_coinbase(&block2.transactions[0]).into();
block3.transactions[0] = transaction_v4_from_coinbase(&block3.transactions[0]).into();
block2
.transactions
.push(spend_transaction1.into());
block3
.transactions
.push(spend_transaction2.into());
let block2 = Arc::new(block2);
if use_finalized_state_spend {
let block2 = FinalizedBlock::from(block2.clone());
let commit_result = state.disk.commit_finalized_direct(block2.clone(), "test");
// the block was committed
prop_assert_eq!(Some((Height(2), block2.hash)), state.best_tip());
prop_assert!(commit_result.is_ok());
// the non-finalized state didn't change
prop_assert!(state.mem.eq_internal_state(&previous_mem));
// the finalized state has spent the UTXO
prop_assert!(state.disk.utxo(&expected_outpoint).is_none());
// the non-finalized state does not have the UTXO
prop_assert!(state.mem.any_utxo(&expected_outpoint).is_none());
} else {
let block2 = block2.clone().prepare();
let commit_result = state.validate_and_commit(block2.clone());
// the block was committed
prop_assert_eq!(commit_result, Ok(()));
prop_assert_eq!(Some((Height(2), block2.hash)), state.best_tip());
// the block data is in the non-finalized state
prop_assert!(!state.mem.eq_internal_state(&previous_mem));
prop_assert_eq!(state.mem.chain_set.len(), 1);
if use_finalized_state_output {
// the finalized state has the unspent UTXO
prop_assert!(state.disk.utxo(&expected_outpoint).is_some());
// the non-finalized state has spent the UTXO
prop_assert!(state
.mem
.chain_set
.iter()
.next()
.unwrap()
.spent_utxos
.contains(&expected_outpoint));
} else {
// the non-finalized state has created and spent the UTXO
prop_assert!(!state
.mem
.chain_set
.iter()
.next()
.unwrap()
.unspent_utxos()
.contains_key(&expected_outpoint));
prop_assert!(state
.mem
.chain_set
.iter()
.next()
.unwrap()
.created_utxos
.contains_key(&expected_outpoint));
prop_assert!(state
.mem
.chain_set
.iter()
.next()
.unwrap()
.spent_utxos
.contains(&expected_outpoint));
// the finalized state does not have the UTXO
prop_assert!(state.disk.utxo(&expected_outpoint).is_none());
}
previous_mem = state.mem.clone();
}
let block3 = Arc::new(block3).prepare();
let commit_result = state.validate_and_commit(block3);
// the block was rejected
if use_finalized_state_spend {
prop_assert_eq!(
commit_result,
Err(MissingTransparentOutput {
outpoint: expected_outpoint,
location: "the non-finalized and finalized chain",
}.into())
);
} else {
prop_assert_eq!(
commit_result,
Err(DuplicateTransparentSpend {
outpoint: expected_outpoint,
location: "the non-finalized chain",
}.into())
);
}
prop_assert_eq!(Some((Height(2), block2.hash())), state.best_tip());
// the non-finalized state did not change
prop_assert!(state.mem.eq_internal_state(&previous_mem));
// Since the non-finalized state has not changed, we don't need to check it again
if use_finalized_state_spend {
// the finalized state has spent the UTXO
prop_assert!(state.disk.utxo(&expected_outpoint).is_none());
} else if use_finalized_state_output {
// the finalized state has the unspent UTXO
// but the non-finalized state has spent it
prop_assert!(state.disk.utxo(&expected_outpoint).is_some());
} else {
// the non-finalized state has created and spent the UTXO
// and the finalized state does not have the UTXO
prop_assert!(state.disk.utxo(&expected_outpoint).is_none());
}
}
/// Make sure a transparent spend with a missing UTXO
/// is rejected by state contextual validation.
#[test]
fn reject_missing_transparent_spend(
prevout_input in TypeNameToDebug::<transparent::Input>::arbitrary_with(None),
) {
zebra_test::init();
let mut block1 = zebra_test::vectors::BLOCK_MAINNET_1_BYTES
.zcash_deserialize_into::<Block>()
.expect("block should deserialize");
let expected_outpoint = prevout_input.outpoint().unwrap();
let spend_transaction = transaction_v4_with_transparent_data([prevout_input.0], []);
// convert the coinbase transaction to a version that the non-finalized state will accept
block1.transactions[0] = transaction_v4_from_coinbase(&block1.transactions[0]).into();
block1
.transactions
.push(spend_transaction.into());
let (mut state, genesis) = new_state_with_mainnet_genesis();
let previous_mem = state.mem.clone();
let block1 = Arc::new(block1).prepare();
let commit_result = state.validate_and_commit(block1);
// the block was rejected
prop_assert_eq!(
commit_result,
Err(MissingTransparentOutput {
outpoint: expected_outpoint,
location: "the non-finalized and finalized chain",
}.into())
);
prop_assert_eq!(Some((Height(0), genesis.hash)), state.best_tip());
// the non-finalized state did not change
prop_assert!(state.mem.eq_internal_state(&previous_mem));
// the finalized state does not have the UTXO
prop_assert!(state.disk.utxo(&expected_outpoint).is_none());
}
/// Make sure transparent output spends are rejected by state contextual validation,
/// if they spend an output in the same or later transaction in the block.
///
/// This test covers a potential edge case where later transactions can spend outputs
/// of previous transactions in a block, but earlier transactions can not spend later outputs.
#[test]
fn reject_earlier_transparent_spend_from_this_block(
output in TypeNameToDebug::<transparent::Output>::arbitrary(),
mut prevout_input in TypeNameToDebug::<transparent::Input>::arbitrary_with(None),
) {
zebra_test::init();
let mut block1 = zebra_test::vectors::BLOCK_MAINNET_1_BYTES
.zcash_deserialize_into::<Block>()
.expect("block should deserialize");
// create an output
let output_transaction = transaction_v4_with_transparent_data([], [output.0]);
// create a spend
let expected_outpoint = transparent::OutPoint { hash: output_transaction.hash(), index: 0 };
prevout_input.set_outpoint(expected_outpoint);
let spend_transaction = transaction_v4_with_transparent_data([prevout_input.0], []);
// convert the coinbase transaction to a version that the non-finalized state will accept
block1.transactions[0] = transaction_v4_from_coinbase(&block1.transactions[0]).into();
// put the spend transaction before the output transaction in the block
block1
.transactions
.extend([spend_transaction.into(), output_transaction.into()]);
let (mut state, genesis) = new_state_with_mainnet_genesis();
let previous_mem = state.mem.clone();
let block1 = Arc::new(block1).prepare();
let commit_result = state.validate_and_commit(block1);
// the block was rejected
prop_assert_eq!(
commit_result,
Err(EarlyTransparentSpend {
outpoint: expected_outpoint,
}.into())
);
prop_assert_eq!(Some((Height(0), genesis.hash)), state.best_tip());
// the non-finalized state did not change
prop_assert!(state.mem.eq_internal_state(&previous_mem));
// the finalized state does not have the UTXO
prop_assert!(state.disk.utxo(&expected_outpoint).is_none());
}
}
/// State associated with transparent UTXO tests.
struct TestState {
/// The pre-populated state service.
state: StateService,
/// The genesis block that has already been committed to the `state` service's
/// finalized state.
#[allow(dead_code)]
genesis: FinalizedBlock,
/// A block at height 1, that has already been committed to the `state` service.
block1: Arc<Block>,
}
/// Return a new `StateService` containing the mainnet genesis block.
/// Also returns the finalized genesis block itself.
fn new_state_with_mainnet_transparent_data(
inputs: impl IntoIterator<Item = transparent::Input>,
outputs: impl IntoIterator<Item = transparent::Output>,
use_finalized_state: bool,
) -> TestState {
let (mut state, genesis) = new_state_with_mainnet_genesis();
let previous_mem = state.mem.clone();
let mut block1 = zebra_test::vectors::BLOCK_MAINNET_1_BYTES
.zcash_deserialize_into::<Block>()
.expect("block should deserialize");
let outputs: Vec<_> = outputs.into_iter().collect();
let outputs_len: u32 = outputs
.len()
.try_into()
.expect("unexpectedly large output iterator");
let transaction = transaction_v4_with_transparent_data(inputs, outputs);
let transaction_hash = transaction.hash();
let expected_outpoints = (0..outputs_len).map(|index| transparent::OutPoint {
hash: transaction_hash,
index,
});
block1.transactions[0] = transaction_v4_from_coinbase(&block1.transactions[0]).into();
block1.transactions.push(transaction.into());
let block1 = Arc::new(block1);
if use_finalized_state {
let block1 = FinalizedBlock::from(block1.clone());
let commit_result = state.disk.commit_finalized_direct(block1.clone(), "test");
// the block was committed
assert_eq!(Some((Height(1), block1.hash)), state.best_tip());
assert!(commit_result.is_ok());
// the non-finalized state didn't change
assert!(state.mem.eq_internal_state(&previous_mem));
for expected_outpoint in expected_outpoints {
// the finalized state has the UTXOs
assert!(state.disk.utxo(&expected_outpoint).is_some());
// the non-finalized state does not have the UTXOs
assert!(state.mem.any_utxo(&expected_outpoint).is_none());
}
} else {
let block1 = block1.clone().prepare();
let commit_result = state.validate_and_commit(block1.clone());
// the block was committed
assert_eq!(commit_result, Ok(()));
assert_eq!(Some((Height(1), block1.hash)), state.best_tip());
// the block data is in the non-finalized state
assert!(!state.mem.eq_internal_state(&previous_mem));
assert_eq!(state.mem.chain_set.len(), 1);
for expected_outpoint in expected_outpoints {
// the non-finalized state has the unspent UTXOs
assert!(state
.mem
.chain_set
.iter()
.next()
.unwrap()
.unspent_utxos()
.contains_key(&expected_outpoint));
// the finalized state does not have the UTXOs
assert!(state.disk.utxo(&expected_outpoint).is_none());
}
}
TestState {
state,
genesis,
block1,
}
}
/// Return a `Transaction::V4`, using transparent `inputs` and `outputs`,
///
/// Other fields have empty or default values.
fn transaction_v4_with_transparent_data(
inputs: impl IntoIterator<Item = transparent::Input>,
outputs: impl IntoIterator<Item = transparent::Output>,
) -> Transaction {
let inputs: Vec<_> = inputs.into_iter().collect();
let outputs: Vec<_> = outputs.into_iter().collect();
// do any fixups here, if required
Transaction::V4 {
inputs,
outputs,
lock_time: LockTime::min_lock_time(),
expiry_height: Height(0),
joinsplit_data: None,
sapling_shielded_data: None,
}
}

View File

@ -0,0 +1,111 @@
//! Consensus rule checks for the finalized state.
use std::collections::{HashMap, HashSet};
use zebra_chain::transparent;
use crate::{
service::finalized_state::FinalizedState,
PreparedBlock,
ValidateContextError::{
self, DuplicateTransparentSpend, EarlyTransparentSpend, MissingTransparentOutput,
},
};
/// Reject double-spends of transparent outputs:
/// - duplicate spends that are both in this block,
/// - spends of an output that hasn't been created yet,
/// (in linear chain and transaction order), and
/// - spends of an output that was spent by a previous block.
///
/// Also rejects attempts to spend UTXOs that were never created (in this chain).
///
/// "each output of a particular transaction
/// can only be used as an input once in the block chain.
/// Any subsequent reference is a forbidden double spend-
/// an attempt to spend the same satoshis twice."
///
/// https://developer.bitcoin.org/devguide/block_chain.html#introduction
///
/// "Any input within this block can spend an output which also appears in this block
/// (assuming the spend is otherwise valid).
/// However, the TXID corresponding to the output must be placed at some point
/// before the TXID corresponding to the input.
/// This ensures that any program parsing block chain transactions linearly
/// will encounter each output before it is used as an input."
///
/// https://developer.bitcoin.org/reference/block_chain.html#merkle-trees
pub fn transparent_double_spends(
prepared: &PreparedBlock,
non_finalized_chain_unspent_utxos: &HashMap<transparent::OutPoint, transparent::Utxo>,
non_finalized_chain_spent_utxos: &HashSet<transparent::OutPoint>,
finalized_state: &FinalizedState,
) -> Result<(), ValidateContextError> {
let mut block_spends = HashSet::new();
for (spend_tx_index_in_block, transaction) in prepared.block.transactions.iter().enumerate() {
let spends = transaction.inputs().iter().filter_map(|input| match input {
transparent::Input::PrevOut { outpoint, .. } => Some(outpoint),
// Coinbase inputs represent new coins,
// so there are no UTXOs to mark as spent.
transparent::Input::Coinbase { .. } => None,
});
for spend in spends {
if !block_spends.insert(*spend) {
// reject in-block duplicate spends
return Err(DuplicateTransparentSpend {
outpoint: *spend,
location: "the same block",
});
}
// check spends occur in chain order
//
// because we are in the non-finalized state, we need to check spends within the same block,
// spent non-finalized UTXOs, and unspent non-finalized and finalized UTXOs.
if let Some(output) = prepared.new_outputs.get(spend) {
// reject the spend if it uses an output from this block,
// but the output was not created by an earlier transaction
//
// we know the spend is invalid, because transaction IDs are unique
//
// (transaction IDs also commit to transaction inputs,
// so it should be cryptographically impossible for a transaction
// to spend its own outputs)
if output.tx_index_in_block >= spend_tx_index_in_block {
return Err(EarlyTransparentSpend { outpoint: *spend });
} else {
// a unique spend of a previous transaction's output is ok
continue;
}
}
if non_finalized_chain_spent_utxos.contains(spend) {
// reject the spend if its UTXO is already spent in the
// non-finalized parent chain
return Err(DuplicateTransparentSpend {
outpoint: *spend,
location: "the non-finalized chain",
});
}
if !non_finalized_chain_unspent_utxos.contains_key(spend)
&& finalized_state.utxo(spend).is_none()
{
// we don't keep spent UTXOs in the finalized state,
// so all we can say is that it's missing from both
// the finalized and non-finalized chains
// (it might have been spent in the finalized state,
// or it might never have existed in this chain)
return Err(MissingTransparentOutput {
outpoint: *spend,
location: "the non-finalized and finalized chain",
});
}
}
}
Ok(())
}

View File

@ -25,7 +25,7 @@ use crate::{FinalizedBlock, HashOrHeight, PreparedBlock, ValidateContextError};
use self::chain::Chain;
use super::finalized_state::FinalizedState;
use super::{check, finalized_state::FinalizedState};
/// The state of the chains in memory, incuding queued blocks.
#[derive(Debug, Clone)]
@ -169,9 +169,14 @@ impl NonFinalizedState {
&self,
parent_chain: Chain,
prepared: PreparedBlock,
_finalized_state: &FinalizedState,
finalized_state: &FinalizedState,
) -> Result<Chain, ValidateContextError> {
// TODO: insert validation of `prepared` block and `parent_chain` here
check::utxo::transparent_double_spends(
&prepared,
&parent_chain.unspent_utxos(),
&parent_chain.spent_utxos,
finalized_state,
)?;
parent_chain.push(prepared)
}

View File

@ -27,10 +27,10 @@ pub struct Chain {
///
/// Note that these UTXOs may not be unspent.
/// Outputs can be spent by later transactions or blocks in the chain.
pub(super) created_utxos: HashMap<transparent::OutPoint, transparent::Utxo>,
pub(crate) created_utxos: HashMap<transparent::OutPoint, transparent::Utxo>,
/// The [`OutPoint`]s spent by `blocks`,
/// including those created by earlier transactions or blocks in the chain.
pub(super) spent_utxos: HashSet<transparent::OutPoint>,
pub(crate) spent_utxos: HashSet<transparent::OutPoint>,
/// The sprout anchors created by `blocks`.
///
@ -183,6 +183,17 @@ impl Chain {
pub fn is_empty(&self) -> bool {
self.blocks.is_empty()
}
/// Returns the unspent transaction outputs (UTXOs) in this non-finalized chain.
///
/// Callers should also check the finalized state for available UTXOs.
/// If UTXOs remain unspent when a block is finalized, they are stored in the finalized state,
/// and removed from the relevant chain(s).
pub fn unspent_utxos(&self) -> HashMap<transparent::OutPoint, transparent::Utxo> {
let mut unspent_utxos = self.created_utxos.clone();
unspent_utxos.retain(|out_point, _utxo| !self.spent_utxos.contains(out_point));
unspent_utxos
}
}
/// Helper trait to organize inverse operations done on the `Chain` type. Used to