#![allow(clippy::unwrap_in_result)]
use std::{collections::HashMap, sync::Arc};
use chrono::{DateTime, TimeZone, Utc};
use color_eyre::eyre::Report;
use futures::{FutureExt, TryFutureExt};
use tokio::time::timeout;
use tower::{buffer::Buffer, service_fn, Service, ServiceExt};
use zebra_chain::{
amount::{Amount, NonNegative},
block::{self, Block, Height},
parameters::{
testnet::{ConfiguredActivationHeights, Parameters},
Network, NetworkUpgrade,
},
primitives::{ed25519, x25519, Groth16Proof},
sapling,
serialization::{DateTime32, ZcashDeserialize, ZcashDeserializeInto, ZcashSerialize},
sprout,
transaction::{
arbitrary::{
insert_fake_orchard_shielded_data, test_transactions, transactions_from_blocks,
v5_transactions, with_garbage_orchard_authorization, with_orchard_flags,
with_orchard_value_balance,
},
zip317, Hash, HashType, JoinSplitData, LockTime, Transaction,
},
transparent::{self, CoinbaseSpendRestriction},
};
use zebra_node_services::mempool;
use zebra_state::ValidateContextError;
use zebra_test::mock_service::MockService;
use crate::{error::TransactionError, transaction::POLL_MEMPOOL_DELAY};
use super::{check, BlockRequest, BlockTxVerifier, MempoolRequest, MempoolTxVerifier};
#[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 = zebra_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 v5_transaction_with_orchard_actions_has_inputs_and_outputs() {
for net in Network::iter() {
let tx = v5_transactions(net.block_iter())
.find(|transaction| {
transaction.inputs().is_empty()
&& transaction.outputs().is_empty()
&& transaction.sapling_spends_count() == 0
&& transaction.sapling_outputs().next().is_none()
&& transaction.joinsplit_count() == 0
})
.expect("V5 tx with only Orchard shielded data");
let tx_bytes = tx
.zcash_serialize_to_vec()
.expect("transaction serialization should succeed");
let flags_offset = find_v5_orchard_flags_offset(&tx_bytes);
let mut modified = tx_bytes.clone();
modified[flags_offset] = 0x00; let modified_tx = Transaction::zcash_deserialize(modified.as_slice())
.expect("modified transaction should deserialize");
assert_eq!(
check::has_inputs_and_outputs(&modified_tx),
Err(TransactionError::NoInputs)
);
let mut modified = tx_bytes.clone();
modified[flags_offset] = 0x01; let modified_tx = Transaction::zcash_deserialize(modified.as_slice())
.expect("modified transaction should deserialize");
assert_eq!(
check::has_inputs_and_outputs(&modified_tx),
Err(TransactionError::NoOutputs)
);
let mut modified = tx_bytes.clone();
modified[flags_offset] = 0x02; let modified_tx = Transaction::zcash_deserialize(modified.as_slice())
.expect("modified transaction should deserialize");
assert_eq!(
check::has_inputs_and_outputs(&modified_tx),
Err(TransactionError::NoInputs)
);
let mut modified = tx_bytes.clone();
modified[flags_offset] = 0x03; let modified_tx = Transaction::zcash_deserialize(modified.as_slice())
.expect("modified transaction should deserialize");
assert!(check::has_inputs_and_outputs(&modified_tx).is_ok());
}
}
#[test]
fn v5_transaction_with_orchard_actions_has_flags() {
for net in Network::iter() {
let tx = v5_transactions(net.block_iter())
.find(|transaction| {
transaction.inputs().is_empty()
&& transaction.outputs().is_empty()
&& transaction.sapling_spends_count() == 0
&& transaction.sapling_outputs().next().is_none()
&& transaction.joinsplit_count() == 0
})
.expect("V5 tx with only Orchard actions");
let tx_bytes = tx
.zcash_serialize_to_vec()
.expect("transaction serialization should succeed");
let flags_offset = find_v5_orchard_flags_offset(&tx_bytes);
let mut modified = tx_bytes.clone();
modified[flags_offset] = 0x00;
let modified_tx = Transaction::zcash_deserialize(modified.as_slice())
.expect("modified transaction should deserialize");
assert_eq!(
check::has_enough_orchard_flags(&modified_tx),
Err(TransactionError::NotEnoughOrchardFlags)
);
let mut modified = tx_bytes.clone();
modified[flags_offset] = 0x01;
let modified_tx = Transaction::zcash_deserialize(modified.as_slice())
.expect("modified transaction should deserialize");
assert!(check::has_enough_orchard_flags(&modified_tx).is_ok());
let mut modified = tx_bytes.clone();
modified[flags_offset] = 0x02;
let modified_tx = Transaction::zcash_deserialize(modified.as_slice())
.expect("modified transaction should deserialize");
assert!(check::has_enough_orchard_flags(&modified_tx).is_ok());
let mut modified = tx_bytes.clone();
modified[flags_offset] = 0x03;
let modified_tx = Transaction::zcash_deserialize(modified.as_slice())
.expect("modified transaction should deserialize");
assert!(check::has_enough_orchard_flags(&modified_tx).is_ok());
}
}
#[test]
fn orchard_value_balance_frozen_at_nu6_3() {
let _init_guard = zebra_test::init();
let network = Network::new_regtest(
ConfiguredActivationHeights {
canopy: Some(1),
nu5: Some(2),
nu6: Some(3),
nu6_1: Some(4),
nu6_2: Some(5),
nu6_3: Some(10),
..Default::default()
}
.into(),
);
let nu6_3_height = NetworkUpgrade::Nu6_3
.activation_height(&network)
.expect("NU6.3 activation height is configured");
let pre_nu6_3_height = NetworkUpgrade::Nu6_2
.activation_height(&network)
.expect("NU6.2 activation height is configured");
let mut tx = v5_transactions(Network::Mainnet.block_iter())
.find(|tx| tx.has_orchard_shielded_data())
.expect("a V5 transaction with an Orchard bundle");
tx = with_orchard_value_balance(tx, -1);
assert_eq!(
check::orchard_value_balance_non_negative(
&tx,
NetworkUpgrade::current(&network, nu6_3_height)
),
Err(TransactionError::NegativeOrchardValueBalance),
);
assert!(check::orchard_value_balance_non_negative(
&tx,
NetworkUpgrade::current(&network, pre_nu6_3_height)
)
.is_ok());
tx = with_orchard_value_balance(tx, 0);
assert!(check::orchard_value_balance_non_negative(
&tx,
NetworkUpgrade::current(&network, nu6_3_height)
)
.is_ok());
tx = with_orchard_value_balance(tx, 1);
assert!(check::orchard_value_balance_non_negative(
&tx,
NetworkUpgrade::current(&network, nu6_3_height)
)
.is_ok());
let no_orchard_tx = Transaction::test_v5(
NetworkUpgrade::Nu5,
Vec::new(),
Vec::new(),
LockTime::Height(Height(0)),
Height(0),
);
assert!(check::orchard_value_balance_non_negative(
&no_orchard_tx,
NetworkUpgrade::current(&network, nu6_3_height)
)
.is_ok());
}
#[test]
fn v6_transaction_with_ironwood_actions_must_have_flags() {
use zebra_chain::{
serialization::{ZcashDeserializeInto, ZcashSerialize},
transaction::arbitrary::{
fake_bundle_for_branch, fake_v6_transaction, v6_ironwood_flags_offset,
},
};
let branch_id = zcash_protocol::consensus::BranchId::Nu6_3;
let ironwood = fake_bundle_for_branch(branch_id, ::orchard::ValuePool::Ironwood, 1, 5)
.expect("the Ironwood pool is defined at NU6.3");
let tx = fake_v6_transaction(NetworkUpgrade::Nu6_3, None, Some(ironwood));
assert!(check::has_enough_ironwood_flags(&tx).is_ok());
let mut bytes = tx
.zcash_serialize_to_vec()
.expect("v6 transaction serializes");
bytes[v6_ironwood_flags_offset(1)] = 0;
let tx: Transaction = bytes
.zcash_deserialize_into()
.expect("a bundle with no flags parses; the rule is enforced by the verifier");
assert_eq!(
check::has_enough_ironwood_flags(&tx),
Err(TransactionError::NotEnoughIronwoodFlags),
);
let tx = fake_v6_transaction(NetworkUpgrade::Nu6_3, None, None);
assert!(check::has_enough_ironwood_flags(&tx).is_ok());
}
#[test]
fn v6_orchard_bundle_must_not_enable_cross_address() {
use zebra_chain::transaction::arbitrary::{fake_bundle_for_branch, fake_v6_transaction};
let branch_id = zcash_protocol::consensus::BranchId::Nu6_3;
let orchard = fake_bundle_for_branch(branch_id, ::orchard::ValuePool::Orchard, 1, 9)
.expect("the Orchard pool is defined at NU6.3");
assert!(
!orchard.flags().cross_address_enabled(),
"an orchard_v3 bundle cannot enable cross-address transfers",
);
let tx = fake_v6_transaction(NetworkUpgrade::Nu6_3, Some(orchard), None);
assert!(check::orchard_cross_address_disabled(&tx).is_ok());
}
#[test]
fn coinbase_orchard_component_empty_at_nu6_3() {
let _init_guard = zebra_test::init();
let network = Network::new_regtest(
ConfiguredActivationHeights {
canopy: Some(1),
nu5: Some(2),
nu6: Some(3),
nu6_1: Some(4),
nu6_2: Some(5),
nu6_3: Some(10),
..Default::default()
}
.into(),
);
let nu6_3_height = NetworkUpgrade::Nu6_3
.activation_height(&network)
.expect("NU6.3 activation height is configured");
let pre_nu6_3_height = NetworkUpgrade::Nu6_2
.activation_height(&network)
.expect("NU6.2 activation height is configured");
let tx = v5_transactions(Network::Mainnet.block_iter())
.find(|transaction| transaction.is_coinbase())
.expect("a V5 coinbase transaction");
assert!(check::coinbase_orchard_component_empty(
&tx,
NetworkUpgrade::current(&network, nu6_3_height)
)
.is_ok());
let tx = insert_fake_orchard_shielded_data(tx);
assert!(tx.is_coinbase() && tx.has_orchard_shielded_data());
assert_eq!(
check::coinbase_orchard_component_empty(
&tx,
NetworkUpgrade::current(&network, nu6_3_height)
),
Err(TransactionError::CoinbaseHasOrchardActions),
);
assert!(check::coinbase_orchard_component_empty(
&tx,
NetworkUpgrade::current(&network, pre_nu6_3_height)
)
.is_ok());
let non_coinbase = v5_transactions(Network::Mainnet.block_iter())
.find(|transaction| !transaction.is_coinbase())
.expect("a non-coinbase V5 transaction");
let non_coinbase = insert_fake_orchard_shielded_data(non_coinbase);
assert!(check::coinbase_orchard_component_empty(
&non_coinbase,
NetworkUpgrade::current(&network, nu6_3_height)
)
.is_ok());
}
#[test]
fn v6_transaction_with_duplicate_ironwood_nullifier_is_rejected() {
use ::orchard::bundle::{BundleVersion, Flags as OrchardFlags};
use zcash_protocol::value::ZatBalance;
use zebra_chain::transaction::arbitrary::{
fake_bundle_for_branch, fake_orchard_bundle_duplicate_nullifiers, fake_v6_transaction,
};
let zero = ZatBalance::from_i64(0).expect("zero is a valid balance");
let branch_id = zcash_protocol::consensus::BranchId::Nu6_3;
let ironwood = fake_orchard_bundle_duplicate_nullifiers(
OrchardFlags::ENABLED,
zero,
2,
11,
BundleVersion::ironwood_v3(),
);
let tx = fake_v6_transaction(NetworkUpgrade::Nu6_3, None, Some(ironwood));
assert!(matches!(
check::spend_conflicts(&tx),
Err(TransactionError::DuplicateIronwoodNullifier(_)),
));
let ironwood = fake_bundle_for_branch(branch_id, ::orchard::ValuePool::Ironwood, 2, 11)
.expect("the Ironwood pool is defined at NU6.3");
let tx = fake_v6_transaction(NetworkUpgrade::Nu6_3, None, Some(ironwood));
assert!(check::spend_conflicts(&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::test_v5(
NetworkUpgrade::Nu5,
vec![],
vec![output.clone()],
LockTime::Height(block::Height(0)),
NetworkUpgrade::Nu5.activation_height(&net).expect("height"),
);
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::test_v5(
NetworkUpgrade::Nu5,
vec![input.clone()],
vec![],
LockTime::Height(block::Height(0)),
NetworkUpgrade::Nu5.activation_height(&net).expect("height"),
);
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 = MempoolTxVerifier::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(zebra_state::Request::UnspentBestChainUtxo(input_outpoint))
.map(|responder| responder.respond(zebra_state::Response::UnspentBestChainUtxo(None)));
let verifier_req = verifier.oneshot(MempoolRequest {
transaction: tx.into(),
height,
});
let (rsp, _) = futures::join!(verifier_req, state_req);
assert_eq!(rsp, Err(TransactionError::TransparentInputNotFound));
}
}
#[tokio::test]
async fn mempool_zip218_sapling_limit_precedes_state_and_proofs() {
let _init_guard = zebra_test::init();
let network = Network::new_regtest(
ConfiguredActivationHeights {
canopy: Some(1),
nu5: Some(2),
nu6: Some(3),
nu6_1: Some(4),
nu6_2: Some(5),
nu6_3: Some(6),
nu7: Some(1_000),
..Default::default()
}
.into(),
);
let fixture = v5_transactions(Network::Mainnet.block_iter())
.find(|tx| tx.sapling_outputs().next().is_some())
.expect("mainnet fixtures contain a V5 Sapling output");
let bundle = fixture.sapling_bundle().expect("fixture has Sapling data");
let output = &bundle.shielded_outputs()[0];
let output = sapling_crypto::bundle::OutputDescription::from_parts(
output.cv().clone(),
*output.cmu(),
output.ephemeral_key().clone(),
*output.enc_ciphertext(),
*output.out_ciphertext(),
[0; 192],
);
for (height, sapling_io, coinbase, exceeds_budget) in [
(Height(999), 301, false, false),
(Height(1_000), 300, false, false),
(Height(1_000), 301, false, true),
(Height(1_001), 301, false, true),
(Height(1_000), 301, true, true),
] {
let (input, transparent_output, _) =
mock_transparent_transfer(Height(1), true, 0, Amount::zero());
let outpoint = input.outpoint().expect("transfer has a previous output");
let input = if coinbase {
mock_coinbase_transparent_output(height).0
} else {
input
};
let template = Transaction::test_v5(
NetworkUpgrade::current(&network, height),
vec![input],
vec![transparent_output],
LockTime::unlocked(),
Height(0),
);
let data = zcash_primitives::transaction::TransactionData::from_parts(
template.tx_version(),
template.consensus_branch_id(),
0,
zcash_protocol::consensus::BlockHeight::from_u32(0),
template.transparent_bundle().cloned(),
None,
sapling_crypto::bundle::Bundle::from_parts(
Vec::new(),
vec![output.clone(); sapling_io],
zcash_protocol::value::ZatBalance::zero(),
*bundle.authorization(),
),
None,
);
let mut bytes = Vec::new();
data.freeze()
.expect("test transaction has well-formed components")
.write(&mut bytes)
.expect("test transaction serializes");
let transaction = Transaction::zcash_deserialize(bytes.as_slice())
.expect("invalid proofs do not prevent transaction parsing");
let expected = if coinbase {
TransactionError::CoinbaseInMempool
} else if exceeds_budget {
let error = TransactionError::TooManyShieldedActions {
pool: "Sapling inputs and outputs",
count: 301,
limit: 300,
};
assert_eq!(error.mempool_misbehavior_score(), 0);
error
} else {
TransactionError::TransparentInputNotFound
};
let state = service_fn(move |request| {
assert!(
!coinbase && !exceeds_budget,
"coinbase and oversized transactions must be rejected before state access"
);
assert_eq!(
request,
zebra_state::Request::UnspentBestChainUtxo(outpoint)
);
async { Ok(zebra_state::Response::UnspentBestChainUtxo(None)) }
});
let result = timeout(
test_timeout(),
MempoolTxVerifier::new_for_tests(&network, state).oneshot(MempoolRequest {
transaction: Arc::new(transaction).into(),
height,
}),
)
.await
.expect("mempool admission completes without proof verification");
assert_eq!(
result,
Err(expected),
"{height:?}, {sapling_io} Sapling outputs, coinbase: {coinbase}"
);
}
}
#[tokio::test]
async fn mempool_request_with_present_input_is_accepted() {
let mut state: MockService<_, _, _, _> = MockService::build().for_prop_tests();
let verifier = MempoolTxVerifier::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::test_v4(vec![input], vec![output], LockTime::unlocked(), height);
let input_outpoint = match tx.inputs()[0] {
transparent::Input::PrevOut { outpoint, .. } => outpoint,
transparent::Input::Coinbase { .. } => panic!("requires a non-coinbase transaction"),
};
tokio::spawn(async move {
state
.expect_request(zebra_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::UnspentBestChainUtxo(
known_utxos
.get(&input_outpoint)
.map(|utxo| utxo.utxo.clone()),
));
state
.expect_request_that(|req| {
matches!(
req,
zebra_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
let verifier_response = verifier
.oneshot(MempoolRequest {
transaction: std::sync::Arc::new(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 = MempoolTxVerifier::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::test_v4(
vec![input],
vec![output],
LockTime::max_lock_time_timestamp(),
height,
);
let input_outpoint = match tx.inputs()[0] {
transparent::Input::PrevOut { outpoint, .. } => outpoint,
transparent::Input::Coinbase { .. } => panic!("requires a non-coinbase transaction"),
};
tokio::spawn(async move {
state
.expect_request(zebra_state::Request::BestChainNextMedianTimePast)
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::BestChainNextMedianTimePast(
DateTime32::from(
u32::try_from(LockTime::MIN_TIMESTAMP).expect("min time is valid"),
),
));
state
.expect_request(zebra_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::UnspentBestChainUtxo(
known_utxos
.get(&input_outpoint)
.map(|utxo| utxo.utxo.clone()),
));
state
.expect_request_that(|req| {
matches!(
req,
zebra_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
let verifier_response = verifier
.oneshot(MempoolRequest {
transaction: std::sync::Arc::new(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 = MempoolTxVerifier::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::test_v4(vec![input], vec![output], LockTime::unlocked(), height);
let input_outpoint = match tx.inputs()[0] {
transparent::Input::PrevOut { outpoint, .. } => outpoint,
transparent::Input::Coinbase { .. } => panic!("requires a non-coinbase transaction"),
};
tokio::spawn(async move {
state
.expect_request(zebra_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::UnspentBestChainUtxo(
known_utxos
.get(&input_outpoint)
.map(|utxo| utxo.utxo.clone()),
));
state
.expect_request_that(|req| {
matches!(
req,
zebra_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
let verifier_response = verifier
.oneshot(MempoolRequest {
transaction: std::sync::Arc::new(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 = MempoolTxVerifier::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::test_v4(
vec![input],
vec![output],
LockTime::max_lock_time_timestamp(),
height,
);
let input_outpoint = match tx.inputs()[0] {
transparent::Input::PrevOut { outpoint, .. } => outpoint,
transparent::Input::Coinbase { .. } => panic!("requires a non-coinbase transaction"),
};
tokio::spawn(async move {
state
.expect_request(zebra_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::UnspentBestChainUtxo(
known_utxos
.get(&input_outpoint)
.map(|utxo| utxo.utxo.clone()),
));
state
.expect_request_that(|req| {
matches!(
req,
zebra_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
let verifier_response = verifier
.oneshot(MempoolRequest {
transaction: std::sync::Arc::new(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 = MempoolTxVerifier::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::test_v4(
vec![input],
vec![output],
LockTime::min_lock_time_timestamp(),
height,
);
let input_outpoint = match tx.inputs()[0] {
transparent::Input::PrevOut { outpoint, .. } => outpoint,
transparent::Input::Coinbase { .. } => panic!("requires a non-coinbase transaction"),
};
tokio::spawn(async move {
state
.expect_request(zebra_state::Request::BestChainNextMedianTimePast)
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::BestChainNextMedianTimePast(
DateTime32::MAX,
));
state
.expect_request(zebra_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::UnspentBestChainUtxo(
known_utxos
.get(&input_outpoint)
.map(|utxo| utxo.utxo.clone()),
));
state
.expect_request_that(|req| {
matches!(
req,
zebra_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
let verifier_response = verifier
.oneshot(MempoolRequest {
transaction: std::sync::Arc::new(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 = MempoolTxVerifier::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::test_v4(
vec![input],
vec![output],
LockTime::min_lock_time_timestamp(),
height,
);
let input_outpoint = match tx.inputs()[0] {
transparent::Input::PrevOut { outpoint, .. } => outpoint,
transparent::Input::Coinbase { .. } => panic!("requires a non-coinbase transaction"),
};
tokio::spawn(async move {
state
.expect_request(zebra_state::Request::BestChainNextMedianTimePast)
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::BestChainNextMedianTimePast(
DateTime32::MAX,
));
state
.expect_request(zebra_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::UnspentBestChainUtxo(None));
state
.expect_request_that(|req| {
matches!(
req,
zebra_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_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(MempoolRequest {
transaction: std::sync::Arc::new(tx).into(),
height,
})
.await;
assert!(
verifier_response.is_ok(),
"expected successful verification, got: {verifier_response:?}"
);
let crate::transaction::MempoolResponse {
transaction: _,
spent_mempool_outpoints,
} = verifier_response.expect("already checked that response is ok");
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 block_verification_does_not_use_mempool_verified_state() {
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 mempool_verifier = Buffer::new(
MempoolTxVerifier::new(&Network::Mainnet, state.clone(), mempool_setup_rx),
1,
);
let block_verifier = Buffer::new(BlockTxVerifier::new(&Network::Mainnet, state.clone()), 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::test_v5(
NetworkUpgrade::Nu6,
vec![input],
vec![output],
LockTime::min_lock_time_timestamp(),
height,
);
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(zebra_state::Request::BestChainNextMedianTimePast)
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::BestChainNextMedianTimePast(
DateTime32::MAX,
));
state_clone
.expect_request(zebra_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::UnspentBestChainUtxo(None));
state_clone
.expect_request_that(|req| {
matches!(
req,
zebra_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_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 = mempool_verifier
.clone()
.oneshot(MempoolRequest {
transaction: std::sync::Arc::new(tx.clone()).into(),
height,
})
.await;
assert!(
verifier_response.is_ok(),
"expected successful verification, got: {verifier_response:?}"
);
let crate::transaction::MempoolResponse {
transaction: _,
spent_mempool_outpoints,
} = verifier_response.expect("already checked that response is ok");
assert_eq!(
spent_mempool_outpoints,
vec![input_outpoint],
"spent_mempool_outpoints in tx verifier response should match input_outpoint"
);
let make_request = || BlockRequest {
transaction_hash: tx_hash,
transaction: Arc::new(tx.clone()),
known_utxos: Arc::new(HashMap::new()),
height,
time: Utc::now(),
};
let utxo_clone = utxo.clone();
tokio::spawn(async move {
state
.expect_request(zebra_state::Request::AwaitUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::Utxo(utxo_clone));
state
.expect_request(zebra_state::Request::AwaitUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::Utxo(utxo));
});
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let crate::transaction::BlockResponse { .. } = block_verifier
.clone()
.oneshot(make_request())
.await
.expect("should succeed after calling state service");
let crate::transaction::BlockResponse { .. } = block_verifier
.clone()
.oneshot(make_request())
.await
.expect("should succeed after calling state service");
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 = zebra_test::init();
let mut state: MockService<_, _, _, _> = MockService::build().for_prop_tests();
let verifier = MempoolTxVerifier::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::test_v4(
vec![input],
vec![output],
LockTime::min_lock_time_timestamp(),
height,
);
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 =
zebra_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(zebra_state::Request::BestChainNextMedianTimePast)
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::BestChainNextMedianTimePast(
DateTime32::MAX,
));
state
.expect_request(zebra_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_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,
zebra_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
let verifier_response = verifier
.oneshot(MempoolRequest {
transaction: std::sync::Arc::new(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 = zebra_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 = MempoolTxVerifier::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::test_v5(
NetworkUpgrade::Nu6,
vec![input],
vec![output],
LockTime::min_lock_time_timestamp(),
height,
);
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");
zebra_state::check::transparent_coinbase_spend(input_outpoint, spend_restriction, &utxo)
.expect("check should pass");
tokio::spawn(async move {
state
.expect_request(zebra_state::Request::BestChainNextMedianTimePast)
.await
.respond(zebra_state::Response::BestChainNextMedianTimePast(
DateTime32::MAX,
));
state
.expect_request(zebra_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.respond(zebra_state::Response::UnspentBestChainUtxo(Some(utxo)));
state
.expect_request_that(|req| {
matches!(
req,
zebra_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.respond(zebra_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
verifier
.oneshot(MempoolRequest {
transaction: std::sync::Arc::new(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 zebra_state::DuplicateNullifierError;
let mut state: MockService<_, _, _, _> = MockService::build().for_prop_tests();
let verifier = MempoolTxVerifier::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::test_v4(vec![input], vec![output], LockTime::unlocked(), height);
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(zebra_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::UnspentBestChainUtxo(
known_utxos
.get(&input_outpoint)
.map(|utxo| utxo.utxo.clone()),
));
state
.expect_request_that(|req| {
matches!(
req,
zebra_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(Err::<zebra_state::Response, zebra_state::BoxError>(
make_validate_context_error().into(),
));
});
let verifier_response = verifier
.oneshot(MempoolRequest {
transaction: std::sync::Arc::new(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 = zebra_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 coinbase_tx = v5_transactions(net.block_iter())
.find(|transaction| transaction.is_coinbase())
.expect("V5 coinbase tx");
let tx = graft_orchard_data_onto_v5_tx(&coinbase_tx, &net, Some(0x00));
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 coinbase_tx = v5_transactions(net.block_iter())
.find(|transaction| transaction.is_coinbase())
.expect("V5 coinbase tx");
let tx = graft_orchard_data_onto_v5_tx(&coinbase_tx, &net, Some(0x01));
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 = BlockTxVerifier::new(
&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(BlockRequest {
transaction_hash: tx.hash(),
transaction: Arc::new(tx),
known_utxos: Arc::new(HashMap::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 = zebra_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 = BlockTxVerifier::new(&net, state)
.oneshot(BlockRequest {
transaction_hash: tx.hash(),
transaction: Arc::new(tx),
known_utxos: Arc::new(HashMap::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::test_v4(
vec![input],
vec![output],
LockTime::Height(block::Height(0)),
(transaction_block_height + 1).expect("expiry height is too large"),
);
let transaction_hash = transaction.unmined_id();
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = BlockTxVerifier::new(&network, state_service);
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction),
known_utxos: Arc::new(known_utxos),
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 = BlockTxVerifier::new(&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::test_v4(
vec![input],
vec![output],
LockTime::unlocked(),
block_height,
);
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction.clone()),
known_utxos: Arc::new(known_utxos),
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 = BlockTxVerifier::new(&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::test_v4(
vec![input],
vec![output],
LockTime::unlocked(),
expiry_height,
);
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction.clone()),
known_utxos: Arc::new(HashMap::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 = BlockTxVerifier::new(&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::test_v4(
vec![input],
vec![output],
LockTime::unlocked(),
expiry_height,
);
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction.clone()),
known_utxos: Arc::new(known_utxos),
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 = BlockTxVerifier::new(&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::test_v4(
vec![input],
vec![output],
LockTime::unlocked(),
expiry_height,
);
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction.clone()),
known_utxos: Arc::new(known_utxos),
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 = BlockTxVerifier::new(&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::test_v4(
vec![input],
vec![output],
LockTime::unlocked(),
expiry_height,
);
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction.clone()),
known_utxos: Arc::new(HashMap::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 transaction_with_out_of_range_expiry_height() {
let network = Network::Mainnet;
let state: MockService<_, _, _, _> = MockService::build().for_unit_tests();
for raw_expiry in [0x8000_0000_u32, u32::MAX] {
let expiry_height = block::Height(raw_expiry);
for version in [4_u32, 5, 6] {
let verifier = BlockTxVerifier::new(&network, state.clone());
let (network_upgrade, block_height) = match version {
4 => (NetworkUpgrade::Canopy, block::Height::MAX),
5 => (
NetworkUpgrade::Nu5,
NetworkUpgrade::Nu5
.activation_height(&network)
.expect("NU5 height must be set"),
),
6 => (
NetworkUpgrade::Nu6_3,
NetworkUpgrade::Nu6_3
.activation_height(&network)
.expect("NU6.3 height must be set"),
),
_ => unreachable!("no other versions tested"),
};
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 = match version {
4 => Transaction::test_v4(
vec![input],
vec![output],
LockTime::unlocked(),
expiry_height,
),
5 => Transaction::test_v5(
network_upgrade,
vec![input],
vec![output],
LockTime::unlocked(),
expiry_height,
),
6 => Transaction::test_v6(
network_upgrade,
vec![input],
vec![output],
LockTime::unlocked(),
expiry_height,
),
_ => unreachable!("no other versions tested"),
};
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction.clone()),
known_utxos: Arc::new(known_utxos),
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(),
}),
"V{version} with nExpiryHeight {raw_expiry} must be rejected by block verification"
);
let mempool_verifier = MempoolTxVerifier::new_for_tests(&network, state.clone());
let mempool_result = mempool_verifier
.oneshot(MempoolRequest {
transaction: Arc::new(transaction.clone()).into(),
height: block_height,
})
.await;
assert_eq!(
mempool_result,
Err(TransactionError::MaximumExpiryHeight {
expiry_height,
is_coinbase: false,
block_height,
transaction_hash: transaction.hash(),
}),
"V{version} with nExpiryHeight {raw_expiry} must be rejected by the mempool"
);
}
}
}
#[tokio::test]
async fn coinbase_with_out_of_range_expiry_height() {
let network = Network::Mainnet;
let state: MockService<_, _, _, _> = MockService::build().for_unit_tests();
let nu5_activation = NetworkUpgrade::Nu5
.activation_height(&network)
.expect("NU5 height must be set");
for raw_expiry in [0x8000_0000_u32, u32::MAX] {
let expiry_height = block::Height(raw_expiry);
for block_height in [
(nu5_activation - 1).expect("does not underflow"),
nu5_activation,
] {
let (input, output) = mock_coinbase_transparent_output(block_height);
let transaction = Transaction::test_v4(
vec![input],
vec![output],
LockTime::unlocked(),
expiry_height,
);
let verifier = BlockTxVerifier::new(&network, state.clone());
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction.clone()),
known_utxos: Arc::new(HashMap::new()),
height: block_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
let expected = if block_height < nu5_activation {
TransactionError::MaximumExpiryHeight {
expiry_height,
is_coinbase: true,
block_height,
transaction_hash: transaction.hash(),
}
} else {
TransactionError::CoinbaseExpiryBlockHeight {
expiry_height: Some(expiry_height),
block_height,
transaction_hash: transaction.hash(),
}
};
assert_eq!(
result,
Err(expected),
"coinbase with nExpiryHeight {raw_expiry} at height {block_height:?}"
);
}
}
}
#[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::test_v4(
vec![input],
vec![output],
LockTime::Height(block::Height(0)),
transaction_block_height,
);
let transaction_hash = transaction.unmined_id();
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = BlockTxVerifier::new(&network, state_service);
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction),
known_utxos: Arc::new(HashMap::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::test_v4(
vec![input],
vec![output],
LockTime::Height(block::Height(0)),
(transaction_block_height + 1).expect("expiry height is too large"),
);
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = BlockTxVerifier::new(&network, state_service);
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction),
known_utxos: Arc::new(known_utxos),
height: transaction_block_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result,
Err(TransactionError::InternalDowncastError(
"downcast to known transaction error type failed, original error: ScriptInvalid"
.to_string()
))
);
}
#[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::test_v4(
vec![input.clone(), input.clone()],
vec![output],
LockTime::Height(block::Height(0)),
(transaction_block_height + 1).expect("expiry height is too large"),
);
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = BlockTxVerifier::new(&network, state_service);
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction),
known_utxos: Arc::new(known_utxos),
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 = zebra_test::init();
zebra_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 transaction = build_signed_v4_tx_with_joinsplit_data(
joinsplit_data,
&signing_key,
nu,
(transaction_block_height + 1).expect("expiry height is too large"),
);
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = BlockTxVerifier::new(&network, state_service);
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction),
known_utxos: Arc::new(HashMap::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 = zebra_test::init();
zebra_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 transaction = build_signed_v4_tx_with_joinsplit_data(
joinsplit_data,
&signing_key,
nu,
(transaction_block_height + 1).expect("expiry height is too large"),
);
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = BlockTxVerifier::new(&network, state_service);
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction),
known_utxos: Arc::new(HashMap::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::test_v5(
network_upgrade,
vec![input],
vec![output],
LockTime::Height(block::Height(0)),
(transaction_block_height + 1).expect("expiry height is too large"),
);
let transaction_hash = transaction.unmined_id();
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = BlockTxVerifier::new(&network, state_service);
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction),
known_utxos: Arc::new(known_utxos),
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 = BlockTxVerifier::new(&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::test_v5(
NetworkUpgrade::Nu5,
vec![input],
vec![output],
LockTime::unlocked(),
block_height,
);
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction.clone()),
known_utxos: Arc::new(known_utxos),
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 = BlockTxVerifier::new(&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::test_v5(
NetworkUpgrade::Nu5,
vec![input],
vec![output],
LockTime::unlocked(),
block_height,
);
let result = verifier
.clone()
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction.clone()),
known_utxos: Arc::new(HashMap::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.set_expiry_height(new_expiry_height);
let result = verifier
.clone()
.oneshot(BlockRequest {
transaction_hash: new_transaction.hash(),
transaction: Arc::new(new_transaction.clone()),
known_utxos: Arc::new(HashMap::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.set_expiry_height(new_expiry_height);
let result = verifier
.clone()
.oneshot(BlockRequest {
transaction_hash: new_transaction.hash(),
transaction: Arc::new(new_transaction.clone()),
known_utxos: Arc::new(HashMap::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.set_expiry_height(new_expiry_height);
let height = new_expiry_height;
new_transaction.set_network_upgrade(NetworkUpgrade::current(&network, height));
let verification_result = verifier
.clone()
.oneshot(BlockRequest {
transaction_hash: new_transaction.hash(),
transaction: Arc::new(new_transaction.clone()),
known_utxos: Arc::new(HashMap::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 = BlockTxVerifier::new(&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::test_v5(
NetworkUpgrade::Nu5,
vec![input],
vec![output],
LockTime::unlocked(),
expiry_height,
);
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction.clone()),
known_utxos: Arc::new(known_utxos),
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::test_v5(
NetworkUpgrade::Nu6_3,
vec![input],
vec![output],
LockTime::unlocked(),
expiry_height,
);
let transaction_hash = transaction.hash();
let verification_result = BlockTxVerifier::new(&Network::Mainnet, state)
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction.clone()),
known_utxos: Arc::new(known_utxos),
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::test_v5(
network_upgrade,
vec![input],
vec![output],
LockTime::Height(block::Height(0)),
transaction_block_height,
);
let transaction_hash = transaction.unmined_id();
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = BlockTxVerifier::new(&network, state_service);
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction),
known_utxos: Arc::new(known_utxos),
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::test_v5(
network_upgrade,
vec![input],
vec![output],
LockTime::Height(block::Height(0)),
(transaction_block_height + 1).expect("expiry height is too large"),
);
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = BlockTxVerifier::new(&network, state_service);
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction),
known_utxos: Arc::new(known_utxos),
height: transaction_block_height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result,
Err(TransactionError::InternalDowncastError(
"downcast to known transaction error type failed, original error: ScriptInvalid"
.to_string()
))
);
}
#[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::test_v5(
NetworkUpgrade::Nu5,
vec![input.clone(), input.clone()],
vec![output],
LockTime::Height(block::Height(0)),
height.next().expect("valid height"),
);
let state = service_fn(|_| async { unreachable!("State service should not be called") });
let verification_result = BlockTxVerifier::new(&network, state)
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction: Arc::new(transaction),
known_utxos: Arc::new(known_utxos),
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 = zebra_test::init();
zebra_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.has_sprout_joinsplit_data())
.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 = BlockTxVerifier::new(&network, state_service);
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction,
known_utxos: Arc::new(HashMap::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 = zebra_test::init();
zebra_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, transaction) = test_transactions(&network)
.rev()
.filter(|(_, tx)| {
!tx.is_coinbase() && tx.inputs().is_empty() && !tx.has_sapling_shielded_data()
})
.find(|(_, tx)| tx.joinsplit_count() > 0)
.expect("There should be a tx with Groth16 JoinSplits.");
let expected_error = Err(expected_error);
let mut tx_bytes = transaction
.zcash_serialize_to_vec()
.expect("transaction serialization should succeed");
modify_joinsplit_bytes_and_resign(&mut tx_bytes, &network, height, modification);
let transaction: Arc<Transaction> = Arc::new(
Transaction::zcash_deserialize(tx_bytes.as_slice())
.expect("modified transaction should deserialize"),
);
let state_service =
service_fn(|_| async { unreachable!("State service should not be called.") });
let verifier = BlockTxVerifier::new(&network, state_service);
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction: transaction.clone(),
known_utxos: Arc::new(HashMap::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(result, expected_error);
}
#[test]
fn v4_with_sapling_spends() {
let _init_guard = zebra_test::init();
zebra_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_count() > 0)
.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 = BlockTxVerifier::new(&network, state_service);
let result = timeout(
test_timeout(),
verifier.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction,
known_utxos: Arc::new(HashMap::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
}),
)
.await
.expect("timeout expired");
assert_eq!(
result.expect("unexpected error response").tx_id,
expected_hash
);
});
}
#[test]
fn v4_with_duplicate_sapling_spends() {
let _init_guard = zebra_test::init();
zebra_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_count() > 0)
.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 = BlockTxVerifier::new(&network, state_service);
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction,
known_utxos: Arc::new(HashMap::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 = zebra_test::init();
zebra_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_count() == 0
&& 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 = BlockTxVerifier::new(&network, state_service);
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction,
known_utxos: Arc::new(HashMap::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result.expect("unexpected error response").tx_id,
expected_hash
);
})
}
#[test]
fn v4_with_small_order_rk_sapling_spend() {
let _init_guard = zebra_test::init();
zebra_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()
&& transaction.joinsplit_count() == 0
})
.find(|(_, transaction)| transaction.sapling_spends_count() > 0)
.expect("No transaction found with Sapling spends");
set_first_sapling_spend_rk(
Arc::get_mut(&mut transaction).expect("Transaction only has one active reference"),
small_order_point_bytes(),
);
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = BlockTxVerifier::new(&network, state_service);
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction,
known_utxos: Arc::new(HashMap::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(result, Err(TransactionError::SaplingVerificationFailed));
});
}
#[test]
fn v4_with_small_order_epk_sapling_output() {
let _init_guard = zebra_test::init();
zebra_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()
&& transaction.joinsplit_count() == 0
})
.find(|(_, transaction)| transaction.sapling_outputs().next().is_some())
.expect("No transaction found with Sapling outputs");
set_first_sapling_output_epk(
Arc::get_mut(&mut transaction).expect("Transaction only has one active reference"),
small_order_point_bytes(),
);
let state_service =
service_fn(|_| async { unreachable!("State service should not be called") });
let verifier = BlockTxVerifier::new(&network, state_service);
let result = verifier
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
transaction,
known_utxos: Arc::new(HashMap::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(result, Err(TransactionError::SaplingVerificationFailed));
});
}
#[tokio::test]
async fn v5_with_sapling_spends() {
let _init_guard = zebra_test::init();
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_count() > 0)
.expect("V5 tx with Sapling spends");
let expected_hash = tx.unmined_id();
let height = tx.expiry_height().expect("expiry height");
let verifier = BlockTxVerifier::new(
&net,
service_fn(|_| async { unreachable!("State service should not be called") }),
);
assert_eq!(
timeout(
test_timeout(),
verifier.oneshot(BlockRequest {
transaction_hash: tx.hash(),
transaction: Arc::new(tx),
known_utxos: Arc::new(HashMap::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
)
.await
.expect("timeout expired")
.expect("unexpected error response")
.tx_id,
expected_hash
);
}
}
#[tokio::test]
async fn v5_with_duplicate_sapling_spends() {
let _init_guard = zebra_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_count() > 0)
.expect("V5 tx with Sapling spends");
let height = tx.expiry_height().expect("expiry height");
let duplicate_nullifier = duplicate_sapling_spend(&mut tx);
let verifier = BlockTxVerifier::new(
&net,
service_fn(|_| async { unreachable!("State service should not be called") }),
);
assert_eq!(
verifier
.oneshot(BlockRequest {
transaction_hash: tx.hash(),
transaction: Arc::new(tx),
known_utxos: Arc::new(HashMap::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await,
Err(TransactionError::DuplicateSaplingNullifier(
duplicate_nullifier
))
);
}
}
#[tokio::test]
async fn v5_with_small_order_rk_sapling_spend() {
let _init_guard = zebra_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_count() > 0)
.expect("V5 tx with Sapling spends");
let height = tx.expiry_height().expect("expiry height");
set_first_sapling_spend_rk(&mut tx, small_order_point_bytes());
let verifier = BlockTxVerifier::new(
&net,
service_fn(|_| async { unreachable!("State service should not be called") }),
);
assert_eq!(
verifier
.oneshot(BlockRequest {
transaction_hash: tx.hash(),
transaction: Arc::new(tx),
known_utxos: Arc::new(HashMap::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await,
Err(TransactionError::SaplingVerificationFailed)
);
}
}
#[tokio::test]
async fn v5_with_small_order_epk_sapling_output() {
let _init_guard = zebra_test::init();
for net in [Network::Mainnet] {
let mut tx = v5_transactions(net.block_iter())
.filter(|tx| !tx.is_coinbase() && tx.inputs().is_empty())
.find(|tx| tx.sapling_outputs().next().is_some())
.expect("V5 tx with Sapling outputs");
let height = tx.expiry_height().expect("expiry height");
set_first_sapling_output_epk(&mut tx, small_order_point_bytes());
let verifier = BlockTxVerifier::new(
&net,
service_fn(|_| async { unreachable!("State service should not be called") }),
);
assert_eq!(
verifier
.oneshot(BlockRequest {
transaction_hash: tx.hash(),
transaction: Arc::new(tx),
known_utxos: Arc::new(HashMap::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await,
Err(TransactionError::SaplingVerificationFailed)
);
}
}
#[tokio::test]
async fn v5_with_duplicate_orchard_action() {
use ::orchard::bundle::{Bundle as OrchardBundle, Flags as OrchardFlags};
use nonempty::NonEmpty;
let _init_guard = zebra_test::init();
for net in Network::iter() {
let tx = v5_transactions(net.block_iter())
.rev()
.find(|tx| {
tx.inputs().is_empty()
&& tx.outputs().is_empty()
&& tx.sapling_spends_count() == 0
&& tx.sapling_outputs().next().is_none()
&& tx.joinsplit_count() == 0
&& tx.has_orchard_shielded_data()
})
.expect("V5 tx with only Orchard actions");
let height = tx.expiry_height().expect("expiry height");
let duplicate_nullifier = tx
.orchard_nullifiers()
.next()
.expect("tx has at least one orchard action");
let orig_bundle = tx
.orchard_bundle()
.expect("filter guarantees orchard shielded data");
let first_action = orig_bundle.actions().first().clone();
let mut actions: Vec<_> = orig_bundle.actions().iter().cloned().collect();
actions.push(first_action);
let actions = NonEmpty::from_vec(actions).expect("non-empty");
let new_bundle = OrchardBundle::try_from_parts(
actions,
OrchardFlags::ENABLED,
*orig_bundle.value_balance(),
*orig_bundle.anchor(),
orig_bundle.authorization().clone(),
orig_bundle.bundle_version(),
)
.expect("the duplicated action keeps the proof size and flags valid");
let tx = tx.with_orchard_bundle(Some(new_bundle));
let verifier = BlockTxVerifier::new(
&net,
service_fn(|_| async { unreachable!("State service should not be called") }),
);
assert_eq!(
verifier
.oneshot(BlockRequest {
transaction_hash: tx.hash(),
transaction: Arc::new(tx),
known_utxos: Arc::new(HashMap::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await,
Err(TransactionError::DuplicateOrchardNullifier(
duplicate_nullifier
))
);
}
}
#[test]
fn orchard_disabling_soft_fork_activation_boundary() {
let _init_guard = zebra_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 = zebra_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().next().is_none()
&& transaction.sapling_outputs().next().is_none()
&& transaction.joinsplit_count() == 0
})
.expect("V5 tx with only Orchard actions");
let tx = with_orchard_flags(tx, ::orchard::bundle::Flags::ENABLED);
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 = BlockTxVerifier::new(
&network,
service_fn(|_| async { unreachable!("state service should not be called") }),
)
.oneshot(BlockRequest {
transaction_hash: tx.hash(),
transaction: Arc::new(tx.clone()),
known_utxos: Arc::new(HashMap::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
block_response,
Err(expected_err.clone()),
"block verification must reject a transaction with Orchard actions after the soft fork",
);
let mempool_response = MempoolTxVerifier::new_for_tests(
&network,
service_fn(|_| async { unreachable!("state service should not be called") }),
)
.oneshot(MempoolRequest {
transaction: Arc::new(tx).into(),
height,
})
.await;
assert_eq!(
mempool_response,
Err(expected_err),
"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 = zebra_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::test_v4(
vec![input],
vec![output],
LockTime::Height(block::Height(0)),
(transaction_block_height + 1).expect("expiry height is too large"),
);
let input_outpoint = match transaction.inputs()[0] {
transparent::Input::PrevOut { outpoint, .. } => outpoint,
transparent::Input::Coinbase { .. } => panic!("requires a non-coinbase transaction"),
};
let verifier = MempoolTxVerifier::new_for_tests(&network, state.clone());
tokio::spawn(async move {
state
.expect_request(zebra_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::UnspentBestChainUtxo(
known_utxos
.get(&input_outpoint)
.map(|utxo| utxo.utxo.clone()),
));
state
.expect_request_that(|req| {
matches!(
req,
zebra_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
let response = verifier
.oneshot(MempoolRequest {
transaction: Arc::new(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 = zebra_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().next().is_none()
&& transaction.sapling_outputs().next().is_none()
&& transaction.joinsplit_count() == 0
})
.expect("V5 tx with only Orchard actions");
assert!(
tx.has_orchard_shielded_data(),
"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<zebra_state::Request, zebra_state::Response, _, _> =
MockService::build().for_prop_tests();
let accept_verifier = BlockTxVerifier::new(&accepting_network, state.clone());
tokio::spawn(async move {
state
.expect_request_that(|req| {
matches!(
req,
zebra_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
let accept_response = accept_verifier
.oneshot(BlockRequest {
transaction_hash: tx.hash(),
transaction: Arc::new(tx.clone()),
known_utxos: Arc::new(HashMap::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 = BlockTxVerifier::new(
&rejecting_network,
service_fn(|_| async { unreachable!("state service should not be called") }),
)
.oneshot(BlockRequest {
transaction_hash: tx.hash(),
transaction: Arc::new(tx),
known_utxos: Arc::new(HashMap::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",
);
}
#[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::test_v5(
network_upgrade,
vec![input],
vec![output],
LockTime::unlocked(),
Height::MAX_EXPIRY_HEIGHT,
);
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 block_verifier = Buffer::new(BlockTxVerifier::new(&network, state.clone()), 10);
let mempool_verifier = Buffer::new(
MempoolTxVerifier::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 = block_verifier
.clone()
.oneshot(BlockRequest {
transaction_hash: tx.hash(),
transaction: Arc::new(tx.clone()),
known_utxos: known_utxos.clone(),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.map_err(|err| *err.downcast().expect("`TransactionError` type"));
let mempool_req = mempool_verifier
.clone()
.oneshot(MempoolRequest {
transaction: std::sync::Arc::new(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));
let height = network_upgrade.activation_height(&network).expect("height");
let block_req = block_verifier
.clone()
.oneshot(BlockRequest {
transaction_hash: tx.hash(),
transaction: Arc::new(tx.clone()),
known_utxos: known_utxos.clone(),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.map_ok(|rsp| rsp.tx_id)
.map_err(|e| format!("{e}"));
let mempool_req = mempool_verifier
.clone()
.oneshot(MempoolRequest {
transaction: std::sync::Arc::new(tx.clone()).into(),
height,
})
.map_ok(|rsp| rsp.transaction.transaction.id)
.map_err(|e| format!("{e}"));
let state_req = async {
state
.expect_request(zebra_state::Request::UnspentBestChainUtxo(outpoint))
.map(|r| {
r.respond(zebra_state::Response::UnspentBestChainUtxo(
known_utxos.get(&outpoint).map(|utxo| utxo.utxo.clone()),
))
})
.await;
state
.expect_request_that(|req| {
matches!(
req,
zebra_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.map(|r| {
r.respond(zebra_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.set_network_upgrade(next_nu);
let height = network_upgrade.activation_height(&network).expect("height");
let block_req = block_verifier
.clone()
.oneshot(BlockRequest {
transaction_hash: tx.hash(),
transaction: Arc::new(tx.clone()),
known_utxos: known_utxos.clone(),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.map_err(|err| *err.downcast().expect("`TransactionError` type"));
let mempool_req = mempool_verifier
.clone()
.oneshot(MempoolRequest {
transaction: std::sync::Arc::new(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;
}
}
}
#[test]
fn v4_transactions_are_rejected_from_nu7_onward() {
let tx = Transaction::test_v4(
Vec::new(),
Vec::new(),
LockTime::Height(Height(0)),
Height(1),
);
for nu in NetworkUpgrade::iter() {
let result = super::verify_v4_transaction_network_upgrade(&tx, nu);
match nu {
NetworkUpgrade::Sapling
| NetworkUpgrade::Blossom
| NetworkUpgrade::Heartwood
| NetworkUpgrade::Canopy
| NetworkUpgrade::Nu5
| NetworkUpgrade::Nu6
| NetworkUpgrade::Nu6_1
| NetworkUpgrade::Nu6_2
| NetworkUpgrade::Nu6_3 => {
assert_eq!(result, Ok(()), "V4 must be supported by {nu:?}");
}
_ => assert_eq!(
result,
Err(TransactionError::UnsupportedByNetworkUpgrade(4, nu)),
"V4 must be rejected by {nu:?}",
),
}
}
}
#[tokio::test]
async fn v4_transaction_is_rejected_at_nu7_activation() {
let _init_guard = zebra_test::init();
let network = Network::new_default_testnet();
let nu7_height = NetworkUpgrade::Nu7
.activation_height(&network)
.expect("NU7 activation height is scheduled on Testnet");
let pre_nu7_height = (nu7_height - 1).expect("NU7 does not activate at the genesis height");
let (input, output, known_utxos) = mock_transparent_transfer(
(pre_nu7_height - 1).expect("block height is too small"),
true,
0,
Amount::try_from(10_001).expect("invalid value"),
);
let known_utxos = Arc::new(known_utxos);
let tx = Arc::new(Transaction::test_v4(
vec![input],
vec![output],
LockTime::Height(Height(0)),
(nu7_height + 1).expect("expiry height is too large"),
));
let (input, output) = mock_coinbase_transparent_output(nu7_height);
let coinbase = Arc::new(Transaction::test_v4(
vec![input],
vec![output],
LockTime::Height(Height(0)),
nu7_height,
));
for (transaction, height) in [
(tx.clone(), pre_nu7_height),
(tx.clone(), nu7_height),
(coinbase, nu7_height),
] {
let expected = if height < nu7_height {
Ok(transaction.unmined_id())
} else {
Err(TransactionError::UnsupportedByNetworkUpgrade(
4,
NetworkUpgrade::Nu7,
))
};
let result = BlockTxVerifier::new(
&network,
service_fn(|_| async { unreachable!("State service should not be called") }),
)
.oneshot(BlockRequest {
transaction_hash: transaction.hash(),
known_utxos: if transaction.is_coinbase() {
Arc::default()
} else {
known_utxos.clone()
},
transaction,
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
result.map(|response| response.tx_id),
expected,
"{height:?}"
);
}
let mut state: MockService<_, _, _, _> = MockService::build().for_prop_tests();
let mempool_verifier = MempoolTxVerifier::new_for_tests(&network, state.clone());
let input_outpoint = match tx.inputs()[0] {
transparent::Input::PrevOut { outpoint, .. } => outpoint,
transparent::Input::Coinbase { .. } => panic!("requires a non-coinbase transaction"),
};
tokio::spawn(async move {
state
.expect_request(zebra_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::UnspentBestChainUtxo(
known_utxos
.get(&input_outpoint)
.map(|utxo| utxo.utxo.clone()),
));
});
assert_eq!(
mempool_verifier
.oneshot(MempoolRequest {
transaction: tx.into(),
height: nu7_height,
})
.await
.map(|_| ()),
Err(TransactionError::UnsupportedByNetworkUpgrade(
4,
NetworkUpgrade::Nu7
))
);
}
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_TRUE: u8 = 0x51;
let accepting_unlock_script = transparent::Script::new(&[0x01, OP_TRUE]);
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 rejecting_script = transparent::Script::new(&[0]);
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_script.clone(), accepting_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: rejecting_script,
};
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 build_v4_tx_with_joinsplit_data(
joinsplit_data: Option<JoinSplitData<Groth16Proof>>,
expiry_height: block::Height,
) -> Transaction {
let mut tx = Transaction::test_v4_with_joinsplit_data(joinsplit_data.as_ref());
tx.set_expiry_height(expiry_height);
tx
}
fn build_signed_v4_tx_with_joinsplit_data(
joinsplit_data: JoinSplitData<Groth16Proof>,
signing_key: &ed25519::SigningKey,
network_upgrade: NetworkUpgrade,
expiry_height: block::Height,
) -> Transaction {
let tx = build_v4_tx_with_joinsplit_data(Some(joinsplit_data), expiry_height);
let sighash = tx
.sighash(network_upgrade, HashType::ALL, Arc::new(Vec::new()), None)
.expect("sighash computation should succeed");
let sig = signing_key.sign(sighash.as_ref());
let sig_bytes: [u8; 64] = sig.into();
let mut tx_bytes = tx
.zcash_serialize_to_vec()
.expect("transaction serialization should succeed");
let sig_offset = tx_bytes.len() - 64;
tx_bytes[sig_offset..].copy_from_slice(&sig_bytes);
Transaction::zcash_deserialize(tx_bytes.as_slice())
.expect("signed V4 transaction should deserialize")
}
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(&mut rand::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::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_bytes_and_resign(
tx_bytes: &mut Vec<u8>,
network: &Network,
height: block::Height,
modification: JoinSplitModification,
) {
if let JoinSplitModification::CorruptSignature = modification {
let sig_offset = tx_bytes.len() - 64;
tx_bytes[sig_offset + 10] ^= 0x01;
return;
}
let proof_offset = find_first_joinsplit_proof_offset(tx_bytes);
match modification {
JoinSplitModification::CorruptProof => {
let (first, rest) = tx_bytes[proof_offset..proof_offset + 192].split_at_mut(48);
let mut first_copy = [0u8; 48];
first_copy.copy_from_slice(first);
first.copy_from_slice(&rest[96..144]);
rest[96..144].copy_from_slice(&first_copy);
}
JoinSplitModification::ZeroProof => {
tx_bytes[proof_offset..proof_offset + 192].fill(0);
}
JoinSplitModification::CorruptSignature => {
unreachable!("the signature case returns early above")
}
}
let signing_key = ed25519::SigningKey::new(rand::rng());
let verification_key = ed25519::VerificationKey::from(&signing_key);
let pub_key_offset = tx_bytes.len() - 96;
tx_bytes[pub_key_offset..pub_key_offset + 32]
.copy_from_slice(&<[u8; 32]>::from(verification_key));
let modified = Transaction::zcash_deserialize(tx_bytes.as_slice())
.expect("modified transaction should deserialize");
let nu = NetworkUpgrade::current(network, height);
let sighash = modified
.sighash(nu, HashType::ALL, Arc::new(Vec::new()), None)
.expect("network upgrade should be valid for tx");
let sig = signing_key.sign(sighash.as_ref());
let sig_offset = tx_bytes.len() - 64;
tx_bytes[sig_offset..].copy_from_slice(&<[u8; 64]>::from(sig));
}
fn find_first_joinsplit_proof_offset(tx_bytes: &[u8]) -> usize {
let mut pos = 8usize;
let n_inputs = parse_compact_size(tx_bytes, &mut pos);
for _ in 0..n_inputs {
pos += 36; let script_len = parse_compact_size(tx_bytes, &mut pos);
pos += script_len as usize + 4; }
let n_outputs = parse_compact_size(tx_bytes, &mut pos);
for _ in 0..n_outputs {
pos += 8; let script_len = parse_compact_size(tx_bytes, &mut pos);
pos += script_len as usize; }
pos += 8;
pos += 8;
let n_spends = parse_compact_size(tx_bytes, &mut pos);
for _ in 0..n_spends {
pos += 32 + 32 + 32 + 32 + 192 + 64;
}
let n_sapling_outputs = parse_compact_size(tx_bytes, &mut pos);
for _ in 0..n_sapling_outputs {
pos += 32 + 32 + 32 + 580 + 80 + 192;
}
let n_joinsplits = parse_compact_size(tx_bytes, &mut pos);
assert!(n_joinsplits > 0, "expected at least one joinsplit");
pos += 8 + 8 + 32 + 64 + 64 + 32 + 32 + 64;
pos
}
fn parse_compact_size(bytes: &[u8], pos: &mut usize) -> u64 {
let first = bytes[*pos];
*pos += 1;
match first {
0xfd => {
let val = u16::from_le_bytes([bytes[*pos], bytes[*pos + 1]]);
*pos += 2;
val as u64
}
0xfe => {
let val = u32::from_le_bytes([
bytes[*pos],
bytes[*pos + 1],
bytes[*pos + 2],
bytes[*pos + 3],
]);
*pos += 4;
val as u64
}
0xff => {
let val = u64::from_le_bytes([
bytes[*pos],
bytes[*pos + 1],
bytes[*pos + 2],
bytes[*pos + 3],
bytes[*pos + 4],
bytes[*pos + 5],
bytes[*pos + 6],
bytes[*pos + 7],
]);
*pos += 8;
val
}
n => n as u64,
}
}
fn duplicate_sapling_spend(transaction: &mut Transaction) -> sapling::Nullifier {
let tx_bytes = transaction
.zcash_serialize_to_vec()
.expect("transaction serialization should succeed");
let is_v5 = transaction.version() >= 5;
if is_v5 {
let mut pos = skip_v5_header_and_transparent(&tx_bytes);
let spend_count_pos = pos;
let n_spends = parse_compact_size(&tx_bytes, &mut pos);
assert!(n_spends > 0, "expected sapling spends");
let first_spend_start = pos;
let first_spend_bytes = tx_bytes[first_spend_start..first_spend_start + 96].to_vec();
let mut nf_bytes = [0u8; 32];
nf_bytes.copy_from_slice(&tx_bytes[first_spend_start + 32..first_spend_start + 64]);
let duplicate_nullifier = sapling::Nullifier::from(nf_bytes);
let spends_end = first_spend_start + (n_spends as usize * 96);
pos = spends_end;
let outputs_section_start = pos;
let n_sapling_outputs = parse_compact_size(&tx_bytes, &mut pos);
pos += n_sapling_outputs as usize * 756;
let has_sapling_data = n_spends > 0 || n_sapling_outputs > 0;
if has_sapling_data {
pos += 8;
}
if n_spends > 0 {
pos += 32;
}
let pre_proofs_end = pos;
let proof_section_start = pos;
let first_proof_bytes = tx_bytes[proof_section_start..proof_section_start + 192].to_vec();
pos += n_spends as usize * 192;
let sig_section_start = pos;
let first_sig_bytes = tx_bytes[sig_section_start..sig_section_start + 64].to_vec();
pos += n_spends as usize * 64;
let remainder = tx_bytes[pos..].to_vec();
let new_n_spends = n_spends + 1;
let mut new_bytes = Vec::new();
new_bytes.extend_from_slice(&tx_bytes[..spend_count_pos]);
write_compact_size(&mut new_bytes, new_n_spends);
new_bytes.extend_from_slice(&tx_bytes[first_spend_start..spends_end]);
new_bytes.extend_from_slice(&first_spend_bytes);
new_bytes.extend_from_slice(&tx_bytes[outputs_section_start..pre_proofs_end]);
new_bytes.extend_from_slice(
&tx_bytes[proof_section_start..proof_section_start + n_spends as usize * 192],
);
new_bytes.extend_from_slice(&first_proof_bytes);
new_bytes.extend_from_slice(
&tx_bytes[sig_section_start..sig_section_start + n_spends as usize * 64],
);
new_bytes.extend_from_slice(&first_sig_bytes);
new_bytes.extend_from_slice(&remainder);
*transaction = Transaction::zcash_deserialize(new_bytes.as_slice())
.expect("modified V5 transaction with duplicated sapling spend should deserialize");
duplicate_nullifier
} else {
let mut pos = 8usize;
let n_inputs = parse_compact_size(&tx_bytes, &mut pos);
for _ in 0..n_inputs {
pos += 36;
let script_len = parse_compact_size(&tx_bytes, &mut pos);
pos += script_len as usize + 4;
}
let n_outputs = parse_compact_size(&tx_bytes, &mut pos);
for _ in 0..n_outputs {
pos += 8;
let script_len = parse_compact_size(&tx_bytes, &mut pos);
pos += script_len as usize;
}
pos += 16;
let spend_count_pos = pos;
let n_spends = parse_compact_size(&tx_bytes, &mut pos);
assert!(n_spends > 0, "expected sapling spends");
let first_spend_start = pos;
let spend_size = 384;
let first_spend_bytes =
tx_bytes[first_spend_start..first_spend_start + spend_size].to_vec();
let nf_start = first_spend_start + 64;
let mut nf_bytes = [0u8; 32];
nf_bytes.copy_from_slice(&tx_bytes[nf_start..nf_start + 32]);
let duplicate_nullifier = sapling::Nullifier::from(nf_bytes);
let spends_end = first_spend_start + (n_spends as usize * spend_size);
let new_n_spends = n_spends + 1;
let mut new_bytes = Vec::new();
new_bytes.extend_from_slice(&tx_bytes[..spend_count_pos]);
write_compact_size(&mut new_bytes, new_n_spends);
new_bytes.extend_from_slice(&tx_bytes[first_spend_start..spends_end]);
new_bytes.extend_from_slice(&first_spend_bytes);
new_bytes.extend_from_slice(&tx_bytes[spends_end..]);
*transaction = Transaction::zcash_deserialize(new_bytes.as_slice())
.expect("modified V4 transaction with duplicated sapling spend should deserialize");
duplicate_nullifier
}
}
fn small_order_point_bytes() -> [u8; 32] {
let bytes = jubjub::AffinePoint::identity().to_bytes();
let point = jubjub::AffinePoint::from_bytes(bytes).unwrap();
assert!(bool::from(point.is_small_order()));
bytes
}
fn skip_v4_header_and_transparent(tx_bytes: &[u8]) -> usize {
let mut pos = 8usize;
let n_inputs = parse_compact_size(tx_bytes, &mut pos);
for _ in 0..n_inputs {
pos += 36;
let script_len = parse_compact_size(tx_bytes, &mut pos);
pos += script_len as usize + 4;
}
let n_outputs = parse_compact_size(tx_bytes, &mut pos);
for _ in 0..n_outputs {
pos += 8;
let script_len = parse_compact_size(tx_bytes, &mut pos);
pos += script_len as usize;
}
pos
}
fn set_first_sapling_spend_rk(transaction: &mut Transaction, rk_bytes: [u8; 32]) {
let mut tx_bytes = transaction
.zcash_serialize_to_vec()
.expect("transaction serialization should succeed");
let rk_pos = if transaction.version() >= 5 {
let mut pos = skip_v5_header_and_transparent(&tx_bytes);
let n_spends = parse_compact_size(&tx_bytes, &mut pos);
assert!(n_spends > 0, "expected sapling spends");
pos + 64
} else {
let mut pos = skip_v4_header_and_transparent(&tx_bytes) + 16;
let n_spends = parse_compact_size(&tx_bytes, &mut pos);
assert!(n_spends > 0, "expected sapling spends");
pos + 96
};
tx_bytes[rk_pos..rk_pos + 32].copy_from_slice(&rk_bytes);
*transaction = Transaction::zcash_deserialize(tx_bytes.as_slice())
.expect("modified transaction with small-order rk should deserialize");
let parsed_rk: [u8; 32] = (*transaction
.sapling_spends()
.next()
.expect("mutated transaction keeps its sapling spend")
.rk())
.into();
assert_eq!(parsed_rk, rk_bytes, "rk offset drifted");
}
fn set_first_sapling_output_epk(transaction: &mut Transaction, epk_bytes: [u8; 32]) {
let mut tx_bytes = transaction
.zcash_serialize_to_vec()
.expect("transaction serialization should succeed");
let epk_pos = if transaction.version() >= 5 {
let mut pos = skip_v5_header_and_transparent(&tx_bytes);
let n_spends = parse_compact_size(&tx_bytes, &mut pos);
pos += n_spends as usize * 96; let n_outputs = parse_compact_size(&tx_bytes, &mut pos);
assert!(n_outputs > 0, "expected sapling outputs");
pos + 64
} else {
let mut pos = skip_v4_header_and_transparent(&tx_bytes) + 16;
let n_spends = parse_compact_size(&tx_bytes, &mut pos);
pos += n_spends as usize * 384;
let n_outputs = parse_compact_size(&tx_bytes, &mut pos);
assert!(n_outputs > 0, "expected sapling outputs");
pos + 64
};
tx_bytes[epk_pos..epk_pos + 32].copy_from_slice(&epk_bytes);
*transaction = Transaction::zcash_deserialize(tx_bytes.as_slice())
.expect("modified transaction with small-order epk should deserialize");
let parsed_epk = transaction
.sapling_outputs()
.next()
.expect("mutated transaction keeps its sapling output")
.ephemeral_key()
.0;
assert_eq!(parsed_epk, epk_bytes, "epk offset drifted");
}
fn graft_orchard_data_onto_v5_tx(
target: &Transaction,
net: &Network,
override_flags: Option<u8>,
) -> Transaction {
let donor = v5_transactions(net.block_iter())
.find(|tx| tx.has_orchard_shielded_data())
.expect("V5 tx with orchard data");
let target_bytes = target
.zcash_serialize_to_vec()
.expect("target serialization should succeed");
let donor_bytes = donor
.zcash_serialize_to_vec()
.expect("donor serialization should succeed");
let target_orchard_start = find_v5_orchard_section_start(&target_bytes);
let donor_orchard_start = find_v5_orchard_section_start(&donor_bytes);
let mut new_bytes = target_bytes[..target_orchard_start].to_vec();
new_bytes.extend_from_slice(&donor_bytes[donor_orchard_start..]);
if let Some(flags) = override_flags {
let flags_offset = find_v5_orchard_flags_offset(&new_bytes);
new_bytes[flags_offset] = flags;
}
Transaction::zcash_deserialize(new_bytes.as_slice())
.expect("grafted V5 transaction should deserialize")
}
fn skip_v5_header_and_transparent(tx_bytes: &[u8]) -> usize {
let mut pos = 20usize;
let n_inputs = parse_compact_size(tx_bytes, &mut pos);
for _ in 0..n_inputs {
pos += 36;
let script_len = parse_compact_size(tx_bytes, &mut pos);
pos += script_len as usize + 4;
}
let n_outputs = parse_compact_size(tx_bytes, &mut pos);
for _ in 0..n_outputs {
pos += 8;
let script_len = parse_compact_size(tx_bytes, &mut pos);
pos += script_len as usize;
}
pos
}
fn skip_v5_sapling_section(tx_bytes: &[u8], pos: &mut usize) {
let n_spends = parse_compact_size(tx_bytes, pos);
*pos += n_spends as usize * 96;
let n_sapling_outputs = parse_compact_size(tx_bytes, pos);
*pos += n_sapling_outputs as usize * 756;
let has_sapling_data = n_spends > 0 || n_sapling_outputs > 0;
if has_sapling_data {
*pos += 8; }
if n_spends > 0 {
*pos += 32; }
*pos += n_spends as usize * 192; *pos += n_spends as usize * 64; *pos += n_sapling_outputs as usize * 192;
if has_sapling_data {
*pos += 64; }
}
fn find_v5_orchard_section_start(tx_bytes: &[u8]) -> usize {
let mut pos = skip_v5_header_and_transparent(tx_bytes);
skip_v5_sapling_section(tx_bytes, &mut pos);
pos
}
fn find_v5_orchard_flags_offset(tx_bytes: &[u8]) -> usize {
let mut pos = find_v5_orchard_section_start(tx_bytes);
let n_actions = parse_compact_size(tx_bytes, &mut pos);
assert!(n_actions > 0, "expected orchard actions");
pos += n_actions as usize * 820;
pos
}
fn write_compact_size(bytes: &mut Vec<u8>, value: u64) {
if value < 0xfd {
bytes.push(value as u8);
} else if value <= 0xffff {
bytes.push(0xfd);
bytes.extend_from_slice(&(value as u16).to_le_bytes());
} else if value <= 0xffff_ffff {
bytes.push(0xfe);
bytes.extend_from_slice(&(value as u32).to_le_bytes());
} else {
bytes.push(0xff);
bytes.extend_from_slice(&value.to_le_bytes());
}
}
#[test]
fn add_to_sprout_pool_after_nu() {
let _init_guard = zebra_test::init();
let block: Arc<_> = zebra_chain::block::Block::zcash_deserialize(
&zebra_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_sum: i64 = block.transactions[4]
.output_values_to_sprout()
.into_iter()
.sum();
let vpub_old = Amount::<NonNegative>::try_from(vpub_old_sum).expect("valid vpub_old sum");
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_sum: i64 = block.transactions[7]
.output_values_to_sprout()
.into_iter()
.sum();
let vpub_old = Amount::<NonNegative>::try_from(vpub_old_sum).expect("valid vpub_old sum");
assert_eq!(vpub_old, zero);
assert_eq!(
check::disabled_add_to_sprout_pool(&block.transactions[7], block_height, &network),
Ok(())
);
}
#[test]
fn coinbase_outputs_are_decryptable() -> Result<(), Report> {
let _init_guard = zebra_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 with_note_encryption_vector(
tx: Transaction,
v: &zebra_test::vectors::TestVector,
) -> Transaction {
use zcash_protocol::value::ZatBalance;
use zebra_chain::transaction::arbitrary::{
fake_orchard_bundle_with_note, outputs_enabled_flags,
};
let bundle_version =
zcash_primitives::transaction::components::orchard::bundle_version_for_branch(
tx.consensus_branch_id(),
::orchard::ValuePool::Orchard,
)
.expect("a mined v5 transaction is on a branch that defines the Orchard pool");
let bundle = fake_orchard_bundle_with_note(
outputs_enabled_flags(bundle_version),
ZatBalance::from_i64(0).expect("zero is a valid balance"),
bundle_version,
&v.cv_net,
&v.rho,
&v.cmx,
v.ephemeral_key,
v.c_enc,
v.c_out,
);
tx.with_orchard_bundle(Some(bundle))
}
#[test]
fn coinbase_outputs_are_decryptable_for_fake_v5_blocks() {
let _init_guard = zebra_test::init();
for v in zebra_test::vectors::ORCHARD_NOTE_ENCRYPTION_ZERO_VECTOR.iter() {
for net in Network::iter() {
let tx = v5_transactions(net.block_iter())
.find(|tx| tx.is_coinbase())
.expect("coinbase V5 tx");
let tx = with_note_encryption_vector(tx, v);
assert!(
tx.has_shielded_outputs(),
"the rule only applies to transactions with shielded outputs",
);
assert_eq!(
check::coinbase_outputs_are_decryptable(
&tx,
&net,
NetworkUpgrade::Nu5.activation_height(&net).unwrap(),
),
Ok(())
);
}
}
}
#[test]
fn shielded_outputs_are_not_decryptable_for_fake_v5_blocks() {
let _init_guard = zebra_test::init();
for v in zebra_test::vectors::ORCHARD_NOTE_ENCRYPTION_VECTOR.iter() {
for net in Network::iter() {
let tx = v5_transactions(net.block_iter())
.find(|tx| tx.is_coinbase())
.expect("V5 coinbase tx");
let tx = with_note_encryption_vector(tx, v);
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 = MempoolTxVerifier::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).expect("valid amount"),
);
let tx = Transaction::test_v5(
NetworkUpgrade::Nu5,
vec![input],
vec![output],
LockTime::unlocked(),
height,
);
let input_outpoint = match tx.inputs()[0] {
transparent::Input::PrevOut { outpoint, .. } => outpoint,
transparent::Input::Coinbase { .. } => panic!("requires a non-coinbase transaction"),
};
tokio::spawn(async move {
state
.expect_request(zebra_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::UnspentBestChainUtxo(
known_utxos
.get(&input_outpoint)
.map(|utxo| utxo.utxo.clone()),
));
});
let verifier_response = verifier
.oneshot(MempoolRequest {
transaction: std::sync::Arc::new(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 = MempoolTxVerifier::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(2_001).expect("valid amount"),
);
let tx = Transaction::test_v5(
NetworkUpgrade::Nu5,
vec![input],
vec![output],
LockTime::unlocked(),
height,
);
let input_outpoint = match tx.inputs()[0] {
transparent::Input::PrevOut { outpoint, .. } => outpoint,
transparent::Input::Coinbase { .. } => panic!("requires a non-coinbase transaction"),
};
tokio::spawn(async move {
state
.expect_request(zebra_state::Request::UnspentBestChainUtxo(input_outpoint))
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::UnspentBestChainUtxo(
known_utxos
.get(&input_outpoint)
.map(|utxo| utxo.utxo.clone()),
));
state
.expect_request_that(|req| {
matches!(
req,
zebra_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("verifier should call mock state service with correct request")
.respond(zebra_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
let verifier_response = verifier
.oneshot(MempoolRequest {
transaction: std::sync::Arc::new(tx).into(),
height,
})
.await;
assert!(
verifier_response.is_ok(),
"expected successful verification, got: {verifier_response:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn block_with_garbage_orchard_proofs_is_rejected() {
let _init_guard = zebra_test::init();
let state: MockService<_, _, _, _> = MockService::build().for_prop_tests();
let verifier = BlockTxVerifier::new(&Network::Mainnet, state.clone());
let verifier = Buffer::new(verifier, 1);
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 tx = Transaction::test_v5(
NetworkUpgrade::Nu6,
vec![input],
vec![output],
LockTime::min_lock_time_timestamp(),
height,
);
let tx = insert_fake_orchard_shielded_data(tx);
let tx_hash = tx.hash();
let garbage_tx = with_garbage_orchard_authorization(tx.clone());
assert_eq!(tx.hash(), garbage_tx.hash());
let resp = verifier
.clone()
.oneshot(BlockRequest {
transaction_hash: tx_hash,
transaction: Arc::new(garbage_tx),
known_utxos: Arc::new(known_utxos),
height,
time: Utc::now(),
})
.await;
let err = resp.expect_err("garbage proof must be rejected");
let tx_err = err
.downcast::<TransactionError>()
.expect("error should downcast to TransactionError");
assert!(
matches!(*tx_err, TransactionError::Halo2VerificationFailed),
"expected Halo2VerificationFailed for garbage Orchard proofs; got: {tx_err:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn mempool_cached_result_bypasses_expiry_check_for_block_at_next_height() {
let _init_guard = zebra_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::test_v4(
vec![input],
vec![output],
LockTime::unlocked(),
mempool_height,
);
let tx_hash = tx.hash();
let state: MockService<_, _, _, _> = MockService::build().for_unit_tests();
let verifier = BlockTxVerifier::new(&network, state.clone());
let verifier = Buffer::new(verifier, 1);
let result = timeout(
test_timeout(),
verifier.clone().oneshot(BlockRequest {
transaction_hash: tx_hash,
transaction: Arc::new(tx.clone()),
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 whose \
nExpiryHeight is below the block height",
);
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:?}; \
got: {tx_err:?}"
);
}
#[test]
fn mempool_standard_input_scripts_limits_p2sh_redeem_sigops() {
let _init_guard = zebra_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::test_v5(
NetworkUpgrade::Nu5,
vec![transparent::Input::PrevOut {
outpoint: transparent::OutPoint {
hash: Hash([0u8; 32]),
index: 0,
},
unlock_script: transparent::Script::new(unlock_bytes),
sequence: u32::MAX,
}],
vec![spent_output.clone()],
LockTime::unlocked(),
Height(0),
)
};
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 = zebra_test::init();
let tx_spending = |unlock_bytes: &[u8], spent_output: &transparent::Output| {
Transaction::test_v5(
NetworkUpgrade::Nu5,
vec![transparent::Input::PrevOut {
outpoint: transparent::OutPoint {
hash: Hash([0u8; 32]),
index: 0,
},
unlock_script: transparent::Script::new(unlock_bytes),
sequence: u32::MAX,
}],
vec![spent_output.clone()],
LockTime::unlocked(),
Height(0),
)
};
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 = zebra_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::test_v5(
NetworkUpgrade::Nu5,
vec![transparent::Input::PrevOut {
outpoint: transparent::OutPoint {
hash: Hash([0u8; 32]),
index: 0,
},
unlock_script: transparent::Script::new(unlock_bytes),
sequence: u32::MAX,
}],
vec![spent_output.clone()],
LockTime::unlocked(),
Height(0),
)
};
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 = zebra_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 = zebra_test::init();
assert_eq!(check::count_script_push_ops(&[]), 0);
}
#[test]
fn count_script_push_ops_pushdata_variants() {
let _init_guard = zebra_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 = zebra_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 = zebra_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 = zebra_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 = zebra_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));
}
type CacheTestState = MockService<
zebra_state::Request,
zebra_state::Response,
zebra_test::mock_service::PropTestAssertion,
zebra_state::BoxError,
>;
fn cacheable_mainnet_orchard_transaction() -> Transaction {
zebra_test::vectors::MAINNET_BLOCKS
.values()
.flat_map(|bytes| {
let block: Block = bytes
.zcash_deserialize_into()
.expect("hard-coded test vector must deserialize");
block.transactions.clone()
})
.find(|tx| {
tx.has_orchard_shielded_data()
&& tx.inputs().is_empty()
&& !tx.has_sapling_shielded_data()
&& !tx.lock_time_is_time()
&& tx
.value_balance(&HashMap::new())
.ok()
.and_then(|balance| balance.remaining_transaction_value().ok())
.is_some_and(|fee| fee >= zip317::conventional_fee(tx))
})
.map(|tx| tx.as_ref().clone())
.expect("the mainnet test blocks must contain a fee-paying Orchard-only transaction")
}
fn orchard_item(
tx: &Transaction,
network_upgrade: NetworkUpgrade,
) -> crate::primitives::halo2::Item {
let sighasher = tx
.sighasher(network_upgrade, Arc::new(Vec::new()))
.expect("a mainnet Orchard transaction has a sighasher at its own network upgrade");
let bundle = sighasher
.orchard_bundle()
.expect("the transaction was selected for having an Orchard bundle");
crate::primitives::halo2::Item::new_with_wtx_id(
bundle,
sighasher.sighash(HashType::ALL, None),
zebra_chain::transaction::WtxId {
id: tx.hash(),
auth_digest: tx
.auth_digest()
.expect("a v5 transaction has an authorizing-data digest"),
},
)
}
fn respond_to_nullifier_and_anchor_check(state: &CacheTestState) {
let mut state = state.clone();
tokio::spawn(async move {
state
.expect_request_that(|req| {
matches!(
req,
zebra_state::Request::CheckBestChainTipNullifiersAndAnchors(_)
)
})
.await
.expect("a mempool verification must check nullifiers and anchors")
.respond(zebra_state::Response::ValidBestChainTipNullifiersAndAnchors);
});
}
tokio::task_local! {
pub(super) static HALO2_CACHE_VERIFIER: crate::primitives::halo2::CachedVerifierService;
pub(super) static SAPLING_CACHE_VERIFIER: crate::primitives::sapling::VerifierService;
}
fn cache_test_block_request(tx: &Transaction, height: Height) -> BlockRequest {
BlockRequest {
transaction_hash: tx.hash(),
transaction: Arc::new(tx.clone()),
known_utxos: Arc::new(HashMap::new()),
height,
time: Utc::now(),
}
}
#[test]
fn the_halo2_cache_is_reused_only_for_the_transaction_that_earned_it() {
let _init_guard = zebra_test::init();
zebra_test::MULTI_THREADED_RUNTIME.block_on(async {
let tx = cacheable_mainnet_orchard_transaction();
let expiry_height = tx
.expiry_height()
.expect("a V5 transaction has an expiry height");
let network_upgrade = NetworkUpgrade::current(&Network::Mainnet, expiry_height);
let item = orchard_item(&tx, network_upgrade);
let global_verifier =
crate::primitives::halo2::cached_orchard_v5_verifier_for(network_upgrade);
global_verifier
.clone()
.oneshot(item.clone())
.await
.expect("the global cache can already contain this transaction");
let verifier = global_verifier.isolated();
HALO2_CACHE_VERIFIER
.scope(verifier.clone(), async {
let state: CacheTestState = MockService::build().for_prop_tests();
let mempool_verifier =
MempoolTxVerifier::new_for_tests(&Network::Mainnet, state.clone());
let mut block_verifier = BlockTxVerifier::new(&Network::Mainnet, state.clone());
assert_eq!(
crate::primitives::halo2::inner_calls_for(&verifier, &item),
0,
"the private cache starts with no inner calls"
);
respond_to_nullifier_and_anchor_check(&state);
mempool_verifier
.oneshot(MempoolRequest {
transaction: Arc::new(tx.clone()).into(),
height: expiry_height,
})
.await
.expect("a real mainnet Orchard transaction must verify at its expiry height");
assert_eq!(
crate::primitives::halo2::inner_calls_for(&verifier, &item),
1,
"the mempool verification must reach the inner Halo2 verifier"
);
block_verifier
.ready()
.await
.expect("the block verifier must become ready")
.call(cache_test_block_request(&tx, expiry_height))
.await
.expect("the same transaction must verify in a block");
assert_eq!(
crate::primitives::halo2::inner_calls_for(&verifier, &item),
1,
"the block verification must be answered from the cache"
);
let too_late =
(expiry_height + 1).expect("a mainnet expiry height is far below the maximum");
let error = block_verifier
.ready()
.await
.expect("the block verifier must become ready")
.call(cache_test_block_request(&tx, too_late))
.await
.expect_err("a transaction mined past its expiry height must be rejected");
assert_eq!(
error,
TransactionError::ExpiredTransaction {
expiry_height,
block_height: too_late,
transaction_hash: tx.hash(),
},
"the rejection must be the expiry rule, not some other failure"
);
let twin = with_garbage_orchard_authorization(tx.clone());
assert_eq!(
tx.hash(),
twin.hash(),
"replacing authorizing data must leave the txid unchanged, or this test proves nothing"
);
let twin_item = orchard_item(&twin, network_upgrade);
assert_eq!(
crate::primitives::halo2::inner_calls_for(&verifier, &twin_item),
0,
"the authorizing-data twin must get a different cache key"
);
let error = block_verifier
.ready()
.await
.expect("the block verifier must become ready")
.call(cache_test_block_request(&twin, expiry_height))
.await
.expect_err("a transaction with replaced authorizing data must be rejected");
assert_eq!(
error,
TransactionError::Halo2VerificationFailed,
"the twin must fail Orchard verification"
);
assert_eq!(
crate::primitives::halo2::inner_calls_for(&verifier, &twin_item),
1,
"the twin must reach the inner Halo2 verifier"
);
})
.await;
});
}
fn cacheable_mainnet_sapling_transaction() -> (NetworkUpgrade, Transaction) {
zebra_test::vectors::MAINNET_BLOCKS
.iter()
.flat_map(|(height, bytes)| {
let block: Block = bytes
.zcash_deserialize_into()
.expect("hard-coded test vector must deserialize");
let nu = NetworkUpgrade::current(&Network::Mainnet, Height(*height));
block
.transactions
.clone()
.into_iter()
.map(move |tx| (nu, tx))
})
.find(|(_nu, tx)| {
tx.has_sapling_shielded_data()
&& !tx.has_orchard_shielded_data()
&& tx.inputs().is_empty()
&& !tx.lock_time_is_time()
&& tx
.value_balance(&HashMap::new())
.ok()
.and_then(|balance| balance.remaining_transaction_value().ok())
.is_some_and(|fee| fee >= zip317::conventional_fee(tx))
})
.map(|(nu, tx)| (nu, tx.as_ref().clone()))
.expect("the mainnet test blocks must contain a fee-paying Sapling-only transaction")
}
fn sapling_item(
tx: &Transaction,
network_upgrade: NetworkUpgrade,
) -> crate::primitives::sapling::Item {
let sighasher = tx
.sighasher(network_upgrade, Arc::new(Vec::new()))
.expect("a mainnet Sapling transaction has a sighasher at its own network upgrade");
let bundle = sighasher
.sapling_bundle()
.expect("the transaction was selected for having a Sapling bundle");
crate::primitives::sapling::Item::new(
bundle,
sighasher.sighash(HashType::ALL, None),
tx.unmined_id(),
)
}
#[test]
fn the_sapling_cache_is_reused_only_for_the_transaction_that_earned_it() {
let _init_guard = zebra_test::init();
zebra_test::MULTI_THREADED_RUNTIME.block_on(async {
let (network_upgrade, tx) = cacheable_mainnet_sapling_transaction();
let expiry_height = tx
.expiry_height()
.expect("a V4 or V5 transaction has an expiry height");
let item = sapling_item(&tx, network_upgrade);
crate::primitives::sapling::VERIFIER
.clone()
.oneshot(item.clone())
.await
.expect("the global cache can already contain this transaction");
let verifier = crate::primitives::sapling::VERIFIER.isolated();
SAPLING_CACHE_VERIFIER
.scope(verifier.clone(), async {
let state: CacheTestState = MockService::build().for_prop_tests();
let mempool_verifier =
MempoolTxVerifier::new_for_tests(&Network::Mainnet, state.clone());
let mut block_verifier = BlockTxVerifier::new(&Network::Mainnet, state.clone());
assert_eq!(
crate::primitives::sapling::inner_calls_for(&verifier, &item),
0,
"the private cache starts with no inner calls"
);
respond_to_nullifier_and_anchor_check(&state);
mempool_verifier
.oneshot(MempoolRequest {
transaction: Arc::new(tx.clone()).into(),
height: expiry_height,
})
.await
.expect("a real mainnet Sapling transaction must verify at its expiry height");
assert_eq!(
crate::primitives::sapling::inner_calls_for(&verifier, &item),
1,
"the mempool verification must reach the inner Sapling verifier"
);
block_verifier
.ready()
.await
.expect("the block verifier must become ready")
.call(cache_test_block_request(&tx, expiry_height))
.await
.expect("the same transaction must verify in a block");
assert_eq!(
crate::primitives::sapling::inner_calls_for(&verifier, &item),
1,
"the block verification must be answered from the cache"
);
let too_late =
(expiry_height + 1).expect("a mainnet expiry height is far below the maximum");
let error = block_verifier
.ready()
.await
.expect("the block verifier must become ready")
.call(cache_test_block_request(&tx, too_late))
.await
.expect_err("a transaction mined past its expiry height must be rejected");
assert_eq!(
error,
TransactionError::ExpiredTransaction {
expiry_height,
block_height: too_late,
transaction_hash: tx.hash(),
},
"the rejection must be the expiry rule, not some other failure"
);
})
.await;
});
}
#[tokio::test]
async fn tx_with_pre_activation_branch_id_is_rejected() {
let network = Network::Mainnet;
let state: MockService<_, _, _, _> = MockService::build().for_unit_tests();
let cases = [
(5_u32, NetworkUpgrade::Canopy, NetworkUpgrade::Nu5),
(6_u32, NetworkUpgrade::Nu5, NetworkUpgrade::Nu6_3),
];
for (version, tx_nu, height_nu) in cases {
let height = height_nu
.activation_height(&network)
.expect("activation height must be set");
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 = match version {
5 => Transaction::test_v5(
tx_nu,
vec![input],
vec![output],
LockTime::unlocked(),
height,
),
6 => Transaction::test_v6(
tx_nu,
vec![input],
vec![output],
LockTime::unlocked(),
height,
),
_ => unreachable!("no other versions tested"),
};
let block_verifier = BlockTxVerifier::new(&network, state.clone());
let block_rsp = block_verifier
.oneshot(BlockRequest {
transaction_hash: tx.hash(),
transaction: Arc::new(tx.clone()),
known_utxos: Arc::new(known_utxos),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(
block_rsp,
Err(TransactionError::WrongConsensusBranchId),
"block verification of V{version} with {tx_nu:?} branch ID at {height_nu:?} height"
);
let mempool_verifier = MempoolTxVerifier::new_for_tests(&network, state.clone());
let mempool_rsp = mempool_verifier
.oneshot(MempoolRequest {
transaction: Arc::new(tx.clone()).into(),
height,
})
.await;
assert_eq!(
mempool_rsp,
Err(TransactionError::WrongConsensusBranchId),
"mempool verification of V{version} with {tx_nu:?} branch ID at {height_nu:?} height"
);
}
}
#[tokio::test]
async fn null_prevout_hash_with_other_index_is_not_coinbase() {
let network = Network::Mainnet;
let mut raw_tx = Vec::new();
raw_tx.extend_from_slice(&1_u32.to_le_bytes()); raw_tx.push(1); raw_tx.extend_from_slice(&[0; 32]); raw_tx.extend_from_slice(&5_u32.to_le_bytes()); raw_tx.push(0); raw_tx.extend_from_slice(&0xFFFF_FFFF_u32.to_le_bytes()); raw_tx.push(1); raw_tx.extend_from_slice(&1_u64.to_le_bytes()); raw_tx.push(0); raw_tx.extend_from_slice(&0_u32.to_le_bytes());
let tx: Transaction = raw_tx
.zcash_deserialize_into()
.expect("null prevout hash with another index parses as a regular input");
assert!(!tx.is_coinbase(), "must not be detected as coinbase");
let input_outpoint = match tx.inputs()[0] {
transparent::Input::PrevOut { outpoint, .. } => outpoint,
transparent::Input::Coinbase { .. } => panic!("must parse as a PrevOut input"),
};
assert_eq!(input_outpoint.hash, Hash([0; 32]));
assert_eq!(input_outpoint.index, 5);
let block = Block::zcash_deserialize(&zebra_test::vectors::BLOCK_MAINNET_434873_BYTES[..])
.expect("block should deserialize");
let mut block = block;
block.transactions[0] = Arc::new(tx.clone());
assert_eq!(
crate::block::check::coinbase_is_first(&block)
.expect_err("a block whose first transaction is not a coinbase must be rejected"),
crate::error::BlockError::Transaction(TransactionError::CoinbasePosition),
);
let mut state: MockService<_, _, _, _> = MockService::build().for_unit_tests();
let verifier = MempoolTxVerifier::new_for_tests(&network, state.clone());
let height = NetworkUpgrade::Nu5
.activation_height(&network)
.expect("NU5 height must be set");
let state_req = state
.expect_request(zebra_state::Request::UnspentBestChainUtxo(input_outpoint))
.map(|responder| responder.respond(zebra_state::Response::UnspentBestChainUtxo(None)));
let verifier_req = verifier.oneshot(MempoolRequest {
transaction: Arc::new(tx).into(),
height,
});
let (rsp, _) = futures::join!(verifier_req, state_req);
assert_eq!(rsp, Err(TransactionError::TransparentInputNotFound));
}
#[test]
fn non_coinbase_expiry_height_accepts_zero_and_spec_max() {
let block_height = block::Height(1_000_000);
for raw in [0, 499_999_999] {
let tx = Transaction::test_v5(
NetworkUpgrade::Nu5,
Vec::new(),
Vec::new(),
LockTime::unlocked(),
block::Height(raw),
);
assert_eq!(
check::non_coinbase_expiry_height(&block_height, &tx),
Ok(())
);
}
}
fn non_coinbase_tx_with_null_prevout_input() -> Transaction {
let mut raw = Vec::new();
raw.extend_from_slice(&1_u32.to_le_bytes()); raw.push(2); raw.extend_from_slice(&[0; 32]); raw.extend_from_slice(&0xFFFF_FFFF_u32.to_le_bytes()); raw.push(2); raw.extend_from_slice(&[0x51, 0x00]);
raw.extend_from_slice(&0xFFFF_FFFF_u32.to_le_bytes()); raw.extend_from_slice(&[1; 32]); raw.extend_from_slice(&0_u32.to_le_bytes()); raw.push(0); raw.extend_from_slice(&0xFFFF_FFFF_u32.to_le_bytes()); raw.push(1); raw.extend_from_slice(&1_u64.to_le_bytes()); raw.push(0); raw.extend_from_slice(&0_u32.to_le_bytes());
raw.zcash_deserialize_into()
.expect("a null-prevout input with a valid height script parses")
}
#[tokio::test]
async fn non_coinbase_with_null_prevout_input_is_rejected() {
let network = Network::Mainnet;
let tx = non_coinbase_tx_with_null_prevout_input();
assert!(!tx.is_coinbase(), "two inputs is never a coinbase");
assert!(
matches!(tx.inputs()[0], transparent::Input::Coinbase { .. }),
"the null-prevout input is parsed as a coinbase input"
);
let block = Block::zcash_deserialize(&zebra_test::vectors::BLOCK_MAINNET_434873_BYTES[..])
.expect("block should deserialize");
let mut first_block = block.clone();
first_block.transactions[0] = Arc::new(tx.clone());
assert_eq!(
crate::block::check::coinbase_is_first(&first_block)
.expect_err("a first transaction that is not a coinbase must be rejected"),
crate::error::BlockError::Transaction(TransactionError::CoinbasePosition),
);
let mut later_block = block;
later_block.transactions.push(Arc::new(tx.clone()));
assert_eq!(
crate::block::check::coinbase_is_first(&later_block)
.expect_err("a later transaction with a null-prevout input must be rejected"),
crate::error::BlockError::Transaction(TransactionError::CoinbaseAfterFirst),
);
let height = NetworkUpgrade::Nu5
.activation_height(&network)
.expect("NU5 height must be set");
let state: MockService<_, _, _, _> = MockService::build().for_unit_tests();
let rsp = BlockTxVerifier::new(&network, state)
.oneshot(BlockRequest {
transaction_hash: tx.hash(),
transaction: Arc::new(tx.clone()),
known_utxos: Arc::new(HashMap::new()),
height,
time: DateTime::<Utc>::MAX_UTC,
})
.await;
assert_eq!(rsp, Err(TransactionError::NonCoinbaseHasCoinbaseInput));
let state: MockService<_, _, _, _> = MockService::build().for_unit_tests();
let rsp = MempoolTxVerifier::new_for_tests(&network, state)
.oneshot(MempoolRequest {
transaction: Arc::new(tx).into(),
height,
})
.await;
assert_eq!(rsp, Err(TransactionError::NonCoinbaseHasCoinbaseInput));
}