use std::{collections::HashMap, convert::TryFrom, convert::TryInto, sync::Arc}; use tower::{service_fn, ServiceExt}; use zebra_chain::{ amount::{Amount, NonNegative}, block, orchard, parameters::{Network, NetworkUpgrade}, primitives::{ed25519, x25519, Groth16Proof}, sapling, serialization::ZcashDeserialize, sprout, transaction::{ arbitrary::{ fake_v5_transactions_for_network, insert_fake_orchard_shielded_data, test_transactions, }, Hash, HashType, JoinSplitData, LockTime, Transaction, }, transparent::{self, CoinbaseData}, }; use super::{check, Request, Verifier}; use crate::{error::TransactionError, script}; use color_eyre::eyre::Report; #[test] fn v5_fake_transactions() -> Result<(), Report> { zebra_test::init(); let networks = vec![ (Network::Mainnet, zebra_test::vectors::MAINNET_BLOCKS.iter()), (Network::Testnet, zebra_test::vectors::TESTNET_BLOCKS.iter()), ]; for (network, blocks) in networks { for transaction in fake_v5_transactions_for_network(network, blocks) { match check::has_inputs_and_outputs(&transaction) { Ok(()) => (), Err(TransactionError::NoInputs) | Err(TransactionError::NoOutputs) => (), Err(_) => panic!("error must be NoInputs or NoOutputs"), }; // make sure there are no joinsplits nor spends in coinbase check::coinbase_tx_no_prevout_joinsplit_spend(&transaction)?; // validate the sapling shielded data if transaction.version() == 5 { for spend in transaction.sapling_spends_per_anchor() { check::spend_cv_rk_not_small_order(&spend)?; } for output in transaction.sapling_outputs() { check::output_cv_epk_not_small_order(output)?; } } else { panic!("we should have no tx other than 5"); } } } Ok(()) } #[test] fn fake_v5_transaction_with_orchard_actions_has_inputs_and_outputs() { // Find a transaction with no inputs or outputs to use as base let mut transaction = fake_v5_transactions_for_network( Network::Mainnet, zebra_test::vectors::MAINNET_BLOCKS.iter(), ) .rev() .find(|transaction| { transaction.inputs().is_empty() && transaction.outputs().is_empty() && transaction.sapling_spends_per_anchor().next().is_none() && transaction.sapling_outputs().next().is_none() && transaction.joinsplit_count() == 0 }) .expect("At least one fake V5 transaction with no inputs and no outputs"); // Insert fake Orchard shielded data to the transaction, which has at least one action (this is // guaranteed structurally by `orchard::ShieldedData`) insert_fake_orchard_shielded_data(&mut transaction); // The check will fail if the transaction has no flags assert_eq!( check::has_inputs_and_outputs(&transaction), Err(TransactionError::NoInputs) ); // If we add ENABLE_SPENDS flag it will pass the inputs check but fails with the outputs // TODO: Avoid new calls to `insert_fake_orchard_shielded_data` for each check #2409. let shielded_data = insert_fake_orchard_shielded_data(&mut transaction); shielded_data.flags = orchard::Flags::ENABLE_SPENDS; assert_eq!( check::has_inputs_and_outputs(&transaction), Err(TransactionError::NoOutputs) ); // If we add ENABLE_OUTPUTS flag it will pass the outputs check but fails with the inputs let shielded_data = insert_fake_orchard_shielded_data(&mut transaction); shielded_data.flags = orchard::Flags::ENABLE_OUTPUTS; assert_eq!( check::has_inputs_and_outputs(&transaction), Err(TransactionError::NoInputs) ); // Finally make it valid by adding both required flags let shielded_data = insert_fake_orchard_shielded_data(&mut transaction); shielded_data.flags = orchard::Flags::ENABLE_SPENDS | orchard::Flags::ENABLE_OUTPUTS; assert!(check::has_inputs_and_outputs(&transaction).is_ok()); } #[test] fn v5_transaction_with_no_inputs_fails_validation() { let transaction = fake_v5_transactions_for_network( Network::Mainnet, zebra_test::vectors::MAINNET_BLOCKS.iter(), ) .rev() .find(|transaction| { transaction.inputs().is_empty() && transaction.sapling_spends_per_anchor().next().is_none() && transaction.orchard_actions().next().is_none() && transaction.joinsplit_count() == 0 && (!transaction.outputs().is_empty() || transaction.sapling_outputs().next().is_some()) }) .expect("At least one fake v5 transaction with no inputs in the test vectors"); assert_eq!( check::has_inputs_and_outputs(&transaction), Err(TransactionError::NoInputs) ); } #[test] fn v5_transaction_with_no_outputs_fails_validation() { let transaction = fake_v5_transactions_for_network( Network::Mainnet, zebra_test::vectors::MAINNET_BLOCKS.iter(), ) .rev() .find(|transaction| { transaction.outputs().is_empty() && transaction.sapling_outputs().next().is_none() && transaction.orchard_actions().next().is_none() && transaction.joinsplit_count() == 0 && (!transaction.inputs().is_empty() || transaction.sapling_spends_per_anchor().next().is_some()) }) .expect("At least one fake v5 transaction with no outputs in the test vectors"); assert_eq!( check::has_inputs_and_outputs(&transaction), Err(TransactionError::NoOutputs) ); } #[test] fn v5_coinbase_transaction_without_enable_spends_flag_passes_validation() { let mut transaction = fake_v5_transactions_for_network( Network::Mainnet, zebra_test::vectors::MAINNET_BLOCKS.iter(), ) .rev() .find(|transaction| transaction.has_valid_coinbase_transaction_inputs()) .expect("At least one fake V5 coinbase transaction in the test vectors"); insert_fake_orchard_shielded_data(&mut transaction); assert!(check::coinbase_tx_no_prevout_joinsplit_spend(&transaction).is_ok(),); } #[test] fn v5_coinbase_transaction_with_enable_spends_flag_fails_validation() { let mut transaction = fake_v5_transactions_for_network( Network::Mainnet, zebra_test::vectors::MAINNET_BLOCKS.iter(), ) .rev() .find(|transaction| transaction.has_valid_coinbase_transaction_inputs()) .expect("At least one fake V5 coinbase transaction in the test vectors"); let shielded_data = insert_fake_orchard_shielded_data(&mut transaction); shielded_data.flags = orchard::Flags::ENABLE_SPENDS; assert_eq!( check::coinbase_tx_no_prevout_joinsplit_spend(&transaction), Err(TransactionError::CoinbaseHasEnableSpendsOrchard) ); } #[tokio::test] async fn v5_transaction_is_rejected_before_nu5_activation() { const V5_TRANSACTION_VERSION: u32 = 5; let canopy = NetworkUpgrade::Canopy; let networks = vec![ (Network::Mainnet, zebra_test::vectors::MAINNET_BLOCKS.iter()), (Network::Testnet, zebra_test::vectors::TESTNET_BLOCKS.iter()), ]; for (network, blocks) in networks { let state_service = service_fn(|_| async { unreachable!("Service should not be called") }); let script_verifier = script::Verifier::new(state_service); let verifier = Verifier::new(network, script_verifier); let transaction = fake_v5_transactions_for_network(network, blocks) .rev() .next() .expect("At least one fake V5 transaction in the test vectors"); let result = verifier .oneshot(Request::Block { transaction: Arc::new(transaction), known_utxos: Arc::new(HashMap::new()), height: canopy .activation_height(network) .expect("Canopy activation height is specified"), }) .await; assert_eq!( result, Err(TransactionError::UnsupportedByNetworkUpgrade( V5_TRANSACTION_VERSION, canopy )) ); } } #[tokio::test] // TODO: Remove `should_panic` once the NU5 activation height for mainnet has been // defined. #[should_panic] async fn v5_transaction_is_accepted_after_nu5_activation_mainnet() { v5_transaction_is_accepted_after_nu5_activation_for_network(Network::Mainnet).await } #[tokio::test] async fn v5_transaction_is_accepted_after_nu5_activation_testnet() { v5_transaction_is_accepted_after_nu5_activation_for_network(Network::Testnet).await } async fn v5_transaction_is_accepted_after_nu5_activation_for_network(network: Network) { let nu5 = NetworkUpgrade::Nu5; let blocks = match network { Network::Mainnet => zebra_test::vectors::MAINNET_BLOCKS.iter(), Network::Testnet => zebra_test::vectors::TESTNET_BLOCKS.iter(), }; let state_service = service_fn(|_| async { unreachable!("Service should not be called") }); let script_verifier = script::Verifier::new(state_service); let verifier = Verifier::new(network, script_verifier); let transaction = fake_v5_transactions_for_network(network, blocks) .rev() .next() .expect("At least one fake V5 transaction in the test vectors"); let expected_hash = transaction.unmined_id(); let result = verifier .oneshot(Request::Block { transaction: Arc::new(transaction), known_utxos: Arc::new(HashMap::new()), height: nu5 .activation_height(network) .expect("NU5 activation height is specified"), }) .await; assert_eq!( result.expect("unexpected error response").tx_id(), expected_hash ); } /// Test if V4 transaction with transparent funds is accepted. #[tokio::test] async fn v4_transaction_with_transparent_transfer_is_accepted() { let network = Network::Mainnet; let canopy_activation_height = NetworkUpgrade::Canopy .activation_height(network) .expect("Canopy activation height is specified"); let transaction_block_height = (canopy_activation_height + 10).expect("transaction block height is too large"); let fake_source_fund_height = (transaction_block_height - 1).expect("fake source fund block height is too small"); // Create a fake transparent transfer that should succeed let (input, output, known_utxos) = mock_transparent_transfer(fake_source_fund_height, true); // Create a V4 transaction let transaction = Transaction::V4 { inputs: vec![input], outputs: vec![output], lock_time: LockTime::Height(block::Height(0)), expiry_height: (transaction_block_height + 1).expect("expiry height is too large"), joinsplit_data: None, sapling_shielded_data: None, }; let transaction_hash = transaction.unmined_id(); let state_service = service_fn(|_| async { unreachable!("State service should not be called") }); let script_verifier = script::Verifier::new(state_service); let verifier = Verifier::new(network, script_verifier); let result = verifier .oneshot(Request::Block { transaction: Arc::new(transaction), known_utxos: Arc::new(known_utxos), height: transaction_block_height, }) .await; assert_eq!( result.expect("unexpected error response").tx_id(), transaction_hash ); } /// Test if V4 coinbase transaction is accepted. #[tokio::test] async fn v4_coinbase_transaction_is_accepted() { let network = Network::Mainnet; let canopy_activation_height = NetworkUpgrade::Canopy .activation_height(network) .expect("Canopy activation height is specified"); let transaction_block_height = (canopy_activation_height + 10).expect("transaction block height is too large"); // Create a fake transparent coinbase that should succeed let (input, output) = mock_coinbase_transparent_output(transaction_block_height); // Create a V4 coinbase transaction let transaction = Transaction::V4 { inputs: vec![input], outputs: vec![output], lock_time: LockTime::Height(block::Height(0)), expiry_height: transaction_block_height, joinsplit_data: None, sapling_shielded_data: None, }; let transaction_hash = transaction.unmined_id(); let state_service = service_fn(|_| async { unreachable!("State service should not be called") }); let script_verifier = script::Verifier::new(state_service); let verifier = Verifier::new(network, script_verifier); let result = verifier .oneshot(Request::Block { transaction: Arc::new(transaction), known_utxos: Arc::new(HashMap::new()), height: transaction_block_height, }) .await; assert_eq!( result.expect("unexpected error response").tx_id(), transaction_hash ); } /// Test if V4 transaction with transparent funds is rejected if the source script prevents it. /// /// This test simulates the case where the script verifier rejects the transaction because the /// script prevents spending the source UTXO. #[tokio::test] async fn v4_transaction_with_transparent_transfer_is_rejected_by_the_script() { let network = Network::Mainnet; let canopy_activation_height = NetworkUpgrade::Canopy .activation_height(network) .expect("Canopy activation height is specified"); let transaction_block_height = (canopy_activation_height + 10).expect("transaction block height is too large"); let fake_source_fund_height = (transaction_block_height - 1).expect("fake source fund block height is too small"); // Create a fake transparent transfer that should not succeed let (input, output, known_utxos) = mock_transparent_transfer(fake_source_fund_height, false); // Create a V4 transaction let transaction = Transaction::V4 { inputs: vec![input], outputs: vec![output], lock_time: LockTime::Height(block::Height(0)), expiry_height: (transaction_block_height + 1).expect("expiry height is too large"), joinsplit_data: None, sapling_shielded_data: None, }; let state_service = service_fn(|_| async { unreachable!("State service should not be called") }); let script_verifier = script::Verifier::new(state_service); let verifier = Verifier::new(network, script_verifier); let result = verifier .oneshot(Request::Block { transaction: Arc::new(transaction), known_utxos: Arc::new(known_utxos), height: transaction_block_height, }) .await; assert_eq!( result, Err(TransactionError::InternalDowncastError( "downcast to known transaction error type failed, original error: ScriptInvalid" .to_string() )) ); } /// Test if V4 transaction with an internal double spend of transparent funds is rejected. #[tokio::test] async fn v4_transaction_with_conflicting_transparent_spend_is_rejected() { let network = Network::Mainnet; let canopy_activation_height = NetworkUpgrade::Canopy .activation_height(network) .expect("Canopy activation height is specified"); let transaction_block_height = (canopy_activation_height + 10).expect("transaction block height is too large"); let fake_source_fund_height = (transaction_block_height - 1).expect("fake source fund block height is too small"); // Create a fake transparent transfer that should succeed let (input, output, known_utxos) = mock_transparent_transfer(fake_source_fund_height, true); // Create a V4 transaction let transaction = Transaction::V4 { inputs: vec![input.clone(), input.clone()], outputs: vec![output], lock_time: LockTime::Height(block::Height(0)), expiry_height: (transaction_block_height + 1).expect("expiry height is too large"), joinsplit_data: None, sapling_shielded_data: None, }; let state_service = service_fn(|_| async { unreachable!("State service should not be called") }); let script_verifier = script::Verifier::new(state_service); let verifier = Verifier::new(network, script_verifier); let result = verifier .oneshot(Request::Block { transaction: Arc::new(transaction), known_utxos: Arc::new(known_utxos), height: transaction_block_height, }) .await; let expected_outpoint = input.outpoint().expect("Input should have an outpoint"); assert_eq!( result, Err(TransactionError::DuplicateTransparentSpend( expected_outpoint )) ); } /// Test if V4 transaction with a joinsplit that has duplicate nullifiers is rejected. #[test] fn v4_transaction_with_conflicting_sprout_nullifier_inside_joinsplit_is_rejected() { zebra_test::init(); zebra_test::RUNTIME.block_on(async { let network = Network::Mainnet; let network_upgrade = NetworkUpgrade::Canopy; let canopy_activation_height = NetworkUpgrade::Canopy .activation_height(network) .expect("Canopy activation height is specified"); let transaction_block_height = (canopy_activation_height + 10).expect("transaction block height is too large"); // Create a fake Sprout join split let (mut joinsplit_data, signing_key) = mock_sprout_join_split_data(); // Make both nullifiers the same inside the joinsplit transaction let duplicate_nullifier = joinsplit_data.first.nullifiers[0]; joinsplit_data.first.nullifiers[1] = duplicate_nullifier; // Create a V4 transaction let mut transaction = Transaction::V4 { inputs: vec![], outputs: vec![], lock_time: LockTime::Height(block::Height(0)), expiry_height: (transaction_block_height + 1).expect("expiry height is too large"), joinsplit_data: Some(joinsplit_data), sapling_shielded_data: None, }; // Sign the transaction let sighash = transaction.sighash(network_upgrade, HashType::ALL, None); match &mut transaction { Transaction::V4 { joinsplit_data: Some(joinsplit_data), .. } => joinsplit_data.sig = signing_key.sign(sighash.as_ref()), _ => unreachable!("Mock transaction was created incorrectly"), } let state_service = service_fn(|_| async { unreachable!("State service should not be called") }); let script_verifier = script::Verifier::new(state_service); let verifier = Verifier::new(network, script_verifier); let result = verifier .oneshot(Request::Block { transaction: Arc::new(transaction), known_utxos: Arc::new(HashMap::new()), height: transaction_block_height, }) .await; assert_eq!( result, Err(TransactionError::DuplicateSproutNullifier( duplicate_nullifier )) ); }); } /// Test if V4 transaction with duplicate nullifiers across joinsplits is rejected. #[test] fn v4_transaction_with_conflicting_sprout_nullifier_across_joinsplits_is_rejected() { zebra_test::init(); zebra_test::RUNTIME.block_on(async { let network = Network::Mainnet; let network_upgrade = NetworkUpgrade::Canopy; let canopy_activation_height = NetworkUpgrade::Canopy .activation_height(network) .expect("Canopy activation height is specified"); let transaction_block_height = (canopy_activation_height + 10).expect("transaction block height is too large"); // Create a fake Sprout join split let (mut joinsplit_data, signing_key) = mock_sprout_join_split_data(); // Duplicate a nullifier from the created joinsplit let duplicate_nullifier = joinsplit_data.first.nullifiers[1]; // Add a new joinsplit with the duplicate nullifier let mut new_joinsplit = joinsplit_data.first.clone(); new_joinsplit.nullifiers[0] = duplicate_nullifier; new_joinsplit.nullifiers[1] = sprout::note::Nullifier([2u8; 32]); joinsplit_data.rest.push(new_joinsplit); // Create a V4 transaction let mut transaction = Transaction::V4 { inputs: vec![], outputs: vec![], lock_time: LockTime::Height(block::Height(0)), expiry_height: (transaction_block_height + 1).expect("expiry height is too large"), joinsplit_data: Some(joinsplit_data), sapling_shielded_data: None, }; // Sign the transaction let sighash = transaction.sighash(network_upgrade, HashType::ALL, None); match &mut transaction { Transaction::V4 { joinsplit_data: Some(joinsplit_data), .. } => joinsplit_data.sig = signing_key.sign(sighash.as_ref()), _ => unreachable!("Mock transaction was created incorrectly"), } let state_service = service_fn(|_| async { unreachable!("State service should not be called") }); let script_verifier = script::Verifier::new(state_service); let verifier = Verifier::new(network, script_verifier); let result = verifier .oneshot(Request::Block { transaction: Arc::new(transaction), known_utxos: Arc::new(HashMap::new()), height: transaction_block_height, }) .await; assert_eq!( result, Err(TransactionError::DuplicateSproutNullifier( duplicate_nullifier )) ); }); } /// Test if V5 transaction with transparent funds is accepted. #[tokio::test] async fn v5_transaction_with_transparent_transfer_is_accepted() { let network = Network::Testnet; let network_upgrade = NetworkUpgrade::Nu5; let nu5_activation_height = network_upgrade .activation_height(network) .expect("NU5 activation height is specified"); let transaction_block_height = (nu5_activation_height + 10).expect("transaction block height is too large"); let fake_source_fund_height = (transaction_block_height - 1).expect("fake source fund block height is too small"); // Create a fake transparent transfer that should succeed let (input, output, known_utxos) = mock_transparent_transfer(fake_source_fund_height, true); // Create a V5 transaction let transaction = Transaction::V5 { inputs: vec![input], outputs: vec![output], lock_time: LockTime::Height(block::Height(0)), expiry_height: (transaction_block_height + 1).expect("expiry height is too large"), sapling_shielded_data: None, orchard_shielded_data: None, network_upgrade, }; let transaction_hash = transaction.unmined_id(); let state_service = service_fn(|_| async { unreachable!("State service should not be called") }); let script_verifier = script::Verifier::new(state_service); let verifier = Verifier::new(network, script_verifier); let result = verifier .oneshot(Request::Block { transaction: Arc::new(transaction), known_utxos: Arc::new(known_utxos), height: transaction_block_height, }) .await; assert_eq!( result.expect("unexpected error response").tx_id(), transaction_hash ); } /// Test if V5 coinbase transaction is accepted. #[tokio::test] async fn v5_coinbase_transaction_is_accepted() { let network = Network::Testnet; let network_upgrade = NetworkUpgrade::Nu5; let nu5_activation_height = network_upgrade .activation_height(network) .expect("NU5 activation height is specified"); let transaction_block_height = (nu5_activation_height + 10).expect("transaction block height is too large"); // Create a fake transparent coinbase that should succeed let (input, output) = mock_coinbase_transparent_output(transaction_block_height); let known_utxos = HashMap::new(); // Create a V5 coinbase transaction let transaction = Transaction::V5 { network_upgrade, inputs: vec![input], outputs: vec![output], lock_time: LockTime::Height(block::Height(0)), expiry_height: transaction_block_height, sapling_shielded_data: None, orchard_shielded_data: None, }; let transaction_hash = transaction.unmined_id(); let state_service = service_fn(|_| async { unreachable!("State service should not be called") }); let script_verifier = script::Verifier::new(state_service); let verifier = Verifier::new(network, script_verifier); let result = verifier .oneshot(Request::Block { transaction: Arc::new(transaction), known_utxos: Arc::new(known_utxos), height: transaction_block_height, }) .await; assert_eq!( result.expect("unexpected error response").tx_id(), transaction_hash ); } /// Test if V5 transaction with transparent funds is rejected if the source script prevents it. /// /// This test simulates the case where the script verifier rejects the transaction because the /// script prevents spending the source UTXO. #[tokio::test] async fn v5_transaction_with_transparent_transfer_is_rejected_by_the_script() { let network = Network::Testnet; let network_upgrade = NetworkUpgrade::Nu5; let nu5_activation_height = network_upgrade .activation_height(network) .expect("NU5 activation height is specified"); let transaction_block_height = (nu5_activation_height + 10).expect("transaction block height is too large"); let fake_source_fund_height = (transaction_block_height - 1).expect("fake source fund block height is too small"); // Create a fake transparent transfer that should not succeed let (input, output, known_utxos) = mock_transparent_transfer(fake_source_fund_height, false); // Create a V5 transaction let transaction = Transaction::V5 { inputs: vec![input], outputs: vec![output], lock_time: LockTime::Height(block::Height(0)), expiry_height: (transaction_block_height + 1).expect("expiry height is too large"), sapling_shielded_data: None, orchard_shielded_data: None, network_upgrade, }; let state_service = service_fn(|_| async { unreachable!("State service should not be called") }); let script_verifier = script::Verifier::new(state_service); let verifier = Verifier::new(network, script_verifier); let result = verifier .oneshot(Request::Block { transaction: Arc::new(transaction), known_utxos: Arc::new(known_utxos), height: transaction_block_height, }) .await; assert_eq!( result, Err(TransactionError::InternalDowncastError( "downcast to known transaction error type failed, original error: ScriptInvalid" .to_string() )) ); } /// Test if V5 transaction with an internal double spend of transparent funds is rejected. #[tokio::test] async fn v5_transaction_with_conflicting_transparent_spend_is_rejected() { let network = Network::Mainnet; let network_upgrade = NetworkUpgrade::Nu5; let canopy_activation_height = NetworkUpgrade::Canopy .activation_height(network) .expect("Canopy activation height is specified"); let transaction_block_height = (canopy_activation_height + 10).expect("transaction block height is too large"); let fake_source_fund_height = (transaction_block_height - 1).expect("fake source fund block height is too small"); // Create a fake transparent transfer that should succeed let (input, output, known_utxos) = mock_transparent_transfer(fake_source_fund_height, true); // Create a V4 transaction let transaction = Transaction::V5 { inputs: vec![input.clone(), input.clone()], outputs: vec![output], lock_time: LockTime::Height(block::Height(0)), expiry_height: (transaction_block_height + 1).expect("expiry height is too large"), sapling_shielded_data: None, orchard_shielded_data: None, network_upgrade, }; let state_service = service_fn(|_| async { unreachable!("State service should not be called") }); let script_verifier = script::Verifier::new(state_service); let verifier = Verifier::new(network, script_verifier); let result = verifier .oneshot(Request::Block { transaction: Arc::new(transaction), known_utxos: Arc::new(known_utxos), height: transaction_block_height, }) .await; let expected_outpoint = input.outpoint().expect("Input should have an outpoint"); assert_eq!( result, Err(TransactionError::DuplicateTransparentSpend( expected_outpoint )) ); } /// Test if signed V4 transaction with a dummy [`sprout::JoinSplit`] is accepted. /// - Test if an unsigned V4 transaction with a dummy [`sprout::JoinSplit`] is rejected. /// /// This test verifies if the transaction verifier correctly accepts a signed transaction. #[test] fn v4_with_signed_sprout_transfer_is_accepted() { zebra_test::init(); zebra_test::RUNTIME.block_on(async { let network = Network::Mainnet; let network_upgrade = NetworkUpgrade::Canopy; let canopy_activation_height = network_upgrade .activation_height(network) .expect("Canopy activation height is not set"); let transaction_block_height = (canopy_activation_height + 10).expect("Canopy activation height is too large"); // Initialize the verifier let state_service = service_fn(|_| async { unreachable!("State service should not be called") }); let script_verifier = script::Verifier::new(state_service); let verifier = Verifier::new(network, script_verifier); // Create a fake Sprout join split let (joinsplit_data, signing_key) = mock_sprout_join_split_data(); let mut transaction = Transaction::V4 { inputs: vec![], outputs: vec![], lock_time: LockTime::Height(block::Height(0)), expiry_height: (transaction_block_height + 1).expect("expiry height is too large"), joinsplit_data: Some(joinsplit_data), sapling_shielded_data: None, }; // Sign the transaction let sighash = transaction.sighash(network_upgrade, HashType::ALL, None); match &mut transaction { Transaction::V4 { joinsplit_data: Some(joinsplit_data), .. } => joinsplit_data.sig = signing_key.sign(sighash.as_ref()), _ => unreachable!("Mock transaction was created incorrectly"), } let expected_hash = transaction.unmined_id(); // Test the transaction verifier let result = verifier .clone() .oneshot(Request::Block { transaction: Arc::new(transaction), known_utxos: Arc::new(HashMap::new()), height: transaction_block_height, }) .await; assert_eq!( result.expect("unexpected error response").tx_id(), expected_hash ); }); } /// Test if an unsigned V4 transaction with a dummy [`sprout::JoinSplit`] is rejected. /// /// This test verifies if the transaction verifier correctly rejects the transaction because of the /// invalid signature. #[test] fn v4_with_unsigned_sprout_transfer_is_rejected() { zebra_test::init(); zebra_test::RUNTIME.block_on(async { let network = Network::Mainnet; let network_upgrade = NetworkUpgrade::Canopy; let canopy_activation_height = network_upgrade .activation_height(network) .expect("Canopy activation height is not set"); let transaction_block_height = (canopy_activation_height + 10).expect("Canopy activation height is too large"); // Initialize the verifier let state_service = service_fn(|_| async { unreachable!("State service should not be called") }); let script_verifier = script::Verifier::new(state_service); let verifier = Verifier::new(network, script_verifier); // Create a fake Sprout join split let (joinsplit_data, _) = mock_sprout_join_split_data(); let transaction = Transaction::V4 { inputs: vec![], outputs: vec![], lock_time: LockTime::Height(block::Height(0)), expiry_height: (transaction_block_height + 1).expect("expiry height is too large"), joinsplit_data: Some(joinsplit_data), sapling_shielded_data: None, }; // Test the transaction verifier let result = verifier .clone() .oneshot(Request::Block { transaction: Arc::new(transaction), known_utxos: Arc::new(HashMap::new()), height: transaction_block_height, }) .await; assert_eq!( result, Err( // TODO: Fix error downcast // Err(TransactionError::Ed25519(ed25519::Error::InvalidSignature)) TransactionError::InternalDowncastError( "downcast to known transaction error type failed, original error: InvalidSignature" .to_string(), ) ) ); }); } /// Test if a V4 transaction with Sapling spends is accepted by the verifier. #[test] fn v4_with_sapling_spends() { zebra_test::init(); zebra_test::RUNTIME.block_on(async { let network = Network::Mainnet; let (height, transaction) = test_transactions(network) .rev() .filter(|(_, transaction)| { !transaction.has_valid_coinbase_transaction_inputs() && transaction.inputs().is_empty() }) .find(|(_, transaction)| transaction.sapling_spends_per_anchor().next().is_some()) .expect("No transaction found with Sapling spends"); let expected_hash = transaction.unmined_id(); // Initialize the verifier let state_service = service_fn(|_| async { unreachable!("State service should not be called") }); let script_verifier = script::Verifier::new(state_service); let verifier = Verifier::new(network, script_verifier); // Test the transaction verifier let result = verifier .clone() .oneshot(Request::Block { transaction, known_utxos: Arc::new(HashMap::new()), height, }) .await; assert_eq!( result.expect("unexpected error response").tx_id(), expected_hash ); }); } /// Test if a V4 transaction with a duplicate Sapling spend is rejected by the verifier. #[test] fn v4_with_duplicate_sapling_spends() { zebra_test::init(); zebra_test::RUNTIME.block_on(async { let network = Network::Mainnet; let (height, mut transaction) = test_transactions(network) .rev() .filter(|(_, transaction)| { !transaction.has_valid_coinbase_transaction_inputs() && transaction.inputs().is_empty() }) .find(|(_, transaction)| transaction.sapling_spends_per_anchor().next().is_some()) .expect("No transaction found with Sapling spends"); // Duplicate one of the spends let duplicate_nullifier = duplicate_sapling_spend( Arc::get_mut(&mut transaction).expect("Transaction only has one active reference"), ); // Initialize the verifier let state_service = service_fn(|_| async { unreachable!("State service should not be called") }); let script_verifier = script::Verifier::new(state_service); let verifier = Verifier::new(network, script_verifier); // Test the transaction verifier let result = verifier .clone() .oneshot(Request::Block { transaction, known_utxos: Arc::new(HashMap::new()), height, }) .await; assert_eq!( result, Err(TransactionError::DuplicateSaplingNullifier( duplicate_nullifier )) ); }); } /// Test if a V4 transaction with Sapling outputs but no spends is accepted by the verifier. #[test] fn v4_with_sapling_outputs_and_no_spends() { zebra_test::init(); zebra_test::RUNTIME.block_on(async { let network = Network::Mainnet; let (height, transaction) = test_transactions(network) .rev() .filter(|(_, transaction)| { !transaction.has_valid_coinbase_transaction_inputs() && transaction.inputs().is_empty() }) .find(|(_, transaction)| { transaction.sapling_spends_per_anchor().next().is_none() && transaction.sapling_outputs().next().is_some() }) .expect("No transaction found with Sapling outputs and no Sapling spends"); let expected_hash = transaction.unmined_id(); // Initialize the verifier let state_service = service_fn(|_| async { unreachable!("State service should not be called") }); let script_verifier = script::Verifier::new(state_service); let verifier = Verifier::new(network, script_verifier); // Test the transaction verifier let result = verifier .clone() .oneshot(Request::Block { transaction, known_utxos: Arc::new(HashMap::new()), height, }) .await; assert_eq!( result.expect("unexpected error response").tx_id(), expected_hash ); }); } /// Test if a V5 transaction with Sapling spends is accepted by the verifier. #[test] // TODO: Remove `should_panic` once V5 transaction verification is complete. #[should_panic] fn v5_with_sapling_spends() { zebra_test::init(); zebra_test::RUNTIME.block_on(async { let network = Network::Mainnet; let transaction = fake_v5_transactions_for_network(network, zebra_test::vectors::MAINNET_BLOCKS.iter()) .rev() .filter(|transaction| { !transaction.has_valid_coinbase_transaction_inputs() && transaction.inputs().is_empty() }) .find(|transaction| transaction.sapling_spends_per_anchor().next().is_some()) .expect("No transaction found with Sapling spends"); let expected_hash = transaction.unmined_id(); let height = transaction .expiry_height() .expect("Transaction is missing expiry height"); // Initialize the verifier let state_service = service_fn(|_| async { unreachable!("State service should not be called") }); let script_verifier = script::Verifier::new(state_service); let verifier = Verifier::new(network, script_verifier); // Test the transaction verifier let result = verifier .clone() .oneshot(Request::Block { transaction: Arc::new(transaction), known_utxos: Arc::new(HashMap::new()), height, }) .await; assert_eq!( result.expect("unexpected error response").tx_id(), expected_hash ); }); } /// Test if a V5 transaction with a duplicate Sapling spend is rejected by the verifier. #[test] fn v5_with_duplicate_sapling_spends() { zebra_test::init(); zebra_test::RUNTIME.block_on(async { let network = Network::Mainnet; let mut transaction = fake_v5_transactions_for_network(network, zebra_test::vectors::MAINNET_BLOCKS.iter()) .rev() .filter(|transaction| { !transaction.has_valid_coinbase_transaction_inputs() && transaction.inputs().is_empty() }) .find(|transaction| transaction.sapling_spends_per_anchor().next().is_some()) .expect("No transaction found with Sapling spends"); let height = transaction .expiry_height() .expect("Transaction is missing expiry height"); // Duplicate one of the spends let duplicate_nullifier = duplicate_sapling_spend(&mut transaction); // Initialize the verifier let state_service = service_fn(|_| async { unreachable!("State service should not be called") }); let script_verifier = script::Verifier::new(state_service); let verifier = Verifier::new(network, script_verifier); // Test the transaction verifier let result = verifier .clone() .oneshot(Request::Block { transaction: Arc::new(transaction), known_utxos: Arc::new(HashMap::new()), height, }) .await; assert_eq!( result, Err(TransactionError::DuplicateSaplingNullifier( duplicate_nullifier )) ); }); } /// Test if a V5 transaction with a duplicate Orchard action is rejected by the verifier. #[test] fn v5_with_duplicate_orchard_action() { zebra_test::init(); zebra_test::RUNTIME.block_on(async { let network = Network::Mainnet; // Find a transaction with no inputs or outputs to use as base let mut transaction = fake_v5_transactions_for_network(network, zebra_test::vectors::MAINNET_BLOCKS.iter()) .rev() .find(|transaction| { transaction.inputs().is_empty() && transaction.outputs().is_empty() && transaction.sapling_spends_per_anchor().next().is_none() && transaction.sapling_outputs().next().is_none() && transaction.joinsplit_count() == 0 }) .expect("At least one fake V5 transaction with no inputs and no outputs"); let height = transaction .expiry_height() .expect("Transaction is missing expiry height"); // Insert fake Orchard shielded data to the transaction, which has at least one action (this is // guaranteed structurally by `orchard::ShieldedData`) let shielded_data = insert_fake_orchard_shielded_data(&mut transaction); // Enable spends shielded_data.flags = orchard::Flags::ENABLE_SPENDS | orchard::Flags::ENABLE_OUTPUTS; // Duplicate the first action let duplicate_action = shielded_data.actions.first().clone(); let duplicate_nullifier = duplicate_action.action.nullifier; shielded_data.actions.push(duplicate_action); // Initialize the verifier let state_service = service_fn(|_| async { unreachable!("State service should not be called") }); let script_verifier = script::Verifier::new(state_service); let verifier = Verifier::new(network, script_verifier); // Test the transaction verifier let result = verifier .clone() .oneshot(Request::Block { transaction: Arc::new(transaction), known_utxos: Arc::new(HashMap::new()), height, }) .await; assert_eq!( result, Err(TransactionError::DuplicateOrchardNullifier( duplicate_nullifier )) ); }); } // Utility functions /// Create a mock transparent transfer to be included in a transaction. /// /// First, this creates a fake unspent transaction output from a fake transaction included in the /// specified `previous_utxo_height` block height. This fake [`Utxo`] also contains a simple script /// that can either accept or reject any spend attempt, depending on if `script_should_succeed` is /// `true` or `false`. Since the `tx_index_in_block` is irrelevant for blocks that have already /// been verified, it is set to `1`. /// /// Then, a [`transparent::Input::PrevOut`] is created that attempts to spend the previously created fake /// UTXO to a new [`transparent::Output`]. /// /// Finally, the initial fake UTXO is placed in a `known_utxos` [`HashMap`] so that it can be /// retrieved during verification. /// /// The function then returns the generated transparent input and output, as well as the /// `known_utxos` map. /// /// Note: `known_utxos` is only intended to be used for UTXOs within the same block, /// so future verification changes might break this mocking function. fn mock_transparent_transfer( previous_utxo_height: block::Height, script_should_succeed: bool, ) -> ( transparent::Input, transparent::Output, HashMap, ) { // A script with a single opcode that accepts the transaction (pushes true on the stack) let accepting_script = transparent::Script::new(&[1, 1]); // A script with a single opcode that rejects the transaction (OP_FALSE) let rejecting_script = transparent::Script::new(&[0]); // Mock an unspent transaction output let previous_outpoint = transparent::OutPoint { hash: Hash([1u8; 32]), index: 0, }; let lock_script = if script_should_succeed { accepting_script.clone() } else { rejecting_script.clone() }; let previous_output = transparent::Output { value: Amount::try_from(1).expect("1 is an invalid amount"), lock_script, }; let previous_utxo = transparent::OrderedUtxo::new(previous_output, previous_utxo_height, false, 1); // Use the `previous_outpoint` as input let input = transparent::Input::PrevOut { outpoint: previous_outpoint, unlock_script: accepting_script, sequence: 0, }; // The output resulting from the transfer // Using the rejecting script pretends the amount is burned because it can't be spent again let output = transparent::Output { value: Amount::try_from(1).expect("1 is an invalid amount"), lock_script: rejecting_script, }; // Cache the source of the fund so that it can be used during verification let mut known_utxos = HashMap::new(); known_utxos.insert(previous_outpoint, previous_utxo); (input, output, known_utxos) } /// Create a mock coinbase input with a transparent output. /// /// Create a [`transparent::Input::Coinbase`] at `coinbase_height`. /// Then create UTXO with a [`transparent::Output`] spending some coinbase funds. /// /// Returns the generated coinbase input and transparent output. fn mock_coinbase_transparent_output( coinbase_height: block::Height, ) -> (transparent::Input, transparent::Output) { // A script with a single opcode that rejects the transaction (OP_FALSE) let rejecting_script = transparent::Script::new(&[0]); let input = transparent::Input::Coinbase { height: coinbase_height, data: CoinbaseData::new(Vec::new()), sequence: u32::MAX, }; // The output resulting from the transfer // Using the rejecting script pretends the amount is burned because it can't be spent again let output = transparent::Output { value: Amount::try_from(1).expect("1 is an invalid amount"), lock_script: rejecting_script, }; (input, output) } /// Create a mock [`sprout::JoinSplit`] and include it in a [`transaction::JoinSplitData`]. /// /// This creates a dummy join split. By itself it is invalid, but it is useful for including in a /// transaction to check the signatures. /// /// The [`transaction::JoinSplitData`] with the dummy [`sprout::JoinSplit`] is returned together /// with the [`ed25519::SigningKey`] that can be used to create a signature to later add to the /// returned join split data. fn mock_sprout_join_split_data() -> (JoinSplitData, ed25519::SigningKey) { // Prepare dummy inputs for the join split let zero_amount = 0_i32 .try_into() .expect("Invalid JoinSplit transparent input"); let anchor = sprout::tree::Root::default(); let first_nullifier = sprout::note::Nullifier([0u8; 32]); let second_nullifier = sprout::note::Nullifier([1u8; 32]); let commitment = sprout::commitment::NoteCommitment::from([0u8; 32]); let ephemeral_key = x25519::PublicKey::from(&x25519::EphemeralSecret::new(rand07::thread_rng())); let random_seed = [0u8; 32]; let mac = sprout::note::Mac::zcash_deserialize(&[0u8; 32][..]) .expect("Failure to deserialize dummy MAC"); let zkproof = Groth16Proof([0u8; 192]); let encrypted_note = sprout::note::EncryptedNote([0u8; 601]); // Create an dummy join split let joinsplit = sprout::JoinSplit { vpub_old: zero_amount, vpub_new: zero_amount, anchor, nullifiers: [first_nullifier, second_nullifier], commitments: [commitment; 2], ephemeral_key, random_seed, vmacs: [mac.clone(), mac], zkproof, enc_ciphertexts: [encrypted_note; 2], }; // Create a usable signing key let signing_key = ed25519::SigningKey::new(rand::thread_rng()); let verification_key = ed25519::VerificationKey::from(&signing_key); // Populate join split data with the dummy join split. let joinsplit_data = JoinSplitData { first: joinsplit, rest: vec![], pub_key: verification_key.into(), sig: [0u8; 64].into(), }; (joinsplit_data, signing_key) } /// Duplicate a Sapling spend inside a `transaction`. /// /// Returns the nullifier of the duplicate spend. /// /// # Panics /// /// Will panic if the `transaction` does not have Sapling spends. fn duplicate_sapling_spend(transaction: &mut Transaction) -> sapling::Nullifier { match transaction { Transaction::V4 { sapling_shielded_data: Some(ref mut shielded_data), .. } => duplicate_sapling_spend_in_shielded_data(shielded_data), Transaction::V5 { sapling_shielded_data: Some(ref mut shielded_data), .. } => duplicate_sapling_spend_in_shielded_data(shielded_data), _ => unreachable!("Transaction has no Sapling shielded data"), } } /// Duplicates the first spend of the `shielded_data`. /// /// Returns the nullifier of the duplicate spend. /// /// # Panics /// /// Will panic if `shielded_data` has no spends. fn duplicate_sapling_spend_in_shielded_data( shielded_data: &mut sapling::ShieldedData, ) -> sapling::Nullifier { match shielded_data.transfers { sapling::TransferData::SpendsAndMaybeOutputs { ref mut spends, .. } => { let duplicate_spend = spends.first().clone(); let duplicate_nullifier = duplicate_spend.nullifier; spends.push(duplicate_spend); duplicate_nullifier } sapling::TransferData::JustOutputs { .. } => { unreachable!("Sapling shielded data has no spends") } } } #[test] fn add_to_sprout_pool_after_nu() { zebra_test::init(); // get a block that we know it haves a transaction with `vpub_old` field greater than 0. let block: Arc<_> = zebra_chain::block::Block::zcash_deserialize( &zebra_test::vectors::BLOCK_MAINNET_419199_BYTES[..], ) .unwrap() .into(); // create a block height at canopy activation. let network = Network::Mainnet; let block_height = NetworkUpgrade::Canopy.activation_height(network).unwrap(); // create a zero amount. let zero = Amount::::try_from(0).expect("an amount of 0 is always valid"); // the coinbase transaction should pass the check. assert_eq!( check::disabled_add_to_sprout_pool(&block.transactions[0], block_height, network), Ok(()) ); // the 2nd transaction has no joinsplits, should pass the check. assert_eq!(block.transactions[1].joinsplit_count(), 0); assert_eq!( check::disabled_add_to_sprout_pool(&block.transactions[1], block_height, network), Ok(()) ); // the 5th transaction has joinsplits and the `vpub_old` cumulative is greater than 0, // should fail the check. assert!(block.transactions[4].joinsplit_count() > 0); let vpub_old: Amount = block.transactions[4] .output_values_to_sprout() .fold(zero, |acc, &x| (acc + x).unwrap()); assert!(vpub_old > zero); assert_eq!( check::disabled_add_to_sprout_pool(&block.transactions[3], block_height, network), Err(TransactionError::DisabledAddToSproutPool) ); // the 8th transaction has joinsplits and the `vpub_old` cumulative is 0, // should pass the check. assert!(block.transactions[7].joinsplit_count() > 0); let vpub_old: Amount = block.transactions[7] .output_values_to_sprout() .fold(zero, |acc, &x| (acc + x).unwrap()); assert_eq!(vpub_old, zero); assert_eq!( check::disabled_add_to_sprout_pool(&block.transactions[7], block_height, network), Ok(()) ); }