use std::{
borrow::Cow,
collections::{HashMap, HashSet},
hash::Hash,
sync::Arc,
};
use chrono::{DateTime, Utc};
use zakura_chain::{
amount::{Amount, NegativeAllowed, NonNegative},
block::Height,
orchard::Flags,
parameters::{Network, NetworkUpgrade},
primitives::zcash_note_encryption,
transaction::{LockTime, Transaction},
transparent,
};
use zcash_script::{
opcode::PossiblyBad,
script::{self, Evaluable as _},
solver, Opcode,
};
use crate::error::TransactionError;
pub fn lock_time_has_passed(
tx: &Transaction,
block_height: Height,
block_time: impl Into<Option<DateTime<Utc>>>,
) -> Result<(), TransactionError> {
match tx.lock_time() {
Some(LockTime::Height(unlock_height)) => {
if block_height > unlock_height {
Ok(())
} else {
Err(TransactionError::LockedUntilAfterBlockHeight(unlock_height))
}
}
Some(LockTime::Time(unlock_time)) => {
let block_time = block_time
.into()
.expect("time must be provided if LockTime is a time");
if block_time > unlock_time {
Ok(())
} else {
Err(TransactionError::LockedUntilAfterBlockTime(unlock_time))
}
}
None => Ok(()),
}
}
pub fn has_inputs_and_outputs(tx: &Transaction) -> Result<(), TransactionError> {
if !tx.has_transparent_or_shielded_inputs() {
Err(TransactionError::NoInputs)
} else if !tx.has_transparent_or_shielded_outputs() {
Err(TransactionError::NoOutputs)
} else {
Ok(())
}
}
pub fn has_enough_orchard_flags(tx: &Transaction) -> Result<(), TransactionError> {
if !tx.has_enough_orchard_flags() {
return Err(TransactionError::NotEnoughFlags);
}
Ok(())
}
pub fn has_enough_ironwood_flags(tx: &Transaction) -> Result<(), TransactionError> {
if !tx.has_enough_ironwood_flags() {
return Err(TransactionError::NotEnoughIronwoodFlags);
}
Ok(())
}
pub fn orchard_cross_address_disabled(tx: &Transaction) -> Result<(), TransactionError> {
if let Some(orchard_shielded_data) = tx.orchard_shielded_data() {
if orchard_shielded_data
.flags
.contains(Flags::ENABLE_CROSS_ADDRESS)
{
return Err(TransactionError::OrchardHasEnableCrossAddress);
}
}
Ok(())
}
pub fn sapling_point_encodings_are_valid(tx: &Transaction) -> Result<(), TransactionError> {
if !tx.sapling_point_encodings_are_valid() {
return Err(TransactionError::SmallOrder);
}
Ok(())
}
pub fn shielded_proof_size_is_canonical(
tx: &Transaction,
height: Height,
network: &Network,
) -> Result<(), TransactionError> {
if network.orchard_canonical_proof_size_rule_active(height) {
if let Some(orchard_shielded_data) = tx.orchard_shielded_data() {
if !orchard_shielded_data.proof_size_is_canonical() {
return Err(TransactionError::OrchardProofSize);
}
}
if let Some(ironwood_shielded_data) = tx.ironwood_shielded_data() {
if !ironwood_shielded_data.proof_size_is_canonical() {
return Err(TransactionError::IronwoodProofSize);
}
}
}
Ok(())
}
pub fn coinbase_tx_no_prevout_joinsplit_spend(tx: &Transaction) -> Result<(), TransactionError> {
if tx.is_coinbase() {
if tx.joinsplit_count() > 0 {
return Err(TransactionError::CoinbaseHasJoinSplit);
} else if tx.sapling_spends_per_anchor().count() > 0 {
return Err(TransactionError::CoinbaseHasSpend);
}
if let Some(orchard_shielded_data) = tx.orchard_shielded_data() {
if orchard_shielded_data.flags.contains(Flags::ENABLE_SPENDS) {
return Err(TransactionError::CoinbaseHasEnableSpendsOrchard);
}
}
if let Some(ironwood_shielded_data) = tx.ironwood_shielded_data() {
if ironwood_shielded_data.flags.contains(Flags::ENABLE_SPENDS) {
return Err(TransactionError::CoinbaseHasEnableSpendsIronwood);
}
}
}
Ok(())
}
pub fn joinsplit_has_vpub_zero(tx: &Transaction) -> Result<(), TransactionError> {
let zero = Amount::<NonNegative>::zero();
let vpub_pairs = tx
.output_values_to_sprout()
.zip(tx.input_values_from_sprout());
for (vpub_old, vpub_new) in vpub_pairs {
if *vpub_old != zero && *vpub_new != zero {
return Err(TransactionError::BothVPubsNonZero);
}
}
Ok(())
}
pub fn disabled_add_to_sprout_pool(
tx: &Transaction,
height: Height,
network: &Network,
) -> Result<(), TransactionError> {
let canopy_activation_height = NetworkUpgrade::Canopy
.activation_height(network)
.expect("Canopy activation height must be present for both networks");
if height >= canopy_activation_height {
let zero = Amount::<NonNegative>::zero();
let tx_sprout_pool = tx.output_values_to_sprout();
for vpub_old in tx_sprout_pool {
if *vpub_old != zero {
return Err(TransactionError::DisabledAddToSproutPool);
}
}
}
Ok(())
}
pub fn disabled_add_to_orchard_pool(
tx: &Transaction,
height: Height,
network: &Network,
) -> Result<(), TransactionError> {
let Some(nu6_3_activation_height) = NetworkUpgrade::Nu6_3.activation_height(network) else {
return Ok(());
};
let zero = Amount::<NegativeAllowed>::zero();
let value_balance_orchard = tx
.orchard_shielded_data()
.map(|shielded_data| shielded_data.value_balance)
.unwrap_or_else(Amount::<NegativeAllowed>::zero);
if height >= nu6_3_activation_height && value_balance_orchard < zero {
return Err(TransactionError::DisabledAddToOrchardPool);
}
Ok(())
}
pub fn coinbase_has_no_orchard_shielded_data(
tx: &Transaction,
height: Height,
network: &Network,
) -> Result<(), TransactionError> {
let Some(nu6_3_activation_height) = NetworkUpgrade::Nu6_3.activation_height(network) else {
return Ok(());
};
if height >= nu6_3_activation_height && tx.is_coinbase() && tx.orchard_shielded_data().is_some()
{
return Err(TransactionError::CoinbaseHasOrchardShieldedData);
}
Ok(())
}
pub fn spend_conflicts(transaction: &Transaction) -> Result<(), TransactionError> {
use crate::error::TransactionError::*;
let transparent_outpoints = transaction.spent_outpoints().map(Cow::Owned);
let sprout_nullifiers = transaction.sprout_nullifiers().map(Cow::Borrowed);
let sapling_nullifiers = transaction.sapling_nullifiers().map(Cow::Borrowed);
let orchard_nullifiers = transaction.orchard_nullifiers().map(Cow::Borrowed);
let ironwood_nullifiers = transaction.ironwood_nullifiers().map(Cow::Borrowed);
check_for_duplicates(transparent_outpoints, DuplicateTransparentSpend)?;
check_for_duplicates(sprout_nullifiers, DuplicateSproutNullifier)?;
check_for_duplicates(sapling_nullifiers, DuplicateSaplingNullifier)?;
check_for_duplicates(orchard_nullifiers, DuplicateOrchardNullifier)?;
check_for_duplicates(ironwood_nullifiers, DuplicateIronwoodNullifier)?;
Ok(())
}
fn check_for_duplicates<'t, T>(
items: impl IntoIterator<Item = Cow<'t, T>>,
error_wrapper: impl FnOnce(T) -> TransactionError,
) -> Result<(), TransactionError>
where
T: Clone + Eq + Hash + 't,
{
let mut hash_set = HashSet::new();
for item in items {
if let Some(duplicate) = hash_set.replace(item) {
return Err(error_wrapper(duplicate.into_owned()));
}
}
Ok(())
}
pub fn coinbase_outputs_are_decryptable(
transaction: &Transaction,
network: &Network,
height: Height,
) -> Result<(), TransactionError> {
if !transaction.has_shielded_outputs() {
return Ok(());
}
if height
< NetworkUpgrade::Heartwood
.activation_height(network)
.expect("Heartwood height is known")
{
return Ok(());
}
if !transaction.is_coinbase() {
return Err(TransactionError::NotCoinbase);
}
if !zcash_note_encryption::decrypts_successfully(transaction, network, height) {
return Err(TransactionError::CoinbaseOutputsNotDecryptable);
}
Ok(())
}
pub fn coinbase_expiry_height(
block_height: &Height,
coinbase: &Transaction,
network: &Network,
) -> Result<(), TransactionError> {
let expiry_height = coinbase.expiry_height();
if let Some(nu5_activation_height) = NetworkUpgrade::Nu5.activation_height(network) {
if *block_height >= nu5_activation_height {
if expiry_height != Some(*block_height) {
return Err(TransactionError::CoinbaseExpiryBlockHeight {
expiry_height,
block_height: *block_height,
transaction_hash: coinbase.hash(),
});
} else {
return Ok(());
}
}
}
validate_expiry_height_max(expiry_height, true, block_height, coinbase)
}
pub fn non_coinbase_expiry_height(
block_height: &Height,
transaction: &Transaction,
) -> Result<(), TransactionError> {
if transaction.is_overwintered() {
let expiry_height = transaction.expiry_height();
validate_expiry_height_max(expiry_height, false, block_height, transaction)?;
validate_expiry_height_mined(expiry_height, block_height, transaction)?;
}
Ok(())
}
fn validate_expiry_height_max(
expiry_height: Option<Height>,
is_coinbase: bool,
block_height: &Height,
transaction: &Transaction,
) -> Result<(), TransactionError> {
if let Some(expiry_height) = expiry_height {
if expiry_height > Height::MAX_EXPIRY_HEIGHT {
Err(TransactionError::MaximumExpiryHeight {
expiry_height,
is_coinbase,
block_height: *block_height,
transaction_hash: transaction.hash(),
})?;
}
}
Ok(())
}
fn validate_expiry_height_mined(
expiry_height: Option<Height>,
block_height: &Height,
transaction: &Transaction,
) -> Result<(), TransactionError> {
if let Some(expiry_height) = expiry_height {
if *block_height > expiry_height {
Err(TransactionError::ExpiredTransaction {
expiry_height,
block_height: *block_height,
transaction_hash: transaction.hash(),
})?;
}
}
Ok(())
}
pub fn tx_transparent_coinbase_spends_maturity(
network: &Network,
tx: Arc<Transaction>,
height: Height,
block_new_outputs: Arc<HashMap<transparent::OutPoint, transparent::OrderedUtxo>>,
spent_utxos: &HashMap<transparent::OutPoint, transparent::Utxo>,
) -> Result<(), TransactionError> {
for spend in tx.spent_outpoints() {
let utxo = block_new_outputs
.get(&spend)
.map(|ordered_utxo| ordered_utxo.utxo.clone())
.or_else(|| spent_utxos.get(&spend).cloned())
.expect("load_spent_utxos_fut.await should return an error if a utxo is missing");
let spend_restriction = tx.coinbase_spend_restriction(network, height);
zakura_state::check::transparent_coinbase_spend(spend, spend_restriction, &utxo)?;
}
Ok(())
}
pub const MAX_P2SH_SIGOPS: u32 = 15;
pub const MAX_STANDARD_SCRIPTSIG_SIZE: usize = 1650;
pub fn standard_script_kind(lock_script: &transparent::Script) -> Option<solver::ScriptKind> {
let code = script::Code(lock_script.as_raw_bytes().to_vec());
let component = code.to_component().ok()?.refine().ok()?;
solver::standard(&component)
}
pub(super) fn script_sig_args_expected(kind: &solver::ScriptKind) -> Option<usize> {
match kind {
solver::ScriptKind::PubKey { .. } => Some(1),
solver::ScriptKind::PubKeyHash { .. } => Some(2),
solver::ScriptKind::ScriptHash { .. } => Some(1),
solver::ScriptKind::MultiSig { required, .. } => Some(*required as usize + 1),
solver::ScriptKind::NullData { .. } => None,
}
}
pub(super) fn extract_p2sh_redeemed_script(unlock_script: &transparent::Script) -> Option<Vec<u8>> {
let code = script::Code(unlock_script.as_raw_bytes().to_vec());
let mut last_push_data: Option<Vec<u8>> = None;
for opcode in code.parse().flatten() {
if let PossiblyBad::Good(Opcode::PushValue(pv)) = opcode {
last_push_data = Some(pv.value());
}
}
last_push_data
}
pub(super) fn count_script_push_ops(script_bytes: &[u8]) -> usize {
let code = script::Code(script_bytes.to_vec());
code.parse()
.filter(|op| matches!(op, Ok(PossiblyBad::Good(Opcode::PushValue(_)))))
.count()
}
pub fn are_inputs_standard(tx: &Transaction, spent_outputs: &[transparent::Output]) -> bool {
if tx.inputs().len() != spent_outputs.len() {
return false;
}
for (input, spent_output) in tx.inputs().iter().zip(spent_outputs.iter()) {
let unlock_script = match input {
transparent::Input::PrevOut { unlock_script, .. } => unlock_script,
transparent::Input::Coinbase { .. } => continue,
};
let script_kind = match standard_script_kind(&spent_output.lock_script) {
Some(kind) => kind,
None => return false,
};
let mut n_args_expected = match script_sig_args_expected(&script_kind) {
Some(n) => n,
None => return false,
};
let stack_size = count_script_push_ops(unlock_script.as_raw_bytes());
if matches!(script_kind, solver::ScriptKind::ScriptHash { .. }) {
let Some(redeemed_bytes) = extract_p2sh_redeemed_script(unlock_script) else {
return false;
};
let redeemed_code = script::Code(redeemed_bytes);
let redeemed_kind = {
let component = redeemed_code
.to_component()
.ok()
.and_then(|c| c.refine().ok());
component.and_then(|c| solver::standard(&c))
};
match redeemed_kind {
Some(ref inner_kind) => {
match script_sig_args_expected(inner_kind) {
Some(inner) => n_args_expected += inner,
None => return false,
}
}
None => {
let sigops = redeemed_code.sig_op_count(true);
if sigops > MAX_P2SH_SIGOPS {
return false;
}
continue;
}
}
}
if stack_size != n_args_expected {
return false;
}
}
true
}
pub fn mempool_standard_input_scripts(
tx: &Transaction,
spent_outputs: &[transparent::Output],
) -> Result<(), TransactionError> {
if tx.inputs().len() != spent_outputs.len() {
return Err(TransactionError::Other(format!(
"spent_outputs must align with transaction inputs for non-coinbase txs: inputs={}, spent_outputs={}",
tx.inputs().len(),
spent_outputs.len(),
)));
}
for (input_index, input) in tx.inputs().iter().enumerate() {
let unlock_script = match input {
transparent::Input::PrevOut { unlock_script, .. } => unlock_script,
transparent::Input::Coinbase { .. } => continue,
};
let size = unlock_script.as_raw_bytes().len();
if size > MAX_STANDARD_SCRIPTSIG_SIZE {
return Err(TransactionError::NonStandardScriptSigSize { input_index, size });
}
if !script::Code(unlock_script.as_raw_bytes().to_vec()).is_push_only() {
return Err(TransactionError::NonStandardScriptSigNotPushOnly { input_index });
}
}
if !are_inputs_standard(tx, spent_outputs) {
return Err(TransactionError::NonStandardInputs);
}
Ok(())
}
pub fn consensus_branch_id(
tx: &Transaction,
height: Height,
network: &Network,
) -> Result<(), TransactionError> {
let current_nu = NetworkUpgrade::current(network, height);
if current_nu < NetworkUpgrade::Nu5 || tx.version() < 5 {
return Ok(());
}
let Some(tx_nu) = tx.network_upgrade() else {
return Err(TransactionError::MissingConsensusBranchId);
};
if tx_nu != current_nu {
return Err(TransactionError::WrongConsensusBranchId);
}
Ok(())
}