//! Block and transaction validation rules. //! //! Implements consensus rules for validating: //! - Transaction structure and signatures //! - Block structure and proof of work //! - UTXO spending rules //! - Script execution (simplified) use crate::utxo::{UtxoEntry, UtxoError, UtxoSet}; use synor_types::{ block::{Block, BlockHeader}, transaction::{Outpoint, ScriptType, Transaction, TxInput}, Amount, Hash256, }; use thiserror::Error; /// Transaction validator. pub struct TransactionValidator { /// Maximum transaction size. max_tx_size: usize, /// Minimum relay fee rate (sompi per byte). min_fee_rate: u64, } impl TransactionValidator { /// Creates a new transaction validator with default settings. pub fn new() -> Self { TransactionValidator { max_tx_size: crate::MAX_TRANSACTION_SIZE, min_fee_rate: 1, // 1 sompi per byte } } /// Creates a validator with custom settings. pub fn with_settings(max_tx_size: usize, min_fee_rate: u64) -> Self { TransactionValidator { max_tx_size, min_fee_rate, } } /// Validates a transaction's structure (without UTXO checks). pub fn validate_structure(&self, tx: &Transaction) -> Result<(), ValidationError> { // Check version if tx.version == 0 || tx.version > 2 { return Err(ValidationError::InvalidVersion(tx.version)); } // Coinbase specific checks if tx.is_coinbase() { return self.validate_coinbase_structure(tx); } // Must have at least one input if tx.inputs.is_empty() { return Err(ValidationError::NoInputs); } // Must have at least one output if tx.outputs.is_empty() { return Err(ValidationError::NoOutputs); } // Check for duplicate inputs let mut seen_inputs = std::collections::HashSet::new(); for input in &tx.inputs { if !seen_inputs.insert(input.previous_output) { return Err(ValidationError::DuplicateInput(input.previous_output)); } } // Validate outputs let mut total_output = Amount::ZERO; for output in &tx.outputs { // Check for zero amount (except OP_RETURN) if output.amount == Amount::ZERO && output.script_pubkey.script_type != ScriptType::OpReturn { return Err(ValidationError::ZeroValueOutput); } // Check for overflow total_output = total_output .checked_add(output.amount) .ok_or(ValidationError::OutputOverflow)?; } // Check total doesn't exceed max supply if total_output.as_sompi() > Amount::MAX_SUPPLY { return Err(ValidationError::ExceedsMaxSupply); } Ok(()) } /// Validates coinbase transaction structure. fn validate_coinbase_structure(&self, tx: &Transaction) -> Result<(), ValidationError> { // Coinbase must have exactly one input if tx.inputs.len() != 1 { return Err(ValidationError::InvalidCoinbase( "Must have exactly one input".to_string(), )); } // Input must be null outpoint if !tx.inputs[0].is_coinbase() { return Err(ValidationError::InvalidCoinbase( "Input must have null outpoint".to_string(), )); } // Must have at least one output if tx.outputs.is_empty() { return Err(ValidationError::NoOutputs); } // Coinbase script must not be too long if tx.inputs[0].signature_script.len() > 100 { return Err(ValidationError::InvalidCoinbase( "Script too long".to_string(), )); } Ok(()) } /// Validates a transaction against the UTXO set. pub fn validate_against_utxos( &self, tx: &Transaction, utxo_set: &UtxoSet, current_daa_score: u64, ) -> Result { if tx.is_coinbase() { return Ok(Amount::ZERO); // Coinbase doesn't spend UTXOs } let mut total_input = Amount::ZERO; for input in &tx.inputs { // Get the UTXO being spent let utxo = utxo_set .get(&input.previous_output) .ok_or(ValidationError::UtxoNotFound(input.previous_output))?; // Check maturity if !utxo.is_mature(current_daa_score) { return Err(ValidationError::ImmatureCoinbase(input.previous_output)); } // Verify signature (simplified - in production would execute script) self.verify_input_script(input, &utxo)?; total_input = total_input .checked_add(utxo.amount()) .ok_or(ValidationError::InputOverflow)?; } let total_output = tx.total_output(); // Check input >= output (difference is fee) if total_input < total_output { return Err(ValidationError::InsufficientInputs { input: total_input, output: total_output, }); } let fee = total_input.saturating_sub(total_output); // Check minimum fee let tx_size = tx.weight(); let min_fee = Amount::from_sompi(tx_size * self.min_fee_rate); if fee < min_fee { return Err(ValidationError::InsufficientFee { provided: fee, required: min_fee, }); } Ok(fee) } /// Verifies an input's signature script against the UTXO. fn verify_input_script( &self, input: &TxInput, utxo: &UtxoEntry, ) -> Result<(), ValidationError> { // Simplified verification - in production would: // 1. Parse the signature script // 2. Execute against the UTXO's script pubkey // 3. Verify signatures let script_pubkey = utxo.script_pubkey(); match script_pubkey.script_type { ScriptType::P2PKH | ScriptType::P2pkhPqc => { // For P2PKH, signature script should contain signature + pubkey if input.signature_script.len() < 64 { return Err(ValidationError::InvalidSignature( "Signature script too short".to_string(), )); } // In production: extract pubkey, hash it, compare to script_pubkey.data // Then verify signature } ScriptType::P2SH | ScriptType::P2shPqc => { // For P2SH, signature script contains actual script + signatures if input.signature_script.is_empty() { return Err(ValidationError::InvalidSignature( "Empty signature script".to_string(), )); } // In production: deserialize script, hash it, compare, execute } ScriptType::OpReturn => { return Err(ValidationError::SpendingOpReturn); } } Ok(()) } } impl Default for TransactionValidator { fn default() -> Self { Self::new() } } /// Block validator. pub struct BlockValidator { /// Transaction validator. tx_validator: TransactionValidator, /// Maximum block mass. max_block_mass: u64, /// Maximum transactions per block. max_transactions: usize, } impl BlockValidator { /// Creates a new block validator. pub fn new() -> Self { BlockValidator { tx_validator: TransactionValidator::new(), max_block_mass: crate::MAX_BLOCK_MASS, max_transactions: synor_types::block::MAX_TRANSACTIONS, } } /// Validates a block header. pub fn validate_header(&self, header: &BlockHeader) -> Result<(), ValidationError> { // Check version if header.version == 0 { return Err(ValidationError::InvalidVersion(header.version as u16)); } // Check timestamp is not too far in future let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_millis() as u64; let header_timestamp = header.timestamp.as_millis(); if header_timestamp > now + 2 * 60 * 60 * 1000 { // 2 hours in future return Err(ValidationError::TimestampTooFar(header_timestamp)); } // Check parents if header.parents.is_empty() && !header.block_id().is_zero() { // Only genesis can have no parents return Err(ValidationError::NoParents); } // Check for duplicate parents let mut seen_parents = std::collections::HashSet::new(); for parent in &header.parents { if !seen_parents.insert(*parent) { return Err(ValidationError::DuplicateParent(*parent)); } } Ok(()) } /// Validates proof of work. pub fn validate_pow( &self, header: &BlockHeader, target: Hash256, ) -> Result<(), ValidationError> { let hash = header.block_id(); // Check hash is below target if hash > target { return Err(ValidationError::InsufficientPow { hash, target }); } Ok(()) } /// Validates a complete block. pub fn validate_block( &self, block: &Block, utxo_set: &UtxoSet, expected_reward: Amount, ) -> Result { // Validate header self.validate_header(&block.header)?; // Check transaction count if block.body.transactions.len() > self.max_transactions { return Err(ValidationError::TooManyTransactions { count: block.body.transactions.len(), max: self.max_transactions, }); } // First transaction must be coinbase if block.body.transactions.is_empty() { return Err(ValidationError::MissingCoinbase); } if !block.body.transactions[0].is_coinbase() { return Err(ValidationError::FirstTxNotCoinbase); } // No other transactions can be coinbase for tx in block.body.transactions.iter().skip(1) { if tx.is_coinbase() { return Err(ValidationError::MultipleCoinbase); } } // Validate all transactions let mut total_fees = Amount::ZERO; let mut total_mass = 0u64; for (i, tx) in block.body.transactions.iter().enumerate() { // Validate structure self.tx_validator.validate_structure(tx)?; // Validate against UTXOs (skip coinbase) if i > 0 { let fee = self.tx_validator.validate_against_utxos( tx, utxo_set, block.header.daa_score, )?; total_fees = total_fees.saturating_add(fee); } // Track mass total_mass += tx.weight(); } // Check block mass if total_mass > self.max_block_mass { return Err(ValidationError::BlockMassExceeded { mass: total_mass, max: self.max_block_mass, }); } // Validate coinbase output let coinbase = &block.body.transactions[0]; let coinbase_output = coinbase.total_output(); let max_coinbase = expected_reward.saturating_add(total_fees); if coinbase_output > max_coinbase { return Err(ValidationError::InvalidCoinbaseAmount { output: coinbase_output, max: max_coinbase, }); } // Verify merkle root let computed_merkle = block.body.merkle_root(); if computed_merkle != block.header.merkle_root { return Err(ValidationError::InvalidMerkleRoot { expected: block.header.merkle_root, computed: computed_merkle, }); } Ok(total_fees) } } impl Default for BlockValidator { fn default() -> Self { Self::new() } } /// Validation errors. #[derive(Debug, Error)] pub enum ValidationError { #[error("Invalid version: {0}")] InvalidVersion(u16), #[error("Transaction has no inputs")] NoInputs, #[error("Transaction has no outputs")] NoOutputs, #[error("Block has no parents")] NoParents, #[error("Duplicate input: {0}")] DuplicateInput(Outpoint), #[error("Duplicate parent: {0}")] DuplicateParent(Hash256), #[error("Zero value output (non-OP_RETURN)")] ZeroValueOutput, #[error("Output amount overflow")] OutputOverflow, #[error("Input amount overflow")] InputOverflow, #[error("Total output exceeds max supply")] ExceedsMaxSupply, #[error("Invalid coinbase transaction: {0}")] InvalidCoinbase(String), #[error("UTXO not found: {0}")] UtxoNotFound(Outpoint), #[error("Immature coinbase: {0}")] ImmatureCoinbase(Outpoint), #[error("Insufficient inputs: have {input}, need {output}")] InsufficientInputs { input: Amount, output: Amount }, #[error("Insufficient fee: provided {provided}, required {required}")] InsufficientFee { provided: Amount, required: Amount }, #[error("Invalid signature: {0}")] InvalidSignature(String), #[error("Cannot spend OP_RETURN output")] SpendingOpReturn, #[error("Timestamp too far in future: {0}")] TimestampTooFar(u64), #[error("Insufficient proof of work: hash {hash}, target {target}")] InsufficientPow { hash: Hash256, target: Hash256 }, #[error("Too many transactions: {count} (max {max})")] TooManyTransactions { count: usize, max: usize }, #[error("Missing coinbase transaction")] MissingCoinbase, #[error("First transaction must be coinbase")] FirstTxNotCoinbase, #[error("Multiple coinbase transactions")] MultipleCoinbase, #[error("Block mass exceeded: {mass} (max {max})")] BlockMassExceeded { mass: u64, max: u64 }, #[error("Invalid coinbase amount: output {output}, max {max}")] InvalidCoinbaseAmount { output: Amount, max: Amount }, #[error("Invalid merkle root: expected {expected}, computed {computed}")] InvalidMerkleRoot { expected: Hash256, computed: Hash256, }, #[error("UTXO error: {0}")] UtxoError(#[from] UtxoError), } #[cfg(test)] mod tests { use super::*; use synor_types::transaction::{ScriptPubKey, SubnetworkId}; use synor_types::TxOutput; fn make_p2pkh_output(amount: u64) -> TxOutput { TxOutput::new(Amount::from_sompi(amount), ScriptPubKey::p2pkh(&[0u8; 32])) } #[test] fn test_validate_empty_tx() { let validator = TransactionValidator::new(); let tx = Transaction { version: 1, inputs: vec![], outputs: vec![make_p2pkh_output(1000)], lock_time: 0, subnetwork_id: SubnetworkId::default(), gas: 0, payload: vec![], }; let result = validator.validate_structure(&tx); assert!(matches!(result, Err(ValidationError::NoInputs))); } #[test] fn test_validate_no_outputs() { let validator = TransactionValidator::new(); let tx = Transaction { version: 1, inputs: vec![TxInput::new( Outpoint::new(Hash256::ZERO, 0), vec![0u8; 100], )], outputs: vec![], lock_time: 0, subnetwork_id: SubnetworkId::default(), gas: 0, payload: vec![], }; let result = validator.validate_structure(&tx); assert!(matches!(result, Err(ValidationError::NoOutputs))); } #[test] fn test_validate_invalid_version() { let validator = TransactionValidator::new(); let tx = Transaction { version: 0, inputs: vec![TxInput::new( Outpoint::new(Hash256::ZERO, 0), vec![0u8; 100], )], outputs: vec![make_p2pkh_output(1000)], lock_time: 0, subnetwork_id: SubnetworkId::default(), gas: 0, payload: vec![], }; let result = validator.validate_structure(&tx); assert!(matches!(result, Err(ValidationError::InvalidVersion(0)))); } #[test] fn test_validate_valid_coinbase() { let validator = TransactionValidator::new(); let tx = Transaction::coinbase( vec![make_p2pkh_output(50_000_000_000)], b"Synor Genesis".to_vec(), ); let result = validator.validate_structure(&tx); assert!(result.is_ok()); } #[test] fn test_validate_duplicate_input() { let validator = TransactionValidator::new(); let outpoint = Outpoint::new(Hash256::blake3(b"test"), 0); let tx = Transaction { version: 1, inputs: vec![ TxInput::new(outpoint, vec![0u8; 100]), TxInput::new(outpoint, vec![0u8; 100]), // Duplicate ], outputs: vec![make_p2pkh_output(1000)], lock_time: 0, subnetwork_id: SubnetworkId::default(), gas: 0, payload: vec![], }; let result = validator.validate_structure(&tx); assert!(matches!(result, Err(ValidationError::DuplicateInput(_)))); } }