diff --git a/silentpayments/src/receiving.rs b/silentpayments/src/receiving.rs index 2e5cc20..ea136ea 100644 --- a/silentpayments/src/receiving.rs +++ b/silentpayments/src/receiving.rs @@ -19,6 +19,7 @@ use crate::{ utils::{ common::{calculate_P_n, calculate_t_n, SharedSecret}, hash::LabelHash, + OP_1, OP_PUSHBYTES_32, }, Error, Network, Result, SilentPaymentAddress, SpVersion, }; @@ -446,8 +447,8 @@ impl Receiver { let mut res = HashMap::new(); let mut spk = [0u8; 34]; - // hardcoded opcode values for OP_PUSHNUM_1 and OP_PUSHBYTES_32 - spk[..2].copy_from_slice(&[0x51, 0x20]); + // OP_PUSHNUM_1 OP_PUSHBYTES_32 taproot output key + spk[..2].copy_from_slice(&[OP_1, OP_PUSHBYTES_32]); spk[2..].copy_from_slice(&output_key_bytes); res.insert(None, spk); @@ -458,7 +459,7 @@ impl Receiver { let output_key_bytes = P_m0.x_only_public_key().0.serialize(); let mut spk = [0u8; 34]; - spk[..2].copy_from_slice(&[0x51, 0x20]); + spk[..2].copy_from_slice(&[OP_1, OP_PUSHBYTES_32]); spk[2..].copy_from_slice(&output_key_bytes); res.insert(Some(label.clone()), spk); diff --git a/silentpayments/src/utils.rs b/silentpayments/src/utils.rs index 523cd80..15477af 100644 --- a/silentpayments/src/utils.rs +++ b/silentpayments/src/utils.rs @@ -6,6 +6,8 @@ pub(crate) mod hash; #[cfg(feature = "receiving")] pub mod receiving; +#[cfg(any(feature = "sending", feature = "receiving"))] +pub(crate) mod script; #[cfg(feature = "sending")] pub mod sending; @@ -20,16 +22,24 @@ pub const NUMS_H: [u8; 32] = [ 0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, 0x47, 0xbf, 0xee, 0x9a, 0xce, 0x80, 0x3a, 0xc0, ]; +/// Taproot witness annex prefix byte ([BIP341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki)). +pub(crate) const TAPROOT_ANNEX_PREFIX: u8 = 0x50; + // Define OP_CODES used in script template matching for readability -const OP_1: u8 = 0x51; -const OP_0: u8 = 0x00; -const OP_PUSHBYTES_20: u8 = 0x14; -const OP_PUSHBYTES_32: u8 = 0x20; -const OP_HASH160: u8 = 0xA9; -const OP_EQUAL: u8 = 0x87; -const OP_DUP: u8 = 0x76; -const OP_EQUALVERIFY: u8 = 0x88; -const OP_CHECKSIG: u8 = 0xAC; +pub(crate) const OP_0: u8 = 0x00; +pub(crate) const OP_PUSHBYTES_1: u8 = 0x01; +pub(crate) const OP_PUSHBYTES_20: u8 = 0x14; +pub(crate) const OP_PUSHBYTES_32: u8 = 0x20; +pub(crate) const OP_PUSHBYTES_75: u8 = 0x4b; +pub(crate) const OP_1: u8 = 0x51; +pub(crate) const OP_PUSHDATA1: u8 = 0x4c; +pub(crate) const OP_PUSHDATA2: u8 = 0x4d; +pub(crate) const OP_PUSHDATA4: u8 = 0x4e; +pub(crate) const OP_HASH160: u8 = 0xA9; +pub(crate) const OP_EQUAL: u8 = 0x87; +pub(crate) const OP_DUP: u8 = 0x76; +pub(crate) const OP_EQUALVERIFY: u8 = 0x88; +pub(crate) const OP_CHECKSIG: u8 = 0xAC; // Only compressed pubkeys are supported for silent payments const COMPRESSED_PUBKEY_SIZE: usize = 33; diff --git a/silentpayments/src/utils/receiving.rs b/silentpayments/src/utils/receiving.rs index 3ede4e2..841cd09 100644 --- a/silentpayments/src/utils/receiving.rs +++ b/silentpayments/src/utils/receiving.rs @@ -2,18 +2,71 @@ use crate::{ utils::{ common::{NonEmptyArray, OutPoint, SharedSecret}, - OP_0, OP_1, OP_CHECKSIG, OP_DUP, OP_EQUAL, OP_EQUALVERIFY, OP_HASH160, OP_PUSHBYTES_20, - OP_PUSHBYTES_32, + script::{is_p2pkh, is_p2sh, is_p2wpkh}, }, Error, Result, }; + +pub use crate::utils::script::{is_eligible, is_p2tr}; use bitcoin_hashes::{hash160, Hash}; use secp256k1::{ecdh::shared_secret_point, Parity::Even, XOnlyPublicKey}; use secp256k1::{PublicKey, SecretKey}; -use super::{hash::calculate_input_hash, COMPRESSED_PUBKEY_SIZE, NUMS_H}; +use super::{ + hash::calculate_input_hash, COMPRESSED_PUBKEY_SIZE, NUMS_H, OP_PUSHBYTES_1, OP_PUSHBYTES_75, + OP_PUSHDATA1, OP_PUSHDATA2, OP_PUSHDATA4, TAPROOT_ANNEX_PREFIX, +}; + +/// Returns the last data push in a push-only script, or `None` if malformed. +fn last_push(script: &[u8]) -> Option<&[u8]> { + let mut i = 0; + let mut last: Option<&[u8]> = None; + while i < script.len() { + let op = script[i]; + i += 1; + let (extra_len_bytes, data_len): (usize, usize) = match op { + OP_PUSHBYTES_1..=OP_PUSHBYTES_75 => (0, op as usize), + OP_PUSHDATA1 => (1, *script.get(i)? as usize), + OP_PUSHDATA2 => { + let lo = *script.get(i)? as usize; + let hi = *script.get(i + 1)? as usize; + (2, lo | (hi << 8)) + } + OP_PUSHDATA4 => { + let b0 = *script.get(i)? as usize; + let b1 = *script.get(i + 1)? as usize; + let b2 = *script.get(i + 2)? as usize; + let b3 = *script.get(i + 3)? as usize; + (4, b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)) + } + _ => return None, + }; + i += extra_len_bytes; + last = Some(script.get(i..i + data_len)?); + i += data_len; + } + last +} + +/// Parse a compressed witness pubkey and verify it matches the expected hash160. +fn witness_compressed_pubkey( + witness_last: &[u8], + expected_hash: &[u8], +) -> Result> { + if witness_last.len() != COMPRESSED_PUBKEY_SIZE { + return Ok(None); + } + let pubkey = match PublicKey::from_slice(witness_last) { + Ok(pk) => pk, + Err(_) => return Ok(None), + }; + if hash160::Hash::hash(witness_last).to_byte_array() != expected_hash { + return Ok(None); + } + Ok(Some(pubkey)) +} -/// Calculate the tweak data of a transaction. +/// Public tweak data for a transaction: `input_hash * sum(eligible input pubkeys)`. /// /// This is useful in combination with the [calculate_ecdh_shared_secret] function, but can also be used /// by indexing servers that don't have access to the recipient scan key. @@ -118,25 +171,15 @@ pub fn get_pubkey_from_input( } else if is_p2sh(script_pub_key) { match (txinwitness.is_empty(), script_sig.is_empty()) { (false, false) => { - let redeem_script = &script_sig[1..]; + let Some(redeem_script) = last_push(script_sig) else { + return Ok(None); + }; + if hash160::Hash::hash(redeem_script).to_byte_array() != script_pub_key[2..22] { + return Ok(None); + } if is_p2wpkh(redeem_script) { if let Some(value) = txinwitness.last() { - match ( - PublicKey::from_slice(value), - value.len() == COMPRESSED_PUBKEY_SIZE, - ) { - (Ok(pubkey), true) => { - return Ok(Some(pubkey)); - } - (_, false) => { - return Ok(None); - } - // Not sure how we could get an error here, so just return none for now - // if the pubkey cant be parsed - (Err(_), _) => { - return Ok(None); - } - } + return witness_compressed_pubkey(value, &redeem_script[2..22]); } } } @@ -151,22 +194,7 @@ pub fn get_pubkey_from_input( match (txinwitness.is_empty(), script_sig.is_empty()) { (false, true) => { if let Some(value) = txinwitness.last() { - match ( - PublicKey::from_slice(value), - value.len() == COMPRESSED_PUBKEY_SIZE, - ) { - (Ok(pubkey), true) => { - return Ok(Some(pubkey)); - } - (_, false) => { - return Ok(None); - } - // Not sure how we could get an error here, so just return none for now - // if the pubkey cant be parsed - (Err(_), _) => { - return Ok(None); - } - } + return witness_compressed_pubkey(value, &script_pub_key[2..22]); } else { return Err(Error::InvalidVin("Empty witness".to_owned())); } @@ -187,15 +215,19 @@ pub fn get_pubkey_from_input( (false, true) => { // check for the optional annex let annex = match txinwitness.last().and_then(|value| value.first()) { - Some(&0x50) => 1, + Some(&TAPROOT_ANNEX_PREFIX) => 1, Some(_) => 0, None => return Err(Error::InvalidVin("Empty or invalid witness".to_owned())), }; - // Check for script path let stack_size = txinwitness.len(); - if stack_size > annex && txinwitness[stack_size - annex - 1][1..33] == NUMS_H { - return Ok(None); + let effective_stack = stack_size - annex; + // Script path: control block is the last effective witness item. + if effective_stack >= 2 { + let control_block = &txinwitness[stack_size - annex - 1]; + if control_block.len() >= 33 && control_block[1..33] == NUMS_H { + return Ok(None); + } } // Return the pubkey from the script pubkey @@ -219,21 +251,3 @@ pub fn get_pubkey_from_input( } Ok(None) } - -// script templates for inputs allowed in BIP352 shared secret derivation -/// Check if a script_pub_key is taproot. -pub fn is_p2tr(spk: &[u8]) -> bool { - matches!(spk, [OP_1, OP_PUSHBYTES_32, ..] if spk.len() == 34) -} - -fn is_p2wpkh(spk: &[u8]) -> bool { - matches!(spk, [OP_0, OP_PUSHBYTES_20, ..] if spk.len() == 22) -} - -fn is_p2sh(spk: &[u8]) -> bool { - matches!(spk, [OP_HASH160, OP_PUSHBYTES_20, .., OP_EQUAL] if spk.len() == 23) -} - -fn is_p2pkh(spk: &[u8]) -> bool { - matches!(spk, [OP_DUP, OP_HASH160, OP_PUSHBYTES_20, .., OP_EQUALVERIFY, OP_CHECKSIG] if spk.len() == 25) -} diff --git a/silentpayments/src/utils/script.rs b/silentpayments/src/utils/script.rs new file mode 100644 index 0000000..2e4605c --- /dev/null +++ b/silentpayments/src/utils/script.rs @@ -0,0 +1,30 @@ +//! Script template matching for silent payment-eligible inputs (BIP352). + +use super::{ + OP_0, OP_1, OP_CHECKSIG, OP_DUP, OP_EQUAL, OP_EQUALVERIFY, OP_HASH160, OP_PUSHBYTES_20, + OP_PUSHBYTES_32, +}; + +/// Check if a script_pub_key is taproot. +pub fn is_p2tr(spk: &[u8]) -> bool { + matches!(spk, [OP_1, OP_PUSHBYTES_32, ..] if spk.len() == 34) +} + +pub(crate) fn is_p2wpkh(spk: &[u8]) -> bool { + matches!(spk, [OP_0, OP_PUSHBYTES_20, ..] if spk.len() == 22) +} + +pub(crate) fn is_p2sh(spk: &[u8]) -> bool { + matches!(spk, [OP_HASH160, OP_PUSHBYTES_20, .., OP_EQUAL] if spk.len() == 23) +} + +pub(crate) fn is_p2pkh(spk: &[u8]) -> bool { + matches!(spk, [OP_DUP, OP_HASH160, OP_PUSHBYTES_20, .., OP_EQUALVERIFY, OP_CHECKSIG] if spk.len() == 25) +} + +/// Check if a script_pub_key is silent payment-eligible (BIP352 shared secret derivation). +/// This is supposed to help as a kind of quick sanity check, but the real check must be done by the caller +/// that have access to bitcoin crate and more context. +pub fn is_eligible(spk: &[u8]) -> bool { + is_p2pkh(spk) || is_p2wpkh(spk) || is_p2sh(spk) || is_p2tr(spk) +}