diff --git a/Cargo.lock b/Cargo.lock index 138475a60..6c7e6db58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -244,6 +244,7 @@ dependencies = [ "rand_distr", "rayon", "rocksdb", + "secp256k1", "serde", "serde_json", "smallvec", @@ -1090,6 +1091,25 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +[[package]] +name = "secp256k1" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff55dc09d460954e9ef2fa8a7ced735a964be9981fd50e870b2b3b0705e14964" +dependencies = [ + "rand", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83080e2c2fc1006e625be82e5d1eb6a43b7fd9578b617fcc55814daf286bba4b" +dependencies = [ + "cc", +] + [[package]] name = "serde" version = "1.0.145" diff --git a/consensus/Cargo.toml b/consensus/Cargo.toml index 7c7136863..15c08194c 100644 --- a/consensus/Cargo.toml +++ b/consensus/Cargo.toml @@ -32,6 +32,7 @@ kaspa-utils.workspace = true rocksdb = "0.19" parking_lot = "0.12" crossbeam-channel = "0.5" +secp256k1 = { version = "0.24", features = ["global-context", "rand-std"] } [dev-dependencies] criterion.workspace = true diff --git a/consensus/core/src/hashing/mod.rs b/consensus/core/src/hashing/mod.rs index 23a44297a..0b2c4a964 100644 --- a/consensus/core/src/hashing/mod.rs +++ b/consensus/core/src/hashing/mod.rs @@ -2,6 +2,8 @@ use crate::BlueWorkType; use hashes::HasherBase; pub mod header; +pub mod sighash; +pub mod sighash_type; pub mod tx; pub(crate) trait HasherExtensions { @@ -11,6 +13,18 @@ pub(crate) trait HasherExtensions { /// Writes the boolean as a u8 fn write_bool(&mut self, element: bool) -> &mut Self; + /// Writes a single u8 + fn write_u8(&mut self, element: u8) -> &mut Self; + + /// Writes the u16 as a little endian u8 array + fn write_u16(&mut self, element: u16) -> &mut Self; + + /// Writes the u32 as a little endian u8 array + fn write_u32(&mut self, element: u32) -> &mut Self; + + /// Writes the u64 as a little endian u8 array + fn write_u64(&mut self, element: u64) -> &mut Self; + /// Writes blue work as big endian bytes w/o the leading zeros /// (emulates bigint.bytes() in the kaspad golang ref) fn write_blue_work(&mut self, work: BlueWorkType) -> &mut Self; @@ -38,6 +52,24 @@ impl HasherExtensions for T { self.update(if element { [1u8] } else { [0u8] }) } + fn write_u8(&mut self, element: u8) -> &mut Self { + self.update(element.to_le_bytes()) + } + + fn write_u16(&mut self, element: u16) -> &mut Self { + self.update(element.to_le_bytes()) + } + + #[inline(always)] + fn write_u32(&mut self, element: u32) -> &mut Self { + self.update(element.to_le_bytes()) + } + + #[inline(always)] + fn write_u64(&mut self, element: u64) -> &mut Self { + self.update(element.to_le_bytes()) + } + #[inline(always)] fn write_blue_work(&mut self, work: BlueWorkType) -> &mut Self { let be_bytes = work.to_be_bytes(); diff --git a/consensus/core/src/hashing/sighash.rs b/consensus/core/src/hashing/sighash.rs new file mode 100644 index 000000000..6931f4491 --- /dev/null +++ b/consensus/core/src/hashing/sighash.rs @@ -0,0 +1,261 @@ +use hashes::{Hash, Hasher, HasherBase, TransactionSigningHash, ZERO_HASH}; + +use crate::{ + subnets::SUBNETWORK_ID_NATIVE, + tx::{PopulatedTransaction, ScriptPublicKey, TransactionOutpoint, TransactionOutput}, +}; + +use super::{sighash_type::SigHashType, HasherExtensions}; + +/// Holds all fields used in the calculation of a transaction's sig_hash which are +/// the same for all transaction inputs. +/// Reuse of such values prevents the quadratic hashing problem. +#[derive(Default)] +pub struct SigHashReusedValues { + previous_outputs_hash: Option, + sequences_hash: Option, + sig_op_counts_hash: Option, + outputs_hash: Option, +} + +impl SigHashReusedValues { + pub fn new() -> Self { + Self { previous_outputs_hash: None, sequences_hash: None, sig_op_counts_hash: None, outputs_hash: None } + } +} + +fn previous_outputs_hash(tx: &PopulatedTransaction, hash_type: SigHashType, reused_values: &mut SigHashReusedValues) -> Hash { + if hash_type.is_sighash_anyone_can_pay() { + return ZERO_HASH; + } + + if let Some(previous_outputs_hash) = reused_values.previous_outputs_hash { + previous_outputs_hash + } else { + let mut hasher = TransactionSigningHash::new(); + for input in tx.tx.inputs.iter() { + hasher.update(input.previous_outpoint.transaction_id.as_bytes()); + hasher.write_u32(input.previous_outpoint.index); + } + let previous_outputs_hash = hasher.finalize(); + reused_values.previous_outputs_hash = Some(previous_outputs_hash); + previous_outputs_hash + } +} + +fn sequences_hash(tx: &PopulatedTransaction, hash_type: SigHashType, reused_values: &mut SigHashReusedValues) -> Hash { + if hash_type.is_sighash_single() || hash_type.is_sighash_anyone_can_pay() || hash_type.is_sighash_none() { + return ZERO_HASH; + } + + if let Some(sequences_hash) = reused_values.sequences_hash { + sequences_hash + } else { + let mut hasher = TransactionSigningHash::new(); + for input in tx.tx.inputs.iter() { + hasher.write_u64(input.sequence); + } + let sequence_hash = hasher.finalize(); + reused_values.sequences_hash = Some(sequence_hash); + sequence_hash + } +} + +fn sig_op_counts_hash(tx: &PopulatedTransaction, hash_type: SigHashType, reused_values: &mut SigHashReusedValues) -> Hash { + if hash_type.is_sighash_anyone_can_pay() { + return ZERO_HASH; + } + + if let Some(sig_op_counts_hash) = reused_values.sig_op_counts_hash { + sig_op_counts_hash + } else { + let mut hasher = TransactionSigningHash::new(); + for input in tx.tx.inputs.iter() { + hasher.write_u8(input.sig_op_count); + } + let sig_op_counts_hash = hasher.finalize(); + reused_values.sig_op_counts_hash = Some(sig_op_counts_hash); + sig_op_counts_hash + } +} + +fn payload_hash(tx: &PopulatedTransaction) -> Hash { + if tx.tx.subnetwork_id == SUBNETWORK_ID_NATIVE { + return ZERO_HASH; + } + + // TODO: Right now this branch will never be executed, since payload is disabled + // for all non coinbase transactions. Once payload is enabled, the payload hash + // should be cached to make it cost O(1) instead of O(tx.inputs.len()). + let mut hasher = TransactionSigningHash::new(); + hasher.write_var_bytes(&tx.tx.payload); + hasher.finalize() +} + +fn outputs_hash( + tx: &PopulatedTransaction, + hash_type: SigHashType, + reused_values: &mut SigHashReusedValues, + input_index: usize, +) -> Hash { + if hash_type.is_sighash_none() { + return ZERO_HASH; + } + + if hash_type.is_sighash_single() { + // If the relevant output exists - return its hash, otherwise return zero-hash + if input_index >= tx.outputs().len() { + return ZERO_HASH; + } + + let mut hasher = TransactionSigningHash::new(); + hash_output(&mut hasher, &tx.outputs()[input_index]); + return hasher.finalize(); + } + + // Otherwise, return hash of all outputs. Re-use hash if available. + if let Some(outputs_hash) = reused_values.outputs_hash { + outputs_hash + } else { + let mut hasher = TransactionSigningHash::new(); + for output in tx.tx.outputs.iter() { + hash_output(&mut hasher, output); + } + let outputs_hash = hasher.finalize(); + reused_values.outputs_hash = Some(outputs_hash); + outputs_hash + } +} + +fn hash_outpoint(hasher: &mut impl Hasher, outpoint: TransactionOutpoint) { + hasher.update(outpoint.transaction_id); + hasher.write_u32(outpoint.index); +} + +fn hash_output(hasher: &mut impl Hasher, output: &TransactionOutput) { + hasher.write_u64(output.value); + hash_script_public_key(hasher, &output.script_public_key); +} + +fn hash_script_public_key(hasher: &mut impl Hasher, script_public_key: &ScriptPublicKey) { + hasher.write_u16(script_public_key.version()); + hasher.write_var_bytes(script_public_key.script()); +} + +pub fn calc_schnorr_signature_hash( + tx: &PopulatedTransaction, + input_index: usize, + hash_type: SigHashType, + reused_values: &mut SigHashReusedValues, +) -> Hash { + let input = tx.populated_input(input_index); + let mut hasher = TransactionSigningHash::new(); + hasher + .write_u16(tx.tx.version) + .update(previous_outputs_hash(tx, hash_type, reused_values)) + .update(sequences_hash(tx, hash_type, reused_values)) + .update(sig_op_counts_hash(tx, hash_type, reused_values)); + hash_outpoint(&mut hasher, input.0.previous_outpoint); + hash_script_public_key(&mut hasher, &input.1.script_public_key); + hasher + .write_u64(input.1.amount) + .write_u64(input.0.sequence) + .write_u8(input.0.sig_op_count) + .update(outputs_hash(tx, hash_type, reused_values, input_index)) + .write_u64(tx.tx.lock_time) + .update(&tx.tx.subnetwork_id) + .write_u64(tx.tx.gas) + .update(payload_hash(tx)) + .write_u8(hash_type.to_u8()); + hasher.finalize() +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use smallvec::SmallVec; + + use crate::{ + hashing::sighash_type::SIG_HASH_ALL, + subnets::SubnetworkId, + tx::{Transaction, TransactionId, TransactionInput, UtxoEntry}, + }; + + use super::*; + + #[test] + fn test_signature_hash() { + // TODO: Copy all sighash tests from go kaspad. + let prev_tx_id = TransactionId::from_str("880eb9819a31821d9d2399e2f35e2433b72637e393d71ecc9b8d0250f49153c3").unwrap(); + let mut bytes = [0u8; 34]; + faster_hex::hex_decode("208325613d2eeaf7176ac6c670b13c0043156c427438ed72d74b7800862ad884e8ac".as_bytes(), &mut bytes).unwrap(); + let script_pub_key_1 = SmallVec::from(bytes.to_vec()); + + let mut bytes = [0u8; 34]; + faster_hex::hex_decode("20fcef4c106cf11135bbd70f02a726a92162d2fb8b22f0469126f800862ad884e8ac".as_bytes(), &mut bytes).unwrap(); + let script_pub_key_2 = SmallVec::from_vec(bytes.to_vec()); + + let tx = Transaction::new( + 0, + vec![ + TransactionInput { + previous_outpoint: TransactionOutpoint { transaction_id: prev_tx_id, index: 0 }, + signature_script: vec![], + sequence: 0, + sig_op_count: 0, + }, + TransactionInput { + previous_outpoint: TransactionOutpoint { transaction_id: prev_tx_id, index: 1 }, + signature_script: vec![], + sequence: 1, + sig_op_count: 0, + }, + TransactionInput { + previous_outpoint: TransactionOutpoint { transaction_id: prev_tx_id, index: 2 }, + signature_script: vec![], + sequence: 2, + sig_op_count: 0, + }, + ], + vec![ + TransactionOutput { value: 300, script_public_key: ScriptPublicKey::new(0, script_pub_key_2.clone()) }, + TransactionOutput { value: 300, script_public_key: ScriptPublicKey::new(0, script_pub_key_1.clone()) }, + ], + 1615462089000, + SubnetworkId::from_bytes([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + 0, + vec![], + ); + + let populated_tx = PopulatedTransaction::new( + &tx, + vec![ + UtxoEntry { + amount: 100, + script_public_key: ScriptPublicKey::new(0, script_pub_key_1), + block_daa_score: 0, + is_coinbase: false, + }, + UtxoEntry { + amount: 200, + script_public_key: ScriptPublicKey::new(0, script_pub_key_2.clone()), + block_daa_score: 0, + is_coinbase: false, + }, + UtxoEntry { + amount: 300, + script_public_key: ScriptPublicKey::new(0, script_pub_key_2), + block_daa_score: 0, + is_coinbase: false, + }, + ], + ); + + let mut reused_values = SigHashReusedValues::new(); + assert_eq!( + calc_schnorr_signature_hash(&populated_tx, 0, SIG_HASH_ALL, &mut reused_values).to_string(), + "b363613fe99c8bb1d3712656ec8dfaea621ee6a9a95d851aec5bb59363b03f5e" + ); + } +} diff --git a/consensus/core/src/hashing/sighash_type.rs b/consensus/core/src/hashing/sighash_type.rs new file mode 100644 index 000000000..55de9a980 --- /dev/null +++ b/consensus/core/src/hashing/sighash_type.rs @@ -0,0 +1,50 @@ +pub const SIG_HASH_ALL: SigHashType = SigHashType(0b00000001); +pub const SIG_HASH_NONE: SigHashType = SigHashType(0b00000010); +pub const SIG_HASH_SINGLE: SigHashType = SigHashType(0b00000100); +pub const SIG_HASH_ANY_ONE_CAN_PAY: SigHashType = SigHashType(0b10000000); + +/// SIG_HASH_MASK defines the number of bits of the hash type which are used +/// to identify which outputs are signed. +pub const SIG_HASH_MASK: u8 = 0b00000111; + +const ALLOWED_SIG_HASH_TYPES_VALUES: [u8; 6] = [ + SIG_HASH_ALL.0, + SIG_HASH_NONE.0, + SIG_HASH_SINGLE.0, + SIG_HASH_ALL.0 | SIG_HASH_ANY_ONE_CAN_PAY.0, + SIG_HASH_NONE.0 | SIG_HASH_ANY_ONE_CAN_PAY.0, + SIG_HASH_SINGLE.0 | SIG_HASH_ANY_ONE_CAN_PAY.0, +]; + +#[derive(Copy, Clone)] +pub struct SigHashType(u8); + +impl SigHashType { + pub fn is_sighash_all(self) -> bool { + self.0 & SIG_HASH_MASK == SIG_HASH_ALL.0 + } + + pub fn is_sighash_none(self) -> bool { + self.0 & SIG_HASH_MASK == SIG_HASH_NONE.0 + } + + pub fn is_sighash_single(self) -> bool { + self.0 & SIG_HASH_MASK == SIG_HASH_SINGLE.0 + } + + pub fn is_sighash_anyone_can_pay(self) -> bool { + self.0 & SIG_HASH_ANY_ONE_CAN_PAY.0 == SIG_HASH_ANY_ONE_CAN_PAY.0 + } + + pub fn to_u8(self) -> u8 { + self.0 + } + + pub fn from_u8(val: u8) -> Result { + if !ALLOWED_SIG_HASH_TYPES_VALUES.contains(&val) { + return Err("invalid sighash type"); + } + + Ok(Self(val)) + } +} diff --git a/consensus/core/src/subnets.rs b/consensus/core/src/subnets.rs index 8a2c2ee87..23cfeb139 100644 --- a/consensus/core/src/subnets.rs +++ b/consensus/core/src/subnets.rs @@ -22,6 +22,10 @@ impl SubnetworkId { SubnetworkId(bytes) } + pub const fn from_bytes(bytes: [u8; SUBNETWORK_ID_SIZE]) -> SubnetworkId { + SubnetworkId(bytes) + } + /// Returns true if the subnetwork is a built-in subnetwork, which /// means all nodes, including partial nodes, must validate it, and its transactions /// always use 0 gas. diff --git a/consensus/core/src/tx.rs b/consensus/core/src/tx.rs index 7a1c1e35d..2625e7056 100644 --- a/consensus/core/src/tx.rs +++ b/consensus/core/src/tx.rs @@ -84,7 +84,7 @@ impl Display for TransactionOutpoint { #[derive(Debug, Serialize, Deserialize, Clone)] pub struct TransactionInput { pub previous_outpoint: TransactionOutpoint, - pub signature_script: Vec, + pub signature_script: Vec, // TODO: Consider using SmallVec pub sequence: u64, pub sig_op_count: u8, } @@ -182,6 +182,10 @@ impl<'a> PopulatedTransaction<'a> { self.tx.inputs.iter().zip(self.entries.iter()) } + pub fn populated_input(&self, index: usize) -> (&TransactionInput, &UtxoEntry) { + (&self.tx.inputs[index], &self.entries[index]) + } + pub fn outputs(&self) -> &[TransactionOutput] { &self.tx.outputs } diff --git a/consensus/src/processes/transaction_validator/mod.rs b/consensus/src/processes/transaction_validator/mod.rs index f76c3c46c..65050f66f 100644 --- a/consensus/src/processes/transaction_validator/mod.rs +++ b/consensus/src/processes/transaction_validator/mod.rs @@ -2,10 +2,18 @@ pub mod errors; pub mod transaction_validator_populated; mod tx_validation_in_isolation; pub mod tx_validation_not_utxo_related; -use crate::model::stores::ghostdag; +use crate::model::stores::{database::prelude::Cache, ghostdag}; pub use tx_validation_in_isolation::*; +// TODO: Move it to the script engine once it's ready +#[derive(Clone, Hash, PartialEq, Eq)] +pub(crate) struct SigCacheKey { + signature: secp256k1::schnorr::Signature, + pub_key: secp256k1::XOnlyPublicKey, + message: secp256k1::Message, +} + #[derive(Clone)] pub struct TransactionValidator { max_tx_inputs: usize, @@ -15,6 +23,7 @@ pub struct TransactionValidator { ghostdag_k: ghostdag::KType, coinbase_payload_script_public_key_max_len: u8, coinbase_maturity: u64, + sig_cache: Cache, // TODO: Move sig_cache to the script engine once it's ready } impl TransactionValidator { @@ -35,6 +44,7 @@ impl TransactionValidator { ghostdag_k, coinbase_payload_script_public_key_max_len, coinbase_maturity, + sig_cache: Cache::new(10_000), } } } diff --git a/consensus/src/processes/transaction_validator/transaction_validator_populated.rs b/consensus/src/processes/transaction_validator/transaction_validator_populated.rs index 431847113..b07d93d73 100644 --- a/consensus/src/processes/transaction_validator/transaction_validator_populated.rs +++ b/consensus/src/processes/transaction_validator/transaction_validator_populated.rs @@ -1,9 +1,15 @@ use crate::constants::{MAX_SOMPI, SEQUENCE_LOCK_TIME_DISABLED, SEQUENCE_LOCK_TIME_MASK}; -use consensus_core::tx::PopulatedTransaction; +use consensus_core::{ + hashing::{ + sighash::{calc_schnorr_signature_hash, SigHashReusedValues}, + sighash_type::SIG_HASH_ALL, + }, + tx::PopulatedTransaction, +}; use super::{ errors::{TxResult, TxRuleError}, - TransactionValidator, + SigCacheKey, TransactionValidator, }; impl TransactionValidator { @@ -13,7 +19,7 @@ impl TransactionValidator { let total_out = Self::check_transaction_output_values(tx, total_in)?; Self::check_sequence_lock(tx, pov_daa_score)?; Self::check_sig_op_counts(tx)?; - Self::check_scripts(tx)?; + self.check_scripts(tx)?; Ok(total_in - total_out) } @@ -95,8 +101,30 @@ impl TransactionValidator { Ok(()) } - fn check_scripts(_tx: &PopulatedTransaction) -> TxResult<()> { - // TODO: Implement this + fn check_scripts(&self, tx: &PopulatedTransaction) -> TxResult<()> { + let mut reused_values = SigHashReusedValues::new(); + for (i, (input, entry)) in tx.populated_inputs().enumerate() { + // TODO: this is a temporary implementation and not ready for consensus since any invalid signature + // will crash the node. We need to replace it with a proper script engine once it's ready. + let pk = &entry.script_public_key.script()[1..33]; + let pk = secp256k1::XOnlyPublicKey::from_slice(pk).unwrap(); + let sig = secp256k1::schnorr::Signature::from_slice(&input.signature_script[1..65]).unwrap(); + let sig_hash = calc_schnorr_signature_hash(tx, i, SIG_HASH_ALL, &mut reused_values); + let msg = secp256k1::Message::from_slice(sig_hash.as_bytes().as_slice()).unwrap(); + let sig_cache_key = SigCacheKey { signature: sig, pub_key: pk, message: msg }; + match self.sig_cache.get(&sig_cache_key) { + Some(valid) => { + assert!(valid, "invalid signature in sig cache"); + } + None => { + // TODO: Find a way to parallelize this part. This will be less trivial + // once this code is inside the script engine. + sig.verify(&msg, &pk).unwrap(); + self.sig_cache.insert(sig_cache_key, true); + } + } + } + Ok(()) } }