diff --git a/zebra-chain/src/transaction/arbitrary.rs b/zebra-chain/src/transaction/arbitrary.rs index d97f2bbdd62..27b92a7b97d 100644 --- a/zebra-chain/src/transaction/arbitrary.rs +++ b/zebra-chain/src/transaction/arbitrary.rs @@ -930,16 +930,7 @@ pub fn test_transactions( Network::Testnet => zebra_test::vectors::TESTNET_BLOCKS.iter(), }; - blocks.flat_map(|(&block_height, &block_bytes)| { - let block = block_bytes - .zcash_deserialize_into::() - .expect("block is structurally valid"); - - block - .transactions - .into_iter() - .map(move |transaction| (block::Height(block_height), transaction)) - }) + transactions_from_blocks(blocks) } /// Generate an iterator over fake V5 transactions. @@ -950,18 +941,23 @@ pub fn fake_v5_transactions_for_network<'b>( network: Network, blocks: impl DoubleEndedIterator + 'b, ) -> impl DoubleEndedIterator + 'b { - blocks.flat_map(move |(height, original_bytes)| { - let original_block = original_bytes + transactions_from_blocks(blocks) + .map(move |(height, transaction)| transaction_to_fake_v5(&transaction, network, height)) +} + +/// Generate an iterator over ([`block::Height`], [`Arc`]). +pub fn transactions_from_blocks<'a>( + blocks: impl DoubleEndedIterator + 'a, +) -> impl DoubleEndedIterator)> + 'a { + blocks.flat_map(|(&block_height, &block_bytes)| { + let block = block_bytes .zcash_deserialize_into::() .expect("block is structurally valid"); - original_block + block .transactions .into_iter() - .map(move |transaction| { - transaction_to_fake_v5(&transaction, network, block::Height(*height)) - }) - .map(Transaction::from) + .map(move |transaction| (block::Height(block_height), transaction)) }) } diff --git a/zebra-consensus/src/error.rs b/zebra-consensus/src/error.rs index 4e08620b729..6a40f032dbf 100644 --- a/zebra-consensus/src/error.rs +++ b/zebra-consensus/src/error.rs @@ -177,6 +177,9 @@ pub enum TransactionError { #[error("must have at least one active orchard flag")] NotEnoughFlags, + + #[error("could not find a mempool transaction input UTXO in the best chain")] + TransparentInputNotFound, } impl From for TransactionError { diff --git a/zebra-consensus/src/transaction.rs b/zebra-consensus/src/transaction.rs index f992bdda3d8..7a2f6cd565a 100644 --- a/zebra-consensus/src/transaction.rs +++ b/zebra-consensus/src/transaction.rs @@ -357,8 +357,9 @@ where // https://zips.z.cash/zip-0213#specification // Load spent UTXOs from state. + // TODO: Make this a method of `Request` and replace `tx.clone()` with `self.transaction()`? let (spent_utxos, spent_outputs) = - Self::spent_utxos(tx.clone(), req.known_utxos(), state).await?; + Self::spent_utxos(tx.clone(), req.known_utxos(), req.is_mempool(), state).await?; let cached_ffi_transaction = Arc::new(CachedFfiTransaction::new(tx.clone(), spent_outputs)); @@ -462,6 +463,7 @@ where async fn spent_utxos( tx: Arc, known_utxos: Arc>, + is_mempool: bool, state: Timeout, ) -> Result< ( @@ -476,9 +478,20 @@ where for input in inputs { if let transparent::Input::PrevOut { outpoint, .. } = input { tracing::trace!("awaiting outpoint lookup"); + // Currently, Zebra only supports known UTXOs in block transactions. + // But it might support them in the mempool in future. let utxo = if let Some(output) = known_utxos.get(outpoint) { tracing::trace!("UXTO in known_utxos, discarding query"); output.utxo.clone() + } else if is_mempool { + let query = state + .clone() + .oneshot(zs::Request::UnspentBestChainUtxo(*outpoint)); + if let zebra_state::Response::UnspentBestChainUtxo(utxo) = query.await? { + utxo.ok_or(TransactionError::TransparentInputNotFound)? + } else { + unreachable!("UnspentBestChainUtxo always responds with Option") + } } else { let query = state .clone() diff --git a/zebra-consensus/src/transaction/tests.rs b/zebra-consensus/src/transaction/tests.rs index 21f579884cd..71061c9bc49 100644 --- a/zebra-consensus/src/transaction/tests.rs +++ b/zebra-consensus/src/transaction/tests.rs @@ -19,12 +19,15 @@ use zebra_chain::{ transaction::{ arbitrary::{ fake_v5_transactions_for_network, insert_fake_orchard_shielded_data, test_transactions, + transactions_from_blocks, }, Hash, HashType, JoinSplitData, LockTime, Transaction, }, transparent::{self, CoinbaseData}, }; +use zebra_test::mock_service::MockService; + use crate::error::TransactionError; use super::{check, Request, Verifier}; @@ -177,6 +180,92 @@ fn v5_transaction_with_no_inputs_fails_validation() { ); } +#[tokio::test] +async fn mempool_request_with_missing_input_is_rejected() { + let mut state: MockService<_, _, _, _> = MockService::build().for_prop_tests(); + let verifier = Verifier::new(Network::Mainnet, state.clone()); + + let (height, tx) = transactions_from_blocks(zebra_test::vectors::MAINNET_BLOCKS.iter()) + .find(|(_, tx)| !(tx.is_coinbase() || tx.inputs().is_empty())) + .expect("At least one non-coinbase transaction with transparent inputs in test vectors"); + + let expected_state_request = zebra_state::Request::UnspentBestChainUtxo(match tx.inputs()[0] { + transparent::Input::PrevOut { outpoint, .. } => outpoint, + transparent::Input::Coinbase { .. } => panic!("requires a non-coinbase transaction"), + }); + + tokio::spawn(async move { + state + .expect_request(expected_state_request) + .await + .expect("verifier should call mock state service") + .respond(zebra_state::Response::UnspentBestChainUtxo(None)); + }); + + let verifier_response = verifier + .oneshot(Request::Mempool { + transaction: tx.into(), + height, + }) + .await; + + assert_eq!( + verifier_response, + Err(TransactionError::TransparentInputNotFound) + ); +} + +#[tokio::test] +async fn mempool_request_with_present_input_is_accepted() { + let mut state: MockService<_, _, _, _> = MockService::build().for_prop_tests(); + let verifier = Verifier::new(Network::Mainnet, state.clone()); + + let height = NetworkUpgrade::Canopy + .activation_height(Network::Mainnet) + .expect("Canopy activation height is specified"); + let fund_height = (height - 1).expect("fake source fund block height is too small"); + let (input, output, known_utxos) = mock_transparent_transfer(fund_height, true, 0); + + // Create a non-coinbase V4 tx with the last valid expiry height. + let tx = Transaction::V4 { + inputs: vec![input], + outputs: vec![output], + lock_time: LockTime::unlocked(), + expiry_height: height, + joinsplit_data: None, + sapling_shielded_data: None, + }; + + let input_outpoint = match tx.inputs()[0] { + transparent::Input::PrevOut { outpoint, .. } => outpoint, + transparent::Input::Coinbase { .. } => panic!("requires a non-coinbase transaction"), + }; + + tokio::spawn(async move { + state + .expect_request(zebra_state::Request::UnspentBestChainUtxo(input_outpoint)) + .await + .expect("verifier should call mock state service") + .respond(zebra_state::Response::UnspentBestChainUtxo( + known_utxos + .get(&input_outpoint) + .map(|utxo| utxo.utxo.clone()), + )); + }); + + let verifier_response = verifier + .oneshot(Request::Mempool { + transaction: tx.into(), + height, + }) + .await; + + assert!( + verifier_response.is_ok(), + "expected successful verification, got: {verifier_response:?}" + ); +} + #[test] fn v5_transaction_with_no_outputs_fails_validation() { let transaction = fake_v5_transactions_for_network( diff --git a/zebra-state/src/request.rs b/zebra-state/src/request.rs index bca3f01409b..bad181453ab 100644 --- a/zebra-state/src/request.rs +++ b/zebra-state/src/request.rs @@ -458,6 +458,12 @@ pub enum Request { /// * [`Response::Transaction(None)`](Response::Transaction) otherwise. Transaction(transaction::Hash), + /// Looks up a UTXO identified by the given [`OutPoint`](transparent::OutPoint), + /// returning `None` immediately if it is unknown. + /// + /// Checks verified blocks in the finalized chain and the _best_ non-finalized chain. + UnspentBestChainUtxo(transparent::OutPoint), + /// Looks up a block by hash or height in the current best chain. /// /// Returns @@ -545,6 +551,7 @@ impl Request { Request::Tip => "tip", Request::BlockLocator => "block_locator", Request::Transaction(_) => "transaction", + Request::UnspentBestChainUtxo { .. } => "unspent_best_chain_utxo", Request::Block(_) => "block", Request::FindBlockHashes { .. } => "find_block_hashes", Request::FindBlockHeaders { .. } => "find_block_headers", @@ -613,10 +620,7 @@ pub enum ReadRequest { /// returning `None` immediately if it is unknown. /// /// Checks verified blocks in the finalized chain and the _best_ non-finalized chain. - /// - /// This request is purely informational, there is no guarantee that - /// the UTXO remains unspent in the best chain. - BestChainUtxo(transparent::OutPoint), + UnspentBestChainUtxo(transparent::OutPoint), /// Looks up a UTXO identified by the given [`OutPoint`](transparent::OutPoint), /// returning `None` immediately if it is unknown. @@ -741,7 +745,7 @@ impl ReadRequest { ReadRequest::Block(_) => "block", ReadRequest::Transaction(_) => "transaction", ReadRequest::TransactionIdsForBlock(_) => "transaction_ids_for_block", - ReadRequest::BestChainUtxo { .. } => "best_chain_utxo", + ReadRequest::UnspentBestChainUtxo { .. } => "unspent_best_chain_utxo", ReadRequest::AnyChainUtxo { .. } => "any_chain_utxo", ReadRequest::BlockLocator => "block_locator", ReadRequest::FindBlockHashes { .. } => "find_block_hashes", @@ -778,6 +782,9 @@ impl TryFrom for ReadRequest { Request::Block(hash_or_height) => Ok(ReadRequest::Block(hash_or_height)), Request::Transaction(tx_hash) => Ok(ReadRequest::Transaction(tx_hash)), + Request::UnspentBestChainUtxo(outpoint) => { + Ok(ReadRequest::UnspentBestChainUtxo(outpoint)) + } Request::BlockLocator => Ok(ReadRequest::BlockLocator), Request::FindBlockHashes { known_blocks, stop } => { diff --git a/zebra-state/src/response.rs b/zebra-state/src/response.rs index 7ca08b3fd60..f6d184d71f8 100644 --- a/zebra-state/src/response.rs +++ b/zebra-state/src/response.rs @@ -36,6 +36,9 @@ pub enum Response { /// Response to [`Request::Transaction`] with the specified transaction. Transaction(Option>), + /// Response to [`Request::UnspentBestChainUtxo`] with the UTXO + UnspentBestChainUtxo(Option), + /// Response to [`Request::Block`] with the specified block. Block(Option>), @@ -81,12 +84,9 @@ pub enum ReadResponse { /// The response to a `FindBlockHeaders` request. BlockHeaders(Vec), - /// The response to a `BestChainUtxo` request, from verified blocks in the + /// The response to a `UnspentBestChainUtxo` request, from verified blocks in the /// _best_ non-finalized chain, or the finalized chain. - /// - /// This response is purely informational, there is no guarantee that - /// the UTXO remains unspent in the best chain. - BestChainUtxo(Option), + UnspentBestChainUtxo(Option), /// The response to an `AnyChainUtxo` request, from verified blocks in /// _any_ non-finalized chain, or the finalized chain. @@ -127,6 +127,8 @@ impl TryFrom for Response { ReadResponse::Transaction(tx_and_height) => { Ok(Response::Transaction(tx_and_height.map(|(tx, _height)| tx))) } + ReadResponse::UnspentBestChainUtxo(utxo) => Ok(Response::UnspentBestChainUtxo(utxo)), + ReadResponse::AnyChainUtxo(_) => Err("ReadService does not track pending UTXOs. \ Manually unwrap the response, and handle pending UTXOs."), @@ -136,7 +138,6 @@ impl TryFrom for Response { ReadResponse::BlockHeaders(headers) => Ok(Response::BlockHeaders(headers)), ReadResponse::TransactionIdsForBlock(_) - | ReadResponse::BestChainUtxo(_) | ReadResponse::SaplingTree(_) | ReadResponse::OrchardTree(_) | ReadResponse::AddressBalance(_) diff --git a/zebra-state/src/service.rs b/zebra-state/src/service.rs index d9534008c8e..45f50260545 100644 --- a/zebra-state/src/service.rs +++ b/zebra-state/src/service.rs @@ -1016,6 +1016,7 @@ impl Service for StateService { | Request::Tip | Request::BlockLocator | Request::Transaction(_) + | Request::UnspentBestChainUtxo(_) | Request::Block(_) | Request::FindBlockHashes { .. } | Request::FindBlockHeaders { .. } => { @@ -1212,7 +1213,7 @@ impl Service for ReadStateService { } // Currently unused. - ReadRequest::BestChainUtxo(outpoint) => { + ReadRequest::UnspentBestChainUtxo(outpoint) => { let timer = CodeTimer::start(); let state = self.clone(); @@ -1222,17 +1223,21 @@ impl Service for ReadStateService { span.in_scope(move || { let utxo = state.non_finalized_state_receiver.with_watch_data( |non_finalized_state| { - read::utxo(non_finalized_state.best_chain(), &state.db, outpoint) + read::unspent_utxo( + non_finalized_state.best_chain(), + &state.db, + outpoint, + ) }, ); // The work is done in the future. - timer.finish(module_path!(), line!(), "ReadRequest::BestChainUtxo"); + timer.finish(module_path!(), line!(), "ReadRequest::UnspentBestChainUtxo"); - Ok(ReadResponse::BestChainUtxo(utxo)) + Ok(ReadResponse::UnspentBestChainUtxo(utxo)) }) }) - .map(|join_result| join_result.expect("panic in ReadRequest::BestChainUtxo")) + .map(|join_result| join_result.expect("panic in ReadRequest::UnspentBestChainUtxo")) .boxed() } diff --git a/zebra-state/src/service/read.rs b/zebra-state/src/service/read.rs index f39e2c543a3..4be400a8894 100644 --- a/zebra-state/src/service/read.rs +++ b/zebra-state/src/service/read.rs @@ -27,7 +27,9 @@ pub use address::{ tx_id::transparent_tx_ids, utxo::{address_utxos, AddressUtxos, ADDRESS_HEIGHTS_FULL_RANGE}, }; -pub use block::{any_utxo, block, block_header, transaction, transaction_hashes_for_block, utxo}; +pub use block::{ + any_utxo, block, block_header, transaction, transaction_hashes_for_block, unspent_utxo, utxo, +}; pub use find::{ best_tip, block_locator, chain_contains_hash, depth, find_chain_hashes, find_chain_headers, hash_by_height, height_by_hash, tip, tip_height, diff --git a/zebra-state/src/service/read/block.rs b/zebra-state/src/service/read/block.rs index 54eb485c367..c07de18f278 100644 --- a/zebra-state/src/service/read/block.rs +++ b/zebra-state/src/service/read/block.rs @@ -141,6 +141,22 @@ where .or_else(|| db.utxo(&outpoint).map(|utxo| utxo.utxo)) } +/// Returns the [`Utxo`] for [`transparent::OutPoint`], if it exists and is unspent in the +/// non-finalized `chain` or finalized `db`. +pub fn unspent_utxo( + chain: Option, + db: &ZebraDb, + outpoint: transparent::OutPoint, +) -> Option +where + C: AsRef, +{ + match chain { + Some(chain) if chain.as_ref().spent_utxos.contains(&outpoint) => None, + chain => utxo(chain, db, outpoint), + } +} + /// Returns the [`Utxo`] for [`transparent::OutPoint`], if it exists in any chain /// in the `non_finalized_state`, or in the finalized `db`. ///