diff --git a/zebra-chain/src/block/arbitrary.rs b/zebra-chain/src/block/arbitrary.rs index 5091fdfca48..eb813f1fbbf 100644 --- a/zebra-chain/src/block/arbitrary.rs +++ b/zebra-chain/src/block/arbitrary.rs @@ -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::::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()) }) diff --git a/zebra-chain/src/transaction.rs b/zebra-chain/src/transaction.rs index 908e1a74b90..e0f738b9e84 100644 --- a/zebra-chain/src/transaction.rs +++ b/zebra-chain/src/transaction.rs @@ -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 { + 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 { diff --git a/zebra-chain/src/transparent.rs b/zebra-chain/src/transparent.rs index 7534651e485..6200b03069d 100644 --- a/zebra-chain/src/transparent.rs +++ b/zebra-chain/src/transparent.rs @@ -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 { + 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 diff --git a/zebra-state/src/error.rs b/zebra-state/src/error.rs index 56ff25a7064..4965dbad56d 100644 --- a/zebra-state/src/error.rs +++ b/zebra-state/src/error.rs @@ -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 { diff --git a/zebra-state/src/service/arbitrary.rs b/zebra-state/src/service/arbitrary.rs index 3b85151c9b3..dae3f266148 100644 --- a/zebra-state/src/service/arbitrary.rs +++ b/zebra-state/src/service/arbitrary.rs @@ -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::>() + .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, + } +} diff --git a/zebra-state/src/service/check.rs b/zebra-state/src/service/check.rs index 6b952cbf429..4bde7dd8d2b 100644 --- a/zebra-state/src/service/check.rs +++ b/zebra-state/src/service/check.rs @@ -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; diff --git a/zebra-state/src/service/check/tests.rs b/zebra-state/src/service/check/tests.rs index 28bad070b43..42ab35384ff 100644 --- a/zebra-state/src/service/check/tests.rs +++ b/zebra-state/src/service/check/tests.rs @@ -1,4 +1,5 @@ //! Tests for state contextual validation checks. mod nullifier; +mod utxo; mod vectors; diff --git a/zebra-state/src/service/check/tests/nullifier.rs b/zebra-state/src/service/check/tests/nullifier.rs index 5d6acff3db4..66ff781d01c 100644 --- a/zebra-state/src/service/check/tests/nullifier.rs +++ b/zebra-state/src/service/check/tests/nullifier.rs @@ -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::>() - .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, @@ -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, - } -} diff --git a/zebra-state/src/service/check/tests/utxo.rs b/zebra-state/src/service/check/tests/utxo.rs new file mode 100644 index 00000000000..b1aaebbb186 --- /dev/null +++ b/zebra-state/src/service/check/tests/utxo.rs @@ -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::::arbitrary(), + mut prevout_input in TypeNameToDebug::::arbitrary_with(None), + use_finalized_state in any::(), + ) { + zebra_test::init(); + + let mut block1 = zebra_test::vectors::BLOCK_MAINNET_1_BYTES + .zcash_deserialize_into::() + .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::::arbitrary(), + mut prevout_input in TypeNameToDebug::::arbitrary_with(None), + use_finalized_state_output in any::(), + mut use_finalized_state_spend in any::(), + ) { + 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::() + .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::::arbitrary(), + mut prevout_input1 in TypeNameToDebug::::arbitrary_with(None), + mut prevout_input2 in TypeNameToDebug::::arbitrary_with(None), + ) { + zebra_test::init(); + + let mut block1 = zebra_test::vectors::BLOCK_MAINNET_1_BYTES + .zcash_deserialize_into::() + .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::::arbitrary(), + mut prevout_input1 in TypeNameToDebug::::arbitrary_with(None), + mut prevout_input2 in TypeNameToDebug::::arbitrary_with(None), + use_finalized_state_output in any::(), + ) { + zebra_test::init(); + + let mut block2 = zebra_test::vectors::BLOCK_MAINNET_2_BYTES + .zcash_deserialize_into::() + .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::::arbitrary(), + mut prevout_input1 in TypeNameToDebug::::arbitrary_with(None), + mut prevout_input2 in TypeNameToDebug::::arbitrary_with(None), + use_finalized_state_output in any::(), + ) { + zebra_test::init(); + + let mut block2 = zebra_test::vectors::BLOCK_MAINNET_2_BYTES + .zcash_deserialize_into::() + .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::::arbitrary(), + mut prevout_input1 in TypeNameToDebug::::arbitrary_with(None), + mut prevout_input2 in TypeNameToDebug::::arbitrary_with(None), + use_finalized_state_output in any::(), + mut use_finalized_state_spend in any::(), + ) { + 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::() + .expect("block should deserialize"); + let mut block3 = zebra_test::vectors::BLOCK_MAINNET_3_BYTES + .zcash_deserialize_into::() + .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::::arbitrary_with(None), + ) { + zebra_test::init(); + + let mut block1 = zebra_test::vectors::BLOCK_MAINNET_1_BYTES + .zcash_deserialize_into::() + .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::::arbitrary(), + mut prevout_input in TypeNameToDebug::::arbitrary_with(None), + ) { + zebra_test::init(); + + let mut block1 = zebra_test::vectors::BLOCK_MAINNET_1_BYTES + .zcash_deserialize_into::() + .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, +} + +/// 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, + outputs: impl IntoIterator, + 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::() + .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, + outputs: impl IntoIterator, +) -> 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, + } +} diff --git a/zebra-state/src/service/check/utxo.rs b/zebra-state/src/service/check/utxo.rs new file mode 100644 index 00000000000..faf80920594 --- /dev/null +++ b/zebra-state/src/service/check/utxo.rs @@ -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, + non_finalized_chain_spent_utxos: &HashSet, + 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(()) +} diff --git a/zebra-state/src/service/non_finalized_state.rs b/zebra-state/src/service/non_finalized_state.rs index 9956c5868fa..fa3009d631d 100644 --- a/zebra-state/src/service/non_finalized_state.rs +++ b/zebra-state/src/service/non_finalized_state.rs @@ -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 { - // 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) } diff --git a/zebra-state/src/service/non_finalized_state/chain.rs b/zebra-state/src/service/non_finalized_state/chain.rs index ef6eff5a403..70f34357269 100644 --- a/zebra-state/src/service/non_finalized_state/chain.rs +++ b/zebra-state/src/service/non_finalized_state/chain.rs @@ -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, + pub(crate) created_utxos: HashMap, /// The [`OutPoint`]s spent by `blocks`, /// including those created by earlier transactions or blocks in the chain. - pub(super) spent_utxos: HashSet, + pub(crate) spent_utxos: HashSet, /// 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 { + 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