#![allow(clippy::unwrap_in_result)]
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use chrono::{DateTime, TimeZone, Utc};
use color_eyre::eyre::Report;
use futures::{FutureExt, TryFutureExt};
use halo2::pasta::{group::ff::PrimeField, pallas};
use tokio::time::timeout;
use tower::{buffer::Buffer, service_fn, Service, ServiceExt};
use tower_batch_control::{Batch, BatchControl};
use tower_fallback::Fallback;
use zakura_chain::{
amount::{Amount, NegativeAllowed, NonNegative},
block::{self, Block, Height},
orchard::{Action, AuthorizedAction, Flags},
parameters::{
testnet::{ConfiguredActivationHeights, Parameters},
Network, NetworkUpgrade,
},
primitives::{ed25519, x25519, Groth16Proof},
sapling,
serialization::{
AtLeastOne, DateTime32, ZcashDeserialize, ZcashDeserializeInto, ZcashSerialize,
},
sprout,
transaction::{
arbitrary::{
insert_fake_orchard_shielded_data, test_transactions, transactions_from_blocks,
v5_transactions,
},
zip317, Hash, HashType, JoinSplitData, LockTime, Transaction,
},
transparent::{self, CoinbaseSpendRestriction},
};
use zakura_chain::{ironwood, orchard};
use zakura_node_services::mempool;
use zakura_state::ValidateContextError;
use zakura_test::mock_service::MockService;
use crate::{error::TransactionError, transaction::POLL_MEMPOOL_DELAY};
use super::{check, Request, Verifier};
#[cfg(test)]
mod prop;
fn test_timeout() -> std::time::Duration {
if std::env::var("LLVM_COV_FLAGS").is_ok() || std::env::var("CARGO_LLVM_COV").is_ok() {
std::time::Duration::from_secs(150)
} else {
std::time::Duration::from_secs(30)
}
}
#[test]
fn v5_transactions_basic_check() -> Result<(), Report> {
let _init_guard = zakura_test::init();
for network in Network::iter() {
for transaction in v5_transactions(network.block_iter()) {
match check::has_inputs_and_outputs(&transaction) {
Ok(()) => (),
Err(TransactionError::NoInputs) | Err(TransactionError::NoOutputs) => (),
Err(_) => panic!("error must be NoInputs or NoOutputs"),
};
check::coinbase_tx_no_prevout_joinsplit_spend(&transaction)?;
}
}
Ok(())
}
#[test]
fn matching_orchard_and_ironwood_nullifiers_are_not_conflicts() {
let _init_guard = zakura_test::init();
let shielded_data = Network::iter()
.flat_map(|network| v5_transactions(network.block_iter()))
.find_map(|transaction| transaction.orchard_shielded_data().cloned())
.expect("test vectors include an Orchard transaction");
let transaction = Transaction::V6 {
network_upgrade: NetworkUpgrade::Nu6_3,
lock_time: LockTime::unlocked(),
expiry_height: Height(1),
inputs: Vec::new(),
outputs: Vec::new(),
sapling_shielded_data: None,
orchard_shielded_data: Some(shielded_data.clone()),
ironwood_shielded_data: Some(shielded_data),
};
assert_eq!(
transaction.orchard_nullifiers().collect::<Vec<_>>(),
transaction.ironwood_nullifiers().collect::<Vec<_>>()
);
check::spend_conflicts(&transaction)
.expect("matching bit patterns in separate nullifier sets are not duplicates");
}
#[test]
fn v5_transaction_with_orchard_actions_has_inputs_and_outputs() {
for net in Network::iter() {
let mut tx = v5_transactions(net.block_iter())
.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("V5 tx with only Orchard shielded data");
tx.orchard_shielded_data_mut().unwrap().flags = Flags::empty();
assert_eq!(
check::has_inputs_and_outputs(&tx),
Err(TransactionError::NoInputs)
);
tx.orchard_shielded_data_mut().unwrap().flags = Flags::ENABLE_SPENDS;
assert_eq!(
check::has_inputs_and_outputs(&tx),
Err(TransactionError::NoOutputs)
);
tx.orchard_shielded_data_mut().unwrap().flags = Flags::ENABLE_OUTPUTS;
assert_eq!(
check::has_inputs_and_outputs(&tx),
Err(TransactionError::NoInputs)
);
tx.orchard_shielded_data_mut().unwrap().flags =
Flags::ENABLE_SPENDS | Flags::ENABLE_OUTPUTS;
assert!(check::has_inputs_and_outputs(&tx).is_ok());
}
}
fn nu6_3_test_network_and_height() -> (Network, Height) {
let network = Parameters::build()
.with_activation_heights(ConfiguredActivationHeights {
nu6_3: Some(1),
..Default::default()
})
.expect("failed to set NU6.3 activation height")
.clear_funding_streams()
.to_network()
.expect("failed to build configured network");
(network, Height(1))
}
fn orchard_fixture() -> orchard::ShieldedData {
let default_testnet = Network::new_default_testnet();
v5_transactions(default_testnet.block_iter())
.find_map(|transaction| transaction.orchard_shielded_data().cloned())
.expect("test vectors include a transaction with Orchard shielded data")
}
fn orchard_shielded_data(value_balance: i64, flags: Flags) -> orchard::ShieldedData {
let mut shielded_data = orchard_fixture();
shielded_data.value_balance =
Amount::<NegativeAllowed>::try_from(value_balance).expect("valid test amount");
shielded_data.flags = flags;
shielded_data
}
fn ironwood_shielded_data(value_balance: i64, flags: ironwood::Flags) -> ironwood::ShieldedData {
let mut shielded_data = orchard_fixture();
shielded_data.value_balance =
Amount::<NegativeAllowed>::try_from(value_balance).expect("valid test amount");
shielded_data.flags = flags;
shielded_data
}
fn v6_pool_flow_transaction(
orchard_shielded_data: Option<orchard::ShieldedData>,
ironwood_shielded_data: Option<ironwood::ShieldedData>,
transparent_outputs: Vec<transparent::Output>,
) -> Transaction {
Transaction::V6 {
network_upgrade: NetworkUpgrade::Nu6_3,
lock_time: LockTime::Height(block::Height(0)),
expiry_height: Height(1),
inputs: vec![],
outputs: transparent_outputs,
sapling_shielded_data: None,
orchard_shielded_data,
ironwood_shielded_data,
}
}
fn transparent_output(value: u64) -> transparent::Output {
transparent::Output {
value: Amount::<NonNegative>::try_from(value).expect("valid test amount"),
lock_script: transparent::Script::new(&[1, 1]),
}
}
fn empty_utxos() -> HashMap<transparent::OutPoint, transparent::Utxo> {
HashMap::new()
}
#[test]
fn orchard_rejects_net_deposits_after_nu6_3() {
let (network, height) = nu6_3_test_network_and_height();
let orchard_withdraw = v6_pool_flow_transaction(
Some(orchard_shielded_data(10, Flags::ENABLE_SPENDS)),
None,
vec![transparent_output(10)],
);
assert_eq!(
check::disabled_add_to_orchard_pool(&orchard_withdraw, height, &network),
Ok(())
);
assert_eq!(check::has_inputs_and_outputs(&orchard_withdraw), Ok(()));
assert_eq!(
orchard_withdraw
.value_balance(&empty_utxos())
.expect("valid value balance")
.remaining_transaction_value(),
Ok(Amount::<NonNegative>::zero())
);
let orchard_no_flow = v6_pool_flow_transaction(
Some(orchard_shielded_data(
0,
Flags::ENABLE_SPENDS | Flags::ENABLE_OUTPUTS,
)),
None,
vec![],
);
assert_eq!(
check::disabled_add_to_orchard_pool(&orchard_no_flow, height, &network),
Ok(())
);
let orchard_deposit = v6_pool_flow_transaction(
Some(orchard_shielded_data(-10, Flags::ENABLE_OUTPUTS)),
None,
vec![],
);
assert_eq!(
check::disabled_add_to_orchard_pool(&orchard_deposit, height, &network),
Err(TransactionError::DisabledAddToOrchardPool)
);
}
#[test]
fn coinbase_rejects_orchard_shielded_data_after_nu6_3() {
let (network, height) = nu6_3_test_network_and_height();
let coinbase_input = || transparent::Input::Coinbase {
height,
data: vec![],
sequence: u32::MAX,
};
let coinbase_with_orchard = Transaction::V6 {
network_upgrade: NetworkUpgrade::Nu6_3,
lock_time: LockTime::Height(Height(0)),
expiry_height: height,
inputs: vec![coinbase_input()],
outputs: vec![],
sapling_shielded_data: None,
orchard_shielded_data: Some(orchard_shielded_data(0, Flags::ENABLE_OUTPUTS)),
ironwood_shielded_data: None,
};
assert!(coinbase_with_orchard.is_coinbase());
assert_eq!(
check::coinbase_has_no_orchard_shielded_data(&coinbase_with_orchard, height, &network),
Err(TransactionError::CoinbaseHasOrchardShieldedData)
);
assert_eq!(
check::disabled_add_to_orchard_pool(&coinbase_with_orchard, height, &network),
Ok(())
);
let coinbase_with_ironwood = Transaction::V6 {
network_upgrade: NetworkUpgrade::Nu6_3,
lock_time: LockTime::Height(Height(0)),
expiry_height: height,
inputs: vec![coinbase_input()],
outputs: vec![],
sapling_shielded_data: None,
orchard_shielded_data: None,
ironwood_shielded_data: Some(ironwood_shielded_data(0, ironwood::Flags::ENABLE_OUTPUTS)),
};
assert_eq!(
check::coinbase_has_no_orchard_shielded_data(&coinbase_with_ironwood, height, &network),
Ok(())
);
assert_eq!(
check::coinbase_has_no_orchard_shielded_data(&coinbase_with_orchard, Height(0), &network),
Ok(())
);
}
#[test]
fn orchard_cross_address_flag_is_disabled_after_nu6_3() {
let orchard_cross_address = v6_pool_flow_transaction(
Some(orchard_shielded_data(
0,
Flags::ENABLE_SPENDS | Flags::ENABLE_OUTPUTS | Flags::ENABLE_CROSS_ADDRESS,
)),
None,
vec![],
);
assert_eq!(
check::orchard_cross_address_disabled(&orchard_cross_address),
Err(TransactionError::OrchardHasEnableCrossAddress)
);
let ironwood_cross_address = v6_pool_flow_transaction(
None,
Some(ironwood_shielded_data(
0,
ironwood::Flags::ENABLE_SPENDS
| ironwood::Flags::ENABLE_OUTPUTS
| ironwood::Flags::ENABLE_CROSS_ADDRESS,
)),
vec![],
);
assert_eq!(
check::orchard_cross_address_disabled(&ironwood_cross_address),
Ok(())
);
}
#[test]
fn ironwood_cross_address_flag_is_optional_after_nu6_3() {
let ironwood_without_cross_address = v6_pool_flow_transaction(
None,
Some(ironwood_shielded_data(
0,
ironwood::Flags::ENABLE_SPENDS | ironwood::Flags::ENABLE_OUTPUTS,
)),
vec![],
);
assert_eq!(
check::has_enough_ironwood_flags(&ironwood_without_cross_address),
Ok(())
);
assert_eq!(
check::orchard_cross_address_disabled(&ironwood_without_cross_address),
Ok(())
);
let ironwood_with_cross_address = v6_pool_flow_transaction(
None,
Some(ironwood_shielded_data(
0,
ironwood::Flags::ENABLE_SPENDS
| ironwood::Flags::ENABLE_OUTPUTS
| ironwood::Flags::ENABLE_CROSS_ADDRESS,
)),
vec![],
);
assert_eq!(
check::has_enough_ironwood_flags(&ironwood_with_cross_address),
Ok(())
);
assert_eq!(
check::orchard_cross_address_disabled(&ironwood_with_cross_address),
Ok(())
);
}
#[test]
fn ironwood_cross_address_only_is_not_a_spend_or_output_flag() {
let tx = v6_pool_flow_transaction(
None,
Some(ironwood_shielded_data(
0,
ironwood::Flags::ENABLE_CROSS_ADDRESS,
)),
vec![],
);
assert!(!tx.has_shielded_inputs());
assert!(!tx.has_shielded_outputs());
assert_eq!(
check::has_inputs_and_outputs(&tx),
Err(TransactionError::NoInputs)
);
assert_eq!(
check::has_enough_ironwood_flags(&tx),
Err(TransactionError::NotEnoughIronwoodFlags)
);
}
#[test]
fn orchard_to_ironwood_migration_balances() {
let (network, height) = nu6_3_test_network_and_height();
let tx = v6_pool_flow_transaction(
Some(orchard_shielded_data(10, Flags::ENABLE_SPENDS)),
Some(ironwood_shielded_data(-10, ironwood::Flags::ENABLE_OUTPUTS)),
vec![],
);
assert_eq!(
check::disabled_add_to_orchard_pool(&tx, height, &network),
Ok(())
);
assert_eq!(check::has_inputs_and_outputs(&tx), Ok(()));
let value_balance = tx
.value_balance(&empty_utxos())
.expect("valid value balance");
assert_eq!(
value_balance.orchard_amount(),
Amount::<NegativeAllowed>::try_from(10).expect("valid test amount")
);
assert_eq!(
value_balance.ironwood_amount(),
Amount::<NegativeAllowed>::try_from(-10).expect("valid test amount")
);
assert_eq!(
value_balance.remaining_transaction_value(),
Ok(Amount::<NonNegative>::zero())
);
}
#[test]
fn ironwood_withdraw_balances() {
let tx = v6_pool_flow_transaction(
None,
Some(ironwood_shielded_data(10, ironwood::Flags::ENABLE_SPENDS)),
vec![transparent_output(10)],
);
assert_eq!(check::has_inputs_and_outputs(&tx), Ok(()));
let value_balance = tx
.value_balance(&empty_utxos())
.expect("valid value balance");
assert_eq!(
value_balance.transparent_amount(),
Amount::<NegativeAllowed>::try_from(-10).expect("valid test amount")
);
assert_eq!(
value_balance.ironwood_amount(),
Amount::<NegativeAllowed>::try_from(10).expect("valid test amount")
);
assert_eq!(
value_balance.remaining_transaction_value(),
Ok(Amount::<NonNegative>::zero())
);
}
#[test]
fn v5_transaction_with_orchard_actions_has_flags() {
for net in Network::iter() {
let mut tx = v5_transactions(net.block_iter())
.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("V5 tx with only Orchard actions");
tx.orchard_shielded_data_mut().unwrap().flags = Flags::empty();
assert_eq!(
check::has_enough_orchard_flags(&tx),
Err(TransactionError::NotEnoughFlags)
);
tx.orchard_shielded_data_mut().unwrap().flags = Flags::ENABLE_SPENDS;
assert!(check::has_enough_orchard_flags(&tx).is_ok());
tx.orchard_shielded_data_mut().unwrap().flags = Flags::empty();
tx.orchard_shielded_data_mut().unwrap().flags = Flags::ENABLE_OUTPUTS;
assert!(check::has_enough_orchard_flags(&tx).is_ok());
tx.orchard_shielded_data_mut().unwrap().flags = Flags::empty();
tx.orchard_shielded_data_mut().unwrap().flags =
Flags::ENABLE_SPENDS | Flags::ENABLE_OUTPUTS;
assert!(check::has_enough_orchard_flags(&tx).is_ok());
}
}
#[test]
fn v5_transaction_with_no_inputs_fails_verification() {
let (_, output, _) = mock_transparent_transfer(
Height(1),
true,
0,
Amount::try_from(1).expect("valid value"),
);
for net in Network::iter() {
let transaction = Transaction::V5 {
inputs: vec![],
outputs: vec![output.clone()],
lock_time: LockTime::Height(block::Height(0)),
expiry_height: NetworkUpgrade::Nu5.activation_height(&net).expect("height"),
sapling_shielded_data: None,
orchard_shielded_data: None,
network_upgrade: NetworkUpgrade::Nu5,
};
assert_eq!(
check::has_inputs_and_outputs(&transaction),
Err(TransactionError::NoInputs)
);
}
}
#[test]
fn v5_transaction_with_no_outputs_fails_verification() {
let (input, _, _) = mock_transparent_transfer(
Height(1),
true,
0,
Amount::try_from(1).expect("valid value"),
);
for net in Network::iter() {
let transaction = Transaction::V5 {
inputs: vec![input.clone()],
outputs: vec![],
lock_time: LockTime::Height(block::Height(0)),
expiry_height: NetworkUpgrade::Nu5.activation_height(&net).expect("height"),
sapling_shielded_data: None,
orchard_shielded_data: None,
network_upgrade: NetworkUpgrade::Nu5,
};
assert_eq!(
check::has_inputs_and_outputs(&transaction),
Err(TransactionError::NoOutputs)
);
}
}
#[tokio::test]
async fn mempool_request_with_missing_input_is_rejected() {
let mut state: MockService<_, _, _, _> = MockService::build().for_unit_tests();
for net in Network::iter() {
let verifier = Verifier::new_for_tests(&net, state.clone());
let (height, tx) = transactions_from_blocks(net.block_iter())
.find(|(_, tx)| !(tx.is_coinbase() || tx.inputs().is_empty()))
.expect(
"At least one non-coinbase transaction with transparent inputs in test vectors",
);
let input_outpoint = match tx.inputs()[0] {
transparent::Input::PrevOut { outpoint, .. } => outpoint,
transparent::Input::Coinbase { .. } => panic!("requires a non-coinbase transaction"),
};
let state_req = state
.expect_request(zakura_state::Request::UnspentBestChainUtxo(input_outpoint))
.map(|responder| responder.respond(zakura_state::Response::UnspentBestChainUtxo(None)));
let verifier_req = verifier.oneshot(Request::Mempool {
transaction: tx.into(),
height,
});
let (rsp, _) = futures::join!(verifier_req, state_req);
assert_eq!(rsp, 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_for_tests(&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,
Amount::try_from(10001).expect("invalid value"),
);
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(zakura_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::UnspentBestChainUtxo(
known_utxos
.get(&input_outpoint)
.map(|utxo| utxo.utxo.clone()),
));
state
.expect_request_that(|req| {
matches!(
req,
zakura_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
let verifier_response = verifier
.oneshot(Request::Mempool {
transaction: tx.into(),
height,
})
.await;
assert!(
verifier_response.is_ok(),
"expected successful verification, got: {verifier_response:?}"
);
}
#[tokio::test]
async fn mempool_request_with_invalid_lock_time_is_rejected() {
let mut state: MockService<_, _, _, _> = MockService::build().for_prop_tests();
let verifier = Verifier::new_for_tests(&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,
Amount::try_from(1).expect("invalid value"),
);
let tx = Transaction::V4 {
inputs: vec![input],
outputs: vec![output],
lock_time: LockTime::max_lock_time_timestamp(),
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(zakura_state::Request::BestChainNextMedianTimePast)
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::BestChainNextMedianTimePast(
DateTime32::from(
u32::try_from(LockTime::MIN_TIMESTAMP).expect("min time is valid"),
),
));
state
.expect_request(zakura_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::UnspentBestChainUtxo(
known_utxos
.get(&input_outpoint)
.map(|utxo| utxo.utxo.clone()),
));
state
.expect_request_that(|req| {
matches!(
req,
zakura_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
let verifier_response = verifier
.oneshot(Request::Mempool {
transaction: tx.into(),
height,
})
.await;
assert_eq!(
verifier_response,
Err(TransactionError::LockedUntilAfterBlockTime(
Utc.timestamp_opt(u32::MAX.into(), 0).unwrap()
))
);
}
#[tokio::test]
async fn mempool_request_with_unlocked_lock_time_is_accepted() {
let mut state: MockService<_, _, _, _> = MockService::build().for_prop_tests();
let verifier = Verifier::new_for_tests(&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,
Amount::try_from(10001).expect("invalid value"),
);
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(zakura_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::UnspentBestChainUtxo(
known_utxos
.get(&input_outpoint)
.map(|utxo| utxo.utxo.clone()),
));
state
.expect_request_that(|req| {
matches!(
req,
zakura_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
let verifier_response = verifier
.oneshot(Request::Mempool {
transaction: tx.into(),
height,
})
.await;
assert!(
verifier_response.is_ok(),
"expected successful verification, got: {verifier_response:?}"
);
}
#[tokio::test]
async fn mempool_request_with_lock_time_max_sequence_number_is_accepted() {
let mut state: MockService<_, _, _, _> = MockService::build().for_prop_tests();
let verifier = Verifier::new_for_tests(&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 (mut input, output, known_utxos) = mock_transparent_transfer(
fund_height,
true,
0,
Amount::try_from(10001).expect("invalid value"),
);
input.set_sequence(u32::MAX);
let tx = Transaction::V4 {
inputs: vec![input],
outputs: vec![output],
lock_time: LockTime::max_lock_time_timestamp(),
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(zakura_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::UnspentBestChainUtxo(
known_utxos
.get(&input_outpoint)
.map(|utxo| utxo.utxo.clone()),
));
state
.expect_request_that(|req| {
matches!(
req,
zakura_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
let verifier_response = verifier
.oneshot(Request::Mempool {
transaction: tx.into(),
height,
})
.await;
assert!(
verifier_response.is_ok(),
"expected successful verification, got: {verifier_response:?}"
);
}
#[tokio::test]
async fn mempool_request_with_past_lock_time_is_accepted() {
let mut state: MockService<_, _, _, _> = MockService::build().for_prop_tests();
let verifier = Verifier::new_for_tests(&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,
Amount::try_from(10001).expect("invalid value"),
);
let tx = Transaction::V4 {
inputs: vec![input],
outputs: vec![output],
lock_time: LockTime::min_lock_time_timestamp(),
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(zakura_state::Request::BestChainNextMedianTimePast)
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::BestChainNextMedianTimePast(
DateTime32::MAX,
));
state
.expect_request(zakura_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::UnspentBestChainUtxo(
known_utxos
.get(&input_outpoint)
.map(|utxo| utxo.utxo.clone()),
));
state
.expect_request_that(|req| {
matches!(
req,
zakura_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
let verifier_response = verifier
.oneshot(Request::Mempool {
transaction: tx.into(),
height,
})
.await;
assert!(
verifier_response.is_ok(),
"expected successful verification, got: {verifier_response:?}"
);
}
#[tokio::test]
async fn mempool_request_with_unmined_output_spends_is_accepted() {
let mut state: MockService<_, _, _, _> = MockService::build().for_prop_tests();
let mempool: MockService<_, _, _, _> = MockService::build().for_prop_tests();
let (mempool_setup_tx, mempool_setup_rx) = tokio::sync::oneshot::channel();
let verifier = Verifier::new(&Network::Mainnet, state.clone(), mempool_setup_rx);
mempool_setup_tx
.send(mempool.clone())
.ok()
.expect("send should succeed");
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,
Amount::try_from(10001).expect("invalid value"),
);
let tx = Transaction::V4 {
inputs: vec![input],
outputs: vec![output],
lock_time: LockTime::min_lock_time_timestamp(),
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(zakura_state::Request::BestChainNextMedianTimePast)
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::BestChainNextMedianTimePast(
DateTime32::MAX,
));
state
.expect_request(zakura_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::UnspentBestChainUtxo(None));
state
.expect_request_that(|req| {
matches!(
req,
zakura_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
let mut mempool_clone = mempool.clone();
tokio::spawn(async move {
mempool_clone
.expect_request(mempool::Request::AwaitOutput(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(mempool::Response::UnspentOutput(
known_utxos
.get(&input_outpoint)
.expect("input outpoint should exist in known_utxos")
.utxo
.output
.clone(),
));
});
let verifier_response = verifier
.oneshot(Request::Mempool {
transaction: tx.into(),
height,
})
.await;
assert!(
verifier_response.is_ok(),
"expected successful verification, got: {verifier_response:?}"
);
let crate::transaction::Response::Mempool {
transaction: _,
spent_mempool_outpoints,
} = verifier_response.expect("already checked that response is ok")
else {
panic!("unexpected response variant from transaction verifier for Mempool request")
};
assert_eq!(
spent_mempool_outpoints,
vec![input_outpoint],
"spent_mempool_outpoints in tx verifier response should match input_outpoint"
);
tokio::time::sleep(POLL_MEMPOOL_DELAY * 2).await;
assert_eq!(
mempool.poll_count(),
2,
"the mempool service should have been polled twice"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn dont_skip_verification_of_block_transactions_in_mempool() {
let mut state: MockService<_, _, _, _> = MockService::build().for_prop_tests();
let mempool: MockService<_, _, _, _> = MockService::build().for_prop_tests();
let (mempool_setup_tx, mempool_setup_rx) = tokio::sync::oneshot::channel();
let verifier = Verifier::new(&Network::Mainnet, state.clone(), mempool_setup_rx);
let verifier = Buffer::new(verifier, 1);
mempool_setup_tx
.send(mempool.clone())
.ok()
.expect("send should succeed");
let height = NetworkUpgrade::Nu6
.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,
Amount::try_from(10001).expect("invalid value"),
);
let tx = Transaction::V5 {
network_upgrade: NetworkUpgrade::Nu6,
inputs: vec![input],
outputs: vec![output],
lock_time: LockTime::min_lock_time_timestamp(),
expiry_height: height,
sapling_shielded_data: None,
orchard_shielded_data: None,
};
let tx_hash = tx.hash();
let input_outpoint = match tx.inputs()[0] {
transparent::Input::PrevOut { outpoint, .. } => outpoint,
transparent::Input::Coinbase { .. } => panic!("requires a non-coinbase transaction"),
};
let mut state_clone = state.clone();
tokio::spawn(async move {
state_clone
.expect_request(zakura_state::Request::BestChainNextMedianTimePast)
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::BestChainNextMedianTimePast(
DateTime32::MAX,
));
state_clone
.expect_request(zakura_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::UnspentBestChainUtxo(None));
state_clone
.expect_request_that(|req| {
matches!(
req,
zakura_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
let utxo = known_utxos
.get(&input_outpoint)
.expect("input outpoint should exist in known_utxos")
.utxo
.clone();
let mut mempool_clone = mempool.clone();
let output = utxo.output.clone();
tokio::spawn(async move {
mempool_clone
.expect_request(mempool::Request::AwaitOutput(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(mempool::Response::UnspentOutput(output));
});
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let verifier_response = verifier
.clone()
.oneshot(Request::Mempool {
transaction: tx.clone().into(),
height,
})
.await;
assert!(
verifier_response.is_ok(),
"expected successful verification, got: {verifier_response:?}"
);
let crate::transaction::Response::Mempool {
transaction: _,
spent_mempool_outpoints,
} = verifier_response.expect("already checked that response is ok")
else {
panic!("unexpected response variant from transaction verifier for Mempool request")
};
assert_eq!(
spent_mempool_outpoints,
vec![input_outpoint],
"spent_mempool_outpoints in tx verifier response should match input_outpoint"
);
let make_request = |known_outpoint_hashes| Request::Block {
transaction_hash: tx_hash,
transaction: Arc::new(tx),
known_outpoint_hashes,
known_utxos: Arc::new(HashMap::new()),
height,
time: Utc::now(),
};
let utxo_clone = utxo.clone();
tokio::spawn(async move {
state
.expect_request(zakura_state::Request::AwaitUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::Utxo(utxo_clone));
state
.expect_request(zakura_state::Request::AwaitUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::Utxo(utxo));
});
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let crate::transaction::Response::Block { .. } = verifier
.clone()
.oneshot(make_request.clone()(Arc::new([input_outpoint.hash].into())))
.await
.expect("should succeed after calling state service")
else {
panic!("unexpected response variant from transaction verifier for Block request")
};
let crate::transaction::Response::Block { .. } = verifier
.clone()
.oneshot(make_request.clone()(Arc::new(HashSet::new())))
.await
.expect("should succeed after calling state service")
else {
panic!("unexpected response variant from transaction verifier for Block request")
};
tokio::time::sleep(POLL_MEMPOOL_DELAY * 2).await;
assert_eq!(
mempool.poll_count(),
2,
"the mempool service should have been polled twice"
);
}
#[tokio::test]
async fn mempool_request_with_immature_spend_is_rejected() {
let _init_guard = zakura_test::init();
let mut state: MockService<_, _, _, _> = MockService::build().for_prop_tests();
let verifier = Verifier::new_for_tests(&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,
Amount::try_from(10001).expect("invalid value"),
);
let tx = Transaction::V4 {
inputs: vec![input],
outputs: vec![output],
lock_time: LockTime::min_lock_time_timestamp(),
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"),
};
let spend_restriction = tx.coinbase_spend_restriction(&Network::Mainnet, height);
let coinbase_spend_height = Height(5);
let utxo = known_utxos
.get(&input_outpoint)
.map(|utxo| {
let mut utxo = utxo.utxo.clone();
utxo.height = coinbase_spend_height;
utxo.from_coinbase = true;
utxo
})
.expect("known_utxos should contain the outpoint");
let expected_error =
zakura_state::check::transparent_coinbase_spend(input_outpoint, spend_restriction, &utxo)
.map_err(Box::new)
.map_err(TransactionError::ValidateContextError)
.expect_err("check should fail");
tokio::spawn(async move {
state
.expect_request(zakura_state::Request::BestChainNextMedianTimePast)
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::BestChainNextMedianTimePast(
DateTime32::MAX,
));
state
.expect_request(zakura_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::UnspentBestChainUtxo(
known_utxos.get(&input_outpoint).map(|utxo| {
let mut utxo = utxo.utxo.clone();
utxo.height = coinbase_spend_height;
utxo.from_coinbase = true;
utxo
}),
));
state
.expect_request_that(|req| {
matches!(
req,
zakura_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
let verifier_response = verifier
.oneshot(Request::Mempool {
transaction: tx.into(),
height,
})
.await
.expect_err("verification of transaction with immature spend should fail");
assert_eq!(
verifier_response, expected_error,
"expected to fail verification, got: {verifier_response:?}"
);
}
#[tokio::test]
async fn mempool_request_with_transparent_coinbase_spend_is_accepted_on_regtest() {
let _init_guard = zakura_test::init();
let network = Network::new_regtest(
ConfiguredActivationHeights {
canopy: Some(1),
nu5: Some(100),
nu6: Some(1_000),
..Default::default()
}
.into(),
);
let mut state: MockService<_, _, _, _> = MockService::build().for_unit_tests();
let verifier = Verifier::new_for_tests(&network, state.clone());
let height = NetworkUpgrade::Nu6
.activation_height(&network)
.expect("NU6 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,
Amount::try_from(10001).expect("invalid value"),
);
let tx = Transaction::V5 {
network_upgrade: NetworkUpgrade::Nu6,
inputs: vec![input],
outputs: vec![output],
lock_time: LockTime::min_lock_time_timestamp(),
expiry_height: height,
sapling_shielded_data: None,
orchard_shielded_data: None,
};
let input_outpoint = match tx.inputs()[0] {
transparent::Input::PrevOut { outpoint, .. } => outpoint,
transparent::Input::Coinbase { .. } => panic!("requires a non-coinbase transaction"),
};
let spend_restriction = tx.coinbase_spend_restriction(&network, height);
assert_eq!(
spend_restriction,
CoinbaseSpendRestriction::CheckCoinbaseMaturity {
spend_height: height
}
);
let coinbase_spend_height = Height(5);
let utxo = known_utxos
.get(&input_outpoint)
.map(|utxo| {
let mut utxo = utxo.utxo.clone();
utxo.height = coinbase_spend_height;
utxo.from_coinbase = true;
utxo
})
.expect("known_utxos should contain the outpoint");
zakura_state::check::transparent_coinbase_spend(input_outpoint, spend_restriction, &utxo)
.expect("check should pass");
tokio::spawn(async move {
state
.expect_request(zakura_state::Request::BestChainNextMedianTimePast)
.await
.respond(zakura_state::Response::BestChainNextMedianTimePast(
DateTime32::MAX,
));
state
.expect_request(zakura_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.respond(zakura_state::Response::UnspentBestChainUtxo(Some(utxo)));
state
.expect_request_that(|req| {
matches!(
req,
zakura_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.respond(zakura_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
verifier
.oneshot(Request::Mempool {
transaction: tx.into(),
height,
})
.await
.expect("verification of transaction with mature spend to transparent outputs should pass");
}
#[tokio::test]
async fn state_error_converted_correctly() {
use zakura_state::DuplicateNullifierError;
let mut state: MockService<_, _, _, _> = MockService::build().for_prop_tests();
let verifier = Verifier::new_for_tests(&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,
Amount::try_from(10001).expect("invalid value"),
);
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"),
};
let make_validate_context_error =
|| sprout::Nullifier([0; 32].into()).duplicate_nullifier_error(true);
tokio::spawn(async move {
state
.expect_request(zakura_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::UnspentBestChainUtxo(
known_utxos
.get(&input_outpoint)
.map(|utxo| utxo.utxo.clone()),
));
state
.expect_request_that(|req| {
matches!(
req,
zakura_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(Err::<zakura_state::Response, zakura_state::BoxError>(
make_validate_context_error().into(),
));
});
let verifier_response = verifier
.oneshot(Request::Mempool {
transaction: tx.into(),
height,
})
.await;
let transaction_error =
verifier_response.expect_err("expected failed verification, got: {verifier_response:?}");
assert_eq!(
TransactionError::from(make_validate_context_error()),
transaction_error,
"expected matching state and transaction errors"
);
let state_error = zakura_state::BoxError::from(make_validate_context_error())
.downcast::<ValidateContextError>()
.map(|boxed| TransactionError::from(*boxed))
.expect("downcast should succeed");
assert_eq!(
state_error, transaction_error,
"expected matching state and transaction errors"
);
let TransactionError::ValidateContextError(propagated_validate_context_error) =
transaction_error
else {
panic!("should be a ValidateContextError variant");
};
assert_eq!(
*propagated_validate_context_error,
make_validate_context_error(),
"expected matching state and transaction errors"
);
}
#[test]
fn v5_coinbase_transaction_without_enable_spends_flag_passes_validation() {
for net in Network::iter() {
let mut tx = v5_transactions(net.block_iter())
.find(|transaction| transaction.is_coinbase())
.expect("V5 coinbase tx");
let shielded_data = insert_fake_orchard_shielded_data(&mut tx);
assert!(!shielded_data.flags.contains(Flags::ENABLE_SPENDS));
assert!(check::coinbase_tx_no_prevout_joinsplit_spend(&tx).is_ok());
}
}
#[test]
fn v5_coinbase_transaction_with_enable_spends_flag_fails_validation() {
for net in Network::iter() {
let mut tx = v5_transactions(net.block_iter())
.find(|transaction| transaction.is_coinbase())
.expect("V5 coinbase tx");
let shielded_data = insert_fake_orchard_shielded_data(&mut tx);
assert!(!shielded_data.flags.contains(Flags::ENABLE_SPENDS));
shielded_data.flags = Flags::ENABLE_SPENDS;
assert_eq!(
check::coinbase_tx_no_prevout_joinsplit_spend(&tx),
Err(TransactionError::CoinbaseHasEnableSpendsOrchard)
);
}
}
#[tokio::test]
async fn v5_transaction_is_rejected_before_nu5_activation() {
let sapling = NetworkUpgrade::Sapling;
for net in Network::iter() {
let verifier = Verifier::new_for_tests(
&net,
service_fn(|_| async { unreachable!("Service should not be called") }),
);
let tx = v5_transactions(net.block_iter()).next().expect("V5 tx");
assert_eq!(
verifier
.oneshot(Request::Block {
transaction_hash: tx.hash(),
transaction: Arc::new(tx),
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height: sapling.activation_height(&net).expect("height"),
time: DateTime::<Utc>::MAX_UTC,
})
.await,
Err(TransactionError::UnsupportedByNetworkUpgrade(5, sapling))
);
}
}
#[tokio::test]
async fn v5_transaction_is_accepted_after_nu5_activation() {
let _init_guard = zakura_test::init();
for net in Network::iter() {
let state = service_fn(|_| async { unreachable!("Service should not be called") });
let tx = v5_transactions(net.block_iter()).next().expect("V5 tx");
let tx_height = tx.expiry_height().expect("V5 must have expiry_height");
let expected = tx.unmined_id();
assert!(tx_height >= NetworkUpgrade::Nu5.activation_height(&net).expect("height"));
let verif_res = Verifier::new_for_tests(&net, state)
.oneshot(Request::Block {
transaction_hash: tx.hash(),
transaction: Arc::new(tx),
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height: tx_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(verif_res.expect("success").tx_id(), expected);
}
}
#[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");
let (input, output, known_utxos) = mock_transparent_transfer(
fake_source_fund_height,
true,
0,
Amount::try_from(1).expect("invalid value"),
);
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 verifier = Verifier::new_for_tests(&network, state_service);
let result = verifier
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction),
known_utxos: Arc::new(known_utxos),
known_outpoint_hashes: Arc::new(HashSet::new()),
height: transaction_block_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result.expect("unexpected error response").tx_id(),
transaction_hash
);
}
#[tokio::test]
async fn v4_transaction_with_last_valid_expiry_height() {
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = Verifier::new_for_tests(&Network::Mainnet, state_service);
let block_height = NetworkUpgrade::Canopy
.activation_height(&Network::Mainnet)
.expect("Canopy activation height is specified");
let fund_height = (block_height - 1).expect("fake source fund block height is too small");
let (input, output, known_utxos) = mock_transparent_transfer(
fund_height,
true,
0,
Amount::try_from(1).expect("invalid value"),
);
let transaction = Transaction::V4 {
inputs: vec![input],
outputs: vec![output],
lock_time: LockTime::unlocked(),
expiry_height: block_height,
joinsplit_data: None,
sapling_shielded_data: None,
};
let result = verifier
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction.clone()),
known_utxos: Arc::new(known_utxos),
known_outpoint_hashes: Arc::new(HashSet::new()),
height: block_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result.expect("unexpected error response").tx_id(),
transaction.unmined_id()
);
}
#[tokio::test]
async fn v4_coinbase_transaction_with_low_expiry_height() {
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = Verifier::new_for_tests(&Network::Mainnet, state_service);
let block_height = NetworkUpgrade::Canopy
.activation_height(&Network::Mainnet)
.expect("Canopy activation height is specified");
let (input, output) = mock_coinbase_transparent_output(block_height);
let expiry_height = (block_height - 1).expect("original block height is too small");
let transaction = Transaction::V4 {
inputs: vec![input],
outputs: vec![output],
lock_time: LockTime::unlocked(),
expiry_height,
joinsplit_data: None,
sapling_shielded_data: None,
};
let result = verifier
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction.clone()),
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height: block_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result.expect("unexpected error response").tx_id(),
transaction.unmined_id()
);
}
#[tokio::test]
async fn v4_transaction_with_too_low_expiry_height() {
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = Verifier::new_for_tests(&Network::Mainnet, state_service);
let block_height = NetworkUpgrade::Canopy
.activation_height(&Network::Mainnet)
.expect("Canopy activation height is specified");
let fund_height = (block_height - 1).expect("fake source fund block height is too small");
let (input, output, known_utxos) = mock_transparent_transfer(
fund_height,
true,
0,
Amount::try_from(1).expect("invalid value"),
);
let expiry_height = (block_height - 1).expect("original block height is too small");
let transaction = Transaction::V4 {
inputs: vec![input],
outputs: vec![output],
lock_time: LockTime::unlocked(),
expiry_height,
joinsplit_data: None,
sapling_shielded_data: None,
};
let result = verifier
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction.clone()),
known_utxos: Arc::new(known_utxos),
known_outpoint_hashes: Arc::new(HashSet::new()),
height: block_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result,
Err(TransactionError::ExpiredTransaction {
expiry_height,
block_height,
transaction_hash: transaction.hash(),
})
);
}
#[tokio::test]
async fn v4_transaction_with_exceeding_expiry_height() {
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = Verifier::new_for_tests(&Network::Mainnet, state_service);
let block_height = block::Height::MAX;
let fund_height = (block_height - 1).expect("fake source fund block height is too small");
let (input, output, known_utxos) = mock_transparent_transfer(
fund_height,
true,
0,
Amount::try_from(1).expect("invalid value"),
);
let expiry_height = block::Height(500_000_000);
let transaction = Transaction::V4 {
inputs: vec![input],
outputs: vec![output],
lock_time: LockTime::unlocked(),
expiry_height,
joinsplit_data: None,
sapling_shielded_data: None,
};
let result = verifier
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction.clone()),
known_utxos: Arc::new(known_utxos),
known_outpoint_hashes: Arc::new(HashSet::new()),
height: block_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result,
Err(TransactionError::MaximumExpiryHeight {
expiry_height,
is_coinbase: false,
block_height,
transaction_hash: transaction.hash(),
})
);
}
#[tokio::test]
async fn v4_coinbase_transaction_with_exceeding_expiry_height() {
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = Verifier::new_for_tests(&Network::Mainnet, state_service);
let block_height = (NetworkUpgrade::Nu5
.activation_height(&Network::Mainnet)
.expect("NU5 height must be set")
- 1)
.expect("will not underflow");
let (input, output) = mock_coinbase_transparent_output(block_height);
let expiry_height = block::Height(500_000_000);
let transaction = Transaction::V4 {
inputs: vec![input],
outputs: vec![output],
lock_time: LockTime::unlocked(),
expiry_height,
joinsplit_data: None,
sapling_shielded_data: None,
};
let result = verifier
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction.clone()),
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height: block_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result,
Err(TransactionError::MaximumExpiryHeight {
expiry_height,
is_coinbase: true,
block_height,
transaction_hash: transaction.hash(),
})
);
}
#[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");
let (input, output) = mock_coinbase_transparent_output(transaction_block_height);
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 verifier = Verifier::new_for_tests(&network, state_service);
let result = verifier
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction),
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height: transaction_block_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result.expect("unexpected error response").tx_id(),
transaction_hash
);
}
#[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");
let (input, output, known_utxos) = mock_transparent_transfer(
fake_source_fund_height,
false,
0,
Amount::try_from(1).expect("invalid value"),
);
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 verifier = Verifier::new_for_tests(&network, state_service);
let result = verifier
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction),
known_utxos: Arc::new(known_utxos),
known_outpoint_hashes: Arc::new(HashSet::new()),
height: transaction_block_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result,
Err(TransactionError::Script(
zakura_script::Error::ScriptInvalid
))
);
}
#[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");
let (input, output, known_utxos) = mock_transparent_transfer(
fake_source_fund_height,
true,
0,
Amount::try_from(1).expect("invalid value"),
);
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 verifier = Verifier::new_for_tests(&network, state_service);
let result = verifier
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction),
known_utxos: Arc::new(known_utxos),
known_outpoint_hashes: Arc::new(HashSet::new()),
height: transaction_block_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
let expected_outpoint = input.outpoint().expect("Input should have an outpoint");
assert_eq!(
result,
Err(TransactionError::DuplicateTransparentSpend(
expected_outpoint
))
);
}
#[test]
fn v4_transaction_with_conflicting_sprout_nullifier_inside_joinsplit_is_rejected() {
let _init_guard = zakura_test::init();
zakura_test::MULTI_THREADED_RUNTIME.block_on(async {
let network = Network::Mainnet;
let nu = 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");
let (mut joinsplit_data, signing_key) = mock_sprout_join_split_data();
let duplicate_nullifier = joinsplit_data.first.nullifiers[0];
joinsplit_data.first.nullifiers[1] = duplicate_nullifier;
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,
};
let sighash = transaction
.sighash(nu, HashType::ALL, Arc::new(Vec::new()), None)
.expect("network upgrade should be valid for tx");
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 verifier = Verifier::new_for_tests(&network, state_service);
let result = verifier
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction),
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height: transaction_block_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result,
Err(TransactionError::DuplicateSproutNullifier(
duplicate_nullifier
))
);
});
}
#[test]
fn v4_transaction_with_conflicting_sprout_nullifier_across_joinsplits_is_rejected() {
let _init_guard = zakura_test::init();
zakura_test::MULTI_THREADED_RUNTIME.block_on(async {
let network = Network::Mainnet;
let nu = 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");
let (mut joinsplit_data, signing_key) = mock_sprout_join_split_data();
let duplicate_nullifier = joinsplit_data.first.nullifiers[1];
let mut new_joinsplit = joinsplit_data.first.clone();
new_joinsplit.nullifiers[0] = duplicate_nullifier;
new_joinsplit.nullifiers[1] = sprout::note::Nullifier([2u8; 32].into());
joinsplit_data.rest.push(new_joinsplit);
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,
};
let sighash = transaction
.sighash(nu, HashType::ALL, Arc::new(Vec::new()), None)
.expect("network upgrade should be valid for tx");
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 verifier = Verifier::new_for_tests(&network, state_service);
let result = verifier
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction),
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height: transaction_block_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result,
Err(TransactionError::DuplicateSproutNullifier(
duplicate_nullifier
))
);
});
}
#[tokio::test]
async fn v5_transaction_with_transparent_transfer_is_accepted() {
let network = Network::new_default_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");
let (input, output, known_utxos) = mock_transparent_transfer(
fake_source_fund_height,
true,
0,
Amount::try_from(1).expect("invalid value"),
);
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 verifier = Verifier::new_for_tests(&network, state_service);
let result = verifier
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction),
known_utxos: Arc::new(known_utxos),
known_outpoint_hashes: Arc::new(HashSet::new()),
height: transaction_block_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result.expect("unexpected error response").tx_id(),
transaction_hash
);
}
#[tokio::test]
async fn v5_transaction_with_last_valid_expiry_height() {
let network = Network::new_default_testnet();
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = Verifier::new_for_tests(&network, state_service);
let block_height = NetworkUpgrade::Nu5
.activation_height(&network)
.expect("Nu5 activation height for testnet is specified");
let fund_height = (block_height - 1).expect("fake source fund block height is too small");
let (input, output, known_utxos) = mock_transparent_transfer(
fund_height,
true,
0,
Amount::try_from(1).expect("invalid value"),
);
let transaction = Transaction::V5 {
inputs: vec![input],
outputs: vec![output],
lock_time: LockTime::unlocked(),
expiry_height: block_height,
sapling_shielded_data: None,
orchard_shielded_data: None,
network_upgrade: NetworkUpgrade::Nu5,
};
let result = verifier
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction.clone()),
known_utxos: Arc::new(known_utxos),
known_outpoint_hashes: Arc::new(HashSet::new()),
height: block_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result.expect("unexpected error response").tx_id(),
transaction.unmined_id()
);
}
#[tokio::test]
async fn v5_coinbase_transaction_expiry_height() {
let network = Network::new_default_testnet();
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = Verifier::new_for_tests(&network, state_service);
let verifier = Buffer::new(verifier, 10);
let block_height = NetworkUpgrade::Nu5
.activation_height(&network)
.expect("Nu5 activation height for testnet is specified");
let (input, output) = mock_coinbase_transparent_output(block_height);
let transaction = Transaction::V5 {
inputs: vec![input],
outputs: vec![output],
lock_time: LockTime::unlocked(),
expiry_height: block_height,
sapling_shielded_data: None,
orchard_shielded_data: None,
network_upgrade: NetworkUpgrade::Nu5,
};
let result = verifier
.clone()
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction.clone()),
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height: block_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result.expect("unexpected error response").tx_id(),
transaction.unmined_id()
);
let new_expiry_height = (block_height + 1).expect("transaction block height is too large");
let mut new_transaction = transaction.clone();
*new_transaction.expiry_height_mut() = new_expiry_height;
let result = verifier
.clone()
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction: Arc::new(new_transaction.clone()),
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height: block_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await
.map_err(|err| {
*err.downcast()
.expect("error type should be TransactionError")
});
assert_eq!(
result,
Err(TransactionError::CoinbaseExpiryBlockHeight {
expiry_height: Some(new_expiry_height),
block_height,
transaction_hash: new_transaction.hash(),
})
);
let new_expiry_height = (block_height - 1).expect("transaction block height is too low");
let mut new_transaction = transaction.clone();
*new_transaction.expiry_height_mut() = new_expiry_height;
let result = verifier
.clone()
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction: Arc::new(new_transaction.clone()),
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height: block_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await
.map_err(|err| {
*err.downcast()
.expect("error type should be TransactionError")
});
assert_eq!(
result,
Err(TransactionError::CoinbaseExpiryBlockHeight {
expiry_height: Some(new_expiry_height),
block_height,
transaction_hash: new_transaction.hash(),
})
);
let new_expiry_height = Height::MAX;
let mut new_transaction = transaction.clone();
*new_transaction.expiry_height_mut() = new_expiry_height;
let height = new_expiry_height;
new_transaction
.update_network_upgrade(NetworkUpgrade::current(&network, height))
.expect("updating the network upgrade for a V5 tx should succeed");
let verification_result = verifier
.clone()
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction: Arc::new(new_transaction.clone()),
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
verification_result
.expect("successful verification")
.tx_id(),
new_transaction.unmined_id()
);
}
#[tokio::test]
async fn v5_transaction_with_too_low_expiry_height() {
let network = Network::new_default_testnet();
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = Verifier::new_for_tests(&network, state_service);
let block_height = NetworkUpgrade::Nu5
.activation_height(&network)
.expect("Nu5 activation height for testnet is specified");
let fund_height = (block_height - 1).expect("fake source fund block height is too small");
let (input, output, known_utxos) = mock_transparent_transfer(
fund_height,
true,
0,
Amount::try_from(1).expect("invalid value"),
);
let expiry_height = (block_height - 1).expect("original block height is too small");
let transaction = Transaction::V5 {
inputs: vec![input],
outputs: vec![output],
lock_time: LockTime::unlocked(),
expiry_height,
sapling_shielded_data: None,
orchard_shielded_data: None,
network_upgrade: NetworkUpgrade::Nu5,
};
let result = verifier
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction.clone()),
known_utxos: Arc::new(known_utxos),
known_outpoint_hashes: Arc::new(HashSet::new()),
height: block_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result,
Err(TransactionError::ExpiredTransaction {
expiry_height,
block_height,
transaction_hash: transaction.hash(),
})
);
}
#[tokio::test]
async fn v5_transaction_with_exceeding_expiry_height() {
let state = service_fn(|_| async { unreachable!("State service should not be called") });
let height_max = block::Height::MAX;
let (input, output, known_utxos) = mock_transparent_transfer(
height_max.previous().expect("valid height"),
true,
0,
Amount::try_from(1).expect("valid amount"),
);
let expiry_height = block::Height(500_000_000);
let transaction = Transaction::V5 {
inputs: vec![input],
outputs: vec![output],
lock_time: LockTime::unlocked(),
expiry_height,
sapling_shielded_data: None,
orchard_shielded_data: None,
network_upgrade: NetworkUpgrade::Nu6_3,
};
let transaction_hash = transaction.hash();
let verification_result = Verifier::new_for_tests(&Network::Mainnet, state)
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction.clone()),
known_utxos: Arc::new(known_utxos),
known_outpoint_hashes: Arc::new(HashSet::new()),
height: height_max,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
verification_result,
Err(TransactionError::MaximumExpiryHeight {
expiry_height,
is_coinbase: false,
block_height: height_max,
transaction_hash,
})
);
}
#[tokio::test]
async fn v5_coinbase_transaction_is_accepted() {
let network = Network::new_default_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 (input, output) = mock_coinbase_transparent_output(transaction_block_height);
let known_utxos = HashMap::new();
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 verifier = Verifier::new_for_tests(&network, state_service);
let result = verifier
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction),
known_utxos: Arc::new(known_utxos),
known_outpoint_hashes: Arc::new(HashSet::new()),
height: transaction_block_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result.expect("unexpected error response").tx_id(),
transaction_hash
);
}
#[tokio::test]
async fn v5_transaction_with_transparent_transfer_is_rejected_by_the_script() {
let network = Network::new_default_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");
let (input, output, known_utxos) = mock_transparent_transfer(
fake_source_fund_height,
false,
0,
Amount::try_from(1).expect("invalid value"),
);
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 verifier = Verifier::new_for_tests(&network, state_service);
let result = verifier
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction),
known_utxos: Arc::new(known_utxos),
known_outpoint_hashes: Arc::new(HashSet::new()),
height: transaction_block_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result,
Err(TransactionError::Script(
zakura_script::Error::ScriptInvalid
))
);
}
#[tokio::test]
async fn v5_transaction_with_conflicting_transparent_spend_is_rejected() {
for network in Network::iter() {
let canopy_activation_height = NetworkUpgrade::Canopy
.activation_height(&network)
.expect("Canopy activation height is specified");
let height = (canopy_activation_height + 10).expect("valid height");
let (input, output, known_utxos) = mock_transparent_transfer(
height.previous().expect("valid height"),
true,
0,
Amount::try_from(1).expect("valid amount"),
);
let transaction = Transaction::V5 {
inputs: vec![input.clone(), input.clone()],
outputs: vec![output],
lock_time: LockTime::Height(block::Height(0)),
expiry_height: height.next().expect("valid height"),
sapling_shielded_data: None,
orchard_shielded_data: None,
network_upgrade: NetworkUpgrade::Canopy,
};
let state = service_fn(|_| async { unreachable!("State service should not be called") });
let verification_result = Verifier::new_for_tests(&network, state)
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction),
known_utxos: Arc::new(known_utxos),
known_outpoint_hashes: Arc::new(HashSet::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
verification_result,
Err(TransactionError::DuplicateTransparentSpend(
input.outpoint().expect("Input should have an outpoint")
))
);
}
}
#[test]
fn v4_with_signed_sprout_transfer_is_accepted() {
let _init_guard = zakura_test::init();
zakura_test::MULTI_THREADED_RUNTIME.block_on(async {
let network = Network::Mainnet;
let (height, transaction) = test_transactions(&network)
.rev()
.filter(|(_, transaction)| {
!transaction.is_coinbase() && transaction.inputs().is_empty()
})
.find(|(_, transaction)| transaction.sprout_groth16_joinsplits().next().is_some())
.expect("No transaction found with Groth16 JoinSplits");
let expected_hash = transaction.unmined_id();
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = Verifier::new_for_tests(&network, state_service);
let result = verifier
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction,
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result.expect("unexpected error response").tx_id(),
expected_hash
);
})
}
#[test]
fn v4_with_modified_joinsplit_is_rejected() {
let _init_guard = zakura_test::init();
zakura_test::MULTI_THREADED_RUNTIME.block_on(async {
v4_with_joinsplit_is_rejected_for_modification(
JoinSplitModification::CorruptSignature,
TransactionError::Ed25519(ed25519::Error::InvalidSignature),
)
.await;
v4_with_joinsplit_is_rejected_for_modification(
JoinSplitModification::CorruptProof,
TransactionError::Groth16("proof verification failed".to_string()),
)
.await;
v4_with_joinsplit_is_rejected_for_modification(
JoinSplitModification::ZeroProof,
TransactionError::MalformedGroth16("invalid G1".to_string()),
)
.await;
})
}
async fn v4_with_joinsplit_is_rejected_for_modification(
modification: JoinSplitModification,
expected_error: TransactionError,
) {
let network = Network::Mainnet;
let (height, mut transaction) = test_transactions(&network)
.rev()
.filter(|(_, tx)| {
!tx.is_coinbase() && tx.inputs().is_empty() && !tx.has_sapling_shielded_data()
})
.find(|(_, tx)| tx.sprout_groth16_joinsplits().next().is_some())
.expect("There should be a tx with Groth16 JoinSplits.");
let expected_error = Err(expected_error);
let tx = Arc::get_mut(&mut transaction).expect("The tx should have only one active reference.");
match tx {
Transaction::V4 {
joinsplit_data: Some(ref mut joinsplit_data),
..
} => modify_joinsplit_data(joinsplit_data, modification),
_ => unreachable!("Transaction should have some JoinSplit shielded data."),
}
let state_service =
service_fn(|_| async { unreachable!("State service should not be called.") });
let verifier = Verifier::new_for_tests(&network, state_service);
let verifier = Buffer::new(verifier, 10);
let mut i = 1;
let result = loop {
let result = verifier
.clone()
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction: transaction.clone(),
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await
.map_err(|err| {
*err.downcast()
.expect("error type should be TransactionError")
});
if result == expected_error || i >= 100 {
break result;
}
i += 1;
};
assert_eq!(result, expected_error);
}
#[test]
fn v4_and_v5_with_sapling_spends() {
let _init_guard = zakura_test::init();
zakura_test::MULTI_THREADED_RUNTIME.block_on(async {
let network = Network::Mainnet;
let (height, transaction) = test_transactions(&network)
.rev()
.filter(|(_, transaction)| {
!transaction.is_coinbase() && 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 state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = Verifier::new_for_tests(&network, state_service);
let result = timeout(
test_timeout(),
verifier.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction,
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
}),
)
.await
.expect("timeout expired");
assert_eq!(
result.expect("unexpected error response").tx_id(),
expected_hash
);
for net in Network::iter() {
let nu5_activation = NetworkUpgrade::Nu5.activation_height(&net);
let tx = v5_transactions(net.block_iter())
.filter(|tx| {
!tx.is_coinbase()
&& tx.inputs().is_empty()
&& tx.expiry_height() >= nu5_activation
})
.find(|tx| tx.sapling_spends_per_anchor().next().is_some())
.expect("V5 tx with Sapling spends");
let expected_hash = tx.unmined_id();
let height = tx.expiry_height().expect("expiry height");
let verifier = Verifier::new_for_tests(
&net,
service_fn(|_| async { unreachable!("State service should not be called") }),
);
assert_eq!(
timeout(
test_timeout(),
verifier.oneshot(Request::Block {
transaction_hash: tx.hash(),
transaction: Arc::new(tx),
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
)
.await
.expect("timeout expired")
.expect("unexpected error response")
.tx_id(),
expected_hash
);
}
});
}
#[test]
fn v4_with_invalid_sapling_proof_returns_typed_error() {
let _init_guard = zakura_test::init();
zakura_test::MULTI_THREADED_RUNTIME.block_on(async {
let network = Network::Mainnet;
let (height, mut transaction) = test_transactions(&network)
.rev()
.find(|(_, transaction)| {
!transaction.is_coinbase()
&& transaction.inputs().is_empty()
&& transaction.sapling_spends_per_anchor().next().is_some()
})
.expect("test vectors include a V4 transaction with Sapling spends");
let (valid_bundle_one, valid_bundle_two, valid_sighash) = {
let sighasher = transaction
.sighasher(
NetworkUpgrade::current(&network, height),
Arc::new(Vec::new()),
)
.expect("test fixture has no transparent inputs");
let valid_bundle_one = sighasher
.sapling_bundle()
.expect("test fixture has Sapling shielded data");
let valid_bundle_two = sighasher
.sapling_bundle()
.expect("test fixture has Sapling shielded data");
let valid_sighash = sighasher.sighash(HashType::ALL, None);
(valid_bundle_one, valid_bundle_two, valid_sighash)
};
modify_first_sapling_spend_proof(
Arc::get_mut(&mut transaction).expect("transaction only has one active reference"),
SaplingProofModification::Corrupt,
);
let sighasher = transaction
.sighasher(
NetworkUpgrade::current(&network, height),
Arc::new(Vec::new()),
)
.expect("test fixture has no transparent inputs");
let batch_bundle = sighasher
.sapling_bundle()
.expect("test fixture has Sapling shielded data");
let single_bundle = sighasher
.sapling_bundle()
.expect("test fixture has Sapling shielded data");
let fallback_bundle = sighasher
.sapling_bundle()
.expect("test fixture has Sapling shielded data");
let synchronous_check_bundle = sighasher
.sapling_bundle()
.expect("test fixture has Sapling shielded data");
let sighash = sighasher.sighash(HashType::ALL, None);
drop(sighasher);
let mut verifier = sapling_crypto::BatchValidator::default();
assert!(
verifier.check_bundle(synchronous_check_bundle, sighash.into()),
"the corrupted proof must pass synchronous checks and fail batch validation"
);
let mut batch_verifier = crate::primitives::sapling::Verifier::default();
let batch_result = batch_verifier.call(BatchControl::Item(
crate::primitives::sapling::Item::new(batch_bundle, sighash),
));
let flush_result = batch_verifier.call(BatchControl::Flush);
let (batch_result, flush_result) = futures::join!(batch_result, flush_result);
flush_result.expect("batch flush must complete");
assert_sapling_verification_error(
batch_result.expect_err("corrupted Sapling proof must be rejected"),
);
let single_result = crate::primitives::sapling::verify_single(
crate::primitives::sapling::Item::new(single_bundle, sighash),
)
.await;
assert_sapling_verification_error(
single_result.expect_err("corrupted Sapling proof must be rejected"),
);
let invalid_item = crate::primitives::sapling::Item::new(fallback_bundle, sighash);
let items = vec![
crate::primitives::sapling::Item::new(valid_bundle_one, valid_sighash),
invalid_item.clone(),
crate::primitives::sapling::Item::new(valid_bundle_two, valid_sighash),
];
let expected_results: Vec<_> = futures::future::join_all(
items
.iter()
.cloned()
.map(crate::primitives::sapling::verify_single),
)
.await
.into_iter()
.map(|result| result.is_ok())
.collect();
assert_eq!(
expected_results,
[true, false, true],
"the fixture must contain valid neighbors around one invalid proof"
);
let mut verifier = Fallback::new(
Batch::new(
crate::primitives::sapling::Verifier::default(),
100,
1,
std::time::Duration::from_secs(1000),
),
service_fn(crate::primitives::sapling::verify_single),
);
verifier
.ready()
.await
.expect("test verifier must become ready");
let invalid_singleton = verifier.call(invalid_item);
let mut primary = verifier.primary().clone();
assert!(
primary
.try_flush()
.expect("explicit Sapling singleton flush must not fail"),
"explicit Sapling singleton flush must be queued"
);
assert!(
timeout(test_timeout(), invalid_singleton)
.await
.expect("explicitly flushed Sapling singleton must complete")
.is_err(),
"an explicitly flushed invalid Sapling singleton must be rejected"
);
let mut batch_results = Vec::new();
for item in items {
verifier
.ready()
.await
.expect("test verifier must become ready");
batch_results.push(verifier.call(item));
}
assert!(
primary
.try_flush()
.expect("explicit Sapling test flush must not fail"),
"explicit Sapling test flush must be queued"
);
let actual_results: Vec<_> =
timeout(test_timeout(), futures::future::join_all(batch_results))
.await
.expect("explicitly flushed Sapling verification must complete")
.into_iter()
.map(|result| result.is_ok())
.collect();
assert_eq!(
actual_results, expected_results,
"explicit Sapling batch flush plus fallback must match single verification"
);
let known_outpoint_hashes = Arc::new(HashSet::new());
let _block_batch_flush =
crate::primitives::register_block_verifier_batch_flush(&known_outpoint_hashes, 1);
let transaction_hash = transaction.hash();
let transaction_verifier = Verifier::new_for_tests(
&network,
service_fn(|_| async { unreachable!("fixture has no transparent inputs") }),
);
let block_transaction_result = timeout(
test_timeout(),
transaction_verifier.oneshot(Request::Block {
transaction_hash,
transaction,
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes,
height,
time: DateTime::<Utc>::MAX_UTC,
}),
)
.await
.expect("block-boundary-flushed transaction verification must complete");
assert_eq!(
block_transaction_result,
Err(TransactionError::SaplingVerificationFailed),
"the block-boundary flush path must reject an invalid Sapling proof"
);
});
}
#[test]
fn v4_with_malformed_sapling_proof_returns_typed_error() {
let _init_guard = zakura_test::init();
zakura_test::MULTI_THREADED_RUNTIME.block_on(async {
let network = Network::Mainnet;
let (height, mut transaction) = test_transactions(&network)
.rev()
.find(|(_, transaction)| {
!transaction.is_coinbase()
&& transaction.inputs().is_empty()
&& transaction.sapling_spends_per_anchor().next().is_some()
})
.expect("test vectors include a V4 transaction with Sapling spends");
modify_first_sapling_spend_proof(
Arc::get_mut(&mut transaction).expect("transaction only has one active reference"),
SaplingProofModification::Zero,
);
let sighasher = transaction
.sighasher(
NetworkUpgrade::current(&network, height),
Arc::new(Vec::new()),
)
.expect("test fixture has no transparent inputs");
let check_bundle = sighasher
.sapling_bundle()
.expect("test fixture has Sapling shielded data");
let bundle = sighasher
.sapling_bundle()
.expect("test fixture has Sapling shielded data");
let sighash = sighasher.sighash(HashType::ALL, None);
let mut verifier = sapling_crypto::BatchValidator::default();
assert!(
!verifier.check_bundle(check_bundle, sighash.into()),
"the malformed proof must fail synchronous bundle checks"
);
let result = crate::primitives::sapling::verify_single(
crate::primitives::sapling::Item::new(bundle, sighash),
)
.await;
assert_sapling_verification_error(
result.expect_err("malformed Sapling proof must be rejected"),
);
});
}
fn assert_sapling_verification_error(error: crate::BoxError) {
let error = error
.downcast::<TransactionError>()
.expect("Sapling verification failure must be a TransactionError");
assert_eq!(*error, TransactionError::SaplingVerificationFailed);
}
#[test]
fn v4_with_duplicate_sapling_spends() {
let _init_guard = zakura_test::init();
zakura_test::MULTI_THREADED_RUNTIME.block_on(async {
let network = Network::Mainnet;
let (height, mut transaction) = test_transactions(&network)
.rev()
.filter(|(_, transaction)| {
!transaction.is_coinbase() && transaction.inputs().is_empty()
})
.find(|(_, transaction)| transaction.sapling_spends_per_anchor().next().is_some())
.expect("No transaction found with Sapling spends");
let duplicate_nullifier = duplicate_sapling_spend(
Arc::get_mut(&mut transaction).expect("Transaction only has one active reference"),
);
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = Verifier::new_for_tests(&network, state_service);
let result = verifier
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction,
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result,
Err(TransactionError::DuplicateSaplingNullifier(
duplicate_nullifier
))
);
});
}
#[test]
fn v4_with_sapling_outputs_and_no_spends() {
let _init_guard = zakura_test::init();
zakura_test::MULTI_THREADED_RUNTIME.block_on(async {
let network = Network::Mainnet;
let (height, transaction) = test_transactions(&network)
.rev()
.filter(|(_, transaction)| {
!transaction.is_coinbase() && 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();
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = Verifier::new_for_tests(&network, state_service);
let result = verifier
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction,
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result.expect("unexpected error response").tx_id(),
expected_hash
);
})
}
#[test]
fn sapling_output_with_invalid_ephemeral_key_is_rejected() {
let _init_guard = zakura_test::init();
zakura_test::MULTI_THREADED_RUNTIME.block_on(async {
let network = Network::Mainnet;
let (height, mut transaction) = test_transactions(&network)
.rev()
.filter(|(_, transaction)| {
!transaction.is_coinbase() && transaction.inputs().is_empty()
})
.find(|(_, transaction)| {
transaction.sapling_spends_per_anchor().next().is_none()
&& transaction.sapling_outputs().next().is_some()
})
.expect("a transaction with Sapling outputs and no Sapling spends");
corrupt_first_sapling_output_ephemeral_key(
Arc::get_mut(&mut transaction).expect("transaction only has one active reference"),
);
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = Verifier::new_for_tests(&network, state_service);
let result = verifier
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction,
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result,
Err(TransactionError::SmallOrder),
"a Sapling output with an off-curve ephemeral key must be rejected with SmallOrder",
);
});
}
#[test]
fn sapling_v4_output_with_invalid_value_commitment_is_rejected_after_roundtrip() {
let _init_guard = zakura_test::init();
zakura_test::MULTI_THREADED_RUNTIME.block_on(async {
let network = Network::Mainnet;
let (height, mut transaction) = test_transactions(&network)
.rev()
.filter(|(_, transaction)| {
!transaction.is_coinbase() && transaction.inputs().is_empty()
})
.find(|(_, transaction)| {
transaction.sapling_spends_per_anchor().next().is_none()
&& transaction.sapling_outputs().next().is_some()
})
.expect("a V4 transaction with Sapling outputs and no Sapling spends");
assert!(
matches!(transaction.as_ref(), Transaction::V4 { .. }),
"test vector must exercise the V4 output encoding"
);
corrupt_first_sapling_output_value_commitment(
Arc::get_mut(&mut transaction).expect("transaction only has one active reference"),
);
let serialized = transaction
.zcash_serialize_to_vec()
.expect("corrupted V4 transaction must serialize raw Sapling cv bytes");
let transaction = Arc::new(
serialized
.zcash_deserialize_into::<Transaction>()
.expect("lazy V4 output deserialization must preserve invalid cv bytes"),
);
assert!(
!transaction.sapling_point_encodings_are_valid(),
"the deferred Sapling point check must reject the round-tripped invalid cv"
);
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = Verifier::new_for_tests(&network, state_service);
let result = verifier
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction,
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result,
Err(TransactionError::SmallOrder),
"a V4 Sapling output with an off-curve cv must be rejected with SmallOrder",
);
});
}
#[test]
fn sapling_spends_with_invalid_value_commitments_are_rejected_after_roundtrip() {
let _init_guard = zakura_test::init();
zakura_test::MULTI_THREADED_RUNTIME.block_on(async {
let network = Network::Mainnet;
let v4 = test_transactions(&network)
.rev()
.filter(|(_, transaction)| {
!transaction.is_coinbase() && transaction.inputs().is_empty()
})
.find(|(_, transaction)| {
matches!(transaction.as_ref(), Transaction::V4 { .. })
&& transaction.sapling_spends_per_anchor().next().is_some()
})
.expect("a V4 transaction with Sapling spends");
let v5 = transactions_from_blocks(network.block_iter())
.rev()
.filter(|(_, transaction)| {
!transaction.is_coinbase() && transaction.inputs().is_empty()
})
.find(|(_, transaction)| {
matches!(transaction.as_ref(), Transaction::V5 { .. })
&& transaction.sapling_spends_per_anchor().next().is_some()
})
.expect("a V5 transaction with Sapling spends");
for (version, height, mut transaction) in [("V4", v4.0, v4.1), ("V5", v5.0, v5.1)] {
corrupt_first_sapling_spend_value_commitment(
Arc::get_mut(&mut transaction).expect("transaction only has one active reference"),
);
let serialized = transaction
.zcash_serialize_to_vec()
.expect("corrupted transaction must serialize raw Sapling cv bytes");
let transaction = Arc::new(
serialized
.zcash_deserialize_into::<Transaction>()
.expect("lazy spend deserialization must preserve invalid cv bytes"),
);
assert_eq!(
serialized,
transaction
.zcash_serialize_to_vec()
.expect("round-tripped transaction must reserialize"),
"lazy {version} spend cv bytes must round-trip exactly",
);
assert!(
!transaction.sapling_point_encodings_are_valid(),
"the deferred Sapling point check must reject the round-tripped {version} spend cv"
);
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = Verifier::new_for_tests(&network, state_service);
let result = verifier
.oneshot(Request::Block {
transaction_hash: transaction.hash(),
transaction,
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result,
Err(TransactionError::SmallOrder),
"a {version} Sapling spend with an off-curve cv must be rejected with SmallOrder",
);
}
});
}
fn corrupt_first_sapling_output_ephemeral_key(transaction: &mut Transaction) {
let bad_epk = sapling::keys::EphemeralPublicKey::try_from([0xffu8; 32])
.expect("deserialization defers point validation, so try_from stores the bytes");
match transaction {
Transaction::V4 {
sapling_shielded_data: Some(shielded_data),
..
} => set_first_sapling_output_ephemeral_key(&mut shielded_data.transfers, bad_epk),
Transaction::V5 {
sapling_shielded_data: Some(shielded_data),
..
} => set_first_sapling_output_ephemeral_key(&mut shielded_data.transfers, bad_epk),
_ => panic!("expected a V4 or V5 transaction with Sapling data"),
}
}
fn corrupt_first_sapling_output_value_commitment(transaction: &mut Transaction) {
let bad_cv = [0xffu8; 32][..]
.zcash_deserialize_into::<sapling::ValueCommitment>()
.expect("deserialization defers point validation, so it stores the bytes");
match transaction {
Transaction::V4 {
sapling_shielded_data: Some(shielded_data),
..
} => set_first_sapling_output_value_commitment(&mut shielded_data.transfers, bad_cv),
_ => panic!("expected a V4 transaction with Sapling data"),
}
}
fn corrupt_first_sapling_spend_value_commitment(transaction: &mut Transaction) {
let bad_cv = [0xffu8; 32][..]
.zcash_deserialize_into::<sapling::ValueCommitment>()
.expect("deserialization defers point validation, so it stores the bytes");
match transaction {
Transaction::V4 {
sapling_shielded_data: Some(shielded_data),
..
} => set_first_sapling_spend_value_commitment(&mut shielded_data.transfers, bad_cv),
Transaction::V5 {
sapling_shielded_data: Some(shielded_data),
..
} => set_first_sapling_spend_value_commitment(&mut shielded_data.transfers, bad_cv),
Transaction::V6 {
sapling_shielded_data: Some(shielded_data),
..
} => set_first_sapling_spend_value_commitment(&mut shielded_data.transfers, bad_cv),
_ => panic!("expected a V4, V5, or V6 transaction with Sapling data"),
}
}
fn set_first_sapling_output_ephemeral_key<A: sapling::AnchorVariant + Clone>(
transfers: &mut sapling::TransferData<A>,
ephemeral_key: sapling::keys::EphemeralPublicKey,
) {
match transfers {
sapling::TransferData::JustOutputs { outputs } => {
let mut outputs_vec = outputs.as_slice().to_vec();
outputs_vec[0].ephemeral_key = ephemeral_key;
*outputs = AtLeastOne::from_vec(outputs_vec)
.expect("replacing a field keeps at least one output");
}
sapling::TransferData::SpendsAndMaybeOutputs { maybe_outputs, .. } => {
maybe_outputs[0].ephemeral_key = ephemeral_key;
}
}
}
fn set_first_sapling_output_value_commitment<A: sapling::AnchorVariant + Clone>(
transfers: &mut sapling::TransferData<A>,
value_commitment: sapling::ValueCommitment,
) {
match transfers {
sapling::TransferData::JustOutputs { outputs } => {
let mut outputs_vec = outputs.as_slice().to_vec();
outputs_vec[0].cv = value_commitment;
*outputs = AtLeastOne::from_vec(outputs_vec)
.expect("replacing a field keeps at least one output");
}
sapling::TransferData::SpendsAndMaybeOutputs { maybe_outputs, .. } => {
maybe_outputs[0].cv = value_commitment;
}
}
}
fn set_first_sapling_spend_value_commitment<A: sapling::AnchorVariant + Clone>(
transfers: &mut sapling::TransferData<A>,
value_commitment: sapling::ValueCommitment,
) {
match transfers {
sapling::TransferData::SpendsAndMaybeOutputs { spends, .. } => {
let mut spends_vec = spends.as_slice().to_vec();
spends_vec[0].cv = value_commitment;
*spends = AtLeastOne::from_vec(spends_vec)
.expect("replacing a field keeps at least one spend");
}
sapling::TransferData::JustOutputs { .. } => {
panic!("expected Sapling shielded data with spends")
}
}
}
#[derive(Clone, Copy)]
enum SaplingProofModification {
Corrupt,
Zero,
}
fn modify_first_sapling_spend_proof(
transaction: &mut Transaction,
modification: SaplingProofModification,
) {
match transaction {
Transaction::V4 {
sapling_shielded_data: Some(shielded_data),
..
} => modify_first_sapling_spend_proof_in_transfers(
&mut shielded_data.transfers,
modification,
),
Transaction::V5 {
sapling_shielded_data: Some(shielded_data),
..
}
| Transaction::V6 {
sapling_shielded_data: Some(shielded_data),
..
} => modify_first_sapling_spend_proof_in_transfers(
&mut shielded_data.transfers,
modification,
),
_ => panic!("expected a transaction with Sapling spends"),
}
}
fn modify_first_sapling_spend_proof_in_transfers<A: sapling::AnchorVariant + Clone>(
transfers: &mut sapling::TransferData<A>,
modification: SaplingProofModification,
) {
match transfers {
sapling::TransferData::SpendsAndMaybeOutputs { spends, .. } => {
let mut spends_vec = spends.as_slice().to_vec();
match modification {
SaplingProofModification::Corrupt => {
let (first, rest) = spends_vec[0].zkproof.0.split_at_mut(48);
first.swap_with_slice(&mut rest[96..144]);
}
SaplingProofModification::Zero => spends_vec[0].zkproof.0 = [0; 192],
}
*spends = AtLeastOne::from_vec(spends_vec)
.expect("replacing a proof keeps at least one spend");
}
sapling::TransferData::JustOutputs { .. } => {
panic!("expected Sapling shielded data with spends")
}
}
}
#[tokio::test]
async fn v5_with_duplicate_sapling_spends() {
let _init_guard = zakura_test::init();
for net in Network::iter() {
let mut tx = v5_transactions(net.block_iter())
.filter(|tx| !tx.is_coinbase() && tx.inputs().is_empty())
.find(|tx| tx.sapling_spends_per_anchor().next().is_some())
.expect("V5 tx with Sapling spends");
let height = tx.expiry_height().expect("expiry height");
let duplicate_nullifier = duplicate_sapling_spend(&mut tx);
let verifier = Verifier::new_for_tests(
&net,
service_fn(|_| async { unreachable!("State service should not be called") }),
);
assert_eq!(
verifier
.oneshot(Request::Block {
transaction_hash: tx.hash(),
transaction: Arc::new(tx),
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await,
Err(TransactionError::DuplicateSaplingNullifier(
duplicate_nullifier
))
);
}
}
#[tokio::test]
async fn v5_with_duplicate_orchard_action() {
let _init_guard = zakura_test::init();
for net in Network::iter() {
let mut tx = v5_transactions(net.block_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("V5 tx with only Orchard actions");
let height = tx.expiry_height().expect("expiry height");
let orchard_shielded_data = tx
.orchard_shielded_data_mut()
.expect("tx without transparent, Sprout, or Sapling outputs must have Orchard actions");
orchard_shielded_data.flags = Flags::ENABLE_SPENDS | Flags::ENABLE_OUTPUTS;
let duplicate_action = orchard_shielded_data.actions.first().clone();
let duplicate_nullifier = duplicate_action.action.nullifier;
let mut actions_vec = orchard_shielded_data.actions.as_slice().to_vec();
actions_vec.push(duplicate_action.clone());
orchard_shielded_data.actions = AtLeastOne::from_vec(actions_vec)
.expect("pushing one element never breaks at least one constraints");
let verifier = Verifier::new_for_tests(
&net,
service_fn(|_| async { unreachable!("State service should not be called") }),
);
assert_eq!(
verifier
.oneshot(Request::Block {
transaction_hash: tx.hash(),
transaction: Arc::new(tx),
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await,
Err(TransactionError::DuplicateOrchardNullifier(
duplicate_nullifier
))
);
}
}
#[test]
fn orchard_disabling_soft_fork_activation_boundary() {
let _init_guard = zakura_test::init();
let soft_fork_height = Height(2_000_000);
let network = Parameters::build()
.with_temporary_orchard_disabling_soft_fork_height(soft_fork_height)
.to_network()
.expect("failed to build configured network");
assert!(
!network.temporary_orchard_disabling_soft_fork_active(Height(1_999_999)),
"soft fork must be inactive below the configured height",
);
assert!(
network.temporary_orchard_disabling_soft_fork_active(soft_fork_height),
"soft fork must be active at the configured height",
);
assert!(
network.temporary_orchard_disabling_soft_fork_active(Height(2_000_001)),
"soft fork must be active above the configured height",
);
let disabled = Parameters::build()
.disable_temporary_orchard_disabling_soft_fork()
.to_network()
.expect("failed to build configured network");
assert!(
!disabled.temporary_orchard_disabling_soft_fork_active(Height(4_042_000)),
"a disabled soft fork must never be active",
);
assert!(
!Network::Mainnet.temporary_orchard_disabling_soft_fork_active(Height(3_363_425)),
"Mainnet soft fork must be inactive below its fixed height",
);
assert!(
Network::Mainnet.temporary_orchard_disabling_soft_fork_active(Height(3_363_426)),
"Mainnet soft fork must be active at its fixed height",
);
}
#[tokio::test]
async fn orchard_disabling_soft_fork_rejects_orchard_actions_in_blocks_and_mempool() {
let _init_guard = zakura_test::init();
let default_testnet = Network::new_default_testnet();
let mut tx = v5_transactions(default_testnet.block_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("V5 tx with only Orchard actions");
tx.orchard_shielded_data_mut()
.expect("tx without transparent, Sprout, or Sapling data must have Orchard actions")
.flags = Flags::ENABLE_SPENDS | Flags::ENABLE_OUTPUTS;
let height = tx.expiry_height().expect("V5 tx has an expiry height");
let network = Parameters::build()
.with_temporary_orchard_disabling_soft_fork_height(height)
.to_network()
.expect("failed to build configured network");
assert!(
network.temporary_orchard_disabling_soft_fork_active(height),
"soft fork must be active at the transaction's height",
);
let expected = Err(TransactionError::Other(
"transaction has Orchard actions (temporarily disabled)".into(),
));
let block_response = Verifier::new_for_tests(
&network,
service_fn(|_| async { unreachable!("state service should not be called") }),
)
.oneshot(Request::Block {
transaction_hash: tx.hash(),
transaction: Arc::new(tx.clone()),
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
block_response, expected,
"block verification must reject a transaction with Orchard actions after the soft fork",
);
let mempool_response = Verifier::new_for_tests(
&network,
service_fn(|_| async { unreachable!("state service should not be called") }),
)
.oneshot(Request::Mempool {
transaction: tx.into(),
height,
})
.await;
assert_eq!(
mempool_response, expected,
"mempool verification must reject a transaction with Orchard actions after the soft fork",
);
}
#[tokio::test]
async fn orchard_disabling_soft_fork_accepts_non_orchard_transactions() {
let _init_guard = zakura_test::init();
let network = Parameters::build()
.with_temporary_orchard_disabling_soft_fork_height(Height(1))
.to_network()
.expect("failed to build configured network");
let mut state: MockService<_, _, _, _> = MockService::build().for_prop_tests();
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");
assert!(
network.temporary_orchard_disabling_soft_fork_active(transaction_block_height),
"soft fork must be active at the transaction's height",
);
let (input, output, known_utxos) = mock_transparent_transfer(
fake_source_fund_height,
true,
0,
Amount::try_from(10001).expect("valid amount"),
);
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 input_outpoint = match transaction.inputs()[0] {
transparent::Input::PrevOut { outpoint, .. } => outpoint,
transparent::Input::Coinbase { .. } => panic!("requires a non-coinbase transaction"),
};
let verifier = Verifier::new_for_tests(&network, state.clone());
tokio::spawn(async move {
state
.expect_request(zakura_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::UnspentBestChainUtxo(
known_utxos
.get(&input_outpoint)
.map(|utxo| utxo.utxo.clone()),
));
state
.expect_request_that(|req| {
matches!(
req,
zakura_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
let response = verifier
.oneshot(Request::Mempool {
transaction: transaction.into(),
height: transaction_block_height,
})
.await;
assert!(
response.is_ok(),
"non-Orchard transaction must be accepted while the soft fork is active, got: {response:?}",
);
}
#[tokio::test]
async fn orchard_disabling_soft_fork_accepts_orchard_actions_below_activation_height() {
let _init_guard = zakura_test::init();
let default_testnet = Network::new_default_testnet();
let tx = v5_transactions(default_testnet.block_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("V5 tx with only Orchard actions");
assert!(
tx.orchard_shielded_data().is_some(),
"test transaction must contain Orchard actions",
);
let height = tx.expiry_height().expect("V5 tx has an expiry height");
let accepting_network = Parameters::build()
.with_temporary_orchard_disabling_soft_fork_height(
(height + 1).expect("height is too large"),
)
.to_network()
.expect("failed to build configured network");
assert!(
!accepting_network.temporary_orchard_disabling_soft_fork_active(height),
"soft fork must be inactive below its activation height",
);
let mut state: MockService<zakura_state::Request, zakura_state::Response, _, _> =
MockService::build().for_prop_tests();
let accept_verifier = Verifier::new_for_tests(&accepting_network, state.clone());
tokio::spawn(async move {
state
.expect_request_that(|req| {
matches!(
req,
zakura_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
let accept_response = accept_verifier
.oneshot(Request::Block {
transaction_hash: tx.hash(),
transaction: Arc::new(tx.clone()),
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert!(
accept_response.is_ok(),
"Orchard transaction must be accepted below the soft fork height, got: {accept_response:?}",
);
let rejecting_network = Parameters::build()
.with_temporary_orchard_disabling_soft_fork_height(height)
.to_network()
.expect("failed to build configured network");
let reject_response = Verifier::new_for_tests(
&rejecting_network,
service_fn(|_| async { unreachable!("state service should not be called") }),
)
.oneshot(Request::Block {
transaction_hash: tx.hash(),
transaction: Arc::new(tx),
known_utxos: Arc::new(HashMap::new()),
known_outpoint_hashes: Arc::new(HashSet::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
reject_response,
Err(TransactionError::Other(
"transaction has Orchard actions (temporarily disabled)".into()
)),
"Orchard transaction must be rejected at the soft fork height",
);
}
#[test]
fn public_nu6_3_consensus_branch_id_boundary() {
let mut tx = Transaction::V5 {
inputs: Vec::new(),
outputs: Vec::new(),
lock_time: LockTime::unlocked(),
expiry_height: Height::MAX_EXPIRY_HEIGHT,
sapling_shielded_data: None,
orchard_shielded_data: None,
network_upgrade: NetworkUpgrade::Nu6_2,
};
for network in Network::iter() {
let activation_height = NetworkUpgrade::Nu6_3
.activation_height(&network)
.expect("NU6.3 is scheduled on both public networks");
let previous_height = (activation_height - 1).expect("NU6.3 is not genesis");
assert_eq!(
check::consensus_branch_id(&tx, previous_height, &network),
Ok(()),
);
assert_eq!(
check::consensus_branch_id(&tx, activation_height, &network),
Err(TransactionError::WrongConsensusBranchId),
);
assert!(super::is_nu6_3_branch_id_misbehavior_grace_period(
&tx,
(activation_height + 39).expect("NU6.3 grace period should fit in a height"),
&network,
));
assert!(!super::is_nu6_3_branch_id_misbehavior_grace_period(
&tx,
(activation_height + 40).expect("NU6.3 grace period should fit in a height"),
&network,
));
assert_eq!(
TransactionError::WrongConsensusBranchIdNu6_3GracePeriod.mempool_misbehavior_score(),
0,
);
tx.update_network_upgrade(NetworkUpgrade::Nu6_1)
.expect("V5 transactions support the NU6.1 branch ID");
assert_eq!(
check::consensus_branch_id(&tx, activation_height, &network),
Err(TransactionError::WrongConsensusBranchId),
);
assert_eq!(
TransactionError::WrongConsensusBranchId.mempool_misbehavior_score(),
100,
);
tx.update_network_upgrade(NetworkUpgrade::Nu6_3)
.expect("V5 transactions support the NU6.3 branch ID");
assert_eq!(
check::consensus_branch_id(&tx, previous_height, &network),
Err(TransactionError::WrongConsensusBranchId),
);
assert_eq!(
check::consensus_branch_id(&tx, activation_height, &network),
Ok(()),
);
tx.update_network_upgrade(NetworkUpgrade::Nu6_2)
.expect("V5 transactions support the NU6.2 branch ID");
}
}
#[tokio::test]
async fn v5_consensus_branch_ids() {
let mut state = MockService::build().for_unit_tests();
let (input, output, known_utxos) = mock_transparent_transfer(
Height(1),
true,
0,
Amount::try_from(10001).expect("valid amount"),
);
let known_utxos = Arc::new(known_utxos);
let mut network_upgrade = NetworkUpgrade::Nu5;
let mut tx = Transaction::V5 {
inputs: vec![input],
outputs: vec![output],
lock_time: LockTime::unlocked(),
expiry_height: Height::MAX_EXPIRY_HEIGHT,
sapling_shielded_data: None,
orchard_shielded_data: None,
network_upgrade,
};
let outpoint = match tx.inputs()[0] {
transparent::Input::PrevOut { outpoint, .. } => outpoint,
transparent::Input::Coinbase { .. } => panic!("requires a non-coinbase transaction"),
};
for network in Network::iter() {
let verifier = Buffer::new(Verifier::new_for_tests(&network, state.clone()), 10);
while let Some(next_nu) = network_upgrade.next_upgrade() {
let Some(height) = next_nu.activation_height(&network) else {
tracing::warn!(?next_nu, "missing activation height",);
network_upgrade = next_nu;
continue;
};
let block_req = verifier
.clone()
.oneshot(Request::Block {
transaction_hash: tx.hash(),
transaction: Arc::new(tx.clone()),
known_utxos: known_utxos.clone(),
known_outpoint_hashes: Arc::new(HashSet::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.map_err(|err| *err.downcast().expect("`TransactionError` type"));
let mempool_req = verifier
.clone()
.oneshot(Request::Mempool {
transaction: tx.clone().into(),
height,
})
.map_err(|err| *err.downcast().expect("`TransactionError` type"));
let (block_rsp, mempool_rsp) = futures::join!(block_req, mempool_req);
assert_eq!(block_rsp, Err(TransactionError::WrongConsensusBranchId));
let mempool_expected_error =
if network_upgrade == NetworkUpgrade::Nu6_2 && next_nu == NetworkUpgrade::Nu6_3 {
TransactionError::WrongConsensusBranchIdNu6_3GracePeriod
} else {
TransactionError::WrongConsensusBranchId
};
assert_eq!(mempool_rsp, Err(mempool_expected_error));
if network_upgrade == NetworkUpgrade::Nu6_2 && next_nu == NetworkUpgrade::Nu6_3 {
let grace_period_last_height =
(height + 39).expect("NU6.3 grace period should fit in a height");
let grace_period_response = verifier
.clone()
.oneshot(Request::Mempool {
transaction: tx.clone().into(),
height: grace_period_last_height,
})
.map_err(|err| *err.downcast().expect("`TransactionError` type"))
.await;
assert_eq!(
grace_period_response,
Err(TransactionError::WrongConsensusBranchIdNu6_3GracePeriod),
);
let grace_period_end_height =
(height + 40).expect("NU6.3 grace period should fit in a height");
let grace_period_end_response = verifier
.clone()
.oneshot(Request::Mempool {
transaction: tx.clone().into(),
height: grace_period_end_height,
})
.map_err(|err| *err.downcast().expect("`TransactionError` type"))
.await;
assert_eq!(
grace_period_end_response,
Err(TransactionError::WrongConsensusBranchId),
);
}
let height = network_upgrade.activation_height(&network).expect("height");
let block_req = verifier
.clone()
.oneshot(Request::Block {
transaction_hash: tx.hash(),
transaction: Arc::new(tx.clone()),
known_utxos: known_utxos.clone(),
known_outpoint_hashes: Arc::new(HashSet::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.map_ok(|rsp| rsp.tx_id())
.map_err(|e| format!("{e}"));
let mempool_req = verifier
.clone()
.oneshot(Request::Mempool {
transaction: tx.clone().into(),
height,
})
.map_ok(|rsp| rsp.tx_id())
.map_err(|e| format!("{e}"));
let state_req = async {
state
.expect_request(zakura_state::Request::UnspentBestChainUtxo(outpoint))
.map(|r| {
r.respond(zakura_state::Response::UnspentBestChainUtxo(
known_utxos.get(&outpoint).map(|utxo| utxo.utxo.clone()),
))
})
.await;
state
.expect_request_that(|req| {
matches!(
req,
zakura_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.map(|r| {
r.respond(zakura_state::Response::ValidBestChainTipNullifiersAndAnchors)
})
.await;
};
let (block_rsp, mempool_rsp, _) = futures::join!(block_req, mempool_req, state_req);
let txid = tx.unmined_id();
assert_eq!(block_rsp, Ok(txid));
assert_eq!(mempool_rsp, Ok(txid));
tx.update_network_upgrade(next_nu)
.expect("V5 txs support updating NUs");
let height = network_upgrade.activation_height(&network).expect("height");
let block_req = verifier
.clone()
.oneshot(Request::Block {
transaction_hash: tx.hash(),
transaction: Arc::new(tx.clone()),
known_utxos: known_utxos.clone(),
known_outpoint_hashes: Arc::new(HashSet::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.map_err(|err| *err.downcast().expect("`TransactionError` type"));
let mempool_req = verifier
.clone()
.oneshot(Request::Mempool {
transaction: tx.clone().into(),
height,
})
.map_err(|err| *err.downcast().expect("`TransactionError` type"));
let (block_rsp, mempool_rsp) = futures::join!(block_req, mempool_req);
assert_eq!(block_rsp, Err(TransactionError::WrongConsensusBranchId));
assert_eq!(mempool_rsp, Err(TransactionError::WrongConsensusBranchId));
network_upgrade = next_nu;
}
}
}
fn mock_transparent_transfer(
previous_utxo_height: block::Height,
script_should_succeed: bool,
outpoint_index: u32,
previous_output_value: Amount<NonNegative>,
) -> (
transparent::Input,
transparent::Output,
HashMap<transparent::OutPoint, transparent::OrderedUtxo>,
) {
const OP_FALSE: u8 = 0x00;
const OP_TRUE: u8 = 0x51;
let accepting_unlock_script = transparent::Script::new(&[0x01, OP_TRUE]);
let rejecting_unlock_script = transparent::Script::new(&[0x01, OP_FALSE]);
let mut p2sh_lock_bytes = vec![0xa9, 0x14];
p2sh_lock_bytes.extend_from_slice(&[
0xda, 0x17, 0x45, 0xe9, 0xb5, 0x49, 0xbd, 0x0b, 0xfa, 0x1a, 0x56, 0x99, 0x71, 0xc7, 0x7e,
0xba, 0x30, 0xcd, 0x5a, 0x4b,
]);
p2sh_lock_bytes.push(0x87);
let accepting_lock_script = transparent::Script::new(&p2sh_lock_bytes);
let mut p2sh_lock_bytes = vec![0xa9, 0x14];
p2sh_lock_bytes.extend_from_slice(&[
0x9f, 0x7f, 0xd0, 0x96, 0xd3, 0x7e, 0xd2, 0xc0, 0xe3, 0xf7, 0xf0, 0xcf, 0xc9, 0x24, 0xbe,
0xef, 0x4f, 0xfc, 0xeb, 0x68,
]);
p2sh_lock_bytes.push(0x87);
let rejecting_lock_script = transparent::Script::new(&p2sh_lock_bytes);
let previous_outpoint = transparent::OutPoint {
hash: Hash([1u8; 32]),
index: outpoint_index,
};
let (lock_script, unlock_script) = if script_should_succeed {
(accepting_lock_script, accepting_unlock_script)
} else {
(rejecting_lock_script, rejecting_unlock_script)
};
let previous_output = transparent::Output {
value: previous_output_value,
lock_script,
};
let previous_utxo = transparent::OrderedUtxo::new(previous_output, previous_utxo_height, 1);
let input = transparent::Input::PrevOut {
outpoint: previous_outpoint,
unlock_script,
sequence: 0,
};
let output = transparent::Output {
value: Amount::try_from(1).expect("1 is an invalid amount"),
lock_script: transparent::Script::new(&[OP_FALSE]),
};
let mut known_utxos = HashMap::new();
known_utxos.insert(previous_outpoint, previous_utxo);
(input, output, known_utxos)
}
fn mock_coinbase_transparent_output(
coinbase_height: block::Height,
) -> (transparent::Input, transparent::Output) {
let rejecting_script = transparent::Script::new(&[0]);
let input = transparent::Input::Coinbase {
height: coinbase_height,
data: vec![],
sequence: u32::MAX,
};
let output = transparent::Output {
value: Amount::try_from(1).expect("1 is an invalid amount"),
lock_script: rejecting_script,
};
(input, output)
}
fn mock_sprout_join_split_data() -> (JoinSplitData<Groth16Proof>, ed25519::SigningKey) {
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].into());
let second_nullifier = sprout::note::Nullifier([1u8; 32].into());
let commitment = sprout::commitment::NoteCommitment::from([0u8; 32]);
let ephemeral_key =
x25519::PublicKey::from(&x25519::EphemeralSecret::random_from_rng(rand::thread_rng()));
let random_seed = sprout::RandomSeed::from([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]);
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],
};
let signing_key = ed25519::SigningKey::new(rand::thread_rng());
let verification_key = ed25519::VerificationKey::from(&signing_key);
let joinsplit_data = JoinSplitData {
first: joinsplit,
rest: vec![],
pub_key: verification_key.into(),
sig: [0u8; 64].into(),
};
(joinsplit_data, signing_key)
}
#[derive(Clone, Copy)]
enum JoinSplitModification {
CorruptSignature,
CorruptProof,
ZeroProof,
}
fn modify_joinsplit_data(
joinsplit_data: &mut JoinSplitData<Groth16Proof>,
modification: JoinSplitModification,
) {
match modification {
JoinSplitModification::CorruptSignature => {
let mut sig_bytes: [u8; 64] = joinsplit_data.sig.into();
sig_bytes[10] ^= 0x01;
joinsplit_data.sig = sig_bytes.into();
}
JoinSplitModification::CorruptProof => {
let joinsplit = joinsplit_data
.joinsplits_mut()
.next()
.expect("must have a JoinSplit");
{
let (first, rest) = joinsplit.zkproof.0.split_at_mut(48);
first.swap_with_slice(&mut rest[96..144]);
}
}
JoinSplitModification::ZeroProof => {
let joinsplit = joinsplit_data
.joinsplits_mut()
.next()
.expect("must have a JoinSplit");
joinsplit.zkproof.0 = [0; 192];
}
}
}
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"),
}
}
fn duplicate_sapling_spend_in_shielded_data<A: sapling::AnchorVariant + Clone>(
shielded_data: &mut sapling::ShieldedData<A>,
) -> sapling::Nullifier {
match shielded_data.transfers {
sapling::TransferData::SpendsAndMaybeOutputs { ref mut spends, .. } => {
let duplicate_spend = spends.first().clone();
let duplicate_nullifier = duplicate_spend.nullifier;
let mut spends_vec = spends.as_slice().to_vec();
spends_vec.push(duplicate_spend);
*spends = AtLeastOne::from_vec(spends_vec)
.expect("pushing one element never breaks at least one constraints");
duplicate_nullifier
}
sapling::TransferData::JustOutputs { .. } => {
unreachable!("Sapling shielded data has no spends")
}
}
}
#[test]
fn add_to_sprout_pool_after_nu() {
let _init_guard = zakura_test::init();
let block: Arc<_> = zakura_chain::block::Block::zcash_deserialize(
&zakura_test::vectors::BLOCK_MAINNET_419199_BYTES[..],
)
.unwrap()
.into();
let network = Network::Mainnet;
let block_height = NetworkUpgrade::Canopy.activation_height(&network).unwrap();
let zero = Amount::<NonNegative>::try_from(0).expect("an amount of 0 is always valid");
assert_eq!(
check::disabled_add_to_sprout_pool(&block.transactions[0], block_height, &network),
Ok(())
);
assert_eq!(block.transactions[1].joinsplit_count(), 0);
assert_eq!(
check::disabled_add_to_sprout_pool(&block.transactions[1], block_height, &network),
Ok(())
);
assert!(block.transactions[4].joinsplit_count() > 0);
let vpub_old: Amount<NonNegative> = 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)
);
assert!(block.transactions[7].joinsplit_count() > 0);
let vpub_old: Amount<NonNegative> = 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(())
);
}
#[test]
fn add_to_orchard_pool_after_nu6_3() {
let _init_guard = zakura_test::init();
const ADD_TO_ORCHARD_POOL: i64 = -1;
const ORCHARD_POOL_UNCHANGED: i64 = 0;
const REMOVE_FROM_ORCHARD_POOL: i64 = 1;
let nu6_3_height = Height(10);
let network = Parameters::build()
.with_activation_heights(ConfiguredActivationHeights {
nu6_3: Some(nu6_3_height.0),
..Default::default()
})
.expect("failed to set NU6.3 activation height")
.clear_funding_streams()
.to_network()
.expect("failed to build configured network");
let mut tx = v5_transactions(Network::new_default_testnet().block_iter())
.find(|tx| tx.orchard_shielded_data().is_some())
.expect("test vectors include a transaction with Orchard shielded data");
let orchard_amount =
|amount| Amount::<NegativeAllowed>::try_from(amount).expect("valid test amount");
*tx.orchard_value_balance_mut()
.expect("transaction has Orchard shielded data") = orchard_amount(ADD_TO_ORCHARD_POOL);
assert_eq!(
check::disabled_add_to_orchard_pool(
&tx,
(nu6_3_height - 1).expect("NU6.3 is not genesis"),
&network,
),
Ok(()),
"transparent -> Orchard is allowed before NU6.3"
);
assert_eq!(
check::disabled_add_to_orchard_pool(&tx, nu6_3_height, &Network::Mainnet),
Ok(()),
"transparent -> Orchard is allowed if NU6.3 is unscheduled"
);
assert_eq!(
check::disabled_add_to_orchard_pool(&tx, nu6_3_height, &network),
Err(TransactionError::DisabledAddToOrchardPool),
"transparent -> Orchard is rejected after NU6.3"
);
*tx.orchard_value_balance_mut()
.expect("transaction has Orchard shielded data") = orchard_amount(REMOVE_FROM_ORCHARD_POOL);
assert_eq!(
check::disabled_add_to_orchard_pool(&tx, nu6_3_height, &network),
Ok(()),
"Orchard -> transparent is still allowed after NU6.3"
);
*tx.orchard_value_balance_mut()
.expect("transaction has Orchard shielded data") = orchard_amount(ORCHARD_POOL_UNCHANGED);
assert_eq!(
check::disabled_add_to_orchard_pool(&tx, nu6_3_height, &network),
Ok(()),
"zero-balance Orchard spends and outputs are still allowed after NU6.3"
);
}
#[test]
fn coinbase_orchard_bundle_after_nu6_3() {
let _init_guard = zakura_test::init();
let nu6_3_height = Height(10);
let network = Parameters::build()
.with_activation_heights(ConfiguredActivationHeights {
nu6_3: Some(nu6_3_height.0),
..Default::default()
})
.expect("failed to set NU6.3 activation height")
.clear_funding_streams()
.to_network()
.expect("failed to build configured network");
let mut tx = v5_transactions(Network::new_default_testnet().block_iter())
.find(|tx| tx.orchard_shielded_data().is_some())
.expect("test vectors include a transaction with Orchard shielded data");
*tx.inputs_mut() = vec![transparent::Input::Coinbase {
height: nu6_3_height,
data: vec![],
sequence: u32::MAX,
}];
*tx.orchard_value_balance_mut()
.expect("transaction has Orchard shielded data") = Amount::<NegativeAllowed>::zero();
assert!(tx.is_coinbase());
assert_eq!(
check::coinbase_has_no_orchard_shielded_data(&tx, nu6_3_height, &network),
Err(TransactionError::CoinbaseHasOrchardShieldedData),
"coinbase Orchard bundles are rejected after NU6.3"
);
assert_eq!(
check::disabled_add_to_orchard_pool(&tx, nu6_3_height, &network),
Ok(()),
"zero-balance coinbase Orchard bundles need the structural check"
);
assert_eq!(
check::coinbase_has_no_orchard_shielded_data(
&tx,
(nu6_3_height - 1).expect("NU6.3 is not genesis"),
&network,
),
Ok(()),
"coinbase Orchard bundles are still allowed before NU6.3"
);
assert_eq!(
check::coinbase_has_no_orchard_shielded_data(&tx, nu6_3_height, &Network::Mainnet),
Ok(()),
"the rule is inactive when NU6.3 is unscheduled"
);
}
#[test]
fn coinbase_outputs_are_decryptable() -> Result<(), Report> {
let _init_guard = zakura_test::init();
for net in Network::iter() {
let mut tested_post_heartwood_shielded_coinbase_tx = false;
let mut tested_pre_heartwood_shielded_coinbase_tx = false;
let mut tested_post_heartwood_unshielded_coinbase_tx = false;
let mut tested_pre_heartwood_unshielded_coinbase_tx = false;
let mut tested_post_heartwood_shielded_non_coinbase_tx = false;
let mut tested_pre_heartwood_shielded_non_coinbase_tx = false;
let mut tested_post_heartwood_unshielded_non_coinbase_tx = false;
let mut tested_pre_heartwood_unshielded_non_coinbase_tx = false;
for (height, block) in net.block_iter() {
let block = block.zcash_deserialize_into::<Block>().expect("block");
let height = Height(*height);
let is_heartwood = height >= NetworkUpgrade::Heartwood.activation_height(&net).unwrap();
let coinbase = block.transactions.first().expect("coinbase transaction");
if coinbase.has_shielded_outputs() && is_heartwood {
tested_post_heartwood_shielded_coinbase_tx = true;
check::coinbase_outputs_are_decryptable(coinbase, &net, height).expect(
"post-Heartwood shielded coinbase outputs must be decryptable with the zero key",
);
}
if coinbase.has_shielded_outputs() && !is_heartwood {
tested_pre_heartwood_shielded_coinbase_tx = true;
check::coinbase_outputs_are_decryptable(coinbase, &net, height)
.expect("the consensus rule does not apply to pre-Heartwood txs");
}
if !coinbase.has_shielded_outputs() && is_heartwood {
tested_post_heartwood_unshielded_coinbase_tx = true;
check::coinbase_outputs_are_decryptable(coinbase, &net, height)
.expect("the consensus rule does not apply to txs with no shielded outputs");
}
if !coinbase.has_shielded_outputs() && !is_heartwood {
tested_pre_heartwood_unshielded_coinbase_tx = true;
check::coinbase_outputs_are_decryptable(coinbase, &net, height)
.expect("the consensus rule does not apply to pre-Heartwood txs");
}
for non_coinbase in block.transactions.iter().skip(1) {
if non_coinbase.has_shielded_outputs() && is_heartwood {
tested_post_heartwood_shielded_non_coinbase_tx = true;
assert_eq!(
check::coinbase_outputs_are_decryptable(non_coinbase, &net, height),
Err(TransactionError::NotCoinbase)
)
}
if non_coinbase.has_shielded_outputs() && !is_heartwood {
tested_pre_heartwood_shielded_non_coinbase_tx = true;
check::coinbase_outputs_are_decryptable(non_coinbase, &net, height)
.expect("the consensus rule does not apply to pre-Heartwood txs");
}
if !non_coinbase.has_shielded_outputs() && is_heartwood {
tested_post_heartwood_unshielded_non_coinbase_tx = true;
check::coinbase_outputs_are_decryptable(non_coinbase, &net, height).expect(
"the consensus rule does not apply to txs with no shielded outputs",
);
}
if !non_coinbase.has_shielded_outputs() && !is_heartwood {
tested_pre_heartwood_unshielded_non_coinbase_tx = true;
check::coinbase_outputs_are_decryptable(non_coinbase, &net, height)
.expect("the consensus rule does not apply to pre-Heartwood txs");
}
}
}
assert!(tested_post_heartwood_shielded_coinbase_tx);
assert!(!tested_pre_heartwood_shielded_coinbase_tx);
assert!(tested_post_heartwood_unshielded_coinbase_tx);
assert!(tested_pre_heartwood_unshielded_coinbase_tx);
assert!(tested_post_heartwood_shielded_non_coinbase_tx);
assert!(tested_pre_heartwood_shielded_non_coinbase_tx);
assert!(tested_post_heartwood_unshielded_non_coinbase_tx);
assert!(tested_pre_heartwood_unshielded_non_coinbase_tx);
}
Ok(())
}
fn fill_action_with_note_encryption_test_vector(
action: &Action,
v: &zakura_test::vectors::TestVector,
) -> Action {
let mut action = action.clone();
action.cv = v.cv_net.try_into().expect("test vector must be valid");
action.cm_x = pallas::Base::from_repr(v.cmx).unwrap();
action.nullifier = v.rho.try_into().expect("test vector must be valid");
action.ephemeral_key = v
.ephemeral_key
.try_into()
.expect("test vector must be valid");
action.out_ciphertext = v.c_out.into();
action.enc_ciphertext = v.c_enc.into();
action
}
#[test]
fn coinbase_outputs_are_decryptable_for_fake_v5_blocks() {
for v in zakura_test::vectors::ORCHARD_NOTE_ENCRYPTION_ZERO_VECTOR.iter() {
for net in Network::iter() {
let mut transaction = v5_transactions(net.block_iter())
.find(|tx| tx.is_coinbase())
.expect("coinbase V5 tx");
let shielded_data = insert_fake_orchard_shielded_data(&mut transaction);
shielded_data.flags = Flags::ENABLE_OUTPUTS;
let action = fill_action_with_note_encryption_test_vector(
&shielded_data.actions.first().action,
v,
);
let sig = shielded_data.actions.first().spend_auth_sig;
shielded_data.actions = vec![AuthorizedAction::from_parts(action, sig)]
.try_into()
.unwrap();
assert_eq!(
check::coinbase_outputs_are_decryptable(
&transaction,
&net,
NetworkUpgrade::Nu5.activation_height(&net).unwrap(),
),
Ok(())
);
}
}
}
#[test]
fn shielded_outputs_are_not_decryptable_for_fake_v5_blocks() {
for v in zakura_test::vectors::ORCHARD_NOTE_ENCRYPTION_VECTOR.iter() {
for net in Network::iter() {
let mut tx = v5_transactions(net.block_iter())
.find(|tx| tx.is_coinbase())
.expect("V5 coinbase tx");
let shielded_data = insert_fake_orchard_shielded_data(&mut tx);
shielded_data.flags = Flags::ENABLE_OUTPUTS;
let action = fill_action_with_note_encryption_test_vector(
&shielded_data.actions.first().action,
v,
);
let sig = shielded_data.actions.first().spend_auth_sig;
shielded_data.actions = vec![AuthorizedAction::from_parts(action, sig)]
.try_into()
.unwrap();
assert_eq!(
check::coinbase_outputs_are_decryptable(
&tx,
&net,
NetworkUpgrade::Nu5.activation_height(&net).unwrap(),
),
Err(TransactionError::CoinbaseOutputsNotDecryptable)
);
}
}
}
#[tokio::test]
async fn mempool_zip317_error() {
let mut state: MockService<_, _, _, _> = MockService::build().for_prop_tests();
let verifier = Verifier::new_for_tests(&Network::Mainnet, state.clone());
let height = NetworkUpgrade::Nu5
.activation_height(&Network::Mainnet)
.expect("Nu5 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,
false,
0,
Amount::try_from(10).expect("valid amount"),
);
let tx = Transaction::V5 {
inputs: vec![input],
outputs: vec![output],
lock_time: LockTime::unlocked(),
network_upgrade: NetworkUpgrade::Nu5,
expiry_height: height,
sapling_shielded_data: None,
orchard_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(zakura_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_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_err());
assert_eq!(
verifier_response.err(),
Some(TransactionError::Zip317(zip317::Error::UnpaidActions))
);
}
#[tokio::test]
async fn mempool_zip317_ok() {
let mut state: MockService<_, _, _, _> = MockService::build().for_prop_tests();
let verifier = Verifier::new_for_tests(&Network::Mainnet, state.clone());
let height = NetworkUpgrade::Nu5
.activation_height(&Network::Mainnet)
.expect("Nu5 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,
Amount::try_from(10_001).expect("valid amount"),
);
let tx = Transaction::V5 {
inputs: vec![input],
outputs: vec![output],
lock_time: LockTime::unlocked(),
network_upgrade: NetworkUpgrade::Nu5,
expiry_height: height,
sapling_shielded_data: None,
orchard_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(zakura_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::UnspentBestChainUtxo(
known_utxos
.get(&input_outpoint)
.map(|utxo| utxo.utxo.clone()),
));
state
.expect_request_that(|req| {
matches!(
req,
zakura_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
let verifier_response = verifier
.oneshot(Request::Mempool {
transaction: tx.into(),
height,
})
.await;
assert!(
verifier_response.is_ok(),
"expected successful verification, got: {verifier_response:?}"
);
}
#[tokio::test]
async fn mempool_transaction_with_sufficient_fee_is_rejected_by_script() {
let mut state: MockService<_, _, _, _> = MockService::build().for_prop_tests();
let verifier = Verifier::new_for_tests(&Network::Mainnet, state.clone());
let height = NetworkUpgrade::Nu5
.activation_height(&Network::Mainnet)
.expect("Nu5 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,
false,
0,
Amount::try_from(10_001).expect("valid amount"),
);
let tx = Transaction::V5 {
inputs: vec![input],
outputs: vec![output],
lock_time: LockTime::unlocked(),
network_upgrade: NetworkUpgrade::Nu5,
expiry_height: height,
sapling_shielded_data: None,
orchard_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(zakura_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::UnspentBestChainUtxo(
known_utxos
.get(&input_outpoint)
.map(|utxo| utxo.utxo.clone()),
));
state
.expect_request_that(|req| {
matches!(
req,
zakura_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(zakura_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
let verifier_response = verifier
.oneshot(Request::Mempool {
transaction: tx.into(),
height,
})
.await;
assert_eq!(
verifier_response,
Err(TransactionError::Script(
zakura_script::Error::ScriptInvalid
))
);
}
#[tokio::test(flavor = "multi_thread")]
async fn block_with_garbage_orchard_proofs_is_rejected() {
use zakura_chain::{primitives::Halo2Proof, transaction::VerifiedUnminedTx};
let _init_guard = zakura_test::init();
let mempool: MockService<_, _, _, _> = MockService::build().for_prop_tests();
let state: MockService<_, _, _, _> = MockService::build().for_prop_tests();
let (mempool_setup_tx, mempool_setup_rx) = tokio::sync::oneshot::channel();
let verifier = Verifier::new(&Network::Mainnet, state.clone(), mempool_setup_rx);
let verifier = Buffer::new(verifier, 1);
mempool_setup_tx
.send(mempool.clone())
.ok()
.expect("send should succeed");
let height = NetworkUpgrade::Nu6
.activation_height(&Network::Mainnet)
.expect("Nu6 activation height is specified");
let fund_height = (height - 1).expect("too small");
let (input, output, known_utxos) = mock_transparent_transfer(
fund_height,
true,
0,
Amount::try_from(10001).expect("invalid value"),
);
let mut tx = Transaction::V5 {
network_upgrade: NetworkUpgrade::Nu6,
inputs: vec![input],
outputs: vec![output],
lock_time: LockTime::min_lock_time_timestamp(),
expiry_height: height,
sapling_shielded_data: None,
orchard_shielded_data: None,
};
insert_fake_orchard_shielded_data(&mut tx);
let tx_hash = tx.hash();
let input_outpoint = match tx.inputs()[0] {
transparent::Input::PrevOut { outpoint, .. } => outpoint,
transparent::Input::Coinbase { .. } => panic!("not coinbase"),
};
let mut garbage_tx = tx.clone();
let od = garbage_tx.orchard_shielded_data_mut().unwrap();
od.proof = Halo2Proof(vec![0xDE, 0xAD, 0xBE, 0xEF]);
od.binding_sig = [0xFF; 64].into();
for action in od.actions.iter_mut() {
action.spend_auth_sig = [0xFF; 64].into();
}
assert_eq!(tx.hash(), garbage_tx.hash());
let spent_output = known_utxos
.get(&input_outpoint)
.unwrap()
.utxo
.output
.clone();
let verified_tx = VerifiedUnminedTx::new(
tx.clone().into(),
Amount::try_from(10000).unwrap(),
0,
0,
Arc::new(vec![spent_output]),
)
.unwrap();
let mut mc = mempool.clone();
tokio::spawn(async move {
mc.expect_request(mempool::Request::TransactionWithDepsByMinedId(tx_hash))
.await
.unwrap()
.respond(mempool::Response::TransactionWithDeps {
transaction: verified_tx,
dependencies: [input_outpoint.hash].into(),
});
});
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let resp = verifier
.clone()
.oneshot(Request::Block {
transaction_hash: tx_hash,
transaction: Arc::new(garbage_tx),
known_outpoint_hashes: Arc::new([input_outpoint.hash].into()),
known_utxos: Arc::new(HashMap::new()),
height,
time: Utc::now(),
})
.await;
assert!(resp.is_err(), "garbage proof must be rejected");
}
#[tokio::test(flavor = "multi_thread")]
async fn mempool_cached_result_bypasses_expiry_check_for_block_at_next_height() {
let _init_guard = zakura_test::init();
let network = Network::Mainnet;
let canopy_height = NetworkUpgrade::Canopy
.activation_height(&network)
.expect("Canopy activation height is specified");
let mempool_height = (canopy_height + 1).expect("mempool height should be valid");
let expired_block_height = (canopy_height + 2).expect("expired block height should be valid");
let fund_height = (canopy_height - 1).expect("fund height should be valid");
let (input, output, _known_utxos) = mock_transparent_transfer(
fund_height,
true,
0,
Amount::try_from(10001).expect("valid value"),
);
let tx = Transaction::V4 {
inputs: vec![input],
outputs: vec![output],
lock_time: LockTime::unlocked(),
expiry_height: mempool_height,
joinsplit_data: None,
sapling_shielded_data: None,
};
let tx_hash = tx.hash();
let input_outpoint = match tx.inputs()[0] {
transparent::Input::PrevOut { outpoint, .. } => outpoint,
transparent::Input::Coinbase { .. } => panic!("requires a non-coinbase transaction"),
};
let mempool: MockService<_, _, _, _> = MockService::build().for_unit_tests();
let state: MockService<_, _, _, _> = MockService::build().for_unit_tests();
let (mempool_setup_tx, mempool_setup_rx) = tokio::sync::oneshot::channel();
let verifier = Verifier::new(&network, state.clone(), mempool_setup_rx);
let verifier = Buffer::new(verifier, 1);
mempool_setup_tx
.send(mempool.clone())
.ok()
.expect("send should succeed");
let result = timeout(
test_timeout(),
verifier.clone().oneshot(Request::Block {
transaction_hash: tx_hash,
transaction: Arc::new(tx.clone()),
known_outpoint_hashes: Arc::new([input_outpoint.hash].into()),
known_utxos: Arc::new(HashMap::new()),
height: expired_block_height,
time: Utc::now(),
}),
)
.await
.expect("block request should not time out");
let err = result.expect_err(
"expected block verification to fail for a transaction with \
expired nExpiryHeight mined via the mempool cache path",
);
let tx_err = err
.downcast::<TransactionError>()
.expect("error should downcast to TransactionError");
assert!(
matches!(*tx_err, TransactionError::ExpiredTransaction { .. }),
"expected ExpiredTransaction error for block at height {expired_block_height:?} \
with nExpiryHeight {mempool_height:?} via mempool cache; \
got: {tx_err:?}"
);
}
#[test]
fn mempool_standard_input_scripts_limits_p2sh_redeem_sigops() {
let _init_guard = zakura_test::init();
const OP_CHECKSIG: u8 = 0xac;
let mut p2sh_lock_bytes = vec![0xa9, 0x14];
p2sh_lock_bytes.extend_from_slice(&[0u8; 20]);
p2sh_lock_bytes.push(0x87);
let spent_output = transparent::Output {
value: Amount::try_from(1_000_000).expect("valid amount"),
lock_script: transparent::Script::new(&p2sh_lock_bytes),
};
let unlock_bytes_with_sigops = |sigops: usize| {
let mut unlock_bytes = vec![u8::try_from(sigops).expect("small test count")];
unlock_bytes.extend_from_slice(&vec![OP_CHECKSIG; sigops]);
unlock_bytes
};
let tx_spending = |unlock_bytes: &[u8]| Transaction::V5 {
network_upgrade: NetworkUpgrade::Nu5,
inputs: vec![transparent::Input::PrevOut {
outpoint: transparent::OutPoint {
hash: Hash([0u8; 32]),
index: 0,
},
unlock_script: transparent::Script::new(unlock_bytes),
sequence: u32::MAX,
}],
outputs: vec![spent_output.clone()],
lock_time: LockTime::unlocked(),
expiry_height: Height(0),
sapling_shielded_data: None,
orchard_shielded_data: None,
};
let tx = tx_spending(&unlock_bytes_with_sigops(15));
assert_eq!(
check::mempool_standard_input_scripts(&tx, std::slice::from_ref(&spent_output)),
Ok(()),
"P2SH redeem script with MAX_P2SH_SIGOPS sigops should be standard"
);
let tx = tx_spending(&unlock_bytes_with_sigops(16));
assert_eq!(
check::mempool_standard_input_scripts(&tx, std::slice::from_ref(&spent_output)),
Err(TransactionError::NonStandardInputs),
"P2SH redeem script above MAX_P2SH_SIGOPS should be rejected"
);
}
#[test]
fn mempool_standard_input_scripts_rejects_nonstandard_spent_output() {
let _init_guard = zakura_test::init();
let tx_spending = |unlock_bytes: &[u8], spent_output: &transparent::Output| Transaction::V5 {
network_upgrade: NetworkUpgrade::Nu5,
inputs: vec![transparent::Input::PrevOut {
outpoint: transparent::OutPoint {
hash: Hash([0u8; 32]),
index: 0,
},
unlock_script: transparent::Script::new(unlock_bytes),
sequence: u32::MAX,
}],
outputs: vec![spent_output.clone()],
lock_time: LockTime::unlocked(),
expiry_height: Height(0),
sapling_shielded_data: None,
orchard_shielded_data: None,
};
let nonstandard_output = transparent::Output {
value: Amount::try_from(1_000_000).expect("valid amount"),
lock_script: transparent::Script::new(&[0xac; 50]),
};
let tx = tx_spending(&[0x01, 0xaa], &nonstandard_output);
assert_eq!(
check::mempool_standard_input_scripts(&tx, std::slice::from_ref(&nonstandard_output)),
Err(TransactionError::NonStandardInputs),
"spending a non-standard scriptPubKey should be rejected before script verification"
);
let mut p2pkh_lock_bytes = vec![0x76, 0xa9, 0x14];
p2pkh_lock_bytes.extend_from_slice(&[0u8; 20]);
p2pkh_lock_bytes.extend_from_slice(&[0x88, 0xac]);
let p2pkh_output = transparent::Output {
value: Amount::try_from(1_000_000).expect("valid amount"),
lock_script: transparent::Script::new(&p2pkh_lock_bytes),
};
let tx = tx_spending(&[0x01, 0xaa, 0x01, 0xbb], &p2pkh_output);
assert_eq!(
check::mempool_standard_input_scripts(&tx, std::slice::from_ref(&p2pkh_output)),
Ok(()),
"a standard P2PKH input with the correct stack depth should be accepted"
);
}
#[test]
fn mempool_standard_input_scripts_rejects_nonstandard_script_sigs() {
let _init_guard = zakura_test::init();
let mut p2pkh_lock_bytes = vec![0x76, 0xa9, 0x14];
p2pkh_lock_bytes.extend_from_slice(&[0u8; 20]);
p2pkh_lock_bytes.extend_from_slice(&[0x88, 0xac]);
let spent_output = transparent::Output {
value: Amount::try_from(1_000_000).expect("valid amount"),
lock_script: transparent::Script::new(&p2pkh_lock_bytes),
};
let tx_spending = |unlock_bytes: &[u8]| Transaction::V5 {
network_upgrade: NetworkUpgrade::Nu5,
inputs: vec![transparent::Input::PrevOut {
outpoint: transparent::OutPoint {
hash: Hash([0u8; 32]),
index: 0,
},
unlock_script: transparent::Script::new(unlock_bytes),
sequence: u32::MAX,
}],
outputs: vec![spent_output.clone()],
lock_time: LockTime::unlocked(),
expiry_height: Height(0),
sapling_shielded_data: None,
orchard_shielded_data: None,
};
let tx = tx_spending(&[0xac]);
assert_eq!(
check::mempool_standard_input_scripts(&tx, std::slice::from_ref(&spent_output)),
Err(TransactionError::NonStandardScriptSigNotPushOnly { input_index: 0 }),
"non-push-only scriptSig should be rejected"
);
let mut oversized_unlock_bytes = vec![0x4d, 0x70, 0x06];
oversized_unlock_bytes.extend_from_slice(&[0u8; 1648]);
let tx = tx_spending(&oversized_unlock_bytes);
assert_eq!(
check::mempool_standard_input_scripts(&tx, std::slice::from_ref(&spent_output)),
Err(TransactionError::NonStandardScriptSigSize {
input_index: 0,
size: 1651,
}),
"oversized scriptSig should be rejected"
);
}
#[test]
fn count_script_push_ops_counts_pushes() {
let _init_guard = zakura_test::init();
assert_eq!(
check::count_script_push_ops(&[0x00, 0x01, 0xaa, 0x01, 0xbb]),
3
);
}
#[test]
fn count_script_push_ops_empty_script() {
let _init_guard = zakura_test::init();
assert_eq!(check::count_script_push_ops(&[]), 0);
}
#[test]
fn count_script_push_ops_pushdata_variants() {
let _init_guard = zakura_test::init();
assert_eq!(
check::count_script_push_ops(&[0x4c, 0x03, 0xaa, 0xbb, 0xcc]),
1
);
assert_eq!(
check::count_script_push_ops(&[0x4d, 0x03, 0x00, 0xaa, 0xbb, 0xcc]),
1
);
assert_eq!(
check::count_script_push_ops(&[0x4e, 0x02, 0x00, 0x00, 0x00, 0xaa, 0xbb]),
1
);
}
#[test]
fn count_script_push_ops_truncated_script() {
let _init_guard = zakura_test::init();
assert_eq!(check::count_script_push_ops(&[0x0a, 0xaa, 0xbb, 0xcc]), 0);
}
#[test]
fn extract_p2sh_redeemed_script_extracts_last_push() {
let _init_guard = zakura_test::init();
let unlock_script = transparent::Script::new(&[0x03, 0x61, 0x62, 0x63, 0x02, 0x64, 0x65]);
assert_eq!(
check::extract_p2sh_redeemed_script(&unlock_script),
Some(vec![0x64, 0x65])
);
}
#[test]
fn extract_p2sh_redeemed_script_empty_script() {
let _init_guard = zakura_test::init();
assert!(check::extract_p2sh_redeemed_script(&transparent::Script::new(&[])).is_none());
}
#[test]
fn script_sig_args_expected_values() {
use zcash_script::solver::ScriptKind;
let _init_guard = zakura_test::init();
fn p2pk_lock_script(pubkey: &[u8; 33]) -> transparent::Script {
let mut s = Vec::with_capacity(1 + 33 + 1);
s.push(0x21); s.extend_from_slice(pubkey);
s.push(0xac); transparent::Script::new(&s)
}
fn multisig_lock_script(required: u8, pubkeys: &[&[u8; 33]]) -> transparent::Script {
let mut s = Vec::new();
s.push(0x50 + required); for pk in pubkeys {
s.push(0x21); s.extend_from_slice(*pk);
}
s.push(0x50 + pubkeys.len() as u8); s.push(0xae); transparent::Script::new(&s)
}
assert_eq!(
check::script_sig_args_expected(&ScriptKind::PubKeyHash { hash: [0xaa; 20] }),
Some(2)
);
assert_eq!(
check::script_sig_args_expected(&ScriptKind::ScriptHash { hash: [0xbb; 20] }),
Some(1)
);
assert_eq!(
check::script_sig_args_expected(&ScriptKind::NullData { data: vec![] }),
None
);
let p2pk_kind = check::standard_script_kind(&p2pk_lock_script(&[0x02; 33]))
.expect("P2PK should be a standard script kind");
assert_eq!(check::script_sig_args_expected(&p2pk_kind), Some(1));
let ms_kind = multisig_lock_script(1, &[&[0x02; 33]]);
let ms_kind = check::standard_script_kind(&ms_kind)
.expect("1-of-1 multisig should be a standard script kind");
assert_eq!(check::script_sig_args_expected(&ms_kind), Some(2));
}