Fix all Rust clippy warnings that were causing CI failures when built with RUSTFLAGS=-Dwarnings. Changes include: - Replace derivable_impls with derive macros for BlockBody, Network, etc. - Use div_ceil() instead of manual implementation - Fix should_implement_trait by renaming from_str to parse - Add type aliases for type_complexity warnings - Use or_default(), is_some_and(), is_multiple_of() where appropriate - Remove needless borrows and redundant closures - Fix manual_strip with strip_prefix() - Add allow attributes for intentional patterns (too_many_arguments, needless_range_loop in cryptographic code, assertions_on_constants) - Remove unused imports, mut bindings, and dead code in tests
591 lines
17 KiB
Rust
591 lines
17 KiB
Rust
//! 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<Amount, ValidationError> {
|
|
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<Amount, ValidationError> {
|
|
// 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(_))));
|
|
}
|
|
}
|